From 25666811c6b8bfcdea2b27cee190ecd10a5600b5 Mon Sep 17 00:00:00 2001 From: RubenKelevra Date: Tue, 15 Jul 2025 22:08:57 +0200 Subject: [PATCH 0001/1626] Update esp32-camera library version to 2.1.0 --- esphome/components/esp32_camera/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 43e71df432..bfb66ff83a 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -345,7 +345,7 @@ async def to_code(config): cg.add_define("USE_CAMERA") if CORE.using_esp_idf: - add_idf_component(name="espressif/esp32-camera", ref="2.0.15") + add_idf_component(name="espressif/esp32-camera", ref="2.1.0") for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c43b622684..419a9797e3 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.0.15 + version: 2.1.0 espressif/mdns: version: 1.8.2 espressif/esp_wifi_remote: From 0420bb38622385cac1755e9aca3dadb50a361234 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 11:47:52 -1000 Subject: [PATCH 0002/1626] [api] Add conditional compilation for Home Assistant state subscriptions --- esphome/components/api/__init__.py | 6 ++++++ esphome/components/api/api.proto | 3 +++ esphome/components/api/api_connection.cpp | 6 ++++++ esphome/components/api/api_connection.h | 6 ++++++ esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 2 ++ esphome/components/api/api_pb2_dump.cpp | 2 ++ esphome/components/api/api_pb2_service.cpp | 6 ++++++ esphome/components/api/api_pb2_service.h | 8 ++++++++ esphome/components/api/api_server.cpp | 2 ++ esphome/components/api/api_server.h | 4 ++++ esphome/components/api/custom_api_device.h | 2 ++ 12 files changed, 49 insertions(+) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 9cbab8164f..c4d954bdd2 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -53,6 +53,7 @@ SERVICE_ARG_NATIVE_TYPES = { CONF_ENCRYPTION = "encryption" CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" +CONF_HOMEASSISTANT_STATES = "homeassistant_states" def validate_encryption_key(value): @@ -118,6 +119,7 @@ CONFIG_SCHEMA = cv.All( cv.Range(max=cv.TimePeriod(milliseconds=65535)), ), cv.Optional(CONF_CUSTOM_SERVICES, default=False): cv.boolean, + cv.Optional(CONF_HOMEASSISTANT_STATES, default=False): cv.boolean, cv.Optional(CONF_ON_CLIENT_CONNECTED): automation.validate_automation( single=True ), @@ -146,6 +148,10 @@ async def to_code(config): if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: cg.add_define("USE_API_SERVICES") + # Set USE_API_HOMEASSISTANT_STATES if enabled or homeassistant component is loaded + if config[CONF_HOMEASSISTANT_STATES] or "homeassistant" in CORE.loaded_integrations: + cg.add_define("USE_API_HOMEASSISTANT_STATES") + if actions := config.get(CONF_ACTIONS, []): for conf in actions: template_args = [] diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 93e84702e2..5956c8b0b9 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -781,11 +781,13 @@ message HomeassistantServiceResponse { message SubscribeHomeAssistantStatesRequest { option (id) = 38; option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_API_HOMEASSISTANT_STATES"; } message SubscribeHomeAssistantStateResponse { option (id) = 39; option (source) = SOURCE_SERVER; + option (ifdef) = "USE_API_HOMEASSISTANT_STATES"; string entity_id = 1; string attribute = 2; bool once = 3; @@ -795,6 +797,7 @@ message HomeAssistantStateResponse { option (id) = 40; option (source) = SOURCE_CLIENT; option (no_delay) = true; + option (ifdef) = "USE_API_HOMEASSISTANT_STATES"; string entity_id = 1; string state = 2; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 76713c54c8..7a7ea70302 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -242,6 +242,7 @@ void APIConnection::loop() { } #endif +#ifdef USE_API_HOMEASSISTANT_STATES if (state_subs_at_ >= 0) { const auto &subs = this->parent_->get_state_subs(); if (state_subs_at_ < static_cast(subs.size())) { @@ -259,6 +260,7 @@ void APIConnection::loop() { state_subs_at_ = -1; } } +#endif } bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) { @@ -1512,6 +1514,7 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { return this->send_message(resp, DeviceInfoResponse::MESSAGE_TYPE); } +#ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { for (auto &it : this->parent_->get_state_subs()) { if (it.entity_id == msg.entity_id && it.attribute.value() == msg.attribute) { @@ -1519,6 +1522,7 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes } } } +#endif #ifdef USE_API_SERVICES void APIConnection::execute_service(const ExecuteServiceRequest &msg) { bool found = false; @@ -1550,9 +1554,11 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption return this->send_message(resp, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE); } #endif +#ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) { state_subs_at_ = 0; } +#endif bool APIConnection::try_to_clear_buffer(bool log_out_of_space) { if (this->flags_.remove) return false; diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 6214d5ba82..6b140acf3d 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -188,7 +188,9 @@ class APIConnection : public APIServerConnection { // we initiated ping this->flags_.sent_ping = false; } +#ifdef USE_API_HOMEASSISTANT_STATES void on_home_assistant_state_response(const HomeAssistantStateResponse &msg) override; +#endif #ifdef USE_HOMEASSISTANT_TIME void on_get_time_response(const GetTimeResponse &value) override; #endif @@ -210,7 +212,9 @@ class APIConnection : public APIServerConnection { void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) override { this->flags_.service_call_subscription = true; } +#ifdef USE_API_HOMEASSISTANT_STATES void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override; +#endif bool send_get_time_response(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES void execute_service(const ExecuteServiceRequest &msg) override; @@ -492,7 +496,9 @@ class APIConnection : public APIServerConnection { // Group 4: 4-byte types uint32_t last_traffic_; +#ifdef USE_API_HOMEASSISTANT_STATES int state_subs_at_ = -1; +#endif // Function pointer type for message encoding using MessageCreatorPtr = uint16_t (*)(EntityBase *, APIConnection *, uint32_t remaining_size, bool is_single); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 6d2e17dc27..e2d1cfebd4 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -871,6 +871,7 @@ void HomeassistantServiceResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_repeated_message(total_size, 1, this->variables); ProtoSize::add_bool_field(total_size, 1, this->is_event); } +#ifdef USE_API_HOMEASSISTANT_STATES void SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->entity_id_ref_); buffer.encode_string(2, this->attribute_ref_); @@ -897,6 +898,7 @@ bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDel } return true; } +#endif bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { case 1: diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 91a285fc6c..b7d8945e8e 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1092,6 +1092,7 @@ class HomeassistantServiceResponse : public ProtoMessage { protected: }; +#ifdef USE_API_HOMEASSISTANT_STATES class SubscribeHomeAssistantStatesRequest : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 38; @@ -1142,6 +1143,7 @@ class HomeAssistantStateResponse : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; +#endif class GetTimeRequest : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 36; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 5db9b79cfa..d7f9f63f5f 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1066,6 +1066,7 @@ void HomeassistantServiceResponse::dump_to(std::string &out) const { } dump_field(out, "is_event", this->is_event); } +#ifdef USE_API_HOMEASSISTANT_STATES void SubscribeHomeAssistantStatesRequest::dump_to(std::string &out) const { out.append("SubscribeHomeAssistantStatesRequest {}"); } @@ -1081,6 +1082,7 @@ void HomeAssistantStateResponse::dump_to(std::string &out) const { dump_field(out, "state", this->state); dump_field(out, "attribute", this->attribute); } +#endif void GetTimeRequest::dump_to(std::string &out) const { out.append("GetTimeRequest {}"); } void GetTimeResponse::dump_to(std::string &out) const { dump_field(out, "epoch_seconds", this->epoch_seconds); } #ifdef USE_API_SERVICES diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index d7d302a238..4674d04f66 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -176,6 +176,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_get_time_response(msg); break; } +#ifdef USE_API_HOMEASSISTANT_STATES case SubscribeHomeAssistantStatesRequest::MESSAGE_TYPE: { SubscribeHomeAssistantStatesRequest msg; msg.decode(msg_data, msg_size); @@ -185,6 +186,8 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_subscribe_home_assistant_states_request(msg); break; } +#endif +#ifdef USE_API_HOMEASSISTANT_STATES case HomeAssistantStateResponse::MESSAGE_TYPE: { HomeAssistantStateResponse msg; msg.decode(msg_data, msg_size); @@ -194,6 +197,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_home_assistant_state_response(msg); break; } +#endif #ifdef USE_API_SERVICES case ExecuteServiceRequest::MESSAGE_TYPE: { ExecuteServiceRequest msg; @@ -641,11 +645,13 @@ void APIServerConnection::on_subscribe_homeassistant_services_request( this->subscribe_homeassistant_services(msg); } } +#ifdef USE_API_HOMEASSISTANT_STATES void APIServerConnection::on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) { if (this->check_authenticated_()) { this->subscribe_home_assistant_states(msg); } } +#endif void APIServerConnection::on_get_time_request(const GetTimeRequest &msg) { if (this->check_connection_setup_() && !this->send_get_time_response(msg)) { this->on_fatal_error(); diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 38008197fa..19ed85aa0b 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -62,9 +62,13 @@ class APIServerConnectionBase : public ProtoService { virtual void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &value){}; +#ifdef USE_API_HOMEASSISTANT_STATES virtual void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &value){}; +#endif +#ifdef USE_API_HOMEASSISTANT_STATES virtual void on_home_assistant_state_response(const HomeAssistantStateResponse &value){}; +#endif virtual void on_get_time_request(const GetTimeRequest &value){}; virtual void on_get_time_response(const GetTimeResponse &value){}; @@ -215,7 +219,9 @@ class APIServerConnection : public APIServerConnectionBase { virtual void subscribe_states(const SubscribeStatesRequest &msg) = 0; virtual void subscribe_logs(const SubscribeLogsRequest &msg) = 0; virtual void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) = 0; +#ifdef USE_API_HOMEASSISTANT_STATES virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; +#endif virtual bool send_get_time_response(const GetTimeRequest &msg) = 0; #ifdef USE_API_SERVICES virtual void execute_service(const ExecuteServiceRequest &msg) = 0; @@ -333,7 +339,9 @@ class APIServerConnection : public APIServerConnectionBase { void on_subscribe_states_request(const SubscribeStatesRequest &msg) override; void on_subscribe_logs_request(const SubscribeLogsRequest &msg) override; void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &msg) override; +#ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; +#endif void on_get_time_request(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES void on_execute_service_request(const ExecuteServiceRequest &msg) override; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6d1729e611..0454315760 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -375,6 +375,7 @@ void APIServer::send_homeassistant_service_call(const HomeassistantServiceRespon } } +#ifdef USE_API_HOMEASSISTANT_STATES void APIServer::subscribe_home_assistant_state(std::string entity_id, optional attribute, std::function f) { this->state_subs_.push_back(HomeAssistantStateSubscription{ @@ -398,6 +399,7 @@ void APIServer::get_home_assistant_state(std::string entity_id, optional &APIServer::get_state_subs() const { return this->state_subs_; } +#endif uint16_t APIServer::get_port() const { return this->port_; } diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 54663a013f..22e9573d7e 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -126,6 +126,7 @@ class APIServer : public Component, public Controller { bool is_connected() const; +#ifdef USE_API_HOMEASSISTANT_STATES struct HomeAssistantStateSubscription { std::string entity_id; optional attribute; @@ -138,6 +139,7 @@ class APIServer : public Component, public Controller { void get_home_assistant_state(std::string entity_id, optional attribute, std::function f); const std::vector &get_state_subs() const; +#endif #ifdef USE_API_SERVICES const std::vector &get_user_services() const { return this->user_services_; } #endif @@ -171,7 +173,9 @@ class APIServer : public Component, public Controller { std::string password_; #endif std::vector shared_write_buffer_; // Shared proto write buffer for all connections +#ifdef USE_API_HOMEASSISTANT_STATES std::vector state_subs_; +#endif #ifdef USE_API_SERVICES std::vector user_services_; #endif diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index 73c7804ff3..e9e39a0772 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -83,6 +83,7 @@ class CustomAPIDevice { } #endif +#ifdef USE_API_HOMEASSISTANT_STATES /** Subscribe to the state (or attribute state) of an entity from Home Assistant. * * Usage: @@ -134,6 +135,7 @@ class CustomAPIDevice { auto f = std::bind(callback, (T *) this, entity_id, std::placeholders::_1); global_api_server->subscribe_home_assistant_state(entity_id, optional(attribute), f); } +#endif /** Call a Home Assistant service from ESPHome. * From 90587583b490c5ebc692428f9e8d7c82f5411095 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 11:55:07 -1000 Subject: [PATCH 0003/1626] [api] Add conditional compilation for Home Assistant state subscriptions --- esphome/core/defines.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 348f288863..611e283e6d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -109,6 +109,7 @@ #define USE_API #define USE_API_CLIENT_CONNECTED_TRIGGER #define USE_API_CLIENT_DISCONNECTED_TRIGGER +#define USE_API_HOMEASSISTANT_STATES #define USE_API_NOISE #define USE_API_PLAINTEXT #define USE_API_SERVICES From 9c4fc5d35413ba3900d239cbb9a8a962d96c3e3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 11:59:13 -1000 Subject: [PATCH 0004/1626] fixes --- esphome/components/api/__init__.py | 4 ++-- esphome/components/homeassistant/__init__.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index c4d954bdd2..a0f66c3c80 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -148,8 +148,8 @@ async def to_code(config): if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: cg.add_define("USE_API_SERVICES") - # Set USE_API_HOMEASSISTANT_STATES if enabled or homeassistant component is loaded - if config[CONF_HOMEASSISTANT_STATES] or "homeassistant" in CORE.loaded_integrations: + # Set USE_API_HOMEASSISTANT_STATES if enabled + if config[CONF_HOMEASSISTANT_STATES]: cg.add_define("USE_API_HOMEASSISTANT_STATES") if actions := config.get(CONF_ACTIONS, []): diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 223d6c18c3..7b23775b47 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -38,3 +38,4 @@ def setup_home_assistant_entity(var, config): cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) + cg.add_define("USE_API_HOMEASSISTANT_STATES") From e7ea184709f69d1b763110771e323df611b1ebe4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 12:10:41 -1000 Subject: [PATCH 0005/1626] preen --- esphome/components/api/api_connection.cpp | 2 ++ esphome/components/api/api_connection.h | 2 ++ 2 files changed, 4 insertions(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 57a431db34..465cddfe24 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1824,6 +1824,7 @@ uint16_t APIConnection::try_send_ping_request(EntityBase *entity, APIConnection return encode_message_to_buffer(req, PingRequest::MESSAGE_TYPE, conn, remaining_size, is_single); } +#ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::process_state_subscriptions_() { const auto &subs = this->parent_->get_state_subs(); if (this->state_subs_at_ >= static_cast(subs.size())) { @@ -1843,6 +1844,7 @@ void APIConnection::process_state_subscriptions_() { this->state_subs_at_++; } } +#endif // USE_API_HOMEASSISTANT_STATES } // namespace esphome::api #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 8d933c626b..eed9f78b2f 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -292,8 +292,10 @@ class APIConnection : public APIServerConnection { // Helper function to handle authentication completion void complete_authentication_(); +#ifdef USE_API_HOMEASSISTANT_STATES // Process state subscriptions efficiently void process_state_subscriptions_(); +#endif // USE_API_HOMEASSISTANT_STATES // Non-template helper to encode any ProtoMessage static uint16_t encode_message_to_buffer(ProtoMessage &msg, uint8_t message_type, APIConnection *conn, From 9ac38ff8d025f4bf9739721dcf3695d67f09fc6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 13:25:46 -1000 Subject: [PATCH 0006/1626] [api] Add missing USE_API_PASSWORD guards to reduce flash usage --- esphome/components/api/api_connection.cpp | 2 ++ esphome/components/api/api_connection.h | 2 ++ esphome/components/api/proto.h | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 76713c54c8..58f10464c3 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1590,10 +1590,12 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { // Do not set last_traffic_ on send return true; } +#ifdef USE_API_PASSWORD void APIConnection::on_unauthenticated_access() { this->on_fatal_error(); ESP_LOGD(TAG, "%s access without authentication", this->get_client_combined_info().c_str()); } +#endif void APIConnection::on_no_setup_connection() { this->on_fatal_error(); ESP_LOGD(TAG, "%s access without full connection", this->get_client_combined_info().c_str()); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 6214d5ba82..2c6074c046 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -228,7 +228,9 @@ class APIConnection : public APIServerConnection { } uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; } void on_fatal_error() override; +#ifdef USE_API_PASSWORD void on_unauthenticated_access() override; +#endif void on_no_setup_connection() override; ProtoWriteBuffer create_buffer(uint32_t reserve_size) override { // FIXME: ensure no recursive writes can happen diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 44f9716516..7db4b65d55 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -860,7 +860,9 @@ class ProtoService { virtual bool is_authenticated() = 0; virtual bool is_connection_setup() = 0; virtual void on_fatal_error() = 0; +#ifdef USE_API_PASSWORD virtual void on_unauthenticated_access() = 0; +#endif virtual void on_no_setup_connection() = 0; /** * Create a buffer with a reserved size. @@ -901,10 +903,12 @@ class ProtoService { if (!this->check_connection_setup_()) { return false; } +#ifdef USE_API_PASSWORD if (!this->is_authenticated()) { this->on_unauthenticated_access(); return false; } +#endif return true; } }; From 48128d965eb2686f2d52db99d5049f35e34bba53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 13:33:36 -1000 Subject: [PATCH 0007/1626] make clang-tidy happy --- esphome/components/api/proto.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 7db4b65d55..f08175825b 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -908,8 +908,10 @@ class ProtoService { this->on_unauthenticated_access(); return false; } -#endif return true; +#else + return true; +#endif } }; From ed379852fb7574c1991b09898d1528830bb7d4f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 14:35:47 -1000 Subject: [PATCH 0008/1626] cleanup --- esphome/components/api/proto.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index f08175825b..771eaa98d1 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -900,17 +900,17 @@ class ProtoService { } bool check_authenticated_() { +#ifdef USE_API_PASSWORD if (!this->check_connection_setup_()) { return false; } -#ifdef USE_API_PASSWORD if (!this->is_authenticated()) { this->on_unauthenticated_access(); return false; } return true; #else - return true; + return this->check_connection_setup_(); #endif } }; From fd8c77c340d9a1a2d4e73e1bd1a708491b42271a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 14:48:00 -1000 Subject: [PATCH 0009/1626] remove unneeded assertion --- tests/integration/test_host_mode_many_entities.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/integration/test_host_mode_many_entities.py b/tests/integration/test_host_mode_many_entities.py index c95eb5f48d..aaca4555f6 100644 --- a/tests/integration/test_host_mode_many_entities.py +++ b/tests/integration/test_host_mode_many_entities.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from aioesphomeapi import ClimateInfo, ClimateState, EntityState, SensorState +from aioesphomeapi import ClimateInfo, EntityState, SensorState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -71,12 +71,6 @@ async def test_host_mode_many_entities( f"Expected at least 50 sensor states, got {len(sensor_states)}" ) - # Verify we received the climate entity - climate_states = [s for s in states.values() if isinstance(s, ClimateState)] - assert len(climate_states) >= 1, ( - f"Expected at least 1 climate state, got {len(climate_states)}" - ) - # Get entity info to verify climate entity details entities = await client.list_entities_services() climate_infos = [e for e in entities[0] if isinstance(e, ClimateInfo)] From 95b83212847e960c316e715736156781c39b0507 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 15:26:15 -1000 Subject: [PATCH 0010/1626] [api] Add conditional compilation for Home Assistant service subscriptions --- esphome/components/api/__init__.py | 7 +++++++ esphome/components/api/api.proto | 2 ++ esphome/components/api/api_connection.h | 4 ++++ esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 2 ++ esphome/components/api/api_pb2_dump.cpp | 2 ++ esphome/components/api/api_pb2_service.cpp | 4 ++++ esphome/components/api/api_pb2_service.h | 6 ++++++ esphome/components/api/api_server.cpp | 2 ++ esphome/components/api/api_server.h | 2 ++ esphome/components/api/custom_api_device.h | 2 ++ esphome/components/api/homeassistant_service.h | 2 ++ esphome/components/homeassistant/number/__init__.py | 1 + esphome/components/homeassistant/switch/__init__.py | 1 + esphome/core/defines.h | 1 + 15 files changed, 40 insertions(+) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 9cbab8164f..1522d6e524 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -53,6 +53,7 @@ SERVICE_ARG_NATIVE_TYPES = { CONF_ENCRYPTION = "encryption" CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" +CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" def validate_encryption_key(value): @@ -118,6 +119,7 @@ CONFIG_SCHEMA = cv.All( cv.Range(max=cv.TimePeriod(milliseconds=65535)), ), cv.Optional(CONF_CUSTOM_SERVICES, default=False): cv.boolean, + cv.Optional(CONF_HOMEASSISTANT_SERVICES, default=False): cv.boolean, cv.Optional(CONF_ON_CLIENT_CONNECTED): automation.validate_automation( single=True ), @@ -146,6 +148,9 @@ async def to_code(config): if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: cg.add_define("USE_API_SERVICES") + if config[CONF_HOMEASSISTANT_SERVICES]: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + if actions := config.get(CONF_ACTIONS, []): for conf in actions: template_args = [] @@ -235,6 +240,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( HOMEASSISTANT_ACTION_ACTION_SCHEMA, ) async def homeassistant_service_to_code(config, action_id, template_arg, args): + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, False) templ = await cg.templatable(config[CONF_ACTION], args, None) @@ -278,6 +284,7 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( HOMEASSISTANT_EVENT_ACTION_SCHEMA, ) async def homeassistant_event_to_code(config, action_id, template_arg, args): + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) templ = await cg.templatable(config[CONF_EVENT], args, None) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 93e84702e2..34deec9671 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -755,6 +755,7 @@ message NoiseEncryptionSetKeyResponse { message SubscribeHomeassistantServicesRequest { option (id) = 34; option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_API_HOMEASSISTANT_SERVICES"; } message HomeassistantServiceMap { @@ -766,6 +767,7 @@ message HomeassistantServiceResponse { option (id) = 35; option (source) = SOURCE_SERVER; option (no_delay) = true; + option (ifdef) = "USE_API_HOMEASSISTANT_SERVICES"; string service = 1; repeated HomeassistantServiceMap data = 2; diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 6214d5ba82..626a0d981b 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -131,11 +131,13 @@ class APIConnection : public APIServerConnection { void media_player_command(const MediaPlayerCommandRequest &msg) override; #endif bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len); +#ifdef USE_API_HOMEASSISTANT_SERVICES void send_homeassistant_service_call(const HomeassistantServiceResponse &call) { if (!this->flags_.service_call_subscription) return; this->send_message(call, HomeassistantServiceResponse::MESSAGE_TYPE); } +#endif #ifdef USE_BLUETOOTH_PROXY void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) override; void unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) override; @@ -207,9 +209,11 @@ class APIConnection : public APIServerConnection { if (msg.dump_config) App.schedule_dump_config(); } +#ifdef USE_API_HOMEASSISTANT_SERVICES void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) override { this->flags_.service_call_subscription = true; } +#endif void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override; bool send_get_time_response(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 6d2e17dc27..f3037bb6f3 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -843,6 +843,7 @@ void NoiseEncryptionSetKeyResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->success); } #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES void HomeassistantServiceMap::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->key_ref_); buffer.encode_string(2, this->value_ref_); @@ -871,6 +872,7 @@ void HomeassistantServiceResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_repeated_message(total_size, 1, this->variables); ProtoSize::add_bool_field(total_size, 1, this->is_event); } +#endif void SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->entity_id_ref_); buffer.encode_string(2, this->attribute_ref_); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 91a285fc6c..416778791f 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1044,6 +1044,7 @@ class NoiseEncryptionSetKeyResponse : public ProtoMessage { protected: }; #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES class SubscribeHomeassistantServicesRequest : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 34; @@ -1092,6 +1093,7 @@ class HomeassistantServiceResponse : public ProtoMessage { protected: }; +#endif class SubscribeHomeAssistantStatesRequest : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 38; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 5db9b79cfa..3d72b1183f 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1038,6 +1038,7 @@ void NoiseEncryptionSetKeyRequest::dump_to(std::string &out) const { } void NoiseEncryptionSetKeyResponse::dump_to(std::string &out) const { dump_field(out, "success", this->success); } #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES void SubscribeHomeassistantServicesRequest::dump_to(std::string &out) const { out.append("SubscribeHomeassistantServicesRequest {}"); } @@ -1066,6 +1067,7 @@ void HomeassistantServiceResponse::dump_to(std::string &out) const { } dump_field(out, "is_event", this->is_event); } +#endif void SubscribeHomeAssistantStatesRequest::dump_to(std::string &out) const { out.append("SubscribeHomeAssistantStatesRequest {}"); } diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index d7d302a238..6db21f930d 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -149,6 +149,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES case SubscribeHomeassistantServicesRequest::MESSAGE_TYPE: { SubscribeHomeassistantServicesRequest msg; msg.decode(msg_data, msg_size); @@ -158,6 +159,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_subscribe_homeassistant_services_request(msg); break; } +#endif case GetTimeRequest::MESSAGE_TYPE: { GetTimeRequest msg; msg.decode(msg_data, msg_size); @@ -635,12 +637,14 @@ void APIServerConnection::on_subscribe_logs_request(const SubscribeLogsRequest & this->subscribe_logs(msg); } } +#ifdef USE_API_HOMEASSISTANT_SERVICES void APIServerConnection::on_subscribe_homeassistant_services_request( const SubscribeHomeassistantServicesRequest &msg) { if (this->check_authenticated_()) { this->subscribe_homeassistant_services(msg); } } +#endif void APIServerConnection::on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) { if (this->check_authenticated_()) { this->subscribe_home_assistant_states(msg); diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 38008197fa..b8a5f729e3 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -60,7 +60,9 @@ class APIServerConnectionBase : public ProtoService { virtual void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &value){}; #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES virtual void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &value){}; +#endif virtual void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &value){}; @@ -214,7 +216,9 @@ class APIServerConnection : public APIServerConnectionBase { virtual void list_entities(const ListEntitiesRequest &msg) = 0; virtual void subscribe_states(const SubscribeStatesRequest &msg) = 0; virtual void subscribe_logs(const SubscribeLogsRequest &msg) = 0; +#ifdef USE_API_HOMEASSISTANT_SERVICES virtual void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) = 0; +#endif virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; virtual bool send_get_time_response(const GetTimeRequest &msg) = 0; #ifdef USE_API_SERVICES @@ -332,7 +336,9 @@ class APIServerConnection : public APIServerConnectionBase { void on_list_entities_request(const ListEntitiesRequest &msg) override; void on_subscribe_states_request(const SubscribeStatesRequest &msg) 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; +#endif void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; void on_get_time_request(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6d1729e611..60a7a7d888 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -369,11 +369,13 @@ void APIServer::set_password(const std::string &password) { this->password_ = pa void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; } +#ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_service_call(const HomeassistantServiceResponse &call) { for (auto &client : this->clients_) { client->send_homeassistant_service_call(call); } } +#endif void APIServer::subscribe_home_assistant_state(std::string entity_id, optional attribute, std::function f) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 54663a013f..db41e42b43 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -106,7 +106,9 @@ class APIServer : public Component, public Controller { #ifdef USE_MEDIA_PLAYER void on_media_player_update(media_player::MediaPlayer *obj) override; #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES void send_homeassistant_service_call(const HomeassistantServiceResponse &call); +#endif #ifdef USE_API_SERVICES void register_user_service(UserServiceDescriptor *descriptor) { this->user_services_.push_back(descriptor); } #endif diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index 73c7804ff3..7da13e5090 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -135,6 +135,7 @@ class CustomAPIDevice { global_api_server->subscribe_home_assistant_state(entity_id, optional(attribute), f); } +#ifdef USE_API_HOMEASSISTANT_SERVICES /** Call a Home Assistant service from ESPHome. * * Usage: @@ -219,6 +220,7 @@ class CustomAPIDevice { } global_api_server->send_homeassistant_service_call(resp); } +#endif }; } // namespace esphome::api diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 212b3b22d6..ec17c0c7a4 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -2,6 +2,7 @@ #include "api_server.h" #ifdef USE_API +#ifdef USE_API_HOMEASSISTANT_SERVICES #include "api_pb2.h" #include "esphome/core/automation.h" #include "esphome/core/helpers.h" @@ -100,3 +101,4 @@ template class HomeAssistantServiceCallAction : public Action Date: Fri, 25 Jul 2025 15:56:00 -1000 Subject: [PATCH 0011/1626] cleanup --- .../bluetooth_proxy/bluetooth_connection.cpp | 123 +++++++++--------- 1 file changed, 65 insertions(+), 58 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 4b84257e27..b7462803e8 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -113,72 +113,79 @@ void BluetoothConnection::send_service_for_discovery_() { } // Now process characteristics - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - while (true) { // characteristics - uint16_t char_count = 1; - esp_gatt_status_t char_status = - esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, - service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { - break; - } - if (char_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, - this->address_str().c_str(), char_status); - break; - } - if (char_count == 0) { - break; - } - - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); - fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); - characteristic_resp.handle = char_result.char_handle; - characteristic_resp.properties = char_result.properties; - char_offset++; - - // Get the number of descriptors directly with one call - uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = - esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, char_result.char_handle, - service_result.end_handle, 0, &total_desc_count); - - if (desc_count_status == ESP_GATT_OK && total_desc_count > 0) { - // Only reserve if we successfully got a count - characteristic_resp.descriptors.reserve(total_desc_count); - } else if (desc_count_status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, - this->address_str().c_str(), char_result.char_handle, desc_count_status); - } - - // Now process descriptors - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + if (char_count_status == ESP_GATT_OK && total_char_count > 0) { + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + while (true) { // characteristics + uint16_t char_count = 1; + esp_gatt_status_t char_status = + esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &char_count, char_offset); + if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { break; } - if (desc_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, - this->address_str().c_str(), desc_status); + if (char_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, + this->address_str().c_str(), char_status); break; } - if (desc_count == 0) { + if (char_count == 0) { break; } - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); - descriptor_resp.handle = desc_result.handle; - desc_offset++; + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + char_offset++; + + // Get the number of descriptors directly with one call + uint16_t total_desc_count = 0; + esp_gatt_status_t desc_count_status = + esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, char_result.char_handle, + service_result.end_handle, 0, &total_desc_count); + + if (desc_count_status == ESP_GATT_OK && total_desc_count > 0) { + // Only reserve if we successfully got a count + characteristic_resp.descriptors.reserve(total_desc_count); + } else if (desc_count_status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, + this->address_str().c_str(), char_result.char_handle, desc_count_status); + } + + // Skip descriptor processing if there are no descriptors + if (desc_count_status != ESP_GATT_OK || total_desc_count == 0) { + continue; + } + + // Now process descriptors + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (true) { // descriptors + uint16_t desc_count = 1; + esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( + this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + break; + } + if (desc_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, + this->address_str().c_str(), desc_status); + break; + } + if (desc_count == 0) { + break; + } + + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } } - } + } // end if (char_count_status == ESP_GATT_OK && total_char_count > 0) // Send the message (we already checked api_conn is not null at the beginning) api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); From 85a4f05d67de926c63f014152e98cba9300a903e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 15:57:23 -1000 Subject: [PATCH 0012/1626] cleanup --- .../bluetooth_proxy/bluetooth_connection.cpp | 75 ++++++++----------- 1 file changed, 32 insertions(+), 43 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index b7462803e8..8e42ba5951 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -104,16 +104,12 @@ void BluetoothConnection::send_service_for_discovery_() { esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, service_result.start_handle, service_result.end_handle, 0, &total_char_count); - if (char_count_status == ESP_GATT_OK && total_char_count > 0) { - // Only reserve if we successfully got a count - service_resp.characteristics.reserve(total_char_count); - } else if (char_count_status != ESP_GATT_OK) { + if (char_count_status != ESP_GATT_OK) { ESP_LOGW(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, this->address_str().c_str(), char_count_status); - } - - // Now process characteristics - if (char_count_status == ESP_GATT_OK && total_char_count > 0) { + } else if (total_char_count > 0) { + // Reserve space and process characteristics + service_resp.characteristics.reserve(total_char_count); uint16_t char_offset = 0; esp_gattc_char_elem_t char_result; while (true) { // characteristics @@ -146,46 +142,39 @@ void BluetoothConnection::send_service_for_discovery_() { esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, char_result.char_handle, service_result.end_handle, 0, &total_desc_count); - if (desc_count_status == ESP_GATT_OK && total_desc_count > 0) { - // Only reserve if we successfully got a count - characteristic_resp.descriptors.reserve(total_desc_count); - } else if (desc_count_status != ESP_GATT_OK) { + if (desc_count_status != ESP_GATT_OK) { ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, this->address_str().c_str(), char_result.char_handle, desc_count_status); - } + } else if (total_desc_count > 0) { + // Reserve space and process descriptors + characteristic_resp.descriptors.reserve(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (true) { // descriptors + uint16_t desc_count = 1; + esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( + this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + break; + } + if (desc_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, + this->address_str().c_str(), desc_status); + break; + } + if (desc_count == 0) { + break; + } - // Skip descriptor processing if there are no descriptors - if (desc_count_status != ESP_GATT_OK || total_desc_count == 0) { - continue; - } - - // Now process descriptors - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { - break; + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); + descriptor_resp.handle = desc_result.handle; + desc_offset++; } - if (desc_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, - this->address_str().c_str(), desc_status); - break; - } - if (desc_count == 0) { - break; - } - - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); - descriptor_resp.handle = desc_result.handle; - desc_offset++; - } + } // end else if (total_desc_count > 0) } - } // end if (char_count_status == ESP_GATT_OK && total_char_count > 0) + } // end else if (total_char_count > 0) // Send the message (we already checked api_conn is not null at the beginning) api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); From 5d8f38cce4e4ceae508c539a3a48d6c057b1f5f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 15:58:49 -1000 Subject: [PATCH 0013/1626] cleanup --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 8e42ba5951..b4dfb72ab4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -119,8 +119,7 @@ void BluetoothConnection::send_service_for_discovery_() { service_result.end_handle, &char_result, &char_count, char_offset); if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { break; - } - if (char_status != ESP_GATT_OK) { + } else if (char_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, this->address_str().c_str(), char_status); break; @@ -156,8 +155,7 @@ void BluetoothConnection::send_service_for_discovery_() { this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { break; - } - if (desc_status != ESP_GATT_OK) { + } else if (desc_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, this->address_str().c_str(), desc_status); break; From accbc8fb0b29fe3b556ef5b0b511407a056e62fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 16:07:46 -1000 Subject: [PATCH 0014/1626] cleanup --- .../bluetooth_proxy/bluetooth_connection.cpp | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index b4dfb72ab4..7737a249bb 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -80,15 +80,10 @@ void BluetoothConnection::send_service_for_discovery_() { &service_result, &service_count, this->send_service_); this->send_service_++; - if (service_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service error at offset=%d, status=%d", this->connection_index_, - this->address_str().c_str(), this->send_service_ - 1, service_status); - return; - } - - if (service_count == 0) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service missing, service_count=%d", this->connection_index_, - this->address_str().c_str(), service_count); + if (service_status != ESP_GATT_OK || service_count == 0) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", + this->connection_index_, this->address_str().c_str(), service_status != ESP_GATT_OK ? "error" : "missing", + service_status, service_count, this->send_service_ - 1); return; } @@ -107,7 +102,12 @@ void BluetoothConnection::send_service_for_discovery_() { if (char_count_status != ESP_GATT_OK) { ESP_LOGW(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, this->address_str().c_str(), char_count_status); - } else if (total_char_count > 0) { + // Send the message (we already checked api_conn is not null at the beginning) + api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); + return; + } + + if (total_char_count > 0) { // Reserve space and process characteristics service_resp.characteristics.reserve(total_char_count); uint16_t char_offset = 0; @@ -117,16 +117,13 @@ void BluetoothConnection::send_service_for_discovery_() { esp_gatt_status_t char_status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { + if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND || char_count == 0) { break; } else if (char_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, this->address_str().c_str(), char_status); break; } - if (char_count == 0) { - break; - } service_resp.characteristics.emplace_back(); auto &characteristic_resp = service_resp.characteristics.back(); @@ -153,16 +150,13 @@ void BluetoothConnection::send_service_for_discovery_() { uint16_t desc_count = 1; esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND || desc_count == 0) { break; } else if (desc_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, this->address_str().c_str(), desc_status); break; } - if (desc_count == 0) { - break; - } characteristic_resp.descriptors.emplace_back(); auto &descriptor_resp = characteristic_resp.descriptors.back(); From 3396dfe52ab8adf9e0b8735bacbb4c1f2d5e5206 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 16:08:22 -1000 Subject: [PATCH 0015/1626] cleanup --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 7737a249bb..d1a9a8d610 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -102,8 +102,6 @@ void BluetoothConnection::send_service_for_discovery_() { if (char_count_status != ESP_GATT_OK) { ESP_LOGW(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, this->address_str().c_str(), char_count_status); - // Send the message (we already checked api_conn is not null at the beginning) - api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); return; } From b22ff37e3d1229d3a3db515cb1ba6c5006038a7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 16:09:44 -1000 Subject: [PATCH 0016/1626] cleanup --- .../components/bluetooth_proxy/bluetooth_connection.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index d1a9a8d610..72efd17742 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -120,7 +120,7 @@ void BluetoothConnection::send_service_for_discovery_() { } else if (char_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, this->address_str().c_str(), char_status); - break; + return; } service_resp.characteristics.emplace_back(); @@ -139,7 +139,10 @@ void BluetoothConnection::send_service_for_discovery_() { if (desc_count_status != ESP_GATT_OK) { ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, this->address_str().c_str(), char_result.char_handle, desc_count_status); - } else if (total_desc_count > 0) { + return; + } + + if (total_desc_count > 0) { // Reserve space and process descriptors characteristic_resp.descriptors.reserve(total_desc_count); uint16_t desc_offset = 0; @@ -153,7 +156,7 @@ void BluetoothConnection::send_service_for_discovery_() { } else if (desc_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, this->address_str().c_str(), desc_status); - break; + return; } characteristic_resp.descriptors.emplace_back(); From a06c4e1d56ab6c3391337df7fb8b1f9c66fae793 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 17:14:56 -1000 Subject: [PATCH 0017/1626] cleanup --- .../bluetooth_proxy/bluetooth_connection.cpp | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 72efd17742..1bbb6cf68a 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -115,12 +115,14 @@ void BluetoothConnection::send_service_for_discovery_() { esp_gatt_status_t char_status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND || char_count == 0) { + if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { break; } else if (char_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, this->address_str().c_str(), char_status); return; + } else if (char_count == 0) { + break; } service_resp.characteristics.emplace_back(); @@ -141,31 +143,35 @@ void BluetoothConnection::send_service_for_discovery_() { this->address_str().c_str(), char_result.char_handle, desc_count_status); return; } + if (total_desc_count == 0) { + // No descriptors, continue to next characteristic + continue; + } - if (total_desc_count > 0) { - // Reserve space and process descriptors - characteristic_resp.descriptors.reserve(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND || desc_count == 0) { - break; - } else if (desc_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, - this->address_str().c_str(), desc_status); - return; - } - - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); - descriptor_resp.handle = desc_result.handle; - desc_offset++; + // Reserve space and process descriptors + characteristic_resp.descriptors.reserve(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (true) { // descriptors + uint16_t desc_count = 1; + esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( + this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + break; + } else if (desc_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, + this->address_str().c_str(), desc_status); + return; + } else if (desc_count == 0) { + break; // No more descriptors } - } // end else if (total_desc_count > 0) + + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } } } // end else if (total_char_count > 0) From 535e995c7528c3d0a45ae621630cd60488a6a0bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 17:20:37 -1000 Subject: [PATCH 0018/1626] preen --- .../bluetooth_proxy/bluetooth_connection.cpp | 138 +++++++++--------- 1 file changed, 71 insertions(+), 67 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 1bbb6cf68a..35a98a71bc 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -105,75 +105,79 @@ void BluetoothConnection::send_service_for_discovery_() { return; } - if (total_char_count > 0) { - // Reserve space and process characteristics - service_resp.characteristics.reserve(total_char_count); - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - while (true) { // characteristics - uint16_t char_count = 1; - esp_gatt_status_t char_status = - esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, - service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { - break; - } else if (char_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, - this->address_str().c_str(), char_status); - return; - } else if (char_count == 0) { - break; - } + if (total_char_count == 0) { + // No characteristics, just send the service response + api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); + return + } - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); - fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); - characteristic_resp.handle = char_result.char_handle; - characteristic_resp.properties = char_result.properties; - char_offset++; - - // Get the number of descriptors directly with one call - uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = - esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, char_result.char_handle, - service_result.end_handle, 0, &total_desc_count); - - if (desc_count_status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, - this->address_str().c_str(), char_result.char_handle, desc_count_status); - return; - } - if (total_desc_count == 0) { - // No descriptors, continue to next characteristic - continue; - } - - // Reserve space and process descriptors - characteristic_resp.descriptors.reserve(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { - break; - } else if (desc_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, - this->address_str().c_str(), desc_status); - return; - } else if (desc_count == 0) { - break; // No more descriptors - } - - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); - descriptor_resp.handle = desc_result.handle; - desc_offset++; - } + // Reserve space and process characteristics + service_resp.characteristics.reserve(total_char_count); + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + while (true) { // characteristics + uint16_t char_count = 1; + esp_gatt_status_t char_status = + esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &char_count, char_offset); + if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { + break; + } else if (char_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, + this->address_str().c_str(), char_status); + return; + } else if (char_count == 0) { + break; } - } // end else if (total_char_count > 0) + + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + char_offset++; + + // Get the number of descriptors directly with one call + uint16_t total_desc_count = 0; + esp_gatt_status_t desc_count_status = + esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, char_result.char_handle, + service_result.end_handle, 0, &total_desc_count); + + if (desc_count_status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, + this->address_str().c_str(), char_result.char_handle, desc_count_status); + return; + } + if (total_desc_count == 0) { + // No descriptors, continue to next characteristic + continue; + } + + // Reserve space and process descriptors + characteristic_resp.descriptors.reserve(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (true) { // descriptors + uint16_t desc_count = 1; + esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( + this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + break; + } else if (desc_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, + this->address_str().c_str(), desc_status); + return; + } else if (desc_count == 0) { + break; // No more descriptors + } + + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } + } // Send the message (we already checked api_conn is not null at the beginning) api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); From d40a5a1651970fe8974428b3ec6c134b92e5346e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 17:21:38 -1000 Subject: [PATCH 0019/1626] preen --- .../bluetooth_proxy/bluetooth_connection.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 35a98a71bc..ec60a62d1a 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -122,11 +122,13 @@ void BluetoothConnection::send_service_for_discovery_() { service_result.end_handle, &char_result, &char_count, char_offset); if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { break; - } else if (char_status != ESP_GATT_OK) { + } + if (char_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, this->address_str().c_str(), char_status); return; - } else if (char_count == 0) { + } + if (char_count == 0) { break; } @@ -163,11 +165,13 @@ void BluetoothConnection::send_service_for_discovery_() { this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { break; - } else if (desc_status != ESP_GATT_OK) { + } + if (desc_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, this->address_str().c_str(), desc_status); return; - } else if (desc_count == 0) { + } + if (desc_count == 0) { break; // No more descriptors } From 5884bdb9e876cbc018b41b0f5612f3eb88784420 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 17:25:30 -1000 Subject: [PATCH 0020/1626] preen --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index ec60a62d1a..3c18773a5e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -108,7 +108,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (total_char_count == 0) { // No characteristics, just send the service response api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); - return + return; } // Reserve space and process characteristics From 967993f70dca08b48a6b0a59a5871af1bc30ccf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 17:39:14 -1000 Subject: [PATCH 0021/1626] fix descriptor lookup --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 3c18773a5e..b3484032b2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -141,9 +141,8 @@ void BluetoothConnection::send_service_for_discovery_() { // Get the number of descriptors directly with one call uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = - esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, char_result.char_handle, - service_result.end_handle, 0, &total_desc_count); + esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( + this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); if (desc_count_status != ESP_GATT_OK) { ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, From 6d37b916dc6b19594718cbd32f0b0145f7589b87 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 26 Jul 2025 16:23:36 +1200 Subject: [PATCH 0022/1626] [logger] Don't allow ``logger.log`` actions without configuring the ``logger`` (#9821) --- esphome/components/logger/__init__.py | 1 + tests/component_tests/text/test_text.yaml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index cb338df466..d718a7a612 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -421,6 +421,7 @@ CONF_LOGGER_LOG = "logger.log" LOGGER_LOG_ACTION_SCHEMA = cv.All( cv.maybe_simple_value( { + cv.GenerateID(CONF_LOGGER_ID): cv.use_id(Logger), cv.Required(CONF_FORMAT): cv.string, cv.Optional(CONF_ARGS, default=list): cv.ensure_list(cv.lambda_), cv.Optional(CONF_LEVEL, default="DEBUG"): cv.one_of( diff --git a/tests/component_tests/text/test_text.yaml b/tests/component_tests/text/test_text.yaml index d81c909f9d..9b05d59349 100644 --- a/tests/component_tests/text/test_text.yaml +++ b/tests/component_tests/text/test_text.yaml @@ -4,6 +4,8 @@ esphome: esp32: board: esp32dev +logger: + text: - platform: template name: "test 1 text" From de69e78a7871b7775318d6666f1ba60754a085d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 18:26:10 -1000 Subject: [PATCH 0023/1626] [i2c] Fix logging level for bus scan results in dump_config --- esphome/components/i2c/i2c_bus_esp_idf.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index c473a58b5e..44772b1a77 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -151,15 +151,15 @@ void IDFI2CBus::dump_config() { break; } if (this->scan_) { - ESP_LOGI(TAG, "Results from bus scan:"); + ESP_LOGCONFIG(TAG, "Results from bus scan:"); if (scan_results_.empty()) { - ESP_LOGI(TAG, "Found no devices"); + ESP_LOGCONFIG(TAG, "Found no devices"); } else { for (const auto &s : scan_results_) { if (s.second) { - ESP_LOGI(TAG, "Found device at address 0x%02X", s.first); + ESP_LOGCONFIG(TAG, "Found device at address 0x%02X", s.first); } else { - ESP_LOGE(TAG, "Unknown error at address 0x%02X", s.first); + ESP_LOGCONFIG(TAG, "Unknown error at address 0x%02X", s.first); } } } From 9bf666d63fac5a6f3798dc0947fba096a6bbeca9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 26 Jul 2025 04:33:21 +0000 Subject: [PATCH 0024/1626] Bump aioesphomeapi from 37.0.4 to 37.1.0 Bumps [aioesphomeapi](https://github.com/esphome/aioesphomeapi) from 37.0.4 to 37.1.0. - [Release notes](https://github.com/esphome/aioesphomeapi/releases) - [Commits](https://github.com/esphome/aioesphomeapi/compare/v37.0.4...v37.1.0) --- updated-dependencies: - dependency-name: aioesphomeapi dependency-version: 37.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cc69186e49..6b7e9d946a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.9.0 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.0.4 +aioesphomeapi==37.1.0 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 11f970edecc7f5db933a6db3d974c8436d7a1d78 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 26 Jul 2025 04:36:42 +0000 Subject: [PATCH 0025/1626] Bump aioesphomeapi from 37.0.4 to 37.1.0 (#9905) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cc69186e49..6b7e9d946a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.9.0 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.0.4 +aioesphomeapi==37.1.0 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 9010ddf56beef0c5894ab86c92cd5886cf6b1a00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 19:28:20 -1000 Subject: [PATCH 0026/1626] [api] Optimize protobuf empty message handling to reduce flash and runtime overhead --- esphome/components/api/api_pb2.h | 26 +++++++++++----------- esphome/components/api/api_pb2_service.cpp | 26 +++++++++++----------- script/api_protobuf/api_protobuf.py | 18 ++++++++++++--- 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 91a285fc6c..756c320ff4 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -378,7 +378,7 @@ class ConnectResponse : public ProtoMessage { protected: }; -class DisconnectRequest : public ProtoDecodableMessage { +class DisconnectRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 5; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -391,7 +391,7 @@ class DisconnectRequest : public ProtoDecodableMessage { protected: }; -class DisconnectResponse : public ProtoDecodableMessage { +class DisconnectResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 6; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -404,7 +404,7 @@ class DisconnectResponse : public ProtoDecodableMessage { protected: }; -class PingRequest : public ProtoDecodableMessage { +class PingRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 7; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -417,7 +417,7 @@ class PingRequest : public ProtoDecodableMessage { protected: }; -class PingResponse : public ProtoDecodableMessage { +class PingResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 8; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -430,7 +430,7 @@ class PingResponse : public ProtoDecodableMessage { protected: }; -class DeviceInfoRequest : public ProtoDecodableMessage { +class DeviceInfoRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 9; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -546,7 +546,7 @@ class DeviceInfoResponse : public ProtoMessage { protected: }; -class ListEntitiesRequest : public ProtoDecodableMessage { +class ListEntitiesRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 11; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -572,7 +572,7 @@ class ListEntitiesDoneResponse : public ProtoMessage { protected: }; -class SubscribeStatesRequest : public ProtoDecodableMessage { +class SubscribeStatesRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 20; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -1044,7 +1044,7 @@ class NoiseEncryptionSetKeyResponse : public ProtoMessage { protected: }; #endif -class SubscribeHomeassistantServicesRequest : public ProtoDecodableMessage { +class SubscribeHomeassistantServicesRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 34; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -1092,7 +1092,7 @@ class HomeassistantServiceResponse : public ProtoMessage { protected: }; -class SubscribeHomeAssistantStatesRequest : public ProtoDecodableMessage { +class SubscribeHomeAssistantStatesRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 38; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -1142,7 +1142,7 @@ class HomeAssistantStateResponse : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class GetTimeRequest : public ProtoDecodableMessage { +class GetTimeRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 36; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -2040,7 +2040,7 @@ class BluetoothGATTNotifyDataResponse : public ProtoMessage { protected: }; -class SubscribeBluetoothConnectionsFreeRequest : public ProtoDecodableMessage { +class SubscribeBluetoothConnectionsFreeRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 80; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -2159,7 +2159,7 @@ class BluetoothDeviceUnpairingResponse : public ProtoMessage { protected: }; -class UnsubscribeBluetoothLEAdvertisementsRequest : public ProtoDecodableMessage { +class UnsubscribeBluetoothLEAdvertisementsRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 87; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -2415,7 +2415,7 @@ class VoiceAssistantWakeWord : public ProtoMessage { protected: }; -class VoiceAssistantConfigurationRequest : public ProtoDecodableMessage { +class VoiceAssistantConfigurationRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 121; static constexpr uint8_t ESTIMATED_SIZE = 0; diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index d7d302a238..3d75cfe162 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -35,7 +35,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case DisconnectRequest::MESSAGE_TYPE: { DisconnectRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_disconnect_request: %s", msg.dump().c_str()); #endif @@ -44,7 +44,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case DisconnectResponse::MESSAGE_TYPE: { DisconnectResponse msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_disconnect_response: %s", msg.dump().c_str()); #endif @@ -53,7 +53,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case PingRequest::MESSAGE_TYPE: { PingRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_ping_request: %s", msg.dump().c_str()); #endif @@ -62,7 +62,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case PingResponse::MESSAGE_TYPE: { PingResponse msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_ping_response: %s", msg.dump().c_str()); #endif @@ -71,7 +71,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case DeviceInfoRequest::MESSAGE_TYPE: { DeviceInfoRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_device_info_request: %s", msg.dump().c_str()); #endif @@ -80,7 +80,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case ListEntitiesRequest::MESSAGE_TYPE: { ListEntitiesRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_list_entities_request: %s", msg.dump().c_str()); #endif @@ -89,7 +89,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case SubscribeStatesRequest::MESSAGE_TYPE: { SubscribeStatesRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_subscribe_states_request: %s", msg.dump().c_str()); #endif @@ -151,7 +151,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #endif case SubscribeHomeassistantServicesRequest::MESSAGE_TYPE: { SubscribeHomeassistantServicesRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_subscribe_homeassistant_services_request: %s", msg.dump().c_str()); #endif @@ -160,7 +160,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case GetTimeRequest::MESSAGE_TYPE: { GetTimeRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_get_time_request: %s", msg.dump().c_str()); #endif @@ -178,7 +178,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case SubscribeHomeAssistantStatesRequest::MESSAGE_TYPE: { SubscribeHomeAssistantStatesRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_subscribe_home_assistant_states_request: %s", msg.dump().c_str()); #endif @@ -384,7 +384,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #ifdef USE_BLUETOOTH_PROXY case SubscribeBluetoothConnectionsFreeRequest::MESSAGE_TYPE: { SubscribeBluetoothConnectionsFreeRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_subscribe_bluetooth_connections_free_request: %s", msg.dump().c_str()); #endif @@ -395,7 +395,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #ifdef USE_BLUETOOTH_PROXY case UnsubscribeBluetoothLEAdvertisementsRequest::MESSAGE_TYPE: { UnsubscribeBluetoothLEAdvertisementsRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_unsubscribe_bluetooth_le_advertisements_request: %s", msg.dump().c_str()); #endif @@ -549,7 +549,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #ifdef USE_VOICE_ASSISTANT case VoiceAssistantConfigurationRequest::MESSAGE_TYPE: { VoiceAssistantConfigurationRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_voice_assistant_configuration_request: %s", msg.dump().c_str()); #endif diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 92c85d2366..15fbfa84fe 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1738,8 +1738,13 @@ def build_message_type( if base_class: out = f"class {desc.name} : public {base_class} {{\n" else: - # Determine inheritance based on whether the message needs decoding - base_class = "ProtoDecodableMessage" if needs_decode else "ProtoMessage" + # Check if message has any non-deprecated fields + has_fields = any(not field.options.deprecated for field in desc.field) + # Determine inheritance based on whether the message needs decoding and has fields + if needs_decode and has_fields: + base_class = "ProtoDecodableMessage" + else: + base_class = "ProtoMessage" out = f"class {desc.name} : public {base_class} {{\n" out += " public:\n" out += indent("\n".join(public_content)) + "\n" @@ -2005,7 +2010,14 @@ def build_service_message_type( hout += f"virtual void {func}(const {mt.name} &value){{}};\n" case = "" case += f"{mt.name} msg;\n" - case += "msg.decode(msg_data, msg_size);\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 + 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'ESP_LOGVV(TAG, "{func}: %s", msg.dump().c_str());\n' From da491f7090e24cf65c9a7cd01efe7158b9e26f3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 20:21:09 -1000 Subject: [PATCH 0027/1626] [api] Add missing USE_API_PASSWORD guards to reduce flash usage (#9899) --- esphome/components/api/api_connection.cpp | 2 ++ esphome/components/api/api_connection.h | 2 ++ esphome/components/api/proto.h | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 76713c54c8..58f10464c3 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1590,10 +1590,12 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { // Do not set last_traffic_ on send return true; } +#ifdef USE_API_PASSWORD void APIConnection::on_unauthenticated_access() { this->on_fatal_error(); ESP_LOGD(TAG, "%s access without authentication", this->get_client_combined_info().c_str()); } +#endif void APIConnection::on_no_setup_connection() { this->on_fatal_error(); ESP_LOGD(TAG, "%s access without full connection", this->get_client_combined_info().c_str()); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 6214d5ba82..2c6074c046 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -228,7 +228,9 @@ class APIConnection : public APIServerConnection { } uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; } void on_fatal_error() override; +#ifdef USE_API_PASSWORD void on_unauthenticated_access() override; +#endif void on_no_setup_connection() override; ProtoWriteBuffer create_buffer(uint32_t reserve_size) override { // FIXME: ensure no recursive writes can happen diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 44f9716516..771eaa98d1 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -860,7 +860,9 @@ class ProtoService { virtual bool is_authenticated() = 0; virtual bool is_connection_setup() = 0; virtual void on_fatal_error() = 0; +#ifdef USE_API_PASSWORD virtual void on_unauthenticated_access() = 0; +#endif virtual void on_no_setup_connection() = 0; /** * Create a buffer with a reserved size. @@ -898,6 +900,7 @@ class ProtoService { } bool check_authenticated_() { +#ifdef USE_API_PASSWORD if (!this->check_connection_setup_()) { return false; } @@ -906,6 +909,9 @@ class ProtoService { return false; } return true; +#else + return this->check_connection_setup_(); +#endif } }; From a9d6ece752cde288bd7392fb16a79c2686c0728e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 20:28:44 -1000 Subject: [PATCH 0028/1626] [api] Add conditional compilation for Home Assistant state subscriptions (#9898) --- esphome/components/api/__init__.py | 5 +++++ esphome/components/api/api.proto | 3 +++ esphome/components/api/api_connection.cpp | 6 ++++++ esphome/components/api/api_connection.h | 6 ++++++ esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 2 ++ esphome/components/api/api_pb2_dump.cpp | 2 ++ esphome/components/api/api_pb2_service.cpp | 6 ++++++ esphome/components/api/api_pb2_service.h | 8 ++++++++ esphome/components/api/api_server.cpp | 2 ++ esphome/components/api/api_server.h | 4 ++++ esphome/components/api/custom_api_device.h | 2 ++ esphome/components/homeassistant/__init__.py | 1 + esphome/core/defines.h | 1 + 14 files changed, 50 insertions(+) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 9cbab8164f..f48a7d93be 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -53,6 +53,7 @@ SERVICE_ARG_NATIVE_TYPES = { CONF_ENCRYPTION = "encryption" CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" +CONF_HOMEASSISTANT_STATES = "homeassistant_states" def validate_encryption_key(value): @@ -118,6 +119,7 @@ CONFIG_SCHEMA = cv.All( cv.Range(max=cv.TimePeriod(milliseconds=65535)), ), cv.Optional(CONF_CUSTOM_SERVICES, default=False): cv.boolean, + cv.Optional(CONF_HOMEASSISTANT_STATES, default=False): cv.boolean, cv.Optional(CONF_ON_CLIENT_CONNECTED): automation.validate_automation( single=True ), @@ -146,6 +148,9 @@ async def to_code(config): if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: cg.add_define("USE_API_SERVICES") + if config[CONF_HOMEASSISTANT_STATES]: + cg.add_define("USE_API_HOMEASSISTANT_STATES") + if actions := config.get(CONF_ACTIONS, []): for conf in actions: template_args = [] diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 93e84702e2..5956c8b0b9 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -781,11 +781,13 @@ message HomeassistantServiceResponse { message SubscribeHomeAssistantStatesRequest { option (id) = 38; option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_API_HOMEASSISTANT_STATES"; } message SubscribeHomeAssistantStateResponse { option (id) = 39; option (source) = SOURCE_SERVER; + option (ifdef) = "USE_API_HOMEASSISTANT_STATES"; string entity_id = 1; string attribute = 2; bool once = 3; @@ -795,6 +797,7 @@ message HomeAssistantStateResponse { option (id) = 40; option (source) = SOURCE_CLIENT; option (no_delay) = true; + option (ifdef) = "USE_API_HOMEASSISTANT_STATES"; string entity_id = 1; string state = 2; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 58f10464c3..0d3b99cd41 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -242,6 +242,7 @@ void APIConnection::loop() { } #endif +#ifdef USE_API_HOMEASSISTANT_STATES if (state_subs_at_ >= 0) { const auto &subs = this->parent_->get_state_subs(); if (state_subs_at_ < static_cast(subs.size())) { @@ -259,6 +260,7 @@ void APIConnection::loop() { state_subs_at_ = -1; } } +#endif } bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) { @@ -1512,6 +1514,7 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { return this->send_message(resp, DeviceInfoResponse::MESSAGE_TYPE); } +#ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { for (auto &it : this->parent_->get_state_subs()) { if (it.entity_id == msg.entity_id && it.attribute.value() == msg.attribute) { @@ -1519,6 +1522,7 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes } } } +#endif #ifdef USE_API_SERVICES void APIConnection::execute_service(const ExecuteServiceRequest &msg) { bool found = false; @@ -1550,9 +1554,11 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption return this->send_message(resp, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE); } #endif +#ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) { state_subs_at_ = 0; } +#endif bool APIConnection::try_to_clear_buffer(bool log_out_of_space) { if (this->flags_.remove) return false; diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 2c6074c046..4f4b44b0ad 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -188,7 +188,9 @@ class APIConnection : public APIServerConnection { // we initiated ping this->flags_.sent_ping = false; } +#ifdef USE_API_HOMEASSISTANT_STATES void on_home_assistant_state_response(const HomeAssistantStateResponse &msg) override; +#endif #ifdef USE_HOMEASSISTANT_TIME void on_get_time_response(const GetTimeResponse &value) override; #endif @@ -210,7 +212,9 @@ class APIConnection : public APIServerConnection { void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) override { this->flags_.service_call_subscription = true; } +#ifdef USE_API_HOMEASSISTANT_STATES void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override; +#endif bool send_get_time_response(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES void execute_service(const ExecuteServiceRequest &msg) override; @@ -494,7 +498,9 @@ class APIConnection : public APIServerConnection { // Group 4: 4-byte types uint32_t last_traffic_; +#ifdef USE_API_HOMEASSISTANT_STATES int state_subs_at_ = -1; +#endif // Function pointer type for message encoding using MessageCreatorPtr = uint16_t (*)(EntityBase *, APIConnection *, uint32_t remaining_size, bool is_single); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 6d2e17dc27..e2d1cfebd4 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -871,6 +871,7 @@ void HomeassistantServiceResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_repeated_message(total_size, 1, this->variables); ProtoSize::add_bool_field(total_size, 1, this->is_event); } +#ifdef USE_API_HOMEASSISTANT_STATES void SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->entity_id_ref_); buffer.encode_string(2, this->attribute_ref_); @@ -897,6 +898,7 @@ bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDel } return true; } +#endif bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { case 1: diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 91a285fc6c..b7d8945e8e 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1092,6 +1092,7 @@ class HomeassistantServiceResponse : public ProtoMessage { protected: }; +#ifdef USE_API_HOMEASSISTANT_STATES class SubscribeHomeAssistantStatesRequest : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 38; @@ -1142,6 +1143,7 @@ class HomeAssistantStateResponse : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; +#endif class GetTimeRequest : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 36; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 5db9b79cfa..d7f9f63f5f 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1066,6 +1066,7 @@ void HomeassistantServiceResponse::dump_to(std::string &out) const { } dump_field(out, "is_event", this->is_event); } +#ifdef USE_API_HOMEASSISTANT_STATES void SubscribeHomeAssistantStatesRequest::dump_to(std::string &out) const { out.append("SubscribeHomeAssistantStatesRequest {}"); } @@ -1081,6 +1082,7 @@ void HomeAssistantStateResponse::dump_to(std::string &out) const { dump_field(out, "state", this->state); dump_field(out, "attribute", this->attribute); } +#endif void GetTimeRequest::dump_to(std::string &out) const { out.append("GetTimeRequest {}"); } void GetTimeResponse::dump_to(std::string &out) const { dump_field(out, "epoch_seconds", this->epoch_seconds); } #ifdef USE_API_SERVICES diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index d7d302a238..4674d04f66 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -176,6 +176,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_get_time_response(msg); break; } +#ifdef USE_API_HOMEASSISTANT_STATES case SubscribeHomeAssistantStatesRequest::MESSAGE_TYPE: { SubscribeHomeAssistantStatesRequest msg; msg.decode(msg_data, msg_size); @@ -185,6 +186,8 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_subscribe_home_assistant_states_request(msg); break; } +#endif +#ifdef USE_API_HOMEASSISTANT_STATES case HomeAssistantStateResponse::MESSAGE_TYPE: { HomeAssistantStateResponse msg; msg.decode(msg_data, msg_size); @@ -194,6 +197,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_home_assistant_state_response(msg); break; } +#endif #ifdef USE_API_SERVICES case ExecuteServiceRequest::MESSAGE_TYPE: { ExecuteServiceRequest msg; @@ -641,11 +645,13 @@ void APIServerConnection::on_subscribe_homeassistant_services_request( this->subscribe_homeassistant_services(msg); } } +#ifdef USE_API_HOMEASSISTANT_STATES void APIServerConnection::on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) { if (this->check_authenticated_()) { this->subscribe_home_assistant_states(msg); } } +#endif void APIServerConnection::on_get_time_request(const GetTimeRequest &msg) { if (this->check_connection_setup_() && !this->send_get_time_response(msg)) { this->on_fatal_error(); diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 38008197fa..19ed85aa0b 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -62,9 +62,13 @@ class APIServerConnectionBase : public ProtoService { virtual void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &value){}; +#ifdef USE_API_HOMEASSISTANT_STATES virtual void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &value){}; +#endif +#ifdef USE_API_HOMEASSISTANT_STATES virtual void on_home_assistant_state_response(const HomeAssistantStateResponse &value){}; +#endif virtual void on_get_time_request(const GetTimeRequest &value){}; virtual void on_get_time_response(const GetTimeResponse &value){}; @@ -215,7 +219,9 @@ class APIServerConnection : public APIServerConnectionBase { virtual void subscribe_states(const SubscribeStatesRequest &msg) = 0; virtual void subscribe_logs(const SubscribeLogsRequest &msg) = 0; virtual void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) = 0; +#ifdef USE_API_HOMEASSISTANT_STATES virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; +#endif virtual bool send_get_time_response(const GetTimeRequest &msg) = 0; #ifdef USE_API_SERVICES virtual void execute_service(const ExecuteServiceRequest &msg) = 0; @@ -333,7 +339,9 @@ class APIServerConnection : public APIServerConnectionBase { void on_subscribe_states_request(const SubscribeStatesRequest &msg) override; void on_subscribe_logs_request(const SubscribeLogsRequest &msg) override; void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &msg) override; +#ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; +#endif void on_get_time_request(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES void on_execute_service_request(const ExecuteServiceRequest &msg) override; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6d1729e611..0454315760 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -375,6 +375,7 @@ void APIServer::send_homeassistant_service_call(const HomeassistantServiceRespon } } +#ifdef USE_API_HOMEASSISTANT_STATES void APIServer::subscribe_home_assistant_state(std::string entity_id, optional attribute, std::function f) { this->state_subs_.push_back(HomeAssistantStateSubscription{ @@ -398,6 +399,7 @@ void APIServer::get_home_assistant_state(std::string entity_id, optional &APIServer::get_state_subs() const { return this->state_subs_; } +#endif uint16_t APIServer::get_port() const { return this->port_; } diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 54663a013f..22e9573d7e 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -126,6 +126,7 @@ class APIServer : public Component, public Controller { bool is_connected() const; +#ifdef USE_API_HOMEASSISTANT_STATES struct HomeAssistantStateSubscription { std::string entity_id; optional attribute; @@ -138,6 +139,7 @@ class APIServer : public Component, public Controller { void get_home_assistant_state(std::string entity_id, optional attribute, std::function f); const std::vector &get_state_subs() const; +#endif #ifdef USE_API_SERVICES const std::vector &get_user_services() const { return this->user_services_; } #endif @@ -171,7 +173,9 @@ class APIServer : public Component, public Controller { std::string password_; #endif std::vector shared_write_buffer_; // Shared proto write buffer for all connections +#ifdef USE_API_HOMEASSISTANT_STATES std::vector state_subs_; +#endif #ifdef USE_API_SERVICES std::vector user_services_; #endif diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index 73c7804ff3..e9e39a0772 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -83,6 +83,7 @@ class CustomAPIDevice { } #endif +#ifdef USE_API_HOMEASSISTANT_STATES /** Subscribe to the state (or attribute state) of an entity from Home Assistant. * * Usage: @@ -134,6 +135,7 @@ class CustomAPIDevice { auto f = std::bind(callback, (T *) this, entity_id, std::placeholders::_1); global_api_server->subscribe_home_assistant_state(entity_id, optional(attribute), f); } +#endif /** Call a Home Assistant service from ESPHome. * diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 223d6c18c3..7b23775b47 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -38,3 +38,4 @@ def setup_home_assistant_entity(var, config): cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) + cg.add_define("USE_API_HOMEASSISTANT_STATES") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 348f288863..611e283e6d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -109,6 +109,7 @@ #define USE_API #define USE_API_CLIENT_CONNECTED_TRIGGER #define USE_API_CLIENT_DISCONNECTED_TRIGGER +#define USE_API_HOMEASSISTANT_STATES #define USE_API_NOISE #define USE_API_PLAINTEXT #define USE_API_SERVICES From d54db471bdfc133dd58f0aefcb88599b69612efb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 20:29:57 -1000 Subject: [PATCH 0029/1626] [i2c] Fix logging level for bus scan results in dump_config (#9904) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/i2c/i2c_bus_esp_idf.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index c473a58b5e..cf31ba1c0d 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -151,13 +151,13 @@ void IDFI2CBus::dump_config() { break; } if (this->scan_) { - ESP_LOGI(TAG, "Results from bus scan:"); + ESP_LOGCONFIG(TAG, "Results from bus scan:"); if (scan_results_.empty()) { - ESP_LOGI(TAG, "Found no devices"); + ESP_LOGCONFIG(TAG, "Found no devices"); } else { for (const auto &s : scan_results_) { if (s.second) { - ESP_LOGI(TAG, "Found device at address 0x%02X", s.first); + ESP_LOGCONFIG(TAG, "Found device at address 0x%02X", s.first); } else { ESP_LOGE(TAG, "Unknown error at address 0x%02X", s.first); } From d64e4d3c495b97b981d223c95a1d91bcab802b46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 22:54:03 -1000 Subject: [PATCH 0030/1626] [ruff] Enable FURB rules for code modernization (#9896) --- esphome/components/display/__init__.py | 3 +-- esphome/components/logger/__init__.py | 2 +- esphome/components/lvgl/helpers.py | 2 +- esphome/components/speaker/media_player/__init__.py | 3 +-- esphome/components/stepper/__init__.py | 6 ++---- esphome/cpp_generator.py | 3 +-- esphome/writer.py | 2 +- pyproject.toml | 1 + script/generate-esp32-boards.py | 6 ++++-- 9 files changed, 13 insertions(+), 15 deletions(-) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 12c63231e7..81f2536e96 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -51,8 +51,7 @@ DISPLAY_ROTATIONS = { def validate_rotation(value): value = cv.string(value) - if value.endswith("°"): - value = value[:-1] + value = value.removesuffix("°") return cv.enum(DISPLAY_ROTATIONS, int=True)(value) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index d718a7a612..d8c95d75f2 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -409,7 +409,7 @@ def validate_printf(value): [cCdiouxXeEfgGaAnpsSZ] # type ) """ # noqa - matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.X) + matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE) if len(matches) != len(value[CONF_ARGS]): raise cv.Invalid( f"Found {len(matches)} printf-patterns ({', '.join(matches)}), but {len(value[CONF_ARGS])} args were given!" diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py index e04a0105d5..8d5b6354bb 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -33,7 +33,7 @@ def validate_printf(value): [cCdiouxXeEfgGaAnpsSZ] # type ) """ # noqa - matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.X) + matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE) if len(matches) != len(value[CONF_ARGS]): raise cv.Invalid( f"Found {len(matches)} printf-patterns ({', '.join(matches)}), but {len(value[CONF_ARGS])} args were given!" diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 16dcc855c3..1c2e7dc0e1 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -155,8 +155,7 @@ def _read_audio_file_and_type(file_config): import puremagic file_type: str = puremagic.from_string(data) - if file_type.startswith("."): - file_type = file_type[1:] + file_type = file_type.removeprefix(".") media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] if file_type in ("wav"): diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index 66fe88c6c4..c234388e7e 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -27,8 +27,7 @@ SetDecelerationAction = stepper_ns.class_("SetDecelerationAction", automation.Ac def validate_acceleration(value): value = cv.string(value) for suffix in ("steps/s^2", "steps/s*s", "steps/s/s", "steps/ss", "steps/(s*s)"): - if value.endswith(suffix): - value = value[: -len(suffix)] + value = value.removesuffix(suffix) if value == "inf": return 1e6 @@ -48,8 +47,7 @@ def validate_acceleration(value): def validate_speed(value): value = cv.string(value) for suffix in ("steps/s", "steps/s"): - if value.endswith(suffix): - value = value[: -len(suffix)] + value = value.removesuffix(suffix) if value == "inf": return 1e6 diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 72aadcb139..34e4eec1ee 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -771,8 +771,7 @@ class MockObj(Expression): if attr.startswith("P") and self.op not in ["::", ""]: attr = attr[1:] next_op = "->" - if attr.startswith("_"): - attr = attr[1:] + attr = attr.removeprefix("_") return MockObj(f"{self.base}{self.op}{attr}", next_op) def __call__(self, *args: SafeExpType) -> "MockObj": diff --git a/esphome/writer.py b/esphome/writer.py index 2c2e00b513..b5c834722a 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -76,7 +76,7 @@ def get_include_text(): def replace_file_content(text, pattern, repl): - content_new, count = re.subn(pattern, repl, text, flags=re.M) + content_new, count = re.subn(pattern, repl, text, flags=re.MULTILINE) return content_new, count diff --git a/pyproject.toml b/pyproject.toml index 742cd6e83d..200f51a873 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,7 @@ exclude = ['generated'] select = [ "E", # pycodestyle "F", # pyflakes/autoflake + "FURB", # refurb "I", # isort "PERF", # performance "PL", # pylint diff --git a/script/generate-esp32-boards.py b/script/generate-esp32-boards.py index 83d0f0c3e0..3f444ed455 100755 --- a/script/generate-esp32-boards.py +++ b/script/generate-esp32-boards.py @@ -64,8 +64,10 @@ def main(): for line in lines: if line.startswith("BOARDS = {"): f.write("BOARDS = {\n") - for board, info in sorted(boards.items()): - f.write(TEMPLATE % (board, info["name"], info["variant"])) + f.writelines( + TEMPLATE % (board, info["name"], info["variant"]) + for board, info in sorted(boards.items()) + ) f.write("}\n") break From 0155769ffe1d236e2e5c54361ecdf1fe01a79981 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 23:01:24 -1000 Subject: [PATCH 0031/1626] [api] Fix string lifetime issue in Home Assistant service calls with templated values --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 4 +- esphome/components/api/api_pb2.h | 3 +- esphome/components/api/api_pb2_dump.cpp | 2 +- .../components/api/homeassistant_service.h | 9 ++-- script/api_protobuf/api_protobuf.py | 42 ++++++++++++++++--- 7 files changed, 45 insertions(+), 18 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 5956c8b0b9..922fb691d6 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -759,7 +759,7 @@ message SubscribeHomeassistantServicesRequest { message HomeassistantServiceMap { string key = 1; - string value = 2; + string value = 2 [(no_zero_copy) = true]; } message HomeassistantServiceResponse { diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index bb3947e8a3..4f0f52fc6f 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -27,4 +27,5 @@ extend google.protobuf.MessageOptions { extend google.protobuf.FieldOptions { optional string field_ifdef = 1042; optional uint32 fixed_array_size = 50007; + optional bool no_zero_copy = 50008 [default=false]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index e2d1cfebd4..c056892b6c 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -845,11 +845,11 @@ void NoiseEncryptionSetKeyResponse::calculate_size(uint32_t &total_size) const { #endif void HomeassistantServiceMap::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->key_ref_); - buffer.encode_string(2, this->value_ref_); + buffer.encode_string(2, this->value); } void HomeassistantServiceMap::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->key_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->value_ref_.size()); + ProtoSize::add_string_field(total_size, 1, this->value); } void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->service_ref_); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index b7d8945e8e..4a6323b4a4 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1061,8 +1061,7 @@ class HomeassistantServiceMap : public ProtoMessage { public: StringRef key_ref_{}; void set_key(const StringRef &ref) { this->key_ref_ = ref; } - StringRef value_ref_{}; - void set_value(const StringRef &ref) { this->value_ref_ = ref; } + std::string value{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index d7f9f63f5f..2ea7ab616d 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1044,7 +1044,7 @@ void SubscribeHomeassistantServicesRequest::dump_to(std::string &out) const { void HomeassistantServiceMap::dump_to(std::string &out) const { MessageDumpHelper helper(out, "HomeassistantServiceMap"); dump_field(out, "key", this->key_ref_); - dump_field(out, "value", this->value_ref_); + dump_field(out, "value", this->value); } void HomeassistantServiceResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "HomeassistantServiceResponse"); diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 212b3b22d6..7c294e1798 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -69,22 +69,19 @@ template class HomeAssistantServiceCallAction : public Actiondata_template_) { resp.data_template.emplace_back(); auto &kv = resp.data_template.back(); kv.set_key(StringRef(it.key)); - std::string value = it.value.value(x...); - kv.set_value(StringRef(value)); + kv.value = it.value.value(x...); } for (auto &it : this->variables_) { resp.variables.emplace_back(); auto &kv = resp.variables.back(); kv.set_key(StringRef(it.key)); - std::string value = it.value.value(x...); - kv.set_value(StringRef(value)); + kv.value = it.value.value(x...); } this->parent_->send_homeassistant_service_call(resp); } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 92c85d2366..7a603a4670 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -562,11 +562,16 @@ class StringType(TypeInfo): @property def public_content(self) -> list[str]: content: list[str] = [] - # Add std::string storage if message needs decoding - if self._needs_decode: + + # Check if no_zero_copy option is set + no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False) + + # Add std::string storage if message needs decoding OR if no_zero_copy is set + if self._needs_decode or no_zero_copy: content.append(f"std::string {self.field_name}{{}};") - if self._needs_encode: + # Only add StringRef if encoding is needed AND no_zero_copy is not set + if self._needs_encode and not no_zero_copy: content.extend( [ # Add StringRef field if message needs encoding @@ -581,13 +586,28 @@ class StringType(TypeInfo): @property def encode_content(self) -> str: - return f"buffer.encode_string({self.number}, this->{self.field_name}_ref_);" + # Check if no_zero_copy option is set + no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False) + + if no_zero_copy: + # Use the std::string directly + return f"buffer.encode_string({self.number}, this->{self.field_name});" + else: + # Use the StringRef + return f"buffer.encode_string({self.number}, this->{self.field_name}_ref_);" def dump(self, name): + # Check if no_zero_copy option is set + no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False) + # If name is 'it', this is a repeated field element - always use string if name == "it": return "append_quoted_string(out, StringRef(it));" + # If no_zero_copy is set, always use std::string + if no_zero_copy: + return f'out.append("\'").append(this->{self.field_name}).append("\'");' + # For SOURCE_CLIENT only, always use std::string if not self._needs_encode: return f'out.append("\'").append(this->{self.field_name}).append("\'");' @@ -607,6 +627,13 @@ class StringType(TypeInfo): @property def dump_content(self) -> str: + # Check if no_zero_copy option is set + no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False) + + # If no_zero_copy is set, always use std::string + if no_zero_copy: + return f'dump_field(out, "{self.name}", this->{self.field_name});' + # For SOURCE_CLIENT only, use std::string if not self._needs_encode: return f'dump_field(out, "{self.name}", this->{self.field_name});' @@ -622,8 +649,11 @@ class StringType(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - # For SOURCE_CLIENT only messages, use the string field directly - if not self._needs_encode: + # Check if no_zero_copy option is set + no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False) + + # For SOURCE_CLIENT only messages or no_zero_copy, use the string field directly + if not self._needs_encode or no_zero_copy: return self._get_simple_size_calculation(name, force, "add_string_field") # Check if this is being called from a repeated field context From 5feb891e973c50a3277043a2b9fd30af4c4f4e4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 23:08:22 -1000 Subject: [PATCH 0032/1626] fix --- esphome/components/api/api_pb2.cpp | 2 +- script/api_protobuf/api_protobuf.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c056892b6c..173b356cd2 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -849,7 +849,7 @@ void HomeassistantServiceMap::encode(ProtoWriteBuffer buffer) const { } void HomeassistantServiceMap::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->key_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->value); + ProtoSize::add_string_field(total_size, 1, this->value.size()); } void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->service_ref_); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 7a603a4670..4b9a61383d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -654,6 +654,10 @@ class StringType(TypeInfo): # For SOURCE_CLIENT only messages or no_zero_copy, use the string field directly if not self._needs_encode or no_zero_copy: + # For no_zero_copy, we need to use .size() on the string + if no_zero_copy and name != "it": + field_id_size = self.calculate_field_id_size() + return f"ProtoSize::add_string_field(total_size, {field_id_size}, this->{self.field_name}.size());" return self._get_simple_size_calculation(name, force, "add_string_field") # Check if this is being called from a repeated field context From 01b24a7b6986fd6c6e162092adadbaaefe12d305 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 23:11:11 -1000 Subject: [PATCH 0033/1626] fix merge conflict --- esphome/components/api/api_connection.cpp | 2 ++ esphome/components/api/api_connection.h | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 09903b5093..4fb229aa9b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1826,6 +1826,7 @@ uint16_t APIConnection::try_send_ping_request(EntityBase *entity, APIConnection return encode_message_to_buffer(req, PingRequest::MESSAGE_TYPE, conn, remaining_size, is_single); } +#ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::process_state_subscriptions_() { const auto &subs = this->parent_->get_state_subs(); if (this->state_subs_at_ >= static_cast(subs.size())) { @@ -1845,6 +1846,7 @@ void APIConnection::process_state_subscriptions_() { this->state_subs_at_++; } } +#endif // USE_API_HOMEASSISTANT_STATES } // namespace esphome::api #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 381543caa7..6ad18d2b15 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -294,8 +294,9 @@ class APIConnection : public APIServerConnection { // Helper function to handle authentication completion void complete_authentication_(); - // Process state subscriptions efficiently +#ifdef USE_API_HOMEASSISTANT_STATES void process_state_subscriptions_(); +#endif // Non-template helper to encode any ProtoMessage static uint16_t encode_message_to_buffer(ProtoMessage &msg, uint8_t message_type, APIConnection *conn, From ab02f6da3d35d824a430bda49b4526e61a9e75b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 23:15:24 -1000 Subject: [PATCH 0034/1626] custom api --- esphome/components/api/custom_api_device.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index e9e39a0772..3e70f913fb 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -174,7 +174,7 @@ class CustomAPIDevice { resp.data.emplace_back(); auto &kv = resp.data.back(); kv.set_key(StringRef(it.first)); - kv.set_value(StringRef(it.second)); + kv.value = it.second; } global_api_server->send_homeassistant_service_call(resp); } @@ -217,7 +217,7 @@ class CustomAPIDevice { resp.data.emplace_back(); auto &kv = resp.data.back(); kv.set_key(StringRef(it.first)); - kv.set_value(StringRef(it.second)); + kv.value = it.second; } global_api_server->send_homeassistant_service_call(resp); } From 84ffa4274c684107df3a998f5b0fe75d00e1dbe4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Jul 2025 23:27:35 -1000 Subject: [PATCH 0035/1626] Bump aioesphomeapi from 37.1.0 to 37.1.2 (#9910) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6b7e9d946a..80bd470f02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.9.0 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.1.0 +aioesphomeapi==37.1.2 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 17e1d3650cf2b430b2573e6559447720c9f277a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 23:37:40 -1000 Subject: [PATCH 0036/1626] missed ha --- .../homeassistant/number/homeassistant_number.cpp | 6 ++---- .../homeassistant/switch/homeassistant_switch.cpp | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index ffb352c969..87bf6727f2 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -93,14 +93,12 @@ void HomeassistantNumber::control(float value) { resp.data.emplace_back(); auto &entity_id = resp.data.back(); entity_id.set_key(ENTITY_ID_KEY); - entity_id.set_value(StringRef(this->entity_id_)); + entity_id.value = this->entity_id_; resp.data.emplace_back(); auto &entity_value = resp.data.back(); entity_value.set_key(VALUE_KEY); - // to_string() returns a temporary - must store it to avoid dangling reference - std::string value_str = to_string(value); - entity_value.set_value(StringRef(value_str)); + entity_value.value = to_string(value); api::global_api_server->send_homeassistant_service_call(resp); } diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 0fe609bf43..b3300335b9 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -54,7 +54,7 @@ void HomeassistantSwitch::write_state(bool state) { resp.data.emplace_back(); auto &entity_id_kv = resp.data.back(); entity_id_kv.set_key(ENTITY_ID_KEY); - entity_id_kv.set_value(StringRef(this->entity_id_)); + entity_id_kv.value = this->entity_id_; api::global_api_server->send_homeassistant_service_call(resp); } From 0e2520e4c024a4250ade6ec09ab811e0f88ba50b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 27 Jul 2025 04:02:02 +1000 Subject: [PATCH 0037/1626] [core] Fix format error in log printf (#9911) --- esphome/core/component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index e8bd8c1d89..42b0a71d79 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -159,7 +159,7 @@ void Component::call() { this->call_setup(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t setup_time = millis() - start_time; - ESP_LOGD(TAG, "Setup %s took %ums", this->get_component_source(), setup_time); + ESP_LOGD(TAG, "Setup %s took %ums", this->get_component_source(), (unsigned) setup_time); #endif break; } From 981b906579303b21be7beabac96b1b817adf851b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 11:06:01 -1000 Subject: [PATCH 0038/1626] [logger] Use C++17 nested namespace syntax (#9916) --- esphome/components/logger/logger.cpp | 6 ++---- esphome/components/logger/logger.h | 8 ++------ esphome/components/logger/logger_esp32.cpp | 6 ++---- esphome/components/logger/logger_esp8266.cpp | 6 ++---- esphome/components/logger/logger_host.cpp | 6 ++---- esphome/components/logger/logger_libretiny.cpp | 6 ++---- esphome/components/logger/logger_rp2040.cpp | 6 ++---- esphome/components/logger/logger_zephyr.cpp | 6 ++---- esphome/components/logger/select/logger_level_select.cpp | 6 ++---- esphome/components/logger/select/logger_level_select.h | 6 ++---- esphome/components/logger/task_log_buffer.cpp | 6 ++---- esphome/components/logger/task_log_buffer.h | 6 ++---- 12 files changed, 24 insertions(+), 50 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 01a7565699..195e04948d 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -8,8 +8,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -namespace esphome { -namespace logger { +namespace esphome::logger { static const char *const TAG = "logger"; @@ -283,5 +282,4 @@ void Logger::set_log_level(uint8_t level) { Logger *global_logger = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace logger -} // namespace esphome +} // namespace esphome::logger diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 6bd5bb66ed..aa76a188c9 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -34,9 +34,7 @@ struct device; #endif -namespace esphome { - -namespace logger { +namespace esphome::logger { // Color and letter constants for log levels static const char *const LOG_LEVEL_COLORS[] = { @@ -411,6 +409,4 @@ class LoggerMessageTrigger : public Trigger uint8_t level_; }; -} // namespace logger - -} // namespace esphome +} // namespace esphome::logger diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 2ba1efec50..44243d4aa8 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -29,8 +29,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace logger { +namespace esphome::logger { static const char *const TAG = "logger"; @@ -206,6 +205,5 @@ const char *const UART_SELECTIONS[] = { const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } -} // namespace logger -} // namespace esphome +} // namespace esphome::logger #endif diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 5bfeb21007..fb5f6cee5d 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -2,8 +2,7 @@ #include "logger.h" #include "esphome/core/log.h" -namespace esphome { -namespace logger { +namespace esphome::logger { static const char *const TAG = "logger"; @@ -40,6 +39,5 @@ const char *const UART_SELECTIONS[] = {"UART0", "UART1", "UART0_SWAP"}; const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } -} // namespace logger -} // namespace esphome +} // namespace esphome::logger #endif diff --git a/esphome/components/logger/logger_host.cpp b/esphome/components/logger/logger_host.cpp index edffb1a8c3..4abe92286a 100644 --- a/esphome/components/logger/logger_host.cpp +++ b/esphome/components/logger/logger_host.cpp @@ -1,8 +1,7 @@ #if defined(USE_HOST) #include "logger.h" -namespace esphome { -namespace logger { +namespace esphome::logger { void HOT Logger::write_msg_(const char *msg) { time_t rawtime; @@ -18,7 +17,6 @@ void HOT Logger::write_msg_(const char *msg) { void Logger::pre_setup() { global_logger = this; } -} // namespace logger -} // namespace esphome +} // namespace esphome::logger #endif diff --git a/esphome/components/logger/logger_libretiny.cpp b/esphome/components/logger/logger_libretiny.cpp index 12e55b7cef..09d0622bc3 100644 --- a/esphome/components/logger/logger_libretiny.cpp +++ b/esphome/components/logger/logger_libretiny.cpp @@ -1,8 +1,7 @@ #ifdef USE_LIBRETINY #include "logger.h" -namespace esphome { -namespace logger { +namespace esphome::logger { static const char *const TAG = "logger"; @@ -56,7 +55,6 @@ const char *const UART_SELECTIONS[] = {"DEFAULT", "UART0", "UART1", "UART2"}; const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } -} // namespace logger -} // namespace esphome +} // namespace esphome::logger #endif // USE_LIBRETINY diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index 2783d77ac1..f1cad9b283 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -2,8 +2,7 @@ #include "logger.h" #include "esphome/core/log.h" -namespace esphome { -namespace logger { +namespace esphome::logger { static const char *const TAG = "logger"; @@ -34,6 +33,5 @@ const char *const UART_SELECTIONS[] = {"UART0", "UART1", "USB_CDC"}; const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } -} // namespace logger -} // namespace esphome +} // namespace esphome::logger #endif // USE_RP2040 diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 35ef2e9561..58a09facd5 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -8,8 +8,7 @@ #include #include -namespace esphome { -namespace logger { +namespace esphome::logger { static const char *const TAG = "logger"; @@ -82,7 +81,6 @@ const char *const UART_SELECTIONS[] = {"UART0", "UART1", "USB_CDC"}; const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } -} // namespace logger -} // namespace esphome +} // namespace esphome::logger #endif diff --git a/esphome/components/logger/select/logger_level_select.cpp b/esphome/components/logger/select/logger_level_select.cpp index b71a6e02a2..d9c950ce3c 100644 --- a/esphome/components/logger/select/logger_level_select.cpp +++ b/esphome/components/logger/select/logger_level_select.cpp @@ -1,7 +1,6 @@ #include "logger_level_select.h" -namespace esphome { -namespace logger { +namespace esphome::logger { void LoggerLevelSelect::publish_state(int level) { auto value = this->at(level); @@ -23,5 +22,4 @@ void LoggerLevelSelect::control(const std::string &value) { this->parent_->set_log_level(level.value()); } -} // namespace logger -} // namespace esphome +} // namespace esphome::logger diff --git a/esphome/components/logger/select/logger_level_select.h b/esphome/components/logger/select/logger_level_select.h index 2c92c84d13..f31a6f6cdb 100644 --- a/esphome/components/logger/select/logger_level_select.h +++ b/esphome/components/logger/select/logger_level_select.h @@ -3,13 +3,11 @@ #include "esphome/components/select/select.h" #include "esphome/core/component.h" #include "esphome/components/logger/logger.h" -namespace esphome { -namespace logger { +namespace esphome::logger { class LoggerLevelSelect : public Component, public select::Select, public Parented { public: void publish_state(int level); void setup() override; void control(const std::string &value) override; }; -} // namespace logger -} // namespace esphome +} // namespace esphome::logger diff --git a/esphome/components/logger/task_log_buffer.cpp b/esphome/components/logger/task_log_buffer.cpp index 24d9284f1a..b5dd9f0239 100644 --- a/esphome/components/logger/task_log_buffer.cpp +++ b/esphome/components/logger/task_log_buffer.cpp @@ -5,8 +5,7 @@ #ifdef USE_ESPHOME_TASK_LOG_BUFFER -namespace esphome { -namespace logger { +namespace esphome::logger { TaskLogBuffer::TaskLogBuffer(size_t total_buffer_size) { // Store the buffer size @@ -132,7 +131,6 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin return true; } -} // namespace logger -} // namespace esphome +} // namespace esphome::logger #endif // USE_ESPHOME_TASK_LOG_BUFFER diff --git a/esphome/components/logger/task_log_buffer.h b/esphome/components/logger/task_log_buffer.h index 1618a5a121..fdda07190d 100644 --- a/esphome/components/logger/task_log_buffer.h +++ b/esphome/components/logger/task_log_buffer.h @@ -11,8 +11,7 @@ #include #include -namespace esphome { -namespace logger { +namespace esphome::logger { class TaskLogBuffer { public: @@ -63,7 +62,6 @@ class TaskLogBuffer { mutable uint16_t last_processed_counter_{0}; // Tracks last processed message }; -} // namespace logger -} // namespace esphome +} // namespace esphome::logger #endif // USE_ESPHOME_TASK_LOG_BUFFER From cf73f72119dfebb414e490db6fb70061ce8a885a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 27 Jul 2025 07:45:20 +1000 Subject: [PATCH 0039/1626] [wifi] Allow config to use PSRAM (#9866) --- esphome/components/wifi/__init__.py | 6 ++++++ tests/components/wifi/test.esp32-idf.yaml | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 8cb784233f..4e1d2998f6 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,6 +1,7 @@ from esphome import automation from esphome.automation import Condition import esphome.codegen as cg +from esphome.components.const import CONF_USE_PSRAM from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant from esphome.components.network import IPAddress from esphome.config_helpers import filter_source_files_from_platform @@ -334,6 +335,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation( single=True ), + cv.Optional(CONF_USE_PSRAM): cv.All( + cv.requires_component("psram"), cv.boolean + ), } ), _validate, @@ -454,6 +458,8 @@ async def to_code(config): if config[CONF_ENABLE_RRM]: cg.add(var.set_rrm(config[CONF_ENABLE_RRM])) + if config.get(CONF_USE_PSRAM): + add_idf_sdkconfig_option("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP", True) cg.add_define("USE_WIFI") # must register before OTA safe mode check diff --git a/tests/components/wifi/test.esp32-idf.yaml b/tests/components/wifi/test.esp32-idf.yaml index dade44d145..91e235b9ce 100644 --- a/tests/components/wifi/test.esp32-idf.yaml +++ b/tests/components/wifi/test.esp32-idf.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +psram: + +wifi: + use_psram: true + +packages: + - !include common.yaml From 32d6acb3b232542e574c8553dd4ff3516c729f54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 11:52:23 -1000 Subject: [PATCH 0040/1626] [api] Reduce code duplication in send_noise_encryption_set_key_response --- esphome/components/api/api_connection.cpp | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0d3b99cd41..e0d4ec0cc8 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1538,19 +1538,18 @@ void APIConnection::execute_service(const ExecuteServiceRequest &msg) { #endif #ifdef USE_API_NOISE bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryptionSetKeyRequest &msg) { - psk_t psk{}; NoiseEncryptionSetKeyResponse resp; + resp.success = false; + + psk_t psk{}; if (base64_decode(msg.key, psk.data(), msg.key.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); - resp.success = false; - return this->send_message(resp, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE); - } - if (!this->parent_->save_noise_psk(psk, true)) { + } else if (!this->parent_->save_noise_psk(psk, true)) { ESP_LOGW(TAG, "Failed to save encryption key"); - resp.success = false; - return this->send_message(resp, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE); + } else { + resp.success = true; } - resp.success = true; + return this->send_message(resp, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE); } #endif From d111b84ca4433161834d127df5d4a56b98e46b8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 14:03:47 -1000 Subject: [PATCH 0041/1626] Make ProtoSize an object --- esphome/components/api/api_pb2.cpp | 1118 +++++++++++++-------------- esphome/components/api/api_pb2.h | 166 ++-- esphome/components/api/proto.h | 191 +++-- script/api_protobuf/api_protobuf.py | 46 +- 4 files changed, 773 insertions(+), 748 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index e2d1cfebd4..9e061d2cf0 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -36,11 +36,11 @@ void HelloResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(3, this->server_info_ref_); buffer.encode_string(4, this->name_ref_); } -void HelloResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint32_field(total_size, 1, this->api_version_major); - ProtoSize::add_uint32_field(total_size, 1, this->api_version_minor); - ProtoSize::add_string_field(total_size, 1, this->server_info_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void HelloResponse::calculate_size(ProtoSize &size) const { + size.add_uint32_field(1, this->api_version_major); + size.add_uint32_field(1, this->api_version_minor); + size.add_string_field(1, this->server_info_ref_.size()); + size.add_string_field(1, this->name_ref_.size()); } bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { @@ -53,17 +53,17 @@ bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value return true; } void ConnectResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->invalid_password); } -void ConnectResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_bool_field(total_size, 1, this->invalid_password); +} +void ConnectResponse::calculate_size(ProtoSize &size) const { size.add_bool_field(1, this->invalid_password); } } #ifdef USE_AREAS void AreaInfo::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->area_id); buffer.encode_string(2, this->name_ref_); } -void AreaInfo::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint32_field(total_size, 1, this->area_id); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void AreaInfo::calculate_size(ProtoSize &size) const { + size.add_uint32_field(1, this->area_id); + size.add_string_field(1, this->name_ref_.size()); } #endif #ifdef USE_DEVICES @@ -72,10 +72,10 @@ void DeviceInfo::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(2, this->name_ref_); buffer.encode_uint32(3, this->area_id); } -void DeviceInfo::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint32_field(total_size, 1, this->device_id); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_uint32_field(total_size, 1, this->area_id); +void DeviceInfo::calculate_size(ProtoSize &size) const { + size.add_uint32_field(1, this->device_id); + size.add_string_field(1, this->name_ref_.size()); + size.add_uint32_field(1, this->area_id); } #endif void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { @@ -130,52 +130,52 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_message(22, this->area); #endif } -void DeviceInfoResponse::calculate_size(uint32_t &total_size) const { +void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_API_PASSWORD - ProtoSize::add_bool_field(total_size, 1, this->uses_password); + size.add_bool_field(1, this->uses_password); #endif - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->mac_address_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->esphome_version_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->compilation_time_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->model_ref_.size()); + size.add_string_field(1, this->name_ref_.size()); + size.add_string_field(1, this->mac_address_ref_.size()); + size.add_string_field(1, this->esphome_version_ref_.size()); + size.add_string_field(1, this->compilation_time_ref_.size()); + size.add_string_field(1, this->model_ref_.size()); #ifdef USE_DEEP_SLEEP - ProtoSize::add_bool_field(total_size, 1, this->has_deep_sleep); + size.add_bool_field(1, this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoSize::add_string_field(total_size, 1, this->project_name_ref_.size()); + size.add_string_field(1, this->project_name_ref_.size()); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoSize::add_string_field(total_size, 1, this->project_version_ref_.size()); + size.add_string_field(1, this->project_version_ref_.size()); #endif #ifdef USE_WEBSERVER - ProtoSize::add_uint32_field(total_size, 1, this->webserver_port); + size.add_uint32_field(1, this->webserver_port); #endif #ifdef USE_BLUETOOTH_PROXY - ProtoSize::add_uint32_field(total_size, 1, this->bluetooth_proxy_feature_flags); + size.add_uint32_field(1, this->bluetooth_proxy_feature_flags); #endif - ProtoSize::add_string_field(total_size, 1, this->manufacturer_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->friendly_name_ref_.size()); + size.add_string_field(1, this->manufacturer_ref_.size()); + size.add_string_field(1, this->friendly_name_ref_.size()); #ifdef USE_VOICE_ASSISTANT - ProtoSize::add_uint32_field(total_size, 2, this->voice_assistant_feature_flags); + size.add_uint32_field(2, this->voice_assistant_feature_flags); #endif #ifdef USE_AREAS - ProtoSize::add_string_field(total_size, 2, this->suggested_area_ref_.size()); + size.add_string_field(2, this->suggested_area_ref_.size()); #endif #ifdef USE_BLUETOOTH_PROXY - ProtoSize::add_string_field(total_size, 2, this->bluetooth_mac_address_ref_.size()); + size.add_string_field(2, this->bluetooth_mac_address_ref_.size()); #endif #ifdef USE_API_NOISE - ProtoSize::add_bool_field(total_size, 2, this->api_encryption_supported); + size.add_bool_field(2, this->api_encryption_supported); #endif #ifdef USE_DEVICES - ProtoSize::add_repeated_message(total_size, 2, this->devices); + size.add_repeated_message(2, this->devices); #endif #ifdef USE_AREAS - ProtoSize::add_repeated_message(total_size, 2, this->areas); + size.add_repeated_message(2, this->areas); #endif #ifdef USE_AREAS - ProtoSize::add_message_object(total_size, 2, this->area); + size.add_message_object(2, this->area); #endif } #ifdef USE_BINARY_SENSOR @@ -194,19 +194,19 @@ void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(10, this->device_id); #endif } -void ListEntitiesBinarySensorResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->is_status_binary_sensor); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); +void ListEntitiesBinarySensorResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); + size.add_string_field(1, this->device_class_ref_.size()); + size.add_bool_field(1, this->is_status_binary_sensor); + size.add_bool_field(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_enum_field(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void BinarySensorStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -217,12 +217,12 @@ void BinarySensorStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void BinarySensorStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->state); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); +void BinarySensorStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_bool_field(1, this->state); + size.add_bool_field(1, this->missing_state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } #endif @@ -245,22 +245,22 @@ void ListEntitiesCoverResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(13, this->device_id); #endif } -void ListEntitiesCoverResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->assumed_state); - ProtoSize::add_bool_field(total_size, 1, this->supports_position); - ProtoSize::add_bool_field(total_size, 1, this->supports_tilt); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); +void ListEntitiesCoverResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); + size.add_bool_field(1, this->assumed_state); + size.add_bool_field(1, this->supports_position); + size.add_bool_field(1, this->supports_tilt); + size.add_string_field(1, this->device_class_ref_.size()); + size.add_bool_field(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_bool_field(total_size, 1, this->supports_stop); + size.add_enum_field(1, static_cast(this->entity_category)); + size.add_bool_field(1, this->supports_stop); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void CoverStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -272,13 +272,13 @@ void CoverStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(6, this->device_id); #endif } -void CoverStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_float_field(total_size, 1, this->position); - ProtoSize::add_float_field(total_size, 1, this->tilt); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->current_operation)); +void CoverStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_float_field(1, this->position); + size.add_float_field(1, this->tilt); + size.add_enum_field(1, static_cast(this->current_operation)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool CoverCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -340,26 +340,26 @@ void ListEntitiesFanResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(13, this->device_id); #endif } -void ListEntitiesFanResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->supports_oscillation); - ProtoSize::add_bool_field(total_size, 1, this->supports_speed); - ProtoSize::add_bool_field(total_size, 1, this->supports_direction); - ProtoSize::add_int32_field(total_size, 1, this->supported_speed_count); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); +void ListEntitiesFanResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); + size.add_bool_field(1, this->supports_oscillation); + size.add_bool_field(1, this->supports_speed); + size.add_bool_field(1, this->supports_direction); + size.add_int32_field(1, this->supported_speed_count); + size.add_bool_field(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_enum_field(1, static_cast(this->entity_category)); if (!this->supported_preset_modes.empty()) { for (const auto &it : this->supported_preset_modes) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_string_field_repeated(1, it); } } #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void FanStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -373,15 +373,15 @@ void FanStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(8, this->device_id); #endif } -void FanStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->state); - ProtoSize::add_bool_field(total_size, 1, this->oscillating); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->direction)); - ProtoSize::add_int32_field(total_size, 1, this->speed_level); - ProtoSize::add_string_field(total_size, 1, this->preset_mode_ref_.size()); +void FanStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_bool_field(1, this->state); + size.add_bool_field(1, this->oscillating); + size.add_enum_field(1, static_cast(this->direction)); + size.add_int32_field(1, this->speed_level); + size.add_string_field(1, this->preset_mode_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool FanCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -466,29 +466,29 @@ void ListEntitiesLightResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(16, this->device_id); #endif } -void ListEntitiesLightResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesLightResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); if (!this->supported_color_modes.empty()) { for (const auto &it : this->supported_color_modes) { - ProtoSize::add_enum_field_repeated(total_size, 1, static_cast(it)); + size.add_enum_field_repeated(1, static_cast(it)); } } - ProtoSize::add_float_field(total_size, 1, this->min_mireds); - ProtoSize::add_float_field(total_size, 1, this->max_mireds); + size.add_float_field(1, this->min_mireds); + size.add_float_field(1, this->max_mireds); if (!this->effects.empty()) { for (const auto &it : this->effects) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_string_field_repeated(1, it); } } - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); + size.add_bool_field(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_enum_field(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 2, this->device_id); + size.add_uint32_field(2, this->device_id); #endif } void LightStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -509,22 +509,22 @@ void LightStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(14, this->device_id); #endif } -void LightStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->state); - ProtoSize::add_float_field(total_size, 1, this->brightness); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->color_mode)); - ProtoSize::add_float_field(total_size, 1, this->color_brightness); - ProtoSize::add_float_field(total_size, 1, this->red); - ProtoSize::add_float_field(total_size, 1, this->green); - ProtoSize::add_float_field(total_size, 1, this->blue); - ProtoSize::add_float_field(total_size, 1, this->white); - ProtoSize::add_float_field(total_size, 1, this->color_temperature); - ProtoSize::add_float_field(total_size, 1, this->cold_white); - ProtoSize::add_float_field(total_size, 1, this->warm_white); - ProtoSize::add_string_field(total_size, 1, this->effect_ref_.size()); +void LightStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_bool_field(1, this->state); + size.add_float_field(1, this->brightness); + size.add_enum_field(1, static_cast(this->color_mode)); + size.add_float_field(1, this->color_brightness); + size.add_float_field(1, this->red); + size.add_float_field(1, this->green); + size.add_float_field(1, this->blue); + size.add_float_field(1, this->white); + size.add_float_field(1, this->color_temperature); + size.add_float_field(1, this->cold_white); + size.add_float_field(1, this->warm_white); + size.add_string_field(1, this->effect_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool LightCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -654,22 +654,22 @@ void ListEntitiesSensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(14, this->device_id); #endif } -void ListEntitiesSensorResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesSensorResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_string_field(total_size, 1, this->unit_of_measurement_ref_.size()); - ProtoSize::add_int32_field(total_size, 1, this->accuracy_decimals); - ProtoSize::add_bool_field(total_size, 1, this->force_update); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->state_class)); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_string_field(1, this->unit_of_measurement_ref_.size()); + size.add_int32_field(1, this->accuracy_decimals); + size.add_bool_field(1, this->force_update); + size.add_string_field(1, this->device_class_ref_.size()); + size.add_enum_field(1, static_cast(this->state_class)); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void SensorStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -680,12 +680,12 @@ void SensorStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void SensorStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_float_field(total_size, 1, this->state); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); +void SensorStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_float_field(1, this->state); + size.add_bool_field(1, this->missing_state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } #endif @@ -705,19 +705,19 @@ void ListEntitiesSwitchResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(10, this->device_id); #endif } -void ListEntitiesSwitchResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesSwitchResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->assumed_state); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); + size.add_bool_field(1, this->assumed_state); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); + size.add_string_field(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void SwitchStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -727,11 +727,11 @@ void SwitchStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->device_id); #endif } -void SwitchStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->state); +void SwitchStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_bool_field(1, this->state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool SwitchCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -775,18 +775,18 @@ void ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(9, this->device_id); #endif } -void ListEntitiesTextSensorResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesTextSensorResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); + size.add_string_field(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void TextSensorStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -797,12 +797,12 @@ void TextSensorStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void TextSensorStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->state_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); +void TextSensorStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->state_ref_.size()); + size.add_bool_field(1, this->missing_state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } #endif @@ -823,9 +823,9 @@ void SubscribeLogsResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, static_cast(this->level)); buffer.encode_bytes(3, this->message_ptr_, this->message_len_); } -void SubscribeLogsResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_enum_field(total_size, 1, static_cast(this->level)); - ProtoSize::add_bytes_field(total_size, 1, this->message_len_); +void SubscribeLogsResponse::calculate_size(ProtoSize &size) const { + size.add_enum_field(1, static_cast(this->level)); + size.add_bytes_field(1, this->message_len_); } #ifdef USE_API_NOISE bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { @@ -839,17 +839,17 @@ bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthD return true; } void NoiseEncryptionSetKeyResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->success); } -void NoiseEncryptionSetKeyResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_bool_field(total_size, 1, this->success); +} +void NoiseEncryptionSetKeyResponse::calculate_size(ProtoSize &size) const { size.add_bool_field(1, this->success); } } #endif void HomeassistantServiceMap::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->key_ref_); buffer.encode_string(2, this->value_ref_); } -void HomeassistantServiceMap::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->key_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->value_ref_.size()); +void HomeassistantServiceMap::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->key_ref_.size()); + size.add_string_field(1, this->value_ref_.size()); } void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->service_ref_); @@ -864,12 +864,12 @@ void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { } buffer.encode_bool(5, this->is_event); } -void HomeassistantServiceResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->service_ref_.size()); - ProtoSize::add_repeated_message(total_size, 1, this->data); - ProtoSize::add_repeated_message(total_size, 1, this->data_template); - ProtoSize::add_repeated_message(total_size, 1, this->variables); - ProtoSize::add_bool_field(total_size, 1, this->is_event); +void HomeassistantServiceResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->service_ref_.size()); + size.add_repeated_message(1, this->data); + size.add_repeated_message(1, this->data_template); + size.add_repeated_message(1, this->variables); + size.add_bool_field(1, this->is_event); } #ifdef USE_API_HOMEASSISTANT_STATES void SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -877,10 +877,10 @@ void SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer buffer) const buffer.encode_string(2, this->attribute_ref_); buffer.encode_bool(3, this->once); } -void SubscribeHomeAssistantStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->entity_id_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->attribute_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->once); +void SubscribeHomeAssistantStateResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->entity_id_ref_.size()); + size.add_string_field(1, this->attribute_ref_.size()); + size.add_bool_field(1, this->once); } bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { @@ -910,17 +910,17 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { return true; } void GetTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->epoch_seconds); } -void GetTimeResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->epoch_seconds); +} +void GetTimeResponse::calculate_size(ProtoSize &size) const { size.add_fixed32_field(1, this->epoch_seconds); } } #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); buffer.encode_uint32(2, static_cast(this->type)); } -void ListEntitiesServicesArgument::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->type)); +void ListEntitiesServicesArgument::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->name_ref_.size()); + size.add_enum_field(1, static_cast(this->type)); } void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); @@ -929,10 +929,10 @@ void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_message(3, it, true); } } -void ListEntitiesServicesResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_repeated_message(total_size, 1, this->args); +void ListEntitiesServicesResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->name_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_repeated_message(1, this->args); } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1018,17 +1018,17 @@ void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(8, this->device_id); #endif } -void ListEntitiesCameraResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); +void ListEntitiesCameraResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); + size.add_bool_field(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_enum_field(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void CameraImageResponse::encode(ProtoWriteBuffer buffer) const { @@ -1039,12 +1039,12 @@ void CameraImageResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void CameraImageResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bytes_field(total_size, 1, this->data_len_); - ProtoSize::add_bool_field(total_size, 1, this->done); +void CameraImageResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_bytes_field(1, this->data_len_); + size.add_bool_field(1, this->done); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool CameraImageRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1104,58 +1104,58 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(26, this->device_id); #endif } -void ListEntitiesClimateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->supports_current_temperature); - ProtoSize::add_bool_field(total_size, 1, this->supports_two_point_target_temperature); +void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); + size.add_bool_field(1, this->supports_current_temperature); + size.add_bool_field(1, this->supports_two_point_target_temperature); if (!this->supported_modes.empty()) { for (const auto &it : this->supported_modes) { - ProtoSize::add_enum_field_repeated(total_size, 1, static_cast(it)); + size.add_enum_field_repeated(1, static_cast(it)); } } - ProtoSize::add_float_field(total_size, 1, this->visual_min_temperature); - ProtoSize::add_float_field(total_size, 1, this->visual_max_temperature); - ProtoSize::add_float_field(total_size, 1, this->visual_target_temperature_step); - ProtoSize::add_bool_field(total_size, 1, this->supports_action); + size.add_float_field(1, this->visual_min_temperature); + size.add_float_field(1, this->visual_max_temperature); + size.add_float_field(1, this->visual_target_temperature_step); + size.add_bool_field(1, this->supports_action); if (!this->supported_fan_modes.empty()) { for (const auto &it : this->supported_fan_modes) { - ProtoSize::add_enum_field_repeated(total_size, 1, static_cast(it)); + size.add_enum_field_repeated(1, static_cast(it)); } } if (!this->supported_swing_modes.empty()) { for (const auto &it : this->supported_swing_modes) { - ProtoSize::add_enum_field_repeated(total_size, 1, static_cast(it)); + size.add_enum_field_repeated(1, static_cast(it)); } } if (!this->supported_custom_fan_modes.empty()) { for (const auto &it : this->supported_custom_fan_modes) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_string_field_repeated(1, it); } } if (!this->supported_presets.empty()) { for (const auto &it : this->supported_presets) { - ProtoSize::add_enum_field_repeated(total_size, 2, static_cast(it)); + size.add_enum_field_repeated(2, static_cast(it)); } } if (!this->supported_custom_presets.empty()) { for (const auto &it : this->supported_custom_presets) { - ProtoSize::add_string_field_repeated(total_size, 2, it); + size.add_string_field_repeated(2, it); } } - ProtoSize::add_bool_field(total_size, 2, this->disabled_by_default); + size.add_bool_field(2, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 2, this->icon_ref_.size()); + size.add_string_field(2, this->icon_ref_.size()); #endif - ProtoSize::add_enum_field(total_size, 2, static_cast(this->entity_category)); - ProtoSize::add_float_field(total_size, 2, this->visual_current_temperature_step); - ProtoSize::add_bool_field(total_size, 2, this->supports_current_humidity); - ProtoSize::add_bool_field(total_size, 2, this->supports_target_humidity); - ProtoSize::add_float_field(total_size, 2, this->visual_min_humidity); - ProtoSize::add_float_field(total_size, 2, this->visual_max_humidity); + size.add_enum_field(2, static_cast(this->entity_category)); + size.add_float_field(2, this->visual_current_temperature_step); + size.add_bool_field(2, this->supports_current_humidity); + size.add_bool_field(2, this->supports_target_humidity); + size.add_float_field(2, this->visual_min_humidity); + size.add_float_field(2, this->visual_max_humidity); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 2, this->device_id); + size.add_uint32_field(2, this->device_id); #endif } void ClimateStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1177,23 +1177,23 @@ void ClimateStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(16, this->device_id); #endif } -void ClimateStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->mode)); - ProtoSize::add_float_field(total_size, 1, this->current_temperature); - ProtoSize::add_float_field(total_size, 1, this->target_temperature); - ProtoSize::add_float_field(total_size, 1, this->target_temperature_low); - ProtoSize::add_float_field(total_size, 1, this->target_temperature_high); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->action)); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->fan_mode)); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->swing_mode)); - ProtoSize::add_string_field(total_size, 1, this->custom_fan_mode_ref_.size()); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->preset)); - ProtoSize::add_string_field(total_size, 1, this->custom_preset_ref_.size()); - ProtoSize::add_float_field(total_size, 1, this->current_humidity); - ProtoSize::add_float_field(total_size, 1, this->target_humidity); +void ClimateStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_enum_field(1, static_cast(this->mode)); + size.add_float_field(1, this->current_temperature); + size.add_float_field(1, this->target_temperature); + size.add_float_field(1, this->target_temperature_low); + size.add_float_field(1, this->target_temperature_high); + size.add_enum_field(1, static_cast(this->action)); + size.add_enum_field(1, static_cast(this->fan_mode)); + size.add_enum_field(1, static_cast(this->swing_mode)); + size.add_string_field(1, this->custom_fan_mode_ref_.size()); + size.add_enum_field(1, static_cast(this->preset)); + size.add_string_field(1, this->custom_preset_ref_.size()); + size.add_float_field(1, this->current_humidity); + size.add_float_field(1, this->target_humidity); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 2, this->device_id); + size.add_uint32_field(2, this->device_id); #endif } bool ClimateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1306,23 +1306,23 @@ void ListEntitiesNumberResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(14, this->device_id); #endif } -void ListEntitiesNumberResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesNumberResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_float_field(total_size, 1, this->min_value); - ProtoSize::add_float_field(total_size, 1, this->max_value); - ProtoSize::add_float_field(total_size, 1, this->step); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->unit_of_measurement_ref_.size()); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->mode)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); + size.add_float_field(1, this->min_value); + size.add_float_field(1, this->max_value); + size.add_float_field(1, this->step); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); + size.add_string_field(1, this->unit_of_measurement_ref_.size()); + size.add_enum_field(1, static_cast(this->mode)); + size.add_string_field(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void NumberStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1333,12 +1333,12 @@ void NumberStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void NumberStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_float_field(total_size, 1, this->state); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); +void NumberStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_float_field(1, this->state); + size.add_bool_field(1, this->missing_state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool NumberCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1384,22 +1384,22 @@ void ListEntitiesSelectResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(9, this->device_id); #endif } -void ListEntitiesSelectResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesSelectResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif if (!this->options.empty()) { for (const auto &it : this->options) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_string_field_repeated(1, it); } } - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void SelectStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1410,12 +1410,12 @@ void SelectStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void SelectStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->state_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); +void SelectStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->state_ref_.size()); + size.add_bool_field(1, this->missing_state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool SelectCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1470,24 +1470,24 @@ void ListEntitiesSirenResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(11, this->device_id); #endif } -void ListEntitiesSirenResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesSirenResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); + size.add_bool_field(1, this->disabled_by_default); if (!this->tones.empty()) { for (const auto &it : this->tones) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_string_field_repeated(1, it); } } - ProtoSize::add_bool_field(total_size, 1, this->supports_duration); - ProtoSize::add_bool_field(total_size, 1, this->supports_volume); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_bool_field(1, this->supports_duration); + size.add_bool_field(1, this->supports_volume); + size.add_enum_field(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void SirenStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1497,11 +1497,11 @@ void SirenStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->device_id); #endif } -void SirenStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->state); +void SirenStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_bool_field(1, this->state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool SirenCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1576,21 +1576,21 @@ void ListEntitiesLockResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(12, this->device_id); #endif } -void ListEntitiesLockResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesLockResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_bool_field(total_size, 1, this->assumed_state); - ProtoSize::add_bool_field(total_size, 1, this->supports_open); - ProtoSize::add_bool_field(total_size, 1, this->requires_code); - ProtoSize::add_string_field(total_size, 1, this->code_format_ref_.size()); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); + size.add_bool_field(1, this->assumed_state); + size.add_bool_field(1, this->supports_open); + size.add_bool_field(1, this->requires_code); + size.add_string_field(1, this->code_format_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void LockStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1600,11 +1600,11 @@ void LockStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->device_id); #endif } -void LockStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->state)); +void LockStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_enum_field(1, static_cast(this->state)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool LockCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1661,18 +1661,18 @@ void ListEntitiesButtonResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(9, this->device_id); #endif } -void ListEntitiesButtonResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesButtonResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); + size.add_string_field(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool ButtonCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1706,12 +1706,12 @@ void MediaPlayerSupportedFormat::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, static_cast(this->purpose)); buffer.encode_uint32(5, this->sample_bytes); } -void MediaPlayerSupportedFormat::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->format_ref_.size()); - ProtoSize::add_uint32_field(total_size, 1, this->sample_rate); - ProtoSize::add_uint32_field(total_size, 1, this->num_channels); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->purpose)); - ProtoSize::add_uint32_field(total_size, 1, this->sample_bytes); +void MediaPlayerSupportedFormat::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->format_ref_.size()); + size.add_uint32_field(1, this->sample_rate); + size.add_uint32_field(1, this->num_channels); + size.add_enum_field(1, static_cast(this->purpose)); + size.add_uint32_field(1, this->sample_bytes); } void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->object_id_ref_); @@ -1730,19 +1730,19 @@ void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(10, this->device_id); #endif } -void ListEntitiesMediaPlayerResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesMediaPlayerResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_bool_field(total_size, 1, this->supports_pause); - ProtoSize::add_repeated_message(total_size, 1, this->supported_formats); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); + size.add_bool_field(1, this->supports_pause); + size.add_repeated_message(1, this->supported_formats); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void MediaPlayerStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1754,13 +1754,13 @@ void MediaPlayerStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(5, this->device_id); #endif } -void MediaPlayerStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->state)); - ProtoSize::add_float_field(total_size, 1, this->volume); - ProtoSize::add_bool_field(total_size, 1, this->muted); +void MediaPlayerStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_enum_field(1, static_cast(this->state)); + size.add_float_field(1, this->volume); + size.add_bool_field(1, this->muted); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1834,21 +1834,19 @@ void BluetoothLERawAdvertisement::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->address_type); buffer.encode_bytes(4, this->data, this->data_len); } -void BluetoothLERawAdvertisement::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_sint32_field(total_size, 1, this->rssi); - ProtoSize::add_uint32_field(total_size, 1, this->address_type); - if (this->data_len != 0) { - total_size += 1 + ProtoSize::varint(static_cast(this->data_len)) + this->data_len; - } +void BluetoothLERawAdvertisement::calculate_size(ProtoSize &size) const { + size.add_uint64_field(1, this->address); + size.add_sint32_field(1, this->rssi); + size.add_uint32_field(1, this->address_type); + size.add_bytes_field(1, this->data_len); } void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->advertisements) { buffer.encode_message(1, it, true); } } -void BluetoothLERawAdvertisementsResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_repeated_message(total_size, 1, this->advertisements); +void BluetoothLERawAdvertisementsResponse::calculate_size(ProtoSize &size) const { + size.add_repeated_message(1, this->advertisements); } bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1875,11 +1873,11 @@ void BluetoothDeviceConnectionResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->mtu); buffer.encode_int32(4, this->error); } -void BluetoothDeviceConnectionResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_bool_field(total_size, 1, this->connected); - ProtoSize::add_uint32_field(total_size, 1, this->mtu); - ProtoSize::add_int32_field(total_size, 1, this->error); +void BluetoothDeviceConnectionResponse::calculate_size(ProtoSize &size) const { + size.add_uint64_field(1, this->address); + size.add_bool_field(1, this->connected); + size.add_uint32_field(1, this->mtu); + size.add_int32_field(1, this->error); } bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1896,10 +1894,10 @@ void BluetoothGATTDescriptor::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->uuid[1], true); buffer.encode_uint32(2, this->handle); } -void BluetoothGATTDescriptor::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field_repeated(total_size, 1, this->uuid[0]); - ProtoSize::add_uint64_field_repeated(total_size, 1, this->uuid[1]); - ProtoSize::add_uint32_field(total_size, 1, this->handle); +void BluetoothGATTDescriptor::calculate_size(ProtoSize &size) const { + size.add_uint64_field_repeated(1, this->uuid[0]); + size.add_uint64_field_repeated(1, this->uuid[1]); + size.add_uint32_field(1, this->handle); } void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->uuid[0], true); @@ -1910,12 +1908,12 @@ void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { buffer.encode_message(4, it, true); } } -void BluetoothGATTCharacteristic::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field_repeated(total_size, 1, this->uuid[0]); - ProtoSize::add_uint64_field_repeated(total_size, 1, this->uuid[1]); - ProtoSize::add_uint32_field(total_size, 1, this->handle); - ProtoSize::add_uint32_field(total_size, 1, this->properties); - ProtoSize::add_repeated_message(total_size, 1, this->descriptors); +void BluetoothGATTCharacteristic::calculate_size(ProtoSize &size) const { + size.add_uint64_field_repeated(1, this->uuid[0]); + size.add_uint64_field_repeated(1, this->uuid[1]); + size.add_uint32_field(1, this->handle); + size.add_uint32_field(1, this->properties); + size.add_repeated_message(1, this->descriptors); } void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->uuid[0], true); @@ -1925,25 +1923,25 @@ void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { buffer.encode_message(3, it, true); } } -void BluetoothGATTService::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field_repeated(total_size, 1, this->uuid[0]); - ProtoSize::add_uint64_field_repeated(total_size, 1, this->uuid[1]); - ProtoSize::add_uint32_field(total_size, 1, this->handle); - ProtoSize::add_repeated_message(total_size, 1, this->characteristics); +void BluetoothGATTService::calculate_size(ProtoSize &size) const { + size.add_uint64_field_repeated(1, this->uuid[0]); + size.add_uint64_field_repeated(1, this->uuid[1]); + size.add_uint32_field(1, this->handle); + size.add_repeated_message(1, this->characteristics); } void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_message(2, this->services[0], true); } -void BluetoothGATTGetServicesResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_message_object_repeated(total_size, 1, this->services[0]); +void BluetoothGATTGetServicesResponse::calculate_size(ProtoSize &size) const { + size.add_uint64_field(1, this->address); + size.add_message_object_repeated(1, this->services[0]); } void BluetoothGATTGetServicesDoneResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); } -void BluetoothGATTGetServicesDoneResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); +void BluetoothGATTGetServicesDoneResponse::calculate_size(ProtoSize &size) const { + size.add_uint64_field(1, this->address); } bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1963,10 +1961,10 @@ void BluetoothGATTReadResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->handle); buffer.encode_bytes(3, this->data_ptr_, this->data_len_); } -void BluetoothGATTReadResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_uint32_field(total_size, 1, this->handle); - ProtoSize::add_bytes_field(total_size, 1, this->data_len_); +void BluetoothGATTReadResponse::calculate_size(ProtoSize &size) const { + size.add_uint64_field(1, this->address); + size.add_uint32_field(1, this->handle); + size.add_bytes_field(1, this->data_len_); } bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2051,10 +2049,10 @@ void BluetoothGATTNotifyDataResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->handle); buffer.encode_bytes(3, this->data_ptr_, this->data_len_); } -void BluetoothGATTNotifyDataResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_uint32_field(total_size, 1, this->handle); - ProtoSize::add_bytes_field(total_size, 1, this->data_len_); +void BluetoothGATTNotifyDataResponse::calculate_size(ProtoSize &size) const { + size.add_uint64_field(1, this->address); + size.add_uint32_field(1, this->handle); + size.add_bytes_field(1, this->data_len_); } void BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->free); @@ -2063,12 +2061,12 @@ void BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(3, it, true); } } -void BluetoothConnectionsFreeResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint32_field(total_size, 1, this->free); - ProtoSize::add_uint32_field(total_size, 1, this->limit); +void BluetoothConnectionsFreeResponse::calculate_size(ProtoSize &size) const { + size.add_uint32_field(1, this->free); + size.add_uint32_field(1, this->limit); if (!this->allocated.empty()) { for (const auto &it : this->allocated) { - ProtoSize::add_uint64_field_repeated(total_size, 1, it); + size.add_uint64_field_repeated(1, it); } } } @@ -2077,64 +2075,64 @@ void BluetoothGATTErrorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->handle); buffer.encode_int32(3, this->error); } -void BluetoothGATTErrorResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_uint32_field(total_size, 1, this->handle); - ProtoSize::add_int32_field(total_size, 1, this->error); +void BluetoothGATTErrorResponse::calculate_size(ProtoSize &size) const { + size.add_uint64_field(1, this->address); + size.add_uint32_field(1, this->handle); + size.add_int32_field(1, this->error); } void BluetoothGATTWriteResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_uint32(2, this->handle); } -void BluetoothGATTWriteResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_uint32_field(total_size, 1, this->handle); +void BluetoothGATTWriteResponse::calculate_size(ProtoSize &size) const { + size.add_uint64_field(1, this->address); + size.add_uint32_field(1, this->handle); } void BluetoothGATTNotifyResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_uint32(2, this->handle); } -void BluetoothGATTNotifyResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_uint32_field(total_size, 1, this->handle); +void BluetoothGATTNotifyResponse::calculate_size(ProtoSize &size) const { + size.add_uint64_field(1, this->address); + size.add_uint32_field(1, this->handle); } void BluetoothDevicePairingResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_bool(2, this->paired); buffer.encode_int32(3, this->error); } -void BluetoothDevicePairingResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_bool_field(total_size, 1, this->paired); - ProtoSize::add_int32_field(total_size, 1, this->error); +void BluetoothDevicePairingResponse::calculate_size(ProtoSize &size) const { + size.add_uint64_field(1, this->address); + size.add_bool_field(1, this->paired); + size.add_int32_field(1, this->error); } void BluetoothDeviceUnpairingResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_bool(2, this->success); buffer.encode_int32(3, this->error); } -void BluetoothDeviceUnpairingResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_bool_field(total_size, 1, this->success); - ProtoSize::add_int32_field(total_size, 1, this->error); +void BluetoothDeviceUnpairingResponse::calculate_size(ProtoSize &size) const { + size.add_uint64_field(1, this->address); + size.add_bool_field(1, this->success); + size.add_int32_field(1, this->error); } void BluetoothDeviceClearCacheResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_bool(2, this->success); buffer.encode_int32(3, this->error); } -void BluetoothDeviceClearCacheResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_bool_field(total_size, 1, this->success); - ProtoSize::add_int32_field(total_size, 1, this->error); +void BluetoothDeviceClearCacheResponse::calculate_size(ProtoSize &size) const { + size.add_uint64_field(1, this->address); + size.add_bool_field(1, this->success); + size.add_int32_field(1, this->error); } void BluetoothScannerStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, static_cast(this->state)); buffer.encode_uint32(2, static_cast(this->mode)); } -void BluetoothScannerStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_enum_field(total_size, 1, static_cast(this->state)); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->mode)); +void BluetoothScannerStateResponse::calculate_size(ProtoSize &size) const { + size.add_enum_field(1, static_cast(this->state)); + size.add_enum_field(1, static_cast(this->mode)); } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2166,10 +2164,10 @@ void VoiceAssistantAudioSettings::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->auto_gain); buffer.encode_float(3, this->volume_multiplier); } -void VoiceAssistantAudioSettings::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint32_field(total_size, 1, this->noise_suppression_level); - ProtoSize::add_uint32_field(total_size, 1, this->auto_gain); - ProtoSize::add_float_field(total_size, 1, this->volume_multiplier); +void VoiceAssistantAudioSettings::calculate_size(ProtoSize &size) const { + size.add_uint32_field(1, this->noise_suppression_level); + size.add_uint32_field(1, this->auto_gain); + size.add_float_field(1, this->volume_multiplier); } void VoiceAssistantRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->start); @@ -2178,12 +2176,12 @@ void VoiceAssistantRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_message(4, this->audio_settings); buffer.encode_string(5, this->wake_word_phrase_ref_); } -void VoiceAssistantRequest::calculate_size(uint32_t &total_size) const { - ProtoSize::add_bool_field(total_size, 1, this->start); - ProtoSize::add_string_field(total_size, 1, this->conversation_id_ref_.size()); - ProtoSize::add_uint32_field(total_size, 1, this->flags); - ProtoSize::add_message_object(total_size, 1, this->audio_settings); - ProtoSize::add_string_field(total_size, 1, this->wake_word_phrase_ref_.size()); +void VoiceAssistantRequest::calculate_size(ProtoSize &size) const { + size.add_bool_field(1, this->start); + size.add_string_field(1, this->conversation_id_ref_.size()); + size.add_uint32_field(1, this->flags); + size.add_message_object(1, this->audio_settings); + size.add_string_field(1, this->wake_word_phrase_ref_.size()); } bool VoiceAssistantResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2256,9 +2254,9 @@ void VoiceAssistantAudio::encode(ProtoWriteBuffer buffer) const { buffer.encode_bytes(1, this->data_ptr_, this->data_len_); buffer.encode_bool(2, this->end); } -void VoiceAssistantAudio::calculate_size(uint32_t &total_size) const { - ProtoSize::add_bytes_field(total_size, 1, this->data_len_); - ProtoSize::add_bool_field(total_size, 1, this->end); +void VoiceAssistantAudio::calculate_size(ProtoSize &size) const { + size.add_bytes_field(1, this->data_len_); + size.add_bool_field(1, this->end); } bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2319,8 +2317,8 @@ bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLength return true; } void VoiceAssistantAnnounceFinished::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->success); } -void VoiceAssistantAnnounceFinished::calculate_size(uint32_t &total_size) const { - ProtoSize::add_bool_field(total_size, 1, this->success); +} +void VoiceAssistantAnnounceFinished::calculate_size(ProtoSize &size) const { size.add_bool_field(1, this->success); } } void VoiceAssistantWakeWord::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->id_ref_); @@ -2329,12 +2327,12 @@ void VoiceAssistantWakeWord::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(3, it, true); } } -void VoiceAssistantWakeWord::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->id_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->wake_word_ref_.size()); +void VoiceAssistantWakeWord::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->id_ref_.size()); + size.add_string_field(1, this->wake_word_ref_.size()); if (!this->trained_languages.empty()) { for (const auto &it : this->trained_languages) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_string_field_repeated(1, it); } } } @@ -2347,14 +2345,14 @@ void VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer buffer) const } buffer.encode_uint32(3, this->max_active_wake_words); } -void VoiceAssistantConfigurationResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_repeated_message(total_size, 1, this->available_wake_words); +void VoiceAssistantConfigurationResponse::calculate_size(ProtoSize &size) const { + size.add_repeated_message(1, this->available_wake_words); if (!this->active_wake_words.empty()) { for (const auto &it : this->active_wake_words) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_string_field_repeated(1, it); } } - ProtoSize::add_uint32_field(total_size, 1, this->max_active_wake_words); + size.add_uint32_field(1, this->max_active_wake_words); } bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { @@ -2384,20 +2382,20 @@ void ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer buffer) cons buffer.encode_uint32(11, this->device_id); #endif } -void ListEntitiesAlarmControlPanelResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesAlarmControlPanelResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_uint32_field(total_size, 1, this->supported_features); - ProtoSize::add_bool_field(total_size, 1, this->requires_code); - ProtoSize::add_bool_field(total_size, 1, this->requires_code_to_arm); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); + size.add_uint32_field(1, this->supported_features); + size.add_bool_field(1, this->requires_code); + size.add_bool_field(1, this->requires_code_to_arm); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void AlarmControlPanelStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2407,11 +2405,11 @@ void AlarmControlPanelStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->device_id); #endif } -void AlarmControlPanelStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->state)); +void AlarmControlPanelStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_enum_field(1, static_cast(this->state)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2468,21 +2466,21 @@ void ListEntitiesTextResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(12, this->device_id); #endif } -void ListEntitiesTextResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesTextResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_uint32_field(total_size, 1, this->min_length); - ProtoSize::add_uint32_field(total_size, 1, this->max_length); - ProtoSize::add_string_field(total_size, 1, this->pattern_ref_.size()); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->mode)); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); + size.add_uint32_field(1, this->min_length); + size.add_uint32_field(1, this->max_length); + size.add_string_field(1, this->pattern_ref_.size()); + size.add_enum_field(1, static_cast(this->mode)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void TextStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2493,12 +2491,12 @@ void TextStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void TextStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->state_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); +void TextStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->state_ref_.size()); + size.add_bool_field(1, this->missing_state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool TextCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2548,17 +2546,17 @@ void ListEntitiesDateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(8, this->device_id); #endif } -void ListEntitiesDateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesDateResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void DateStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2571,14 +2569,14 @@ void DateStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(6, this->device_id); #endif } -void DateStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); - ProtoSize::add_uint32_field(total_size, 1, this->year); - ProtoSize::add_uint32_field(total_size, 1, this->month); - ProtoSize::add_uint32_field(total_size, 1, this->day); +void DateStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_bool_field(1, this->missing_state); + size.add_uint32_field(1, this->year); + size.add_uint32_field(1, this->month); + size.add_uint32_field(1, this->day); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool DateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2627,17 +2625,17 @@ void ListEntitiesTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(8, this->device_id); #endif } -void ListEntitiesTimeResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesTimeResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void TimeStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2650,14 +2648,14 @@ void TimeStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(6, this->device_id); #endif } -void TimeStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); - ProtoSize::add_uint32_field(total_size, 1, this->hour); - ProtoSize::add_uint32_field(total_size, 1, this->minute); - ProtoSize::add_uint32_field(total_size, 1, this->second); +void TimeStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_bool_field(1, this->missing_state); + size.add_uint32_field(1, this->hour); + size.add_uint32_field(1, this->minute); + size.add_uint32_field(1, this->second); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool TimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2710,23 +2708,23 @@ void ListEntitiesEventResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(10, this->device_id); #endif } -void ListEntitiesEventResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesEventResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); + size.add_string_field(1, this->device_class_ref_.size()); if (!this->event_types.empty()) { for (const auto &it : this->event_types) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_string_field_repeated(1, it); } } #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void EventResponse::encode(ProtoWriteBuffer buffer) const { @@ -2736,11 +2734,11 @@ void EventResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->device_id); #endif } -void EventResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->event_type_ref_.size()); +void EventResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->event_type_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } #endif @@ -2762,21 +2760,21 @@ void ListEntitiesValveResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(12, this->device_id); #endif } -void ListEntitiesValveResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesValveResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->assumed_state); - ProtoSize::add_bool_field(total_size, 1, this->supports_position); - ProtoSize::add_bool_field(total_size, 1, this->supports_stop); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); + size.add_string_field(1, this->device_class_ref_.size()); + size.add_bool_field(1, this->assumed_state); + size.add_bool_field(1, this->supports_position); + size.add_bool_field(1, this->supports_stop); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void ValveStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2787,12 +2785,12 @@ void ValveStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void ValveStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_float_field(total_size, 1, this->position); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->current_operation)); +void ValveStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_float_field(1, this->position); + size.add_enum_field(1, static_cast(this->current_operation)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool ValveCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2841,17 +2839,17 @@ void ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(8, this->device_id); #endif } -void ListEntitiesDateTimeResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesDateTimeResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void DateTimeStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2862,12 +2860,12 @@ void DateTimeStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void DateTimeStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); - ProtoSize::add_fixed32_field(total_size, 1, this->epoch_seconds); +void DateTimeStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_bool_field(1, this->missing_state); + size.add_fixed32_field(1, this->epoch_seconds); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool DateTimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2911,18 +2909,18 @@ void ListEntitiesUpdateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(9, this->device_id); #endif } -void ListEntitiesUpdateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesUpdateResponse::calculate_size(ProtoSize &size) const { + size.add_string_field(1, this->object_id_ref_.size()); + size.add_fixed32_field(1, this->key); + size.add_string_field(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_string_field(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); + size.add_bool_field(1, this->disabled_by_default); + size.add_enum_field(1, static_cast(this->entity_category)); + size.add_string_field(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } void UpdateStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2940,19 +2938,19 @@ void UpdateStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(11, this->device_id); #endif } -void UpdateStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); - ProtoSize::add_bool_field(total_size, 1, this->in_progress); - ProtoSize::add_bool_field(total_size, 1, this->has_progress); - ProtoSize::add_float_field(total_size, 1, this->progress); - ProtoSize::add_string_field(total_size, 1, this->current_version_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->latest_version_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->title_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->release_summary_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->release_url_ref_.size()); +void UpdateStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32_field(1, this->key); + size.add_bool_field(1, this->missing_state); + size.add_bool_field(1, this->in_progress); + size.add_bool_field(1, this->has_progress); + size.add_float_field(1, this->progress); + size.add_string_field(1, this->current_version_ref_.size()); + size.add_string_field(1, this->latest_version_ref_.size()); + size.add_string_field(1, this->title_ref_.size()); + size.add_string_field(1, this->release_summary_ref_.size()); + size.add_string_field(1, this->release_url_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32_field(1, this->device_id); #endif } bool UpdateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index b7d8945e8e..f204acdebe 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -340,7 +340,7 @@ class HelloResponse : public ProtoMessage { StringRef name_ref_{}; void set_name(const StringRef &ref) { this->name_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -371,7 +371,7 @@ class ConnectResponse : public ProtoMessage { #endif bool invalid_password{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -450,7 +450,7 @@ class AreaInfo : public ProtoMessage { StringRef name_ref_{}; void set_name(const StringRef &ref) { this->name_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -466,7 +466,7 @@ class DeviceInfo : public ProtoMessage { void set_name(const StringRef &ref) { this->name_ref_ = ref; } uint32_t area_id{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -539,7 +539,7 @@ class DeviceInfoResponse : public ProtoMessage { AreaInfo area{}; #endif void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -597,7 +597,7 @@ class ListEntitiesBinarySensorResponse : public InfoResponseProtoMessage { void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } bool is_status_binary_sensor{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -614,7 +614,7 @@ class BinarySensorStateResponse : public StateResponseProtoMessage { bool state{false}; bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -637,7 +637,7 @@ class ListEntitiesCoverResponse : public InfoResponseProtoMessage { void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } bool supports_stop{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -655,7 +655,7 @@ class CoverStateResponse : public StateResponseProtoMessage { float tilt{0.0f}; enums::CoverOperation current_operation{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -697,7 +697,7 @@ class ListEntitiesFanResponse : public InfoResponseProtoMessage { int32_t supported_speed_count{0}; std::vector supported_preset_modes{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -718,7 +718,7 @@ class FanStateResponse : public StateResponseProtoMessage { StringRef preset_mode_ref_{}; void set_preset_mode(const StringRef &ref) { this->preset_mode_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -765,7 +765,7 @@ class ListEntitiesLightResponse : public InfoResponseProtoMessage { float max_mireds{0.0f}; std::vector effects{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -793,7 +793,7 @@ class LightStateResponse : public StateResponseProtoMessage { StringRef effect_ref_{}; void set_effect(const StringRef &ref) { this->effect_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -859,7 +859,7 @@ class ListEntitiesSensorResponse : public InfoResponseProtoMessage { void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } enums::SensorStateClass state_class{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -876,7 +876,7 @@ class SensorStateResponse : public StateResponseProtoMessage { float state{0.0f}; bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -896,7 +896,7 @@ class ListEntitiesSwitchResponse : public InfoResponseProtoMessage { StringRef device_class_ref_{}; void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -912,7 +912,7 @@ class SwitchStateResponse : public StateResponseProtoMessage { #endif bool state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -947,7 +947,7 @@ class ListEntitiesTextSensorResponse : public InfoResponseProtoMessage { StringRef device_class_ref_{}; void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -965,7 +965,7 @@ class TextSensorStateResponse : public StateResponseProtoMessage { void set_state(const StringRef &ref) { this->state_ref_ = ref; } bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1004,7 +1004,7 @@ class SubscribeLogsResponse : public ProtoMessage { this->message_len_ = len; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1036,7 +1036,7 @@ class NoiseEncryptionSetKeyResponse : public ProtoMessage { #endif bool success{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1064,7 +1064,7 @@ class HomeassistantServiceMap : public ProtoMessage { StringRef value_ref_{}; void set_value(const StringRef &ref) { this->value_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1085,7 +1085,7 @@ class HomeassistantServiceResponse : public ProtoMessage { std::vector variables{}; bool is_event{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1119,7 +1119,7 @@ class SubscribeHomeAssistantStateResponse : public ProtoMessage { void set_attribute(const StringRef &ref) { this->attribute_ref_ = ref; } bool once{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1166,7 +1166,7 @@ class GetTimeResponse : public ProtoDecodableMessage { #endif uint32_t epoch_seconds{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1181,7 +1181,7 @@ class ListEntitiesServicesArgument : public ProtoMessage { void set_name(const StringRef &ref) { this->name_ref_ = ref; } enums::ServiceArgType type{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1200,7 +1200,7 @@ class ListEntitiesServicesResponse : public ProtoMessage { uint32_t key{0}; std::vector args{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1254,7 +1254,7 @@ class ListEntitiesCameraResponse : public InfoResponseProtoMessage { const char *message_name() const override { return "list_entities_camera_response"; } #endif void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1276,7 +1276,7 @@ class CameraImageResponse : public StateResponseProtoMessage { } bool done{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1326,7 +1326,7 @@ class ListEntitiesClimateResponse : public InfoResponseProtoMessage { float visual_min_humidity{0.0f}; float visual_max_humidity{0.0f}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1356,7 +1356,7 @@ class ClimateStateResponse : public StateResponseProtoMessage { float current_humidity{0.0f}; float target_humidity{0.0f}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1417,7 +1417,7 @@ class ListEntitiesNumberResponse : public InfoResponseProtoMessage { StringRef device_class_ref_{}; void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1434,7 +1434,7 @@ class NumberStateResponse : public StateResponseProtoMessage { float state{0.0f}; bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1468,7 +1468,7 @@ class ListEntitiesSelectResponse : public InfoResponseProtoMessage { #endif std::vector options{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1486,7 +1486,7 @@ class SelectStateResponse : public StateResponseProtoMessage { void set_state(const StringRef &ref) { this->state_ref_ = ref; } bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1523,7 +1523,7 @@ class ListEntitiesSirenResponse : public InfoResponseProtoMessage { bool supports_duration{false}; bool supports_volume{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1539,7 +1539,7 @@ class SirenStateResponse : public StateResponseProtoMessage { #endif bool state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1585,7 +1585,7 @@ class ListEntitiesLockResponse : public InfoResponseProtoMessage { StringRef code_format_ref_{}; void set_code_format(const StringRef &ref) { this->code_format_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1601,7 +1601,7 @@ class LockStateResponse : public StateResponseProtoMessage { #endif enums::LockState state{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1639,7 +1639,7 @@ class ListEntitiesButtonResponse : public InfoResponseProtoMessage { StringRef device_class_ref_{}; void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1672,7 +1672,7 @@ class MediaPlayerSupportedFormat : public ProtoMessage { enums::MediaPlayerFormatPurpose purpose{}; uint32_t sample_bytes{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1689,7 +1689,7 @@ class ListEntitiesMediaPlayerResponse : public InfoResponseProtoMessage { bool supports_pause{false}; std::vector supported_formats{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1707,7 +1707,7 @@ class MediaPlayerStateResponse : public StateResponseProtoMessage { float volume{0.0f}; bool muted{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1763,7 +1763,7 @@ class BluetoothLERawAdvertisement : public ProtoMessage { uint8_t data[62]{}; uint8_t data_len{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1779,7 +1779,7 @@ class BluetoothLERawAdvertisementsResponse : public ProtoMessage { #endif std::vector advertisements{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1816,7 +1816,7 @@ class BluetoothDeviceConnectionResponse : public ProtoMessage { uint32_t mtu{0}; int32_t error{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1843,7 +1843,7 @@ class BluetoothGATTDescriptor : public ProtoMessage { std::array uuid{}; uint32_t handle{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1857,7 +1857,7 @@ class BluetoothGATTCharacteristic : public ProtoMessage { uint32_t properties{0}; std::vector descriptors{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1870,7 +1870,7 @@ class BluetoothGATTService : public ProtoMessage { uint32_t handle{0}; std::vector characteristics{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1887,7 +1887,7 @@ class BluetoothGATTGetServicesResponse : public ProtoMessage { uint64_t address{0}; std::array services{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1903,7 +1903,7 @@ class BluetoothGATTGetServicesDoneResponse : public ProtoMessage { #endif uint64_t address{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1942,7 +1942,7 @@ class BluetoothGATTReadResponse : public ProtoMessage { this->data_len_ = len; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2035,7 +2035,7 @@ class BluetoothGATTNotifyDataResponse : public ProtoMessage { this->data_len_ = len; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2066,7 +2066,7 @@ class BluetoothConnectionsFreeResponse : public ProtoMessage { uint32_t limit{0}; std::vector allocated{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2084,7 +2084,7 @@ class BluetoothGATTErrorResponse : public ProtoMessage { uint32_t handle{0}; int32_t error{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2101,7 +2101,7 @@ class BluetoothGATTWriteResponse : public ProtoMessage { uint64_t address{0}; uint32_t handle{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2118,7 +2118,7 @@ class BluetoothGATTNotifyResponse : public ProtoMessage { uint64_t address{0}; uint32_t handle{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2136,7 +2136,7 @@ class BluetoothDevicePairingResponse : public ProtoMessage { bool paired{false}; int32_t error{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2154,7 +2154,7 @@ class BluetoothDeviceUnpairingResponse : public ProtoMessage { bool success{false}; int32_t error{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2185,7 +2185,7 @@ class BluetoothDeviceClearCacheResponse : public ProtoMessage { bool success{false}; int32_t error{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2202,7 +2202,7 @@ class BluetoothScannerStateResponse : public ProtoMessage { enums::BluetoothScannerState state{}; enums::BluetoothScannerMode mode{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2248,7 +2248,7 @@ class VoiceAssistantAudioSettings : public ProtoMessage { uint32_t auto_gain{0}; float volume_multiplier{0.0f}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2270,7 +2270,7 @@ class VoiceAssistantRequest : public ProtoMessage { StringRef wake_word_phrase_ref_{}; void set_wake_word_phrase(const StringRef &ref) { this->wake_word_phrase_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2337,7 +2337,7 @@ class VoiceAssistantAudio : public ProtoDecodableMessage { } bool end{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2395,7 +2395,7 @@ class VoiceAssistantAnnounceFinished : public ProtoMessage { #endif bool success{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2410,7 +2410,7 @@ class VoiceAssistantWakeWord : public ProtoMessage { void set_wake_word(const StringRef &ref) { this->wake_word_ref_ = ref; } std::vector trained_languages{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2441,7 +2441,7 @@ class VoiceAssistantConfigurationResponse : public ProtoMessage { std::vector active_wake_words{}; uint32_t max_active_wake_words{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2476,7 +2476,7 @@ class ListEntitiesAlarmControlPanelResponse : public InfoResponseProtoMessage { bool requires_code{false}; bool requires_code_to_arm{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2492,7 +2492,7 @@ class AlarmControlPanelStateResponse : public StateResponseProtoMessage { #endif enums::AlarmControlPanelState state{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2532,7 +2532,7 @@ class ListEntitiesTextResponse : public InfoResponseProtoMessage { void set_pattern(const StringRef &ref) { this->pattern_ref_ = ref; } enums::TextMode mode{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2550,7 +2550,7 @@ class TextStateResponse : public StateResponseProtoMessage { void set_state(const StringRef &ref) { this->state_ref_ = ref; } bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2584,7 +2584,7 @@ class ListEntitiesDateResponse : public InfoResponseProtoMessage { const char *message_name() const override { return "list_entities_date_response"; } #endif void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2603,7 +2603,7 @@ class DateStateResponse : public StateResponseProtoMessage { uint32_t month{0}; uint32_t day{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2638,7 +2638,7 @@ class ListEntitiesTimeResponse : public InfoResponseProtoMessage { const char *message_name() const override { return "list_entities_time_response"; } #endif void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2657,7 +2657,7 @@ class TimeStateResponse : public StateResponseProtoMessage { uint32_t minute{0}; uint32_t second{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2695,7 +2695,7 @@ class ListEntitiesEventResponse : public InfoResponseProtoMessage { void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } std::vector event_types{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2712,7 +2712,7 @@ class EventResponse : public StateResponseProtoMessage { StringRef event_type_ref_{}; void set_event_type(const StringRef &ref) { this->event_type_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2734,7 +2734,7 @@ class ListEntitiesValveResponse : public InfoResponseProtoMessage { bool supports_position{false}; bool supports_stop{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2751,7 +2751,7 @@ class ValveStateResponse : public StateResponseProtoMessage { float position{0.0f}; enums::ValveOperation current_operation{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2786,7 +2786,7 @@ class ListEntitiesDateTimeResponse : public InfoResponseProtoMessage { const char *message_name() const override { return "list_entities_date_time_response"; } #endif void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2803,7 +2803,7 @@ class DateTimeStateResponse : public StateResponseProtoMessage { bool missing_state{false}; uint32_t epoch_seconds{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2838,7 +2838,7 @@ class ListEntitiesUpdateResponse : public InfoResponseProtoMessage { StringRef device_class_ref_{}; void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2867,7 +2867,7 @@ class UpdateStateResponse : public StateResponseProtoMessage { StringRef release_url_ref_{}; void set_release_url(const StringRef &ref) { this->release_url_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 771eaa98d1..74a918751c 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -340,7 +340,7 @@ class ProtoMessage { // Default implementation for messages with no fields virtual void encode(ProtoWriteBuffer buffer) const {} // Default implementation for messages with no fields - virtual void calculate_size(uint32_t &total_size) const {} + virtual void calculate_size(ProtoSize &size) const {} #ifdef HAS_PROTO_MESSAGE_DUMP std::string dump() const; virtual void dump_to(std::string &out) const = 0; @@ -361,24 +361,32 @@ class ProtoDecodableMessage : public ProtoMessage { }; class ProtoSize { + private: + uint32_t total_size_ = 0; + public: /** * @brief ProtoSize class for Protocol Buffer serialization size calculation * - * This class provides static methods to calculate the exact byte counts needed - * for encoding various Protocol Buffer field types. All methods are designed to be - * efficient for the common case where many fields have default values. + * This class provides methods to calculate the exact byte counts needed + * for encoding various Protocol Buffer field types. The class now uses an + * object-based approach to reduce parameter passing overhead while keeping + * varint calculation methods static for external use. * * Implements Protocol Buffer encoding size calculation according to: * https://protobuf.dev/programming-guides/encoding/ * * Key features: + * - Object-based approach reduces flash usage by eliminating parameter passing * - Early-return optimization for zero/default values - * - Direct total_size updates to avoid unnecessary additions + * - Static varint methods for external callers * - Specialized handling for different field types according to protobuf spec - * - Templated helpers for repeated fields and messages */ + ProtoSize() = default; + + uint32_t get_size() const { return total_size_; } + /** * @brief Calculates the size in bytes needed to encode a uint32_t value as a varint * @@ -479,9 +487,7 @@ class ProtoSize { * @brief Common parameters for all add_*_field methods * * All add_*_field methods follow these common patterns: - * - * @param total_size Reference to the total message size to update - * @param field_id_size Pre-calculated size of the field ID in bytes + * * @param field_id_size Pre-calculated size of the field ID in bytes * @param value The value to calculate size for (type varies) * @param force Whether to calculate size even if the value is default/zero/empty * @@ -494,85 +500,85 @@ class ProtoSize { /** * @brief Calculates and adds the size of an int32 field to the total message size */ - static inline void add_int32_field(uint32_t &total_size, uint32_t field_id_size, int32_t value) { + inline void add_int32_field(uint32_t field_id_size, int32_t value) { // Skip calculation if value is zero if (value == 0) { - return; // No need to update total_size + return; // No need to update total_size_ } // Calculate and directly add to total_size if (value < 0) { // Negative values are encoded as 10-byte varints in protobuf - total_size += field_id_size + 10; + total_size_ += field_id_size + 10; } else { // For non-negative values, use the standard varint size - total_size += field_id_size + varint(static_cast(value)); + total_size_ += field_id_size + varint(static_cast(value)); } } /** * @brief Calculates and adds the size of an int32 field to the total message size (repeated field version) */ - static inline void add_int32_field_repeated(uint32_t &total_size, uint32_t field_id_size, int32_t value) { + inline void add_int32_field_repeated(uint32_t field_id_size, int32_t value) { // Always calculate size for repeated fields if (value < 0) { // Negative values are encoded as 10-byte varints in protobuf - total_size += field_id_size + 10; + total_size_ += field_id_size + 10; } else { // For non-negative values, use the standard varint size - total_size += field_id_size + varint(static_cast(value)); + total_size_ += field_id_size + varint(static_cast(value)); } } /** * @brief Calculates and adds the size of a uint32 field to the total message size */ - static inline void add_uint32_field(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { + inline void add_uint32_field(uint32_t field_id_size, uint32_t value) { // Skip calculation if value is zero if (value == 0) { - return; // No need to update total_size + return; // No need to update total_size_ } // Calculate and directly add to total_size - total_size += field_id_size + varint(value); + total_size_ += field_id_size + varint(value); } /** * @brief Calculates and adds the size of a uint32 field to the total message size (repeated field version) */ - static inline void add_uint32_field_repeated(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { + inline void add_uint32_field_repeated(uint32_t field_id_size, uint32_t value) { // Always calculate size for repeated fields - total_size += field_id_size + varint(value); + total_size_ += field_id_size + varint(value); } /** * @brief Calculates and adds the size of a boolean field to the total message size */ - static inline void add_bool_field(uint32_t &total_size, uint32_t field_id_size, bool value) { + inline void add_bool_field(uint32_t field_id_size, bool value) { // Skip calculation if value is false if (!value) { - return; // No need to update total_size + return; // No need to update total_size_ } // Boolean fields always use 1 byte when true - total_size += field_id_size + 1; + total_size_ += field_id_size + 1; } /** * @brief Calculates and adds the size of a boolean field to the total message size (repeated field version) */ - static inline void add_bool_field_repeated(uint32_t &total_size, uint32_t field_id_size, bool value) { + inline void add_bool_field_repeated(uint32_t field_id_size, bool value) { // Always calculate size for repeated fields // Boolean fields always use 1 byte - total_size += field_id_size + 1; + total_size_ += field_id_size + 1; } /** * @brief Calculates and adds the size of a float field to the total message size */ - static inline void add_float_field(uint32_t &total_size, uint32_t field_id_size, float value) { + inline void add_float_field(uint32_t field_id_size, float value) { if (value != 0.0f) { - total_size += field_id_size + 4; + total_size_ += field_id_size + 4; } } @@ -582,9 +588,9 @@ class ProtoSize { /** * @brief Calculates and adds the size of a fixed32 field to the total message size */ - static inline void add_fixed32_field(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { + inline void add_fixed32_field(uint32_t field_id_size, uint32_t value) { if (value != 0) { - total_size += field_id_size + 4; + total_size_ += field_id_size + 4; } } @@ -594,9 +600,9 @@ class ProtoSize { /** * @brief Calculates and adds the size of a sfixed32 field to the total message size */ - static inline void add_sfixed32_field(uint32_t &total_size, uint32_t field_id_size, int32_t value) { + inline void add_sfixed32_field(uint32_t field_id_size, int32_t value) { if (value != 0) { - total_size += field_id_size + 4; + total_size_ += field_id_size + 4; } } @@ -608,14 +614,14 @@ class ProtoSize { * * Enum fields are encoded as uint32 varints. */ - static inline void add_enum_field(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { + inline void add_enum_field(uint32_t field_id_size, uint32_t value) { // Skip calculation if value is zero if (value == 0) { - return; // No need to update total_size + return; // No need to update total_size_ } // Enums are encoded as uint32 - total_size += field_id_size + varint(value); + total_size_ += field_id_size + varint(value); } /** @@ -623,10 +629,10 @@ class ProtoSize { * * Enum fields are encoded as uint32 varints. */ - static inline void add_enum_field_repeated(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { + inline void add_enum_field_repeated(uint32_t field_id_size, uint32_t value) { // Always calculate size for repeated fields // Enums are encoded as uint32 - total_size += field_id_size + varint(value); + total_size_ += field_id_size + varint(value); } /** @@ -634,15 +640,15 @@ class ProtoSize { * * Sint32 fields use ZigZag encoding, which is more efficient for negative values. */ - static inline void add_sint32_field(uint32_t &total_size, uint32_t field_id_size, int32_t value) { + inline void add_sint32_field(uint32_t field_id_size, int32_t value) { // Skip calculation if value is zero if (value == 0) { - return; // No need to update total_size + return; // No need to update total_size_ } // ZigZag encoding for sint32: (n << 1) ^ (n >> 31) uint32_t zigzag = (static_cast(value) << 1) ^ (static_cast(value >> 31)); - total_size += field_id_size + varint(zigzag); + total_size_ += field_id_size + varint(zigzag); } /** @@ -650,53 +656,53 @@ class ProtoSize { * * Sint32 fields use ZigZag encoding, which is more efficient for negative values. */ - static inline void add_sint32_field_repeated(uint32_t &total_size, uint32_t field_id_size, int32_t value) { + inline void add_sint32_field_repeated(uint32_t field_id_size, int32_t value) { // Always calculate size for repeated fields // ZigZag encoding for sint32: (n << 1) ^ (n >> 31) uint32_t zigzag = (static_cast(value) << 1) ^ (static_cast(value >> 31)); - total_size += field_id_size + varint(zigzag); + total_size_ += field_id_size + varint(zigzag); } /** * @brief Calculates and adds the size of an int64 field to the total message size */ - static inline void add_int64_field(uint32_t &total_size, uint32_t field_id_size, int64_t value) { + inline void add_int64_field(uint32_t field_id_size, int64_t value) { // Skip calculation if value is zero if (value == 0) { - return; // No need to update total_size + return; // No need to update total_size_ } // Calculate and directly add to total_size - total_size += field_id_size + varint(value); + total_size_ += field_id_size + varint(value); } /** * @brief Calculates and adds the size of an int64 field to the total message size (repeated field version) */ - static inline void add_int64_field_repeated(uint32_t &total_size, uint32_t field_id_size, int64_t value) { + inline void add_int64_field_repeated(uint32_t field_id_size, int64_t value) { // Always calculate size for repeated fields - total_size += field_id_size + varint(value); + total_size_ += field_id_size + varint(value); } /** * @brief Calculates and adds the size of a uint64 field to the total message size */ - static inline void add_uint64_field(uint32_t &total_size, uint32_t field_id_size, uint64_t value) { + inline void add_uint64_field(uint32_t field_id_size, uint64_t value) { // Skip calculation if value is zero if (value == 0) { - return; // No need to update total_size + return; // No need to update total_size_ } // Calculate and directly add to total_size - total_size += field_id_size + varint(value); + total_size_ += field_id_size + varint(value); } /** * @brief Calculates and adds the size of a uint64 field to the total message size (repeated field version) */ - static inline void add_uint64_field_repeated(uint32_t &total_size, uint32_t field_id_size, uint64_t value) { + inline void add_uint64_field_repeated(uint32_t field_id_size, uint64_t value) { // Always calculate size for repeated fields - total_size += field_id_size + varint(value); + total_size_ += field_id_size + varint(value); } // NOTE: sint64 support functions (add_sint64_field, add_sint64_field_repeated) removed @@ -705,38 +711,57 @@ class ProtoSize { /** * @brief Calculates and adds the size of a string field using length */ - static inline void add_string_field(uint32_t &total_size, uint32_t field_id_size, size_t len) { + inline void add_string_field(uint32_t field_id_size, size_t len) { // Skip calculation if string is empty if (len == 0) { - return; // No need to update total_size + return; // No need to update total_size_ } // Field ID + length varint + string bytes - total_size += field_id_size + varint(static_cast(len)) + static_cast(len); + total_size_ += field_id_size + varint(static_cast(len)) + static_cast(len); } /** * @brief Calculates and adds the size of a string/bytes field to the total message size (repeated field version) */ - static inline void add_string_field_repeated(uint32_t &total_size, uint32_t field_id_size, const std::string &str) { + inline void add_string_field_repeated(uint32_t field_id_size, const std::string &str) { // Always calculate size for repeated fields const uint32_t str_size = static_cast(str.size()); - total_size += field_id_size + varint(str_size) + str_size; + total_size_ += field_id_size + varint(str_size) + str_size; } /** * @brief Calculates and adds the size of a bytes field to the total message size */ - static inline void add_bytes_field(uint32_t &total_size, uint32_t field_id_size, size_t len) { + inline void add_bytes_field(uint32_t field_id_size, size_t len) { // Skip calculation if bytes is empty if (len == 0) { - return; // No need to update total_size + return; // No need to update total_size_ } // Field ID + length varint + data bytes - total_size += field_id_size + varint(static_cast(len)) + static_cast(len); + total_size_ += field_id_size + varint(static_cast(len)) + static_cast(len); } + /** + * @brief Calculates and adds the size of a bytes field to the total message size (repeated field version) + */ + inline void add_bytes_field_repeated(uint32_t field_id_size, size_t len) { + // Always calculate size for repeated fields + // Field ID + length varint + data bytes + total_size_ += field_id_size + varint(static_cast(len)) + static_cast(len); + } + + /** + * @brief Adds a pre-calculated size directly to the total + * + * This is used when we can calculate the total size by multiplying the number + * of elements by the bytes per element (for repeated fixed-size types like float, fixed32, etc.) + * + * @param size The pre-calculated total size to add + */ + inline void add_precalculated_size(uint32_t size) { total_size_ += size; } + /** * @brief Calculates and adds the size of a nested message field to the total message size * @@ -745,15 +770,15 @@ class ProtoSize { * * @param nested_size The pre-calculated size of the nested message */ - static inline void add_message_field(uint32_t &total_size, uint32_t field_id_size, uint32_t nested_size) { + inline void add_message_field(uint32_t field_id_size, uint32_t nested_size) { // Skip calculation if nested message is empty if (nested_size == 0) { - return; // No need to update total_size + return; // No need to update total_size_ } // Calculate and directly add to total_size // Field ID + length varint + nested message content - total_size += field_id_size + varint(nested_size) + nested_size; + total_size_ += field_id_size + varint(nested_size) + nested_size; } /** @@ -761,10 +786,10 @@ class ProtoSize { * * @param nested_size The pre-calculated size of the nested message */ - static inline void add_message_field_repeated(uint32_t &total_size, uint32_t field_id_size, uint32_t nested_size) { + inline void add_message_field_repeated(uint32_t field_id_size, uint32_t nested_size) { // Always calculate size for repeated fields // Field ID + length varint + nested message content - total_size += field_id_size + varint(nested_size) + nested_size; + total_size_ += field_id_size + varint(nested_size) + nested_size; } /** @@ -776,12 +801,14 @@ class ProtoSize { * * @param message The nested message object */ - static inline void add_message_object(uint32_t &total_size, uint32_t field_id_size, const ProtoMessage &message) { - uint32_t nested_size = 0; - message.calculate_size(nested_size); + inline void add_message_object(uint32_t field_id_size, const ProtoMessage &message) { + // Calculate nested message size by creating a temporary ProtoSize + ProtoSize nested_calc; + message.calculate_size(nested_calc); + uint32_t nested_size = nested_calc.get_size(); // Use the base implementation with the calculated nested_size - add_message_field(total_size, field_id_size, nested_size); + add_message_field(field_id_size, nested_size); } /** @@ -789,13 +816,14 @@ class ProtoSize { * * @param message The nested message object */ - static inline void add_message_object_repeated(uint32_t &total_size, uint32_t field_id_size, - const ProtoMessage &message) { - uint32_t nested_size = 0; - message.calculate_size(nested_size); + inline void add_message_object_repeated(uint32_t field_id_size, const ProtoMessage &message) { + // Calculate nested message size by creating a temporary ProtoSize + ProtoSize nested_calc; + message.calculate_size(nested_calc); + uint32_t nested_size = nested_calc.get_size(); // Use the base implementation with the calculated nested_size - add_message_field_repeated(total_size, field_id_size, nested_size); + add_message_field_repeated(field_id_size, nested_size); } /** @@ -808,8 +836,7 @@ class ProtoSize { * @param messages Vector of message objects */ template - static inline void add_repeated_message(uint32_t &total_size, uint32_t field_id_size, - const std::vector &messages) { + inline void add_repeated_message(uint32_t field_id_size, const std::vector &messages) { // Skip if the vector is empty if (messages.empty()) { return; @@ -817,7 +844,7 @@ class ProtoSize { // Use the repeated field version for all messages for (const auto &message : messages) { - add_message_object_repeated(total_size, field_id_size, message); + add_message_object_repeated(field_id_size, message); } } }; @@ -827,8 +854,9 @@ inline void ProtoWriteBuffer::encode_message(uint32_t field_id, const ProtoMessa this->encode_field_raw(field_id, 2); // type 2: Length-delimited message // Calculate the message size first - uint32_t msg_length_bytes = 0; - value.calculate_size(msg_length_bytes); + ProtoSize msg_size; + value.calculate_size(msg_size); + uint32_t msg_length_bytes = msg_size.get_size(); // Calculate how many bytes the length varint needs uint32_t varint_length_bytes = ProtoSize::varint(msg_length_bytes); @@ -877,8 +905,9 @@ class ProtoService { // Optimized method that pre-allocates buffer based on message size bool send_message_(const ProtoMessage &msg, uint8_t message_type) { - uint32_t msg_size = 0; - msg.calculate_size(msg_size); + ProtoSize size; + msg.calculate_size(size); + uint32_t msg_size = size.get_size(); // Create a pre-sized buffer auto buffer = this->create_buffer(msg_size); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 92c85d2366..2783d3bad7 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -281,7 +281,7 @@ class TypeInfo(ABC): field_id_size = self.calculate_field_id_size() method = f"{base_method}_repeated" if force else base_method value = value_expr if value_expr else name - return f"ProtoSize::{method}(total_size, {field_id_size}, {value});" + return f"size.{method}({field_id_size}, {value});" @abstractmethod def get_size_calculation(self, name: str, force: bool = False) -> str: @@ -389,7 +389,7 @@ class DoubleType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_double_field(total_size, {field_id_size}, {name});" + return f"size.add_double_field({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 8 @@ -413,7 +413,7 @@ class FloatType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_float_field(total_size, {field_id_size}, {name});" + return f"size.add_float_field({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 4 @@ -497,7 +497,7 @@ class Fixed64Type(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_fixed64_field(total_size, {field_id_size}, {name});" + return f"size.add_fixed64_field({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 8 @@ -521,7 +521,7 @@ class Fixed32Type(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_fixed32_field(total_size, {field_id_size}, {name});" + return f"size.add_fixed32_field({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 4 @@ -631,11 +631,11 @@ class StringType(TypeInfo): if name == "it": # For repeated fields, we need to use add_string_field_repeated which includes field ID field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_string_field_repeated(total_size, {field_id_size}, it);" + return f"size.add_string_field_repeated({field_id_size}, it);" # For messages that need encoding, use the StringRef size field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_string_field(total_size, {field_id_size}, this->{self.field_name}_ref_.size());" + return f"size.add_string_field({field_id_size}, this->{self.field_name}_ref_.size());" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical string @@ -770,7 +770,7 @@ class BytesType(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"ProtoSize::add_bytes_field(total_size, {self.calculate_field_id_size()}, this->{self.field_name}_len_);" + return f"size.add_bytes_field({self.calculate_field_id_size()}, this->{self.field_name}_len_);" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes @@ -845,15 +845,11 @@ class FixedArrayBytesType(TypeInfo): field_id_size = self.calculate_field_id_size() if force: - # For repeated fields, always calculate size - return f"total_size += {field_id_size} + ProtoSize::varint(static_cast({length_field})) + {length_field};" + # For repeated fields, always calculate size (no zero check) + return f"size.add_bytes_field_repeated({field_id_size}, {length_field});" else: - # For non-repeated fields, skip if length is 0 (matching encode_string behavior) - return ( - f"if ({length_field} != 0) {{\n" - f" total_size += {field_id_size} + ProtoSize::varint(static_cast({length_field})) + {length_field};\n" - f"}}" - ) + # For non-repeated fields, add_bytes_field already checks for zero + return f"size.add_bytes_field({field_id_size}, {length_field});" def get_estimated_size(self) -> int: # Estimate based on typical BLE advertisement size @@ -939,7 +935,7 @@ class SFixed32Type(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_sfixed32_field(total_size, {field_id_size}, {name});" + return f"size.add_sfixed32_field({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 4 @@ -963,7 +959,7 @@ class SFixed64Type(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_sfixed64_field(total_size, {field_id_size}, {name});" + return f"size.add_sfixed64_field({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 8 @@ -1240,7 +1236,7 @@ class RepeatedTypeInfo(TypeInfo): if isinstance(self._ti, MessageType): # For repeated messages, use the dedicated helper that handles iteration internally field_id_size = self._ti.calculate_field_id_size() - o = f"ProtoSize::add_repeated_message(total_size, {field_id_size}, {name});" + o = f"size.add_repeated_message({field_id_size}, {name});" return o # For other repeated types, use the underlying type's size calculation with force=True @@ -1253,7 +1249,9 @@ class RepeatedTypeInfo(TypeInfo): field_id_size = self._ti.calculate_field_id_size() # Pre-calculate the total bytes per element bytes_per_element = field_id_size + num_bytes - o += f" total_size += {name}.size() * {bytes_per_element};\n" + o += ( + f" size.add_precalculated_size({name}.size() * {bytes_per_element});\n" + ) else: # Other types need the actual value o += f" for (const auto {'' if self._ti_is_bool else '&'}it : {name}) {{\n" @@ -1685,7 +1683,7 @@ def build_message_type( if needs_encode and encode: o = f"void {desc.name}::encode(ProtoWriteBuffer buffer) const {{" if len(encode) == 1 and len(encode[0]) + len(o) + 3 < 120: - o += f" {encode[0]} " + o += f" {encode[0]} }}\n" else: o += "\n" o += indent("\n".join(encode)) + "\n" @@ -1697,17 +1695,17 @@ def build_message_type( # Add calculate_size method only if this message needs encoding and has fields if needs_encode and size_calc: - o = f"void {desc.name}::calculate_size(uint32_t &total_size) const {{" + o = f"void {desc.name}::calculate_size(ProtoSize &size) const {{" # For a single field, just inline it for simplicity if len(size_calc) == 1 and len(size_calc[0]) + len(o) + 3 < 120: - o += f" {size_calc[0]} " + o += f" {size_calc[0]} }}\n" else: # For multiple fields o += "\n" o += indent("\n".join(size_calc)) + "\n" o += "}\n" cpp += o - prot = "void calculate_size(uint32_t &total_size) const override;" + prot = "void calculate_size(ProtoSize &size) const override;" public_content.append(prot) # If no fields to calculate size for or message doesn't need encoding, the default implementation in ProtoMessage will be used From 1032e5c220630e4cf4c4bf36fb5ccc5d3abc158c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 14:08:43 -1000 Subject: [PATCH 0042/1626] Make ProtoSize an object --- esphome/components/api/api_pb2.cpp | 8 -------- esphome/components/api/proto.h | 3 +++ script/api_protobuf/api_protobuf.py | 4 ++-- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 9e061d2cf0..5526fc442f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -53,9 +53,7 @@ bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value return true; } void ConnectResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->invalid_password); } -} void ConnectResponse::calculate_size(ProtoSize &size) const { size.add_bool_field(1, this->invalid_password); } -} #ifdef USE_AREAS void AreaInfo::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->area_id); @@ -839,9 +837,7 @@ bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthD return true; } void NoiseEncryptionSetKeyResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->success); } -} void NoiseEncryptionSetKeyResponse::calculate_size(ProtoSize &size) const { size.add_bool_field(1, this->success); } -} #endif void HomeassistantServiceMap::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->key_ref_); @@ -910,9 +906,7 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { return true; } void GetTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->epoch_seconds); } -} void GetTimeResponse::calculate_size(ProtoSize &size) const { size.add_fixed32_field(1, this->epoch_seconds); } -} #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); @@ -2317,9 +2311,7 @@ bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLength return true; } void VoiceAssistantAnnounceFinished::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->success); } -} void VoiceAssistantAnnounceFinished::calculate_size(ProtoSize &size) const { size.add_bool_field(1, this->success); } -} void VoiceAssistantWakeWord::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->id_ref_); buffer.encode_string(2, this->wake_word_ref_); diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 74a918751c..eb3d5c7302 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -334,6 +334,9 @@ class ProtoWriteBuffer { std::vector *buffer_; }; +// Forward declaration +class ProtoSize; + class ProtoMessage { public: virtual ~ProtoMessage() = default; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 2783d3bad7..56f74aeaad 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1687,7 +1687,7 @@ def build_message_type( else: o += "\n" o += indent("\n".join(encode)) + "\n" - o += "}\n" + o += "}\n" cpp += o prot = "void encode(ProtoWriteBuffer buffer) const override;" public_content.append(prot) @@ -1703,7 +1703,7 @@ def build_message_type( # For multiple fields o += "\n" o += indent("\n".join(size_calc)) + "\n" - o += "}\n" + o += "}\n" cpp += o prot = "void calculate_size(ProtoSize &size) const override;" public_content.append(prot) From 33ec5e195f89636092bd5cbeb15cd84e2c6b6013 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 14:09:17 -1000 Subject: [PATCH 0043/1626] Make ProtoSize an object --- esphome/components/api/api_connection.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0d3b99cd41..e21fcb694a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -290,8 +290,9 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess #endif // Calculate size - uint32_t calculated_size = 0; - msg.calculate_size(calculated_size); + ProtoSize size_calc; + msg.calculate_size(size_calc); + uint32_t calculated_size = size_calc.get_size(); // Cache frame sizes to avoid repeated virtual calls const uint8_t header_padding = conn->helper_->frame_header_padding(); From 09a30689e9412b94ce896661b6f581f02fa9fe5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 14:19:35 -1000 Subject: [PATCH 0044/1626] preen --- esphome/components/api/api_pb2.cpp | 924 ++++++++++++++-------------- esphome/components/api/proto.h | 42 +- script/api_protobuf/api_protobuf.py | 46 +- 3 files changed, 505 insertions(+), 507 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 5526fc442f..ab6c5513de 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -37,10 +37,10 @@ void HelloResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(4, this->name_ref_); } void HelloResponse::calculate_size(ProtoSize &size) const { - size.add_uint32_field(1, this->api_version_major); - size.add_uint32_field(1, this->api_version_minor); - size.add_string_field(1, this->server_info_ref_.size()); - size.add_string_field(1, this->name_ref_.size()); + size.add_uint32(1, this->api_version_major); + size.add_uint32(1, this->api_version_minor); + size.add_string(1, this->server_info_ref_.size()); + size.add_string(1, this->name_ref_.size()); } bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { @@ -53,15 +53,15 @@ bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value return true; } void ConnectResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->invalid_password); } -void ConnectResponse::calculate_size(ProtoSize &size) const { size.add_bool_field(1, this->invalid_password); } +void ConnectResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->invalid_password); } #ifdef USE_AREAS void AreaInfo::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->area_id); buffer.encode_string(2, this->name_ref_); } void AreaInfo::calculate_size(ProtoSize &size) const { - size.add_uint32_field(1, this->area_id); - size.add_string_field(1, this->name_ref_.size()); + size.add_uint32(1, this->area_id); + size.add_string(1, this->name_ref_.size()); } #endif #ifdef USE_DEVICES @@ -71,9 +71,9 @@ void DeviceInfo::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->area_id); } void DeviceInfo::calculate_size(ProtoSize &size) const { - size.add_uint32_field(1, this->device_id); - size.add_string_field(1, this->name_ref_.size()); - size.add_uint32_field(1, this->area_id); + size.add_uint32(1, this->device_id); + size.add_string(1, this->name_ref_.size()); + size.add_uint32(1, this->area_id); } #endif void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { @@ -130,41 +130,41 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { } void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_API_PASSWORD - size.add_bool_field(1, this->uses_password); + size.add_bool(1, this->uses_password); #endif - size.add_string_field(1, this->name_ref_.size()); - size.add_string_field(1, this->mac_address_ref_.size()); - size.add_string_field(1, this->esphome_version_ref_.size()); - size.add_string_field(1, this->compilation_time_ref_.size()); - size.add_string_field(1, this->model_ref_.size()); + size.add_string(1, this->name_ref_.size()); + size.add_string(1, this->mac_address_ref_.size()); + size.add_string(1, this->esphome_version_ref_.size()); + size.add_string(1, this->compilation_time_ref_.size()); + size.add_string(1, this->model_ref_.size()); #ifdef USE_DEEP_SLEEP - size.add_bool_field(1, this->has_deep_sleep); + size.add_bool(1, this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - size.add_string_field(1, this->project_name_ref_.size()); + size.add_string(1, this->project_name_ref_.size()); #endif #ifdef ESPHOME_PROJECT_NAME - size.add_string_field(1, this->project_version_ref_.size()); + size.add_string(1, this->project_version_ref_.size()); #endif #ifdef USE_WEBSERVER - size.add_uint32_field(1, this->webserver_port); + size.add_uint32(1, this->webserver_port); #endif #ifdef USE_BLUETOOTH_PROXY - size.add_uint32_field(1, this->bluetooth_proxy_feature_flags); + size.add_uint32(1, this->bluetooth_proxy_feature_flags); #endif - size.add_string_field(1, this->manufacturer_ref_.size()); - size.add_string_field(1, this->friendly_name_ref_.size()); + size.add_string(1, this->manufacturer_ref_.size()); + size.add_string(1, this->friendly_name_ref_.size()); #ifdef USE_VOICE_ASSISTANT - size.add_uint32_field(2, this->voice_assistant_feature_flags); + size.add_uint32(2, this->voice_assistant_feature_flags); #endif #ifdef USE_AREAS - size.add_string_field(2, this->suggested_area_ref_.size()); + size.add_string(2, this->suggested_area_ref_.size()); #endif #ifdef USE_BLUETOOTH_PROXY - size.add_string_field(2, this->bluetooth_mac_address_ref_.size()); + size.add_string(2, this->bluetooth_mac_address_ref_.size()); #endif #ifdef USE_API_NOISE - size.add_bool_field(2, this->api_encryption_supported); + size.add_bool(2, this->api_encryption_supported); #endif #ifdef USE_DEVICES size.add_repeated_message(2, this->devices); @@ -193,18 +193,18 @@ void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesBinarySensorResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); - size.add_string_field(1, this->device_class_ref_.size()); - size.add_bool_field(1, this->is_status_binary_sensor); - size.add_bool_field(1, this->disabled_by_default); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); + size.add_string(1, this->device_class_ref_.size()); + size.add_bool(1, this->is_status_binary_sensor); + size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_enum_field(1, static_cast(this->entity_category)); + size.add_enum(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void BinarySensorStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -216,11 +216,11 @@ void BinarySensorStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void BinarySensorStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_bool_field(1, this->state); - size.add_bool_field(1, this->missing_state); + size.add_fixed32(1, this->key); + size.add_bool(1, this->state); + size.add_bool(1, this->missing_state); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } #endif @@ -244,21 +244,21 @@ void ListEntitiesCoverResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesCoverResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); - size.add_bool_field(1, this->assumed_state); - size.add_bool_field(1, this->supports_position); - size.add_bool_field(1, this->supports_tilt); - size.add_string_field(1, this->device_class_ref_.size()); - size.add_bool_field(1, this->disabled_by_default); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); + size.add_bool(1, this->assumed_state); + size.add_bool(1, this->supports_position); + size.add_bool(1, this->supports_tilt); + size.add_string(1, this->device_class_ref_.size()); + size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_enum_field(1, static_cast(this->entity_category)); - size.add_bool_field(1, this->supports_stop); + size.add_enum(1, static_cast(this->entity_category)); + size.add_bool(1, this->supports_stop); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void CoverStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -271,12 +271,12 @@ void CoverStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void CoverStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_float_field(1, this->position); - size.add_float_field(1, this->tilt); - size.add_enum_field(1, static_cast(this->current_operation)); + size.add_fixed32(1, this->key); + size.add_float(1, this->position); + size.add_float(1, this->tilt); + size.add_enum(1, static_cast(this->current_operation)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool CoverCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -339,25 +339,25 @@ void ListEntitiesFanResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesFanResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); - size.add_bool_field(1, this->supports_oscillation); - size.add_bool_field(1, this->supports_speed); - size.add_bool_field(1, this->supports_direction); - size.add_int32_field(1, this->supported_speed_count); - size.add_bool_field(1, this->disabled_by_default); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); + size.add_bool(1, this->supports_oscillation); + size.add_bool(1, this->supports_speed); + size.add_bool(1, this->supports_direction); + size.add_int32(1, this->supported_speed_count); + size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_enum_field(1, static_cast(this->entity_category)); + size.add_enum(1, static_cast(this->entity_category)); if (!this->supported_preset_modes.empty()) { for (const auto &it : this->supported_preset_modes) { - size.add_string_field_repeated(1, it); + size.add_string_repeated(1, it); } } #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void FanStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -372,14 +372,14 @@ void FanStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void FanStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_bool_field(1, this->state); - size.add_bool_field(1, this->oscillating); - size.add_enum_field(1, static_cast(this->direction)); - size.add_int32_field(1, this->speed_level); - size.add_string_field(1, this->preset_mode_ref_.size()); + size.add_fixed32(1, this->key); + size.add_bool(1, this->state); + size.add_bool(1, this->oscillating); + size.add_enum(1, static_cast(this->direction)); + size.add_int32(1, this->speed_level); + size.add_string(1, this->preset_mode_ref_.size()); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool FanCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -465,28 +465,28 @@ void ListEntitiesLightResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesLightResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); if (!this->supported_color_modes.empty()) { for (const auto &it : this->supported_color_modes) { - size.add_enum_field_repeated(1, static_cast(it)); + size.add_enum_repeated(1, static_cast(it)); } } - size.add_float_field(1, this->min_mireds); - size.add_float_field(1, this->max_mireds); + size.add_float(1, this->min_mireds); + size.add_float(1, this->max_mireds); if (!this->effects.empty()) { for (const auto &it : this->effects) { - size.add_string_field_repeated(1, it); + size.add_string_repeated(1, it); } } - size.add_bool_field(1, this->disabled_by_default); + size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_enum_field(1, static_cast(this->entity_category)); + size.add_enum(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - size.add_uint32_field(2, this->device_id); + size.add_uint32(2, this->device_id); #endif } void LightStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -508,21 +508,21 @@ void LightStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void LightStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_bool_field(1, this->state); - size.add_float_field(1, this->brightness); - size.add_enum_field(1, static_cast(this->color_mode)); - size.add_float_field(1, this->color_brightness); - size.add_float_field(1, this->red); - size.add_float_field(1, this->green); - size.add_float_field(1, this->blue); - size.add_float_field(1, this->white); - size.add_float_field(1, this->color_temperature); - size.add_float_field(1, this->cold_white); - size.add_float_field(1, this->warm_white); - size.add_string_field(1, this->effect_ref_.size()); + size.add_fixed32(1, this->key); + size.add_bool(1, this->state); + size.add_float(1, this->brightness); + size.add_enum(1, static_cast(this->color_mode)); + size.add_float(1, this->color_brightness); + size.add_float(1, this->red); + size.add_float(1, this->green); + size.add_float(1, this->blue); + size.add_float(1, this->white); + size.add_float(1, this->color_temperature); + size.add_float(1, this->cold_white); + size.add_float(1, this->warm_white); + size.add_string(1, this->effect_ref_.size()); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool LightCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -653,21 +653,21 @@ void ListEntitiesSensorResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesSensorResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_string_field(1, this->unit_of_measurement_ref_.size()); - size.add_int32_field(1, this->accuracy_decimals); - size.add_bool_field(1, this->force_update); - size.add_string_field(1, this->device_class_ref_.size()); - size.add_enum_field(1, static_cast(this->state_class)); - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); + size.add_string(1, this->unit_of_measurement_ref_.size()); + size.add_int32(1, this->accuracy_decimals); + size.add_bool(1, this->force_update); + size.add_string(1, this->device_class_ref_.size()); + size.add_enum(1, static_cast(this->state_class)); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void SensorStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -679,11 +679,11 @@ void SensorStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void SensorStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_float_field(1, this->state); - size.add_bool_field(1, this->missing_state); + size.add_fixed32(1, this->key); + size.add_float(1, this->state); + size.add_bool(1, this->missing_state); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } #endif @@ -704,18 +704,18 @@ void ListEntitiesSwitchResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesSwitchResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->assumed_state); - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); - size.add_string_field(1, this->device_class_ref_.size()); + size.add_bool(1, this->assumed_state); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); + size.add_string(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void SwitchStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -726,10 +726,10 @@ void SwitchStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void SwitchStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_bool_field(1, this->state); + size.add_fixed32(1, this->key); + size.add_bool(1, this->state); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool SwitchCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -774,17 +774,17 @@ void ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesTextSensorResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); - size.add_string_field(1, this->device_class_ref_.size()); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); + size.add_string(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void TextSensorStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -796,11 +796,11 @@ void TextSensorStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void TextSensorStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->state_ref_.size()); - size.add_bool_field(1, this->missing_state); + size.add_fixed32(1, this->key); + size.add_string(1, this->state_ref_.size()); + size.add_bool(1, this->missing_state); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } #endif @@ -822,8 +822,8 @@ void SubscribeLogsResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bytes(3, this->message_ptr_, this->message_len_); } void SubscribeLogsResponse::calculate_size(ProtoSize &size) const { - size.add_enum_field(1, static_cast(this->level)); - size.add_bytes_field(1, this->message_len_); + size.add_enum(1, static_cast(this->level)); + size.add_bytes(1, this->message_len_); } #ifdef USE_API_NOISE bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { @@ -837,15 +837,15 @@ bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthD return true; } void NoiseEncryptionSetKeyResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->success); } -void NoiseEncryptionSetKeyResponse::calculate_size(ProtoSize &size) const { size.add_bool_field(1, this->success); } +void NoiseEncryptionSetKeyResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->success); } #endif void HomeassistantServiceMap::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->key_ref_); buffer.encode_string(2, this->value_ref_); } void HomeassistantServiceMap::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->key_ref_.size()); - size.add_string_field(1, this->value_ref_.size()); + size.add_string(1, this->key_ref_.size()); + size.add_string(1, this->value_ref_.size()); } void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->service_ref_); @@ -861,11 +861,11 @@ void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(5, this->is_event); } void HomeassistantServiceResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->service_ref_.size()); + size.add_string(1, this->service_ref_.size()); size.add_repeated_message(1, this->data); size.add_repeated_message(1, this->data_template); size.add_repeated_message(1, this->variables); - size.add_bool_field(1, this->is_event); + size.add_bool(1, this->is_event); } #ifdef USE_API_HOMEASSISTANT_STATES void SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -874,9 +874,9 @@ void SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer buffer) const buffer.encode_bool(3, this->once); } void SubscribeHomeAssistantStateResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->entity_id_ref_.size()); - size.add_string_field(1, this->attribute_ref_.size()); - size.add_bool_field(1, this->once); + size.add_string(1, this->entity_id_ref_.size()); + size.add_string(1, this->attribute_ref_.size()); + size.add_bool(1, this->once); } bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { @@ -906,15 +906,15 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { return true; } void GetTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->epoch_seconds); } -void GetTimeResponse::calculate_size(ProtoSize &size) const { size.add_fixed32_field(1, this->epoch_seconds); } +void GetTimeResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->epoch_seconds); } #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); buffer.encode_uint32(2, static_cast(this->type)); } void ListEntitiesServicesArgument::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->name_ref_.size()); - size.add_enum_field(1, static_cast(this->type)); + size.add_string(1, this->name_ref_.size()); + size.add_enum(1, static_cast(this->type)); } void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); @@ -924,8 +924,8 @@ void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { } } void ListEntitiesServicesResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->name_ref_.size()); - size.add_fixed32_field(1, this->key); + size.add_string(1, this->name_ref_.size()); + size.add_fixed32(1, this->key); size.add_repeated_message(1, this->args); } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1013,16 +1013,16 @@ void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesCameraResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); - size.add_bool_field(1, this->disabled_by_default); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); + size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_enum_field(1, static_cast(this->entity_category)); + size.add_enum(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void CameraImageResponse::encode(ProtoWriteBuffer buffer) const { @@ -1034,11 +1034,11 @@ void CameraImageResponse::encode(ProtoWriteBuffer buffer) const { #endif } void CameraImageResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_bytes_field(1, this->data_len_); - size.add_bool_field(1, this->done); + size.add_fixed32(1, this->key); + size.add_bytes(1, this->data_len_); + size.add_bool(1, this->done); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool CameraImageRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1099,57 +1099,57 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); - size.add_bool_field(1, this->supports_current_temperature); - size.add_bool_field(1, this->supports_two_point_target_temperature); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); + size.add_bool(1, this->supports_current_temperature); + size.add_bool(1, this->supports_two_point_target_temperature); if (!this->supported_modes.empty()) { for (const auto &it : this->supported_modes) { - size.add_enum_field_repeated(1, static_cast(it)); + size.add_enum_repeated(1, static_cast(it)); } } - size.add_float_field(1, this->visual_min_temperature); - size.add_float_field(1, this->visual_max_temperature); - size.add_float_field(1, this->visual_target_temperature_step); - size.add_bool_field(1, this->supports_action); + size.add_float(1, this->visual_min_temperature); + size.add_float(1, this->visual_max_temperature); + size.add_float(1, this->visual_target_temperature_step); + size.add_bool(1, this->supports_action); if (!this->supported_fan_modes.empty()) { for (const auto &it : this->supported_fan_modes) { - size.add_enum_field_repeated(1, static_cast(it)); + size.add_enum_repeated(1, static_cast(it)); } } if (!this->supported_swing_modes.empty()) { for (const auto &it : this->supported_swing_modes) { - size.add_enum_field_repeated(1, static_cast(it)); + size.add_enum_repeated(1, static_cast(it)); } } if (!this->supported_custom_fan_modes.empty()) { for (const auto &it : this->supported_custom_fan_modes) { - size.add_string_field_repeated(1, it); + size.add_string_repeated(1, it); } } if (!this->supported_presets.empty()) { for (const auto &it : this->supported_presets) { - size.add_enum_field_repeated(2, static_cast(it)); + size.add_enum_repeated(2, static_cast(it)); } } if (!this->supported_custom_presets.empty()) { for (const auto &it : this->supported_custom_presets) { - size.add_string_field_repeated(2, it); + size.add_string_repeated(2, it); } } - size.add_bool_field(2, this->disabled_by_default); + size.add_bool(2, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size.add_string_field(2, this->icon_ref_.size()); + size.add_string(2, this->icon_ref_.size()); #endif - size.add_enum_field(2, static_cast(this->entity_category)); - size.add_float_field(2, this->visual_current_temperature_step); - size.add_bool_field(2, this->supports_current_humidity); - size.add_bool_field(2, this->supports_target_humidity); - size.add_float_field(2, this->visual_min_humidity); - size.add_float_field(2, this->visual_max_humidity); + size.add_enum(2, static_cast(this->entity_category)); + size.add_float(2, this->visual_current_temperature_step); + size.add_bool(2, this->supports_current_humidity); + size.add_bool(2, this->supports_target_humidity); + size.add_float(2, this->visual_min_humidity); + size.add_float(2, this->visual_max_humidity); #ifdef USE_DEVICES - size.add_uint32_field(2, this->device_id); + size.add_uint32(2, this->device_id); #endif } void ClimateStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1172,22 +1172,22 @@ void ClimateStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ClimateStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_enum_field(1, static_cast(this->mode)); - size.add_float_field(1, this->current_temperature); - size.add_float_field(1, this->target_temperature); - size.add_float_field(1, this->target_temperature_low); - size.add_float_field(1, this->target_temperature_high); - size.add_enum_field(1, static_cast(this->action)); - size.add_enum_field(1, static_cast(this->fan_mode)); - size.add_enum_field(1, static_cast(this->swing_mode)); - size.add_string_field(1, this->custom_fan_mode_ref_.size()); - size.add_enum_field(1, static_cast(this->preset)); - size.add_string_field(1, this->custom_preset_ref_.size()); - size.add_float_field(1, this->current_humidity); - size.add_float_field(1, this->target_humidity); + size.add_fixed32(1, this->key); + size.add_enum(1, static_cast(this->mode)); + size.add_float(1, this->current_temperature); + size.add_float(1, this->target_temperature); + size.add_float(1, this->target_temperature_low); + size.add_float(1, this->target_temperature_high); + size.add_enum(1, static_cast(this->action)); + size.add_enum(1, static_cast(this->fan_mode)); + size.add_enum(1, static_cast(this->swing_mode)); + size.add_string(1, this->custom_fan_mode_ref_.size()); + size.add_enum(1, static_cast(this->preset)); + size.add_string(1, this->custom_preset_ref_.size()); + size.add_float(1, this->current_humidity); + size.add_float(1, this->target_humidity); #ifdef USE_DEVICES - size.add_uint32_field(2, this->device_id); + size.add_uint32(2, this->device_id); #endif } bool ClimateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1301,22 +1301,22 @@ void ListEntitiesNumberResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesNumberResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_float_field(1, this->min_value); - size.add_float_field(1, this->max_value); - size.add_float_field(1, this->step); - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); - size.add_string_field(1, this->unit_of_measurement_ref_.size()); - size.add_enum_field(1, static_cast(this->mode)); - size.add_string_field(1, this->device_class_ref_.size()); + size.add_float(1, this->min_value); + size.add_float(1, this->max_value); + size.add_float(1, this->step); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); + size.add_string(1, this->unit_of_measurement_ref_.size()); + size.add_enum(1, static_cast(this->mode)); + size.add_string(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void NumberStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1328,11 +1328,11 @@ void NumberStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void NumberStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_float_field(1, this->state); - size.add_bool_field(1, this->missing_state); + size.add_fixed32(1, this->key); + size.add_float(1, this->state); + size.add_bool(1, this->missing_state); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool NumberCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1379,21 +1379,21 @@ void ListEntitiesSelectResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesSelectResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif if (!this->options.empty()) { for (const auto &it : this->options) { - size.add_string_field_repeated(1, it); + size.add_string_repeated(1, it); } } - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void SelectStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1405,11 +1405,11 @@ void SelectStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void SelectStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->state_ref_.size()); - size.add_bool_field(1, this->missing_state); + size.add_fixed32(1, this->key); + size.add_string(1, this->state_ref_.size()); + size.add_bool(1, this->missing_state); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool SelectCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1465,23 +1465,23 @@ void ListEntitiesSirenResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesSirenResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); + size.add_bool(1, this->disabled_by_default); if (!this->tones.empty()) { for (const auto &it : this->tones) { - size.add_string_field_repeated(1, it); + size.add_string_repeated(1, it); } } - size.add_bool_field(1, this->supports_duration); - size.add_bool_field(1, this->supports_volume); - size.add_enum_field(1, static_cast(this->entity_category)); + size.add_bool(1, this->supports_duration); + size.add_bool(1, this->supports_volume); + size.add_enum(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void SirenStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1492,10 +1492,10 @@ void SirenStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void SirenStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_bool_field(1, this->state); + size.add_fixed32(1, this->key); + size.add_bool(1, this->state); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool SirenCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1571,20 +1571,20 @@ void ListEntitiesLockResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesLockResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); - size.add_bool_field(1, this->assumed_state); - size.add_bool_field(1, this->supports_open); - size.add_bool_field(1, this->requires_code); - size.add_string_field(1, this->code_format_ref_.size()); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); + size.add_bool(1, this->assumed_state); + size.add_bool(1, this->supports_open); + size.add_bool(1, this->requires_code); + size.add_string(1, this->code_format_ref_.size()); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void LockStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1595,10 +1595,10 @@ void LockStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void LockStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_enum_field(1, static_cast(this->state)); + size.add_fixed32(1, this->key); + size.add_enum(1, static_cast(this->state)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool LockCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1656,17 +1656,17 @@ void ListEntitiesButtonResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesButtonResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); - size.add_string_field(1, this->device_class_ref_.size()); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); + size.add_string(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool ButtonCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1701,11 +1701,11 @@ void MediaPlayerSupportedFormat::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(5, this->sample_bytes); } void MediaPlayerSupportedFormat::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->format_ref_.size()); - size.add_uint32_field(1, this->sample_rate); - size.add_uint32_field(1, this->num_channels); - size.add_enum_field(1, static_cast(this->purpose)); - size.add_uint32_field(1, this->sample_bytes); + size.add_string(1, this->format_ref_.size()); + size.add_uint32(1, this->sample_rate); + size.add_uint32(1, this->num_channels); + size.add_enum(1, static_cast(this->purpose)); + size.add_uint32(1, this->sample_bytes); } void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->object_id_ref_); @@ -1725,18 +1725,18 @@ void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesMediaPlayerResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); - size.add_bool_field(1, this->supports_pause); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); + size.add_bool(1, this->supports_pause); size.add_repeated_message(1, this->supported_formats); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void MediaPlayerStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1749,12 +1749,12 @@ void MediaPlayerStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void MediaPlayerStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_enum_field(1, static_cast(this->state)); - size.add_float_field(1, this->volume); - size.add_bool_field(1, this->muted); + size.add_fixed32(1, this->key); + size.add_enum(1, static_cast(this->state)); + size.add_float(1, this->volume); + size.add_bool(1, this->muted); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1829,10 +1829,10 @@ void BluetoothLERawAdvertisement::encode(ProtoWriteBuffer buffer) const { buffer.encode_bytes(4, this->data, this->data_len); } void BluetoothLERawAdvertisement::calculate_size(ProtoSize &size) const { - size.add_uint64_field(1, this->address); - size.add_sint32_field(1, this->rssi); - size.add_uint32_field(1, this->address_type); - size.add_bytes_field(1, this->data_len); + size.add_uint64(1, this->address); + size.add_sint32(1, this->rssi); + size.add_uint32(1, this->address_type); + size.add_bytes(1, this->data_len); } void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->advertisements) { @@ -1868,10 +1868,10 @@ void BluetoothDeviceConnectionResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_int32(4, this->error); } void BluetoothDeviceConnectionResponse::calculate_size(ProtoSize &size) const { - size.add_uint64_field(1, this->address); - size.add_bool_field(1, this->connected); - size.add_uint32_field(1, this->mtu); - size.add_int32_field(1, this->error); + size.add_uint64(1, this->address); + size.add_bool(1, this->connected); + size.add_uint32(1, this->mtu); + size.add_int32(1, this->error); } bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1889,9 +1889,9 @@ void BluetoothGATTDescriptor::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->handle); } void BluetoothGATTDescriptor::calculate_size(ProtoSize &size) const { - size.add_uint64_field_repeated(1, this->uuid[0]); - size.add_uint64_field_repeated(1, this->uuid[1]); - size.add_uint32_field(1, this->handle); + size.add_uint64_repeated(1, this->uuid[0]); + size.add_uint64_repeated(1, this->uuid[1]); + size.add_uint32(1, this->handle); } void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->uuid[0], true); @@ -1903,10 +1903,10 @@ void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { } } void BluetoothGATTCharacteristic::calculate_size(ProtoSize &size) const { - size.add_uint64_field_repeated(1, this->uuid[0]); - size.add_uint64_field_repeated(1, this->uuid[1]); - size.add_uint32_field(1, this->handle); - size.add_uint32_field(1, this->properties); + size.add_uint64_repeated(1, this->uuid[0]); + size.add_uint64_repeated(1, this->uuid[1]); + size.add_uint32(1, this->handle); + size.add_uint32(1, this->properties); size.add_repeated_message(1, this->descriptors); } void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { @@ -1918,9 +1918,9 @@ void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { } } void BluetoothGATTService::calculate_size(ProtoSize &size) const { - size.add_uint64_field_repeated(1, this->uuid[0]); - size.add_uint64_field_repeated(1, this->uuid[1]); - size.add_uint32_field(1, this->handle); + size.add_uint64_repeated(1, this->uuid[0]); + size.add_uint64_repeated(1, this->uuid[1]); + size.add_uint32(1, this->handle); size.add_repeated_message(1, this->characteristics); } void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { @@ -1928,15 +1928,13 @@ void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_message(2, this->services[0], true); } void BluetoothGATTGetServicesResponse::calculate_size(ProtoSize &size) const { - size.add_uint64_field(1, this->address); + size.add_uint64(1, this->address); size.add_message_object_repeated(1, this->services[0]); } void BluetoothGATTGetServicesDoneResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); } -void BluetoothGATTGetServicesDoneResponse::calculate_size(ProtoSize &size) const { - size.add_uint64_field(1, this->address); -} +void BluetoothGATTGetServicesDoneResponse::calculate_size(ProtoSize &size) const { size.add_uint64(1, this->address); } bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { case 1: @@ -1956,9 +1954,9 @@ void BluetoothGATTReadResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bytes(3, this->data_ptr_, this->data_len_); } void BluetoothGATTReadResponse::calculate_size(ProtoSize &size) const { - size.add_uint64_field(1, this->address); - size.add_uint32_field(1, this->handle); - size.add_bytes_field(1, this->data_len_); + size.add_uint64(1, this->address); + size.add_uint32(1, this->handle); + size.add_bytes(1, this->data_len_); } bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2044,9 +2042,9 @@ void BluetoothGATTNotifyDataResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bytes(3, this->data_ptr_, this->data_len_); } void BluetoothGATTNotifyDataResponse::calculate_size(ProtoSize &size) const { - size.add_uint64_field(1, this->address); - size.add_uint32_field(1, this->handle); - size.add_bytes_field(1, this->data_len_); + size.add_uint64(1, this->address); + size.add_uint32(1, this->handle); + size.add_bytes(1, this->data_len_); } void BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->free); @@ -2056,11 +2054,11 @@ void BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer buffer) const { } } void BluetoothConnectionsFreeResponse::calculate_size(ProtoSize &size) const { - size.add_uint32_field(1, this->free); - size.add_uint32_field(1, this->limit); + size.add_uint32(1, this->free); + size.add_uint32(1, this->limit); if (!this->allocated.empty()) { for (const auto &it : this->allocated) { - size.add_uint64_field_repeated(1, it); + size.add_uint64_repeated(1, it); } } } @@ -2070,25 +2068,25 @@ void BluetoothGATTErrorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_int32(3, this->error); } void BluetoothGATTErrorResponse::calculate_size(ProtoSize &size) const { - size.add_uint64_field(1, this->address); - size.add_uint32_field(1, this->handle); - size.add_int32_field(1, this->error); + size.add_uint64(1, this->address); + size.add_uint32(1, this->handle); + size.add_int32(1, this->error); } void BluetoothGATTWriteResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_uint32(2, this->handle); } void BluetoothGATTWriteResponse::calculate_size(ProtoSize &size) const { - size.add_uint64_field(1, this->address); - size.add_uint32_field(1, this->handle); + size.add_uint64(1, this->address); + size.add_uint32(1, this->handle); } void BluetoothGATTNotifyResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_uint32(2, this->handle); } void BluetoothGATTNotifyResponse::calculate_size(ProtoSize &size) const { - size.add_uint64_field(1, this->address); - size.add_uint32_field(1, this->handle); + size.add_uint64(1, this->address); + size.add_uint32(1, this->handle); } void BluetoothDevicePairingResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); @@ -2096,9 +2094,9 @@ void BluetoothDevicePairingResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_int32(3, this->error); } void BluetoothDevicePairingResponse::calculate_size(ProtoSize &size) const { - size.add_uint64_field(1, this->address); - size.add_bool_field(1, this->paired); - size.add_int32_field(1, this->error); + size.add_uint64(1, this->address); + size.add_bool(1, this->paired); + size.add_int32(1, this->error); } void BluetoothDeviceUnpairingResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); @@ -2106,9 +2104,9 @@ void BluetoothDeviceUnpairingResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_int32(3, this->error); } void BluetoothDeviceUnpairingResponse::calculate_size(ProtoSize &size) const { - size.add_uint64_field(1, this->address); - size.add_bool_field(1, this->success); - size.add_int32_field(1, this->error); + size.add_uint64(1, this->address); + size.add_bool(1, this->success); + size.add_int32(1, this->error); } void BluetoothDeviceClearCacheResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); @@ -2116,17 +2114,17 @@ void BluetoothDeviceClearCacheResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_int32(3, this->error); } void BluetoothDeviceClearCacheResponse::calculate_size(ProtoSize &size) const { - size.add_uint64_field(1, this->address); - size.add_bool_field(1, this->success); - size.add_int32_field(1, this->error); + size.add_uint64(1, this->address); + size.add_bool(1, this->success); + size.add_int32(1, this->error); } void BluetoothScannerStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, static_cast(this->state)); buffer.encode_uint32(2, static_cast(this->mode)); } void BluetoothScannerStateResponse::calculate_size(ProtoSize &size) const { - size.add_enum_field(1, static_cast(this->state)); - size.add_enum_field(1, static_cast(this->mode)); + size.add_enum(1, static_cast(this->state)); + size.add_enum(1, static_cast(this->mode)); } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2159,9 +2157,9 @@ void VoiceAssistantAudioSettings::encode(ProtoWriteBuffer buffer) const { buffer.encode_float(3, this->volume_multiplier); } void VoiceAssistantAudioSettings::calculate_size(ProtoSize &size) const { - size.add_uint32_field(1, this->noise_suppression_level); - size.add_uint32_field(1, this->auto_gain); - size.add_float_field(1, this->volume_multiplier); + size.add_uint32(1, this->noise_suppression_level); + size.add_uint32(1, this->auto_gain); + size.add_float(1, this->volume_multiplier); } void VoiceAssistantRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->start); @@ -2171,11 +2169,11 @@ void VoiceAssistantRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(5, this->wake_word_phrase_ref_); } void VoiceAssistantRequest::calculate_size(ProtoSize &size) const { - size.add_bool_field(1, this->start); - size.add_string_field(1, this->conversation_id_ref_.size()); - size.add_uint32_field(1, this->flags); + size.add_bool(1, this->start); + size.add_string(1, this->conversation_id_ref_.size()); + size.add_uint32(1, this->flags); size.add_message_object(1, this->audio_settings); - size.add_string_field(1, this->wake_word_phrase_ref_.size()); + size.add_string(1, this->wake_word_phrase_ref_.size()); } bool VoiceAssistantResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2249,8 +2247,8 @@ void VoiceAssistantAudio::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(2, this->end); } void VoiceAssistantAudio::calculate_size(ProtoSize &size) const { - size.add_bytes_field(1, this->data_len_); - size.add_bool_field(1, this->end); + size.add_bytes(1, this->data_len_); + size.add_bool(1, this->end); } bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2311,7 +2309,7 @@ bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLength return true; } void VoiceAssistantAnnounceFinished::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->success); } -void VoiceAssistantAnnounceFinished::calculate_size(ProtoSize &size) const { size.add_bool_field(1, this->success); } +void VoiceAssistantAnnounceFinished::calculate_size(ProtoSize &size) const { size.add_bool(1, this->success); } void VoiceAssistantWakeWord::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->id_ref_); buffer.encode_string(2, this->wake_word_ref_); @@ -2320,11 +2318,11 @@ void VoiceAssistantWakeWord::encode(ProtoWriteBuffer buffer) const { } } void VoiceAssistantWakeWord::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->id_ref_.size()); - size.add_string_field(1, this->wake_word_ref_.size()); + size.add_string(1, this->id_ref_.size()); + size.add_string(1, this->wake_word_ref_.size()); if (!this->trained_languages.empty()) { for (const auto &it : this->trained_languages) { - size.add_string_field_repeated(1, it); + size.add_string_repeated(1, it); } } } @@ -2341,10 +2339,10 @@ void VoiceAssistantConfigurationResponse::calculate_size(ProtoSize &size) const size.add_repeated_message(1, this->available_wake_words); if (!this->active_wake_words.empty()) { for (const auto &it : this->active_wake_words) { - size.add_string_field_repeated(1, it); + size.add_string_repeated(1, it); } } - size.add_uint32_field(1, this->max_active_wake_words); + size.add_uint32(1, this->max_active_wake_words); } bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { @@ -2375,19 +2373,19 @@ void ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer buffer) cons #endif } void ListEntitiesAlarmControlPanelResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); - size.add_uint32_field(1, this->supported_features); - size.add_bool_field(1, this->requires_code); - size.add_bool_field(1, this->requires_code_to_arm); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, this->supported_features); + size.add_bool(1, this->requires_code); + size.add_bool(1, this->requires_code_to_arm); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void AlarmControlPanelStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2398,10 +2396,10 @@ void AlarmControlPanelStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void AlarmControlPanelStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_enum_field(1, static_cast(this->state)); + size.add_fixed32(1, this->key); + size.add_enum(1, static_cast(this->state)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2459,20 +2457,20 @@ void ListEntitiesTextResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesTextResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); - size.add_uint32_field(1, this->min_length); - size.add_uint32_field(1, this->max_length); - size.add_string_field(1, this->pattern_ref_.size()); - size.add_enum_field(1, static_cast(this->mode)); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, this->min_length); + size.add_uint32(1, this->max_length); + size.add_string(1, this->pattern_ref_.size()); + size.add_enum(1, static_cast(this->mode)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void TextStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2484,11 +2482,11 @@ void TextStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void TextStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->state_ref_.size()); - size.add_bool_field(1, this->missing_state); + size.add_fixed32(1, this->key); + size.add_string(1, this->state_ref_.size()); + size.add_bool(1, this->missing_state); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool TextCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2539,16 +2537,16 @@ void ListEntitiesDateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesDateResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void DateStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2562,13 +2560,13 @@ void DateStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void DateStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_bool_field(1, this->missing_state); - size.add_uint32_field(1, this->year); - size.add_uint32_field(1, this->month); - size.add_uint32_field(1, this->day); + size.add_fixed32(1, this->key); + size.add_bool(1, this->missing_state); + size.add_uint32(1, this->year); + size.add_uint32(1, this->month); + size.add_uint32(1, this->day); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool DateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2618,16 +2616,16 @@ void ListEntitiesTimeResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesTimeResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void TimeStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2641,13 +2639,13 @@ void TimeStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void TimeStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_bool_field(1, this->missing_state); - size.add_uint32_field(1, this->hour); - size.add_uint32_field(1, this->minute); - size.add_uint32_field(1, this->second); + size.add_fixed32(1, this->key); + size.add_bool(1, this->missing_state); + size.add_uint32(1, this->hour); + size.add_uint32(1, this->minute); + size.add_uint32(1, this->second); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool TimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2701,22 +2699,22 @@ void ListEntitiesEventResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesEventResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); - size.add_string_field(1, this->device_class_ref_.size()); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); + size.add_string(1, this->device_class_ref_.size()); if (!this->event_types.empty()) { for (const auto &it : this->event_types) { - size.add_string_field_repeated(1, it); + size.add_string_repeated(1, it); } } #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void EventResponse::encode(ProtoWriteBuffer buffer) const { @@ -2727,10 +2725,10 @@ void EventResponse::encode(ProtoWriteBuffer buffer) const { #endif } void EventResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->event_type_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->event_type_ref_.size()); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } #endif @@ -2753,20 +2751,20 @@ void ListEntitiesValveResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesValveResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); - size.add_string_field(1, this->device_class_ref_.size()); - size.add_bool_field(1, this->assumed_state); - size.add_bool_field(1, this->supports_position); - size.add_bool_field(1, this->supports_stop); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); + size.add_string(1, this->device_class_ref_.size()); + size.add_bool(1, this->assumed_state); + size.add_bool(1, this->supports_position); + size.add_bool(1, this->supports_stop); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void ValveStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2778,11 +2776,11 @@ void ValveStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ValveStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_float_field(1, this->position); - size.add_enum_field(1, static_cast(this->current_operation)); + size.add_fixed32(1, this->key); + size.add_float(1, this->position); + size.add_enum(1, static_cast(this->current_operation)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool ValveCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2832,16 +2830,16 @@ void ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesDateTimeResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void DateTimeStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2853,11 +2851,11 @@ void DateTimeStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void DateTimeStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_bool_field(1, this->missing_state); - size.add_fixed32_field(1, this->epoch_seconds); + size.add_fixed32(1, this->key); + size.add_bool(1, this->missing_state); + size.add_fixed32(1, this->epoch_seconds); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool DateTimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2902,17 +2900,17 @@ void ListEntitiesUpdateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesUpdateResponse::calculate_size(ProtoSize &size) const { - size.add_string_field(1, this->object_id_ref_.size()); - size.add_fixed32_field(1, this->key); - size.add_string_field(1, this->name_ref_.size()); + size.add_string(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_string(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string_field(1, this->icon_ref_.size()); + size.add_string(1, this->icon_ref_.size()); #endif - size.add_bool_field(1, this->disabled_by_default); - size.add_enum_field(1, static_cast(this->entity_category)); - size.add_string_field(1, this->device_class_ref_.size()); + size.add_bool(1, this->disabled_by_default); + size.add_enum(1, static_cast(this->entity_category)); + size.add_string(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void UpdateStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2931,18 +2929,18 @@ void UpdateStateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void UpdateStateResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32_field(1, this->key); - size.add_bool_field(1, this->missing_state); - size.add_bool_field(1, this->in_progress); - size.add_bool_field(1, this->has_progress); - size.add_float_field(1, this->progress); - size.add_string_field(1, this->current_version_ref_.size()); - size.add_string_field(1, this->latest_version_ref_.size()); - size.add_string_field(1, this->title_ref_.size()); - size.add_string_field(1, this->release_summary_ref_.size()); - size.add_string_field(1, this->release_url_ref_.size()); + size.add_fixed32(1, this->key); + size.add_bool(1, this->missing_state); + size.add_bool(1, this->in_progress); + size.add_bool(1, this->has_progress); + size.add_float(1, this->progress); + size.add_string(1, this->current_version_ref_.size()); + size.add_string(1, this->latest_version_ref_.size()); + size.add_string(1, this->title_ref_.size()); + size.add_string(1, this->release_summary_ref_.size()); + size.add_string(1, this->release_url_ref_.size()); #ifdef USE_DEVICES - size.add_uint32_field(1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool UpdateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index eb3d5c7302..9073477fa3 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -503,7 +503,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of an int32 field to the total message size */ - inline void add_int32_field(uint32_t field_id_size, int32_t value) { + inline void add_int32(uint32_t field_id_size, int32_t value) { // Skip calculation if value is zero if (value == 0) { return; // No need to update total_size_ @@ -522,7 +522,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of an int32 field to the total message size (repeated field version) */ - inline void add_int32_field_repeated(uint32_t field_id_size, int32_t value) { + inline void add_int32_repeated(uint32_t field_id_size, int32_t value) { // Always calculate size for repeated fields if (value < 0) { // Negative values are encoded as 10-byte varints in protobuf @@ -536,7 +536,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a uint32 field to the total message size */ - inline void add_uint32_field(uint32_t field_id_size, uint32_t value) { + inline void add_uint32(uint32_t field_id_size, uint32_t value) { // Skip calculation if value is zero if (value == 0) { return; // No need to update total_size_ @@ -549,7 +549,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a uint32 field to the total message size (repeated field version) */ - inline void add_uint32_field_repeated(uint32_t field_id_size, uint32_t value) { + inline void add_uint32_repeated(uint32_t field_id_size, uint32_t value) { // Always calculate size for repeated fields total_size_ += field_id_size + varint(value); } @@ -557,7 +557,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a boolean field to the total message size */ - inline void add_bool_field(uint32_t field_id_size, bool value) { + inline void add_bool(uint32_t field_id_size, bool value) { // Skip calculation if value is false if (!value) { return; // No need to update total_size_ @@ -570,7 +570,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a boolean field to the total message size (repeated field version) */ - inline void add_bool_field_repeated(uint32_t field_id_size, bool value) { + inline void add_bool_repeated(uint32_t field_id_size, bool value) { // Always calculate size for repeated fields // Boolean fields always use 1 byte total_size_ += field_id_size + 1; @@ -579,7 +579,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a float field to the total message size */ - inline void add_float_field(uint32_t field_id_size, float value) { + inline void add_float(uint32_t field_id_size, float value) { if (value != 0.0f) { total_size_ += field_id_size + 4; } @@ -591,7 +591,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a fixed32 field to the total message size */ - inline void add_fixed32_field(uint32_t field_id_size, uint32_t value) { + inline void add_fixed32(uint32_t field_id_size, uint32_t value) { if (value != 0) { total_size_ += field_id_size + 4; } @@ -603,7 +603,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a sfixed32 field to the total message size */ - inline void add_sfixed32_field(uint32_t field_id_size, int32_t value) { + inline void add_sfixed32(uint32_t field_id_size, int32_t value) { if (value != 0) { total_size_ += field_id_size + 4; } @@ -617,7 +617,7 @@ class ProtoSize { * * Enum fields are encoded as uint32 varints. */ - inline void add_enum_field(uint32_t field_id_size, uint32_t value) { + inline void add_enum(uint32_t field_id_size, uint32_t value) { // Skip calculation if value is zero if (value == 0) { return; // No need to update total_size_ @@ -632,7 +632,7 @@ class ProtoSize { * * Enum fields are encoded as uint32 varints. */ - inline void add_enum_field_repeated(uint32_t field_id_size, uint32_t value) { + inline void add_enum_repeated(uint32_t field_id_size, uint32_t value) { // Always calculate size for repeated fields // Enums are encoded as uint32 total_size_ += field_id_size + varint(value); @@ -643,7 +643,7 @@ class ProtoSize { * * Sint32 fields use ZigZag encoding, which is more efficient for negative values. */ - inline void add_sint32_field(uint32_t field_id_size, int32_t value) { + inline void add_sint32(uint32_t field_id_size, int32_t value) { // Skip calculation if value is zero if (value == 0) { return; // No need to update total_size_ @@ -659,7 +659,7 @@ class ProtoSize { * * Sint32 fields use ZigZag encoding, which is more efficient for negative values. */ - inline void add_sint32_field_repeated(uint32_t field_id_size, int32_t value) { + inline void add_sint32_repeated(uint32_t field_id_size, int32_t value) { // Always calculate size for repeated fields // ZigZag encoding for sint32: (n << 1) ^ (n >> 31) uint32_t zigzag = (static_cast(value) << 1) ^ (static_cast(value >> 31)); @@ -669,7 +669,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of an int64 field to the total message size */ - inline void add_int64_field(uint32_t field_id_size, int64_t value) { + inline void add_int64(uint32_t field_id_size, int64_t value) { // Skip calculation if value is zero if (value == 0) { return; // No need to update total_size_ @@ -682,7 +682,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of an int64 field to the total message size (repeated field version) */ - inline void add_int64_field_repeated(uint32_t field_id_size, int64_t value) { + inline void add_int64_repeated(uint32_t field_id_size, int64_t value) { // Always calculate size for repeated fields total_size_ += field_id_size + varint(value); } @@ -690,7 +690,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a uint64 field to the total message size */ - inline void add_uint64_field(uint32_t field_id_size, uint64_t value) { + inline void add_uint64(uint32_t field_id_size, uint64_t value) { // Skip calculation if value is zero if (value == 0) { return; // No need to update total_size_ @@ -703,7 +703,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a uint64 field to the total message size (repeated field version) */ - inline void add_uint64_field_repeated(uint32_t field_id_size, uint64_t value) { + inline void add_uint64_repeated(uint32_t field_id_size, uint64_t value) { // Always calculate size for repeated fields total_size_ += field_id_size + varint(value); } @@ -714,7 +714,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a string field using length */ - inline void add_string_field(uint32_t field_id_size, size_t len) { + inline void add_string(uint32_t field_id_size, size_t len) { // Skip calculation if string is empty if (len == 0) { return; // No need to update total_size_ @@ -727,7 +727,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a string/bytes field to the total message size (repeated field version) */ - inline void add_string_field_repeated(uint32_t field_id_size, const std::string &str) { + inline void add_string_repeated(uint32_t field_id_size, const std::string &str) { // Always calculate size for repeated fields const uint32_t str_size = static_cast(str.size()); total_size_ += field_id_size + varint(str_size) + str_size; @@ -736,7 +736,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a bytes field to the total message size */ - inline void add_bytes_field(uint32_t field_id_size, size_t len) { + inline void add_bytes(uint32_t field_id_size, size_t len) { // Skip calculation if bytes is empty if (len == 0) { return; // No need to update total_size_ @@ -749,7 +749,7 @@ class ProtoSize { /** * @brief Calculates and adds the size of a bytes field to the total message size (repeated field version) */ - inline void add_bytes_field_repeated(uint32_t field_id_size, size_t len) { + inline void add_bytes_repeated(uint32_t field_id_size, size_t len) { // Always calculate size for repeated fields // Field ID + length varint + data bytes total_size_ += field_id_size + varint(static_cast(len)) + static_cast(len); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 56f74aeaad..a732c1733f 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -275,7 +275,7 @@ class TypeInfo(ABC): Args: name: Field name force: Whether this is for a repeated field - base_method: Base method name (e.g., "add_int32_field") + base_method: Base method name (e.g., "add_int32") value_expr: Optional value expression (defaults to name) """ field_id_size = self.calculate_field_id_size() @@ -389,7 +389,7 @@ class DoubleType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"size.add_double_field({field_id_size}, {name});" + return f"size.add_double({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 8 @@ -413,7 +413,7 @@ class FloatType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"size.add_float_field({field_id_size}, {name});" + return f"size.add_float({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 4 @@ -436,7 +436,7 @@ class Int64Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_int64_field") + return self._get_simple_size_calculation(name, force, "add_int64") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -456,7 +456,7 @@ class UInt64Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_uint64_field") + return self._get_simple_size_calculation(name, force, "add_uint64") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -476,7 +476,7 @@ class Int32Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_int32_field") + return self._get_simple_size_calculation(name, force, "add_int32") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -497,7 +497,7 @@ class Fixed64Type(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"size.add_fixed64_field({field_id_size}, {name});" + return f"size.add_fixed64({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 8 @@ -521,7 +521,7 @@ class Fixed32Type(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"size.add_fixed32_field({field_id_size}, {name});" + return f"size.add_fixed32({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 4 @@ -543,7 +543,7 @@ class BoolType(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_bool_field") + return self._get_simple_size_calculation(name, force, "add_bool") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 1 # field ID + 1 byte @@ -624,18 +624,18 @@ class StringType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: # For SOURCE_CLIENT only messages, use the string field directly if not self._needs_encode: - return self._get_simple_size_calculation(name, force, "add_string_field") + return self._get_simple_size_calculation(name, force, "add_string") # Check if this is being called from a repeated field context # In that case, 'name' will be 'it' and we need to use the repeated version if name == "it": - # For repeated fields, we need to use add_string_field_repeated which includes field ID + # For repeated fields, we need to use add_string_repeated which includes field ID field_id_size = self.calculate_field_id_size() - return f"size.add_string_field_repeated({field_id_size}, it);" + return f"size.add_string_repeated({field_id_size}, it);" # For messages that need encoding, use the StringRef size field_id_size = self.calculate_field_id_size() - return f"size.add_string_field({field_id_size}, this->{self.field_name}_ref_.size());" + return f"size.add_string({field_id_size}, this->{self.field_name}_ref_.size());" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical string @@ -770,7 +770,7 @@ class BytesType(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size.add_bytes_field({self.calculate_field_id_size()}, this->{self.field_name}_len_);" + return f"size.add_bytes({self.calculate_field_id_size()}, this->{self.field_name}_len_);" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes @@ -846,10 +846,10 @@ class FixedArrayBytesType(TypeInfo): if force: # For repeated fields, always calculate size (no zero check) - return f"size.add_bytes_field_repeated({field_id_size}, {length_field});" + return f"size.add_bytes_repeated({field_id_size}, {length_field});" else: - # For non-repeated fields, add_bytes_field already checks for zero - return f"size.add_bytes_field({field_id_size}, {length_field});" + # For non-repeated fields, add_bytes already checks for zero + return f"size.add_bytes({field_id_size}, {length_field});" def get_estimated_size(self) -> int: # Estimate based on typical BLE advertisement size @@ -876,7 +876,7 @@ class UInt32Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_uint32_field") + return self._get_simple_size_calculation(name, force, "add_uint32") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -913,7 +913,7 @@ class EnumType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: return self._get_simple_size_calculation( - name, force, "add_enum_field", f"static_cast({name})" + name, force, "add_enum", f"static_cast({name})" ) def get_estimated_size(self) -> int: @@ -935,7 +935,7 @@ class SFixed32Type(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"size.add_sfixed32_field({field_id_size}, {name});" + return f"size.add_sfixed32({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 4 @@ -959,7 +959,7 @@ class SFixed64Type(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"size.add_sfixed64_field({field_id_size}, {name});" + return f"size.add_sfixed64({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 8 @@ -982,7 +982,7 @@ class SInt32Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_sint32_field") + return self._get_simple_size_calculation(name, force, "add_sint32") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -1002,7 +1002,7 @@ class SInt64Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_sint64_field") + return self._get_simple_size_calculation(name, force, "add_sint64") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint From e94f5bffa33db8c1b65e4ac0cd478478e2c20f74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 14:45:26 -1000 Subject: [PATCH 0045/1626] preen --- esphome/components/api/api_pb2.cpp | 96 ++++++++++++++--------------- esphome/components/api/proto.h | 26 -------- script/api_protobuf/api_protobuf.py | 2 +- 3 files changed, 49 insertions(+), 75 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index ab6c5513de..86ba1dae57 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -202,7 +202,7 @@ void ListEntitiesBinarySensorResponse::calculate_size(ProtoSize &size) const { #ifdef USE_ENTITY_ICON size.add_string(1, this->icon_ref_.size()); #endif - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -255,7 +255,7 @@ void ListEntitiesCoverResponse::calculate_size(ProtoSize &size) const { #ifdef USE_ENTITY_ICON size.add_string(1, this->icon_ref_.size()); #endif - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); size.add_bool(1, this->supports_stop); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); @@ -274,7 +274,7 @@ void CoverStateResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); size.add_float(1, this->position); size.add_float(1, this->tilt); - size.add_enum(1, static_cast(this->current_operation)); + size.add_uint32(1, static_cast(this->current_operation)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -350,7 +350,7 @@ void ListEntitiesFanResponse::calculate_size(ProtoSize &size) const { #ifdef USE_ENTITY_ICON size.add_string(1, this->icon_ref_.size()); #endif - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); if (!this->supported_preset_modes.empty()) { for (const auto &it : this->supported_preset_modes) { size.add_string_repeated(1, it); @@ -375,7 +375,7 @@ void FanStateResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); size.add_bool(1, this->state); size.add_bool(1, this->oscillating); - size.add_enum(1, static_cast(this->direction)); + size.add_uint32(1, static_cast(this->direction)); size.add_int32(1, this->speed_level); size.add_string(1, this->preset_mode_ref_.size()); #ifdef USE_DEVICES @@ -470,7 +470,7 @@ void ListEntitiesLightResponse::calculate_size(ProtoSize &size) const { size.add_string(1, this->name_ref_.size()); if (!this->supported_color_modes.empty()) { for (const auto &it : this->supported_color_modes) { - size.add_enum_repeated(1, static_cast(it)); + size.add_uint32_repeated(1, static_cast(it)); } } size.add_float(1, this->min_mireds); @@ -484,7 +484,7 @@ void ListEntitiesLightResponse::calculate_size(ProtoSize &size) const { #ifdef USE_ENTITY_ICON size.add_string(1, this->icon_ref_.size()); #endif - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES size.add_uint32(2, this->device_id); #endif @@ -511,7 +511,7 @@ void LightStateResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); size.add_bool(1, this->state); size.add_float(1, this->brightness); - size.add_enum(1, static_cast(this->color_mode)); + size.add_uint32(1, static_cast(this->color_mode)); size.add_float(1, this->color_brightness); size.add_float(1, this->red); size.add_float(1, this->green); @@ -663,9 +663,9 @@ void ListEntitiesSensorResponse::calculate_size(ProtoSize &size) const { size.add_int32(1, this->accuracy_decimals); size.add_bool(1, this->force_update); size.add_string(1, this->device_class_ref_.size()); - size.add_enum(1, static_cast(this->state_class)); + size.add_uint32(1, static_cast(this->state_class)); size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -712,7 +712,7 @@ void ListEntitiesSwitchResponse::calculate_size(ProtoSize &size) const { #endif size.add_bool(1, this->assumed_state); size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); size.add_string(1, this->device_class_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); @@ -781,7 +781,7 @@ void ListEntitiesTextSensorResponse::calculate_size(ProtoSize &size) const { size.add_string(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); size.add_string(1, this->device_class_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); @@ -822,7 +822,7 @@ void SubscribeLogsResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bytes(3, this->message_ptr_, this->message_len_); } void SubscribeLogsResponse::calculate_size(ProtoSize &size) const { - size.add_enum(1, static_cast(this->level)); + size.add_uint32(1, static_cast(this->level)); size.add_bytes(1, this->message_len_); } #ifdef USE_API_NOISE @@ -914,7 +914,7 @@ void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { } void ListEntitiesServicesArgument::calculate_size(ProtoSize &size) const { size.add_string(1, this->name_ref_.size()); - size.add_enum(1, static_cast(this->type)); + size.add_uint32(1, static_cast(this->type)); } void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); @@ -1020,7 +1020,7 @@ void ListEntitiesCameraResponse::calculate_size(ProtoSize &size) const { #ifdef USE_ENTITY_ICON size.add_string(1, this->icon_ref_.size()); #endif - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -1106,7 +1106,7 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->supports_two_point_target_temperature); if (!this->supported_modes.empty()) { for (const auto &it : this->supported_modes) { - size.add_enum_repeated(1, static_cast(it)); + size.add_uint32_repeated(1, static_cast(it)); } } size.add_float(1, this->visual_min_temperature); @@ -1115,12 +1115,12 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->supports_action); if (!this->supported_fan_modes.empty()) { for (const auto &it : this->supported_fan_modes) { - size.add_enum_repeated(1, static_cast(it)); + size.add_uint32_repeated(1, static_cast(it)); } } if (!this->supported_swing_modes.empty()) { for (const auto &it : this->supported_swing_modes) { - size.add_enum_repeated(1, static_cast(it)); + size.add_uint32_repeated(1, static_cast(it)); } } if (!this->supported_custom_fan_modes.empty()) { @@ -1130,7 +1130,7 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { } if (!this->supported_presets.empty()) { for (const auto &it : this->supported_presets) { - size.add_enum_repeated(2, static_cast(it)); + size.add_uint32_repeated(2, static_cast(it)); } } if (!this->supported_custom_presets.empty()) { @@ -1142,7 +1142,7 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { #ifdef USE_ENTITY_ICON size.add_string(2, this->icon_ref_.size()); #endif - size.add_enum(2, static_cast(this->entity_category)); + size.add_uint32(2, static_cast(this->entity_category)); size.add_float(2, this->visual_current_temperature_step); size.add_bool(2, this->supports_current_humidity); size.add_bool(2, this->supports_target_humidity); @@ -1173,16 +1173,16 @@ void ClimateStateResponse::encode(ProtoWriteBuffer buffer) const { } void ClimateStateResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); - size.add_enum(1, static_cast(this->mode)); + size.add_uint32(1, static_cast(this->mode)); size.add_float(1, this->current_temperature); size.add_float(1, this->target_temperature); size.add_float(1, this->target_temperature_low); size.add_float(1, this->target_temperature_high); - size.add_enum(1, static_cast(this->action)); - size.add_enum(1, static_cast(this->fan_mode)); - size.add_enum(1, static_cast(this->swing_mode)); + size.add_uint32(1, static_cast(this->action)); + size.add_uint32(1, static_cast(this->fan_mode)); + size.add_uint32(1, static_cast(this->swing_mode)); size.add_string(1, this->custom_fan_mode_ref_.size()); - size.add_enum(1, static_cast(this->preset)); + size.add_uint32(1, static_cast(this->preset)); size.add_string(1, this->custom_preset_ref_.size()); size.add_float(1, this->current_humidity); size.add_float(1, this->target_humidity); @@ -1311,9 +1311,9 @@ void ListEntitiesNumberResponse::calculate_size(ProtoSize &size) const { size.add_float(1, this->max_value); size.add_float(1, this->step); size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); size.add_string(1, this->unit_of_measurement_ref_.size()); - size.add_enum(1, static_cast(this->mode)); + size.add_uint32(1, static_cast(this->mode)); size.add_string(1, this->device_class_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); @@ -1391,7 +1391,7 @@ void ListEntitiesSelectResponse::calculate_size(ProtoSize &size) const { } } size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -1479,7 +1479,7 @@ void ListEntitiesSirenResponse::calculate_size(ProtoSize &size) const { } size.add_bool(1, this->supports_duration); size.add_bool(1, this->supports_volume); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -1578,7 +1578,7 @@ void ListEntitiesLockResponse::calculate_size(ProtoSize &size) const { size.add_string(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); size.add_bool(1, this->assumed_state); size.add_bool(1, this->supports_open); size.add_bool(1, this->requires_code); @@ -1596,7 +1596,7 @@ void LockStateResponse::encode(ProtoWriteBuffer buffer) const { } void LockStateResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); - size.add_enum(1, static_cast(this->state)); + size.add_uint32(1, static_cast(this->state)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -1663,7 +1663,7 @@ void ListEntitiesButtonResponse::calculate_size(ProtoSize &size) const { size.add_string(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); size.add_string(1, this->device_class_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); @@ -1704,7 +1704,7 @@ void MediaPlayerSupportedFormat::calculate_size(ProtoSize &size) const { size.add_string(1, this->format_ref_.size()); size.add_uint32(1, this->sample_rate); size.add_uint32(1, this->num_channels); - size.add_enum(1, static_cast(this->purpose)); + size.add_uint32(1, static_cast(this->purpose)); size.add_uint32(1, this->sample_bytes); } void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { @@ -1732,7 +1732,7 @@ void ListEntitiesMediaPlayerResponse::calculate_size(ProtoSize &size) const { size.add_string(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); size.add_bool(1, this->supports_pause); size.add_repeated_message(1, this->supported_formats); #ifdef USE_DEVICES @@ -1750,7 +1750,7 @@ void MediaPlayerStateResponse::encode(ProtoWriteBuffer buffer) const { } void MediaPlayerStateResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); - size.add_enum(1, static_cast(this->state)); + size.add_uint32(1, static_cast(this->state)); size.add_float(1, this->volume); size.add_bool(1, this->muted); #ifdef USE_DEVICES @@ -2123,8 +2123,8 @@ void BluetoothScannerStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, static_cast(this->mode)); } void BluetoothScannerStateResponse::calculate_size(ProtoSize &size) const { - size.add_enum(1, static_cast(this->state)); - size.add_enum(1, static_cast(this->mode)); + size.add_uint32(1, static_cast(this->state)); + size.add_uint32(1, static_cast(this->mode)); } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2380,7 +2380,7 @@ void ListEntitiesAlarmControlPanelResponse::calculate_size(ProtoSize &size) cons size.add_string(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); size.add_uint32(1, this->supported_features); size.add_bool(1, this->requires_code); size.add_bool(1, this->requires_code_to_arm); @@ -2397,7 +2397,7 @@ void AlarmControlPanelStateResponse::encode(ProtoWriteBuffer buffer) const { } void AlarmControlPanelStateResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); - size.add_enum(1, static_cast(this->state)); + size.add_uint32(1, static_cast(this->state)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -2464,11 +2464,11 @@ void ListEntitiesTextResponse::calculate_size(ProtoSize &size) const { size.add_string(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); size.add_uint32(1, this->min_length); size.add_uint32(1, this->max_length); size.add_string(1, this->pattern_ref_.size()); - size.add_enum(1, static_cast(this->mode)); + size.add_uint32(1, static_cast(this->mode)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -2544,7 +2544,7 @@ void ListEntitiesDateResponse::calculate_size(ProtoSize &size) const { size.add_string(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -2623,7 +2623,7 @@ void ListEntitiesTimeResponse::calculate_size(ProtoSize &size) const { size.add_string(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -2706,7 +2706,7 @@ void ListEntitiesEventResponse::calculate_size(ProtoSize &size) const { size.add_string(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); size.add_string(1, this->device_class_ref_.size()); if (!this->event_types.empty()) { for (const auto &it : this->event_types) { @@ -2758,7 +2758,7 @@ void ListEntitiesValveResponse::calculate_size(ProtoSize &size) const { size.add_string(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); size.add_string(1, this->device_class_ref_.size()); size.add_bool(1, this->assumed_state); size.add_bool(1, this->supports_position); @@ -2778,7 +2778,7 @@ void ValveStateResponse::encode(ProtoWriteBuffer buffer) const { void ValveStateResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); size.add_float(1, this->position); - size.add_enum(1, static_cast(this->current_operation)); + size.add_uint32(1, static_cast(this->current_operation)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -2837,7 +2837,7 @@ void ListEntitiesDateTimeResponse::calculate_size(ProtoSize &size) const { size.add_string(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -2907,7 +2907,7 @@ void ListEntitiesUpdateResponse::calculate_size(ProtoSize &size) const { size.add_string(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); - size.add_enum(1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); size.add_string(1, this->device_class_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 9073477fa3..aab17bef0b 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -612,32 +612,6 @@ class ProtoSize { // NOTE: add_sfixed64_field removed - wire type 1 (64-bit: sfixed64) not supported // to reduce overhead on embedded systems - /** - * @brief Calculates and adds the size of an enum field to the total message size - * - * Enum fields are encoded as uint32 varints. - */ - inline void add_enum(uint32_t field_id_size, uint32_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size_ - } - - // Enums are encoded as uint32 - total_size_ += field_id_size + varint(value); - } - - /** - * @brief Calculates and adds the size of an enum field to the total message size (repeated field version) - * - * Enum fields are encoded as uint32 varints. - */ - inline void add_enum_repeated(uint32_t field_id_size, uint32_t value) { - // Always calculate size for repeated fields - // Enums are encoded as uint32 - total_size_ += field_id_size + varint(value); - } - /** * @brief Calculates and adds the size of a sint32 field to the total message size * diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index a732c1733f..717c1d4009 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -913,7 +913,7 @@ class EnumType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: return self._get_simple_size_calculation( - name, force, "add_enum", f"static_cast({name})" + name, force, "add_uint32", f"static_cast({name})" ) def get_estimated_size(self) -> int: From d98a3fca964e1f05d4b167650337063468892249 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 14:49:43 -1000 Subject: [PATCH 0046/1626] dry --- esphome/components/api/api_pb2.cpp | 276 ++++++++++++++-------------- esphome/components/api/proto.h | 33 +--- script/api_protobuf/api_protobuf.py | 16 +- 3 files changed, 152 insertions(+), 173 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 86ba1dae57..e3794ae56b 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -39,8 +39,8 @@ void HelloResponse::encode(ProtoWriteBuffer buffer) const { void HelloResponse::calculate_size(ProtoSize &size) const { size.add_uint32(1, this->api_version_major); size.add_uint32(1, this->api_version_minor); - size.add_string(1, this->server_info_ref_.size()); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->server_info_ref_.size()); + size.add_length(1, this->name_ref_.size()); } bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { @@ -61,7 +61,7 @@ void AreaInfo::encode(ProtoWriteBuffer buffer) const { } void AreaInfo::calculate_size(ProtoSize &size) const { size.add_uint32(1, this->area_id); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); } #endif #ifdef USE_DEVICES @@ -72,7 +72,7 @@ void DeviceInfo::encode(ProtoWriteBuffer buffer) const { } void DeviceInfo::calculate_size(ProtoSize &size) const { size.add_uint32(1, this->device_id); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); size.add_uint32(1, this->area_id); } #endif @@ -132,19 +132,19 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_API_PASSWORD size.add_bool(1, this->uses_password); #endif - size.add_string(1, this->name_ref_.size()); - size.add_string(1, this->mac_address_ref_.size()); - size.add_string(1, this->esphome_version_ref_.size()); - size.add_string(1, this->compilation_time_ref_.size()); - size.add_string(1, this->model_ref_.size()); + size.add_length(1, this->name_ref_.size()); + size.add_length(1, this->mac_address_ref_.size()); + size.add_length(1, this->esphome_version_ref_.size()); + size.add_length(1, this->compilation_time_ref_.size()); + size.add_length(1, this->model_ref_.size()); #ifdef USE_DEEP_SLEEP size.add_bool(1, this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - size.add_string(1, this->project_name_ref_.size()); + size.add_length(1, this->project_name_ref_.size()); #endif #ifdef ESPHOME_PROJECT_NAME - size.add_string(1, this->project_version_ref_.size()); + size.add_length(1, this->project_version_ref_.size()); #endif #ifdef USE_WEBSERVER size.add_uint32(1, this->webserver_port); @@ -152,16 +152,16 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_BLUETOOTH_PROXY size.add_uint32(1, this->bluetooth_proxy_feature_flags); #endif - size.add_string(1, this->manufacturer_ref_.size()); - size.add_string(1, this->friendly_name_ref_.size()); + size.add_length(1, this->manufacturer_ref_.size()); + size.add_length(1, this->friendly_name_ref_.size()); #ifdef USE_VOICE_ASSISTANT size.add_uint32(2, this->voice_assistant_feature_flags); #endif #ifdef USE_AREAS - size.add_string(2, this->suggested_area_ref_.size()); + size.add_length(2, this->suggested_area_ref_.size()); #endif #ifdef USE_BLUETOOTH_PROXY - size.add_string(2, this->bluetooth_mac_address_ref_.size()); + size.add_length(2, this->bluetooth_mac_address_ref_.size()); #endif #ifdef USE_API_NOISE size.add_bool(2, this->api_encryption_supported); @@ -193,14 +193,14 @@ void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesBinarySensorResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); - size.add_string(1, this->device_class_ref_.size()); + size.add_length(1, this->name_ref_.size()); + size.add_length(1, this->device_class_ref_.size()); size.add_bool(1, this->is_status_binary_sensor); size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES @@ -244,16 +244,16 @@ void ListEntitiesCoverResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesCoverResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); size.add_bool(1, this->assumed_state); size.add_bool(1, this->supports_position); size.add_bool(1, this->supports_tilt); - size.add_string(1, this->device_class_ref_.size()); + size.add_length(1, this->device_class_ref_.size()); size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_uint32(1, static_cast(this->entity_category)); size.add_bool(1, this->supports_stop); @@ -339,21 +339,21 @@ void ListEntitiesFanResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesFanResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); size.add_bool(1, this->supports_oscillation); size.add_bool(1, this->supports_speed); size.add_bool(1, this->supports_direction); size.add_int32(1, this->supported_speed_count); size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_uint32(1, static_cast(this->entity_category)); if (!this->supported_preset_modes.empty()) { for (const auto &it : this->supported_preset_modes) { - size.add_string_repeated(1, it); + size.add_length_repeated(1, it.size()); } } #ifdef USE_DEVICES @@ -377,7 +377,7 @@ void FanStateResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->oscillating); size.add_uint32(1, static_cast(this->direction)); size.add_int32(1, this->speed_level); - size.add_string(1, this->preset_mode_ref_.size()); + size.add_length(1, this->preset_mode_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -465,9 +465,9 @@ void ListEntitiesLightResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesLightResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); if (!this->supported_color_modes.empty()) { for (const auto &it : this->supported_color_modes) { size.add_uint32_repeated(1, static_cast(it)); @@ -477,12 +477,12 @@ void ListEntitiesLightResponse::calculate_size(ProtoSize &size) const { size.add_float(1, this->max_mireds); if (!this->effects.empty()) { for (const auto &it : this->effects) { - size.add_string_repeated(1, it); + size.add_length_repeated(1, it.size()); } } size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES @@ -520,7 +520,7 @@ void LightStateResponse::calculate_size(ProtoSize &size) const { size.add_float(1, this->color_temperature); size.add_float(1, this->cold_white); size.add_float(1, this->warm_white); - size.add_string(1, this->effect_ref_.size()); + size.add_length(1, this->effect_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -653,16 +653,16 @@ void ListEntitiesSensorResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesSensorResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - size.add_string(1, this->unit_of_measurement_ref_.size()); + size.add_length(1, this->unit_of_measurement_ref_.size()); size.add_int32(1, this->accuracy_decimals); size.add_bool(1, this->force_update); - size.add_string(1, this->device_class_ref_.size()); + size.add_length(1, this->device_class_ref_.size()); size.add_uint32(1, static_cast(this->state_class)); size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); @@ -704,16 +704,16 @@ void ListEntitiesSwitchResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesSwitchResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->assumed_state); size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); - size.add_string(1, this->device_class_ref_.size()); + size.add_length(1, this->device_class_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -774,15 +774,15 @@ void ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesTextSensorResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); - size.add_string(1, this->device_class_ref_.size()); + size.add_length(1, this->device_class_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -797,7 +797,7 @@ void TextSensorStateResponse::encode(ProtoWriteBuffer buffer) const { } void TextSensorStateResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); - size.add_string(1, this->state_ref_.size()); + size.add_length(1, this->state_ref_.size()); size.add_bool(1, this->missing_state); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); @@ -823,7 +823,7 @@ void SubscribeLogsResponse::encode(ProtoWriteBuffer buffer) const { } void SubscribeLogsResponse::calculate_size(ProtoSize &size) const { size.add_uint32(1, static_cast(this->level)); - size.add_bytes(1, this->message_len_); + size.add_length(1, this->message_len_); } #ifdef USE_API_NOISE bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { @@ -844,8 +844,8 @@ void HomeassistantServiceMap::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(2, this->value_ref_); } void HomeassistantServiceMap::calculate_size(ProtoSize &size) const { - size.add_string(1, this->key_ref_.size()); - size.add_string(1, this->value_ref_.size()); + size.add_length(1, this->key_ref_.size()); + size.add_length(1, this->value_ref_.size()); } void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->service_ref_); @@ -861,7 +861,7 @@ void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(5, this->is_event); } void HomeassistantServiceResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->service_ref_.size()); + size.add_length(1, this->service_ref_.size()); size.add_repeated_message(1, this->data); size.add_repeated_message(1, this->data_template); size.add_repeated_message(1, this->variables); @@ -874,8 +874,8 @@ void SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer buffer) const buffer.encode_bool(3, this->once); } void SubscribeHomeAssistantStateResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->entity_id_ref_.size()); - size.add_string(1, this->attribute_ref_.size()); + size.add_length(1, this->entity_id_ref_.size()); + size.add_length(1, this->attribute_ref_.size()); size.add_bool(1, this->once); } bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { @@ -913,7 +913,7 @@ void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, static_cast(this->type)); } void ListEntitiesServicesArgument::calculate_size(ProtoSize &size) const { - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); size.add_uint32(1, static_cast(this->type)); } void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { @@ -924,7 +924,7 @@ void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { } } void ListEntitiesServicesResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); size.add_fixed32(1, this->key); size.add_repeated_message(1, this->args); } @@ -1013,12 +1013,12 @@ void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesCameraResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES @@ -1035,7 +1035,7 @@ void CameraImageResponse::encode(ProtoWriteBuffer buffer) const { } void CameraImageResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); - size.add_bytes(1, this->data_len_); + size.add_length(1, this->data_len_); size.add_bool(1, this->done); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); @@ -1099,9 +1099,9 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); size.add_bool(1, this->supports_current_temperature); size.add_bool(1, this->supports_two_point_target_temperature); if (!this->supported_modes.empty()) { @@ -1125,7 +1125,7 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { } if (!this->supported_custom_fan_modes.empty()) { for (const auto &it : this->supported_custom_fan_modes) { - size.add_string_repeated(1, it); + size.add_length_repeated(1, it.size()); } } if (!this->supported_presets.empty()) { @@ -1135,12 +1135,12 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { } if (!this->supported_custom_presets.empty()) { for (const auto &it : this->supported_custom_presets) { - size.add_string_repeated(2, it); + size.add_length_repeated(2, it.size()); } } size.add_bool(2, this->disabled_by_default); #ifdef USE_ENTITY_ICON - size.add_string(2, this->icon_ref_.size()); + size.add_length(2, this->icon_ref_.size()); #endif size.add_uint32(2, static_cast(this->entity_category)); size.add_float(2, this->visual_current_temperature_step); @@ -1181,9 +1181,9 @@ void ClimateStateResponse::calculate_size(ProtoSize &size) const { size.add_uint32(1, static_cast(this->action)); size.add_uint32(1, static_cast(this->fan_mode)); size.add_uint32(1, static_cast(this->swing_mode)); - size.add_string(1, this->custom_fan_mode_ref_.size()); + size.add_length(1, this->custom_fan_mode_ref_.size()); size.add_uint32(1, static_cast(this->preset)); - size.add_string(1, this->custom_preset_ref_.size()); + size.add_length(1, this->custom_preset_ref_.size()); size.add_float(1, this->current_humidity); size.add_float(1, this->target_humidity); #ifdef USE_DEVICES @@ -1301,20 +1301,20 @@ void ListEntitiesNumberResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesNumberResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_float(1, this->min_value); size.add_float(1, this->max_value); size.add_float(1, this->step); size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); - size.add_string(1, this->unit_of_measurement_ref_.size()); + size.add_length(1, this->unit_of_measurement_ref_.size()); size.add_uint32(1, static_cast(this->mode)); - size.add_string(1, this->device_class_ref_.size()); + size.add_length(1, this->device_class_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -1379,15 +1379,15 @@ void ListEntitiesSelectResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesSelectResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif if (!this->options.empty()) { for (const auto &it : this->options) { - size.add_string_repeated(1, it); + size.add_length_repeated(1, it.size()); } } size.add_bool(1, this->disabled_by_default); @@ -1406,7 +1406,7 @@ void SelectStateResponse::encode(ProtoWriteBuffer buffer) const { } void SelectStateResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); - size.add_string(1, this->state_ref_.size()); + size.add_length(1, this->state_ref_.size()); size.add_bool(1, this->missing_state); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); @@ -1465,16 +1465,16 @@ void ListEntitiesSirenResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesSirenResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); if (!this->tones.empty()) { for (const auto &it : this->tones) { - size.add_string_repeated(1, it); + size.add_length_repeated(1, it.size()); } } size.add_bool(1, this->supports_duration); @@ -1571,18 +1571,18 @@ void ListEntitiesLockResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesLockResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); size.add_bool(1, this->assumed_state); size.add_bool(1, this->supports_open); size.add_bool(1, this->requires_code); - size.add_string(1, this->code_format_ref_.size()); + size.add_length(1, this->code_format_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -1656,15 +1656,15 @@ void ListEntitiesButtonResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesButtonResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); - size.add_string(1, this->device_class_ref_.size()); + size.add_length(1, this->device_class_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -1701,7 +1701,7 @@ void MediaPlayerSupportedFormat::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(5, this->sample_bytes); } void MediaPlayerSupportedFormat::calculate_size(ProtoSize &size) const { - size.add_string(1, this->format_ref_.size()); + size.add_length(1, this->format_ref_.size()); size.add_uint32(1, this->sample_rate); size.add_uint32(1, this->num_channels); size.add_uint32(1, static_cast(this->purpose)); @@ -1725,11 +1725,11 @@ void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesMediaPlayerResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); @@ -1832,7 +1832,7 @@ void BluetoothLERawAdvertisement::calculate_size(ProtoSize &size) const { size.add_uint64(1, this->address); size.add_sint32(1, this->rssi); size.add_uint32(1, this->address_type); - size.add_bytes(1, this->data_len); + size.add_length(1, this->data_len); } void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->advertisements) { @@ -1956,7 +1956,7 @@ void BluetoothGATTReadResponse::encode(ProtoWriteBuffer buffer) const { void BluetoothGATTReadResponse::calculate_size(ProtoSize &size) const { size.add_uint64(1, this->address); size.add_uint32(1, this->handle); - size.add_bytes(1, this->data_len_); + size.add_length(1, this->data_len_); } bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2044,7 +2044,7 @@ void BluetoothGATTNotifyDataResponse::encode(ProtoWriteBuffer buffer) const { void BluetoothGATTNotifyDataResponse::calculate_size(ProtoSize &size) const { size.add_uint64(1, this->address); size.add_uint32(1, this->handle); - size.add_bytes(1, this->data_len_); + size.add_length(1, this->data_len_); } void BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->free); @@ -2170,10 +2170,10 @@ void VoiceAssistantRequest::encode(ProtoWriteBuffer buffer) const { } void VoiceAssistantRequest::calculate_size(ProtoSize &size) const { size.add_bool(1, this->start); - size.add_string(1, this->conversation_id_ref_.size()); + size.add_length(1, this->conversation_id_ref_.size()); size.add_uint32(1, this->flags); size.add_message_object(1, this->audio_settings); - size.add_string(1, this->wake_word_phrase_ref_.size()); + size.add_length(1, this->wake_word_phrase_ref_.size()); } bool VoiceAssistantResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2247,7 +2247,7 @@ void VoiceAssistantAudio::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(2, this->end); } void VoiceAssistantAudio::calculate_size(ProtoSize &size) const { - size.add_bytes(1, this->data_len_); + size.add_length(1, this->data_len_); size.add_bool(1, this->end); } bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2318,11 +2318,11 @@ void VoiceAssistantWakeWord::encode(ProtoWriteBuffer buffer) const { } } void VoiceAssistantWakeWord::calculate_size(ProtoSize &size) const { - size.add_string(1, this->id_ref_.size()); - size.add_string(1, this->wake_word_ref_.size()); + size.add_length(1, this->id_ref_.size()); + size.add_length(1, this->wake_word_ref_.size()); if (!this->trained_languages.empty()) { for (const auto &it : this->trained_languages) { - size.add_string_repeated(1, it); + size.add_length_repeated(1, it.size()); } } } @@ -2339,7 +2339,7 @@ void VoiceAssistantConfigurationResponse::calculate_size(ProtoSize &size) const size.add_repeated_message(1, this->available_wake_words); if (!this->active_wake_words.empty()) { for (const auto &it : this->active_wake_words) { - size.add_string_repeated(1, it); + size.add_length_repeated(1, it.size()); } } size.add_uint32(1, this->max_active_wake_words); @@ -2373,11 +2373,11 @@ void ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer buffer) cons #endif } void ListEntitiesAlarmControlPanelResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); @@ -2457,17 +2457,17 @@ void ListEntitiesTextResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesTextResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); size.add_uint32(1, this->min_length); size.add_uint32(1, this->max_length); - size.add_string(1, this->pattern_ref_.size()); + size.add_length(1, this->pattern_ref_.size()); size.add_uint32(1, static_cast(this->mode)); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); @@ -2483,7 +2483,7 @@ void TextStateResponse::encode(ProtoWriteBuffer buffer) const { } void TextStateResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); - size.add_string(1, this->state_ref_.size()); + size.add_length(1, this->state_ref_.size()); size.add_bool(1, this->missing_state); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); @@ -2537,11 +2537,11 @@ void ListEntitiesDateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesDateResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); @@ -2616,11 +2616,11 @@ void ListEntitiesTimeResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesTimeResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); @@ -2699,18 +2699,18 @@ void ListEntitiesEventResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesEventResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); - size.add_string(1, this->device_class_ref_.size()); + size.add_length(1, this->device_class_ref_.size()); if (!this->event_types.empty()) { for (const auto &it : this->event_types) { - size.add_string_repeated(1, it); + size.add_length_repeated(1, it.size()); } } #ifdef USE_DEVICES @@ -2726,7 +2726,7 @@ void EventResponse::encode(ProtoWriteBuffer buffer) const { } void EventResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->key); - size.add_string(1, this->event_type_ref_.size()); + size.add_length(1, this->event_type_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -2751,15 +2751,15 @@ void ListEntitiesValveResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesValveResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); - size.add_string(1, this->device_class_ref_.size()); + size.add_length(1, this->device_class_ref_.size()); size.add_bool(1, this->assumed_state); size.add_bool(1, this->supports_position); size.add_bool(1, this->supports_stop); @@ -2830,11 +2830,11 @@ void ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesDateTimeResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); @@ -2900,15 +2900,15 @@ void ListEntitiesUpdateResponse::encode(ProtoWriteBuffer buffer) const { #endif } void ListEntitiesUpdateResponse::calculate_size(ProtoSize &size) const { - size.add_string(1, this->object_id_ref_.size()); + size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); - size.add_string(1, this->name_ref_.size()); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - size.add_string(1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); - size.add_string(1, this->device_class_ref_.size()); + size.add_length(1, this->device_class_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -2934,11 +2934,11 @@ void UpdateStateResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->in_progress); size.add_bool(1, this->has_progress); size.add_float(1, this->progress); - size.add_string(1, this->current_version_ref_.size()); - size.add_string(1, this->latest_version_ref_.size()); - size.add_string(1, this->title_ref_.size()); - size.add_string(1, this->release_summary_ref_.size()); - size.add_string(1, this->release_url_ref_.size()); + size.add_length(1, this->current_version_ref_.size()); + size.add_length(1, this->latest_version_ref_.size()); + size.add_length(1, this->title_ref_.size()); + size.add_length(1, this->release_summary_ref_.size()); + size.add_length(1, this->release_url_ref_.size()); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index aab17bef0b..eaf3f587af 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -686,32 +686,10 @@ class ProtoSize { // sint64 type is not supported by ESPHome API to reduce overhead on embedded systems /** - * @brief Calculates and adds the size of a string field using length + * @brief Calculates and adds the size of a length-delimited field (string/bytes) to the total message size */ - inline void add_string(uint32_t field_id_size, size_t len) { - // Skip calculation if string is empty - if (len == 0) { - return; // No need to update total_size_ - } - - // Field ID + length varint + string bytes - total_size_ += field_id_size + varint(static_cast(len)) + static_cast(len); - } - - /** - * @brief Calculates and adds the size of a string/bytes field to the total message size (repeated field version) - */ - inline void add_string_repeated(uint32_t field_id_size, const std::string &str) { - // Always calculate size for repeated fields - const uint32_t str_size = static_cast(str.size()); - total_size_ += field_id_size + varint(str_size) + str_size; - } - - /** - * @brief Calculates and adds the size of a bytes field to the total message size - */ - inline void add_bytes(uint32_t field_id_size, size_t len) { - // Skip calculation if bytes is empty + inline void add_length(uint32_t field_id_size, size_t len) { + // Skip calculation if length is zero if (len == 0) { return; // No need to update total_size_ } @@ -721,9 +699,10 @@ class ProtoSize { } /** - * @brief Calculates and adds the size of a bytes field to the total message size (repeated field version) + * @brief Calculates and adds the size of a length-delimited field (string/bytes) to the total message size (repeated + * field version) */ - inline void add_bytes_repeated(uint32_t field_id_size, size_t len) { + inline void add_length_repeated(uint32_t field_id_size, size_t len) { // Always calculate size for repeated fields // Field ID + length varint + data bytes total_size_ += field_id_size + varint(static_cast(len)) + static_cast(len); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 717c1d4009..ce24b2a652 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -624,18 +624,18 @@ class StringType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: # For SOURCE_CLIENT only messages, use the string field directly if not self._needs_encode: - return self._get_simple_size_calculation(name, force, "add_string") + return self._get_simple_size_calculation(name, force, "add_length") # Check if this is being called from a repeated field context # In that case, 'name' will be 'it' and we need to use the repeated version if name == "it": - # For repeated fields, we need to use add_string_repeated which includes field ID + # For repeated fields, we need to use add_length_repeated which includes field ID field_id_size = self.calculate_field_id_size() - return f"size.add_string_repeated({field_id_size}, it);" + return f"size.add_length_repeated({field_id_size}, it.size());" # For messages that need encoding, use the StringRef size field_id_size = self.calculate_field_id_size() - return f"size.add_string({field_id_size}, this->{self.field_name}_ref_.size());" + return f"size.add_length({field_id_size}, this->{self.field_name}_ref_.size());" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical string @@ -770,7 +770,7 @@ class BytesType(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size.add_bytes({self.calculate_field_id_size()}, this->{self.field_name}_len_);" + return f"size.add_length({self.calculate_field_id_size()}, this->{self.field_name}_len_);" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes @@ -846,10 +846,10 @@ class FixedArrayBytesType(TypeInfo): if force: # For repeated fields, always calculate size (no zero check) - return f"size.add_bytes_repeated({field_id_size}, {length_field});" + return f"size.add_length_repeated({field_id_size}, {length_field});" else: - # For non-repeated fields, add_bytes already checks for zero - return f"size.add_bytes({field_id_size}, {length_field});" + # For non-repeated fields, add_length already checks for zero + return f"size.add_length({field_id_size}, {length_field});" def get_estimated_size(self) -> int: # Estimate based on typical BLE advertisement size From 2e16b3ea312a6baeca01e9f5d5e6e278626e19df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 14:52:15 -1000 Subject: [PATCH 0047/1626] dry --- esphome/components/api/proto.h | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index eaf3f587af..03fa5e0452 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -541,9 +541,8 @@ class ProtoSize { if (value == 0) { return; // No need to update total_size_ } - - // Calculate and directly add to total_size - total_size_ += field_id_size + varint(value); + // Delegate to repeated version + add_uint32_repeated(field_id_size, value); } /** @@ -622,10 +621,8 @@ class ProtoSize { if (value == 0) { return; // No need to update total_size_ } - - // ZigZag encoding for sint32: (n << 1) ^ (n >> 31) - uint32_t zigzag = (static_cast(value) << 1) ^ (static_cast(value >> 31)); - total_size_ += field_id_size + varint(zigzag); + // Delegate to repeated version + add_sint32_repeated(field_id_size, value); } /** @@ -648,9 +645,8 @@ class ProtoSize { if (value == 0) { return; // No need to update total_size_ } - - // Calculate and directly add to total_size - total_size_ += field_id_size + varint(value); + // Delegate to repeated version + add_int64_repeated(field_id_size, value); } /** @@ -669,9 +665,8 @@ class ProtoSize { if (value == 0) { return; // No need to update total_size_ } - - // Calculate and directly add to total_size - total_size_ += field_id_size + varint(value); + // Delegate to repeated version + add_uint64_repeated(field_id_size, value); } /** @@ -693,9 +688,8 @@ class ProtoSize { if (len == 0) { return; // No need to update total_size_ } - - // Field ID + length varint + data bytes - total_size_ += field_id_size + varint(static_cast(len)) + static_cast(len); + // Delegate to repeated version + add_length_repeated(field_id_size, len); } /** From ae120976360577e1fdd39c1b7dffbd89898688b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 14:53:14 -1000 Subject: [PATCH 0048/1626] dry --- esphome/components/api/proto.h | 35 ++++++++++------------------------ 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 03fa5e0452..b1dbcd1ac1 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -537,12 +537,9 @@ class ProtoSize { * @brief Calculates and adds the size of a uint32 field to the total message size */ inline void add_uint32(uint32_t field_id_size, uint32_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size_ + if (value != 0) { + add_uint32_repeated(field_id_size, value); } - // Delegate to repeated version - add_uint32_repeated(field_id_size, value); } /** @@ -617,12 +614,9 @@ class ProtoSize { * Sint32 fields use ZigZag encoding, which is more efficient for negative values. */ inline void add_sint32(uint32_t field_id_size, int32_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size_ + if (value != 0) { + add_sint32_repeated(field_id_size, value); } - // Delegate to repeated version - add_sint32_repeated(field_id_size, value); } /** @@ -641,12 +635,9 @@ class ProtoSize { * @brief Calculates and adds the size of an int64 field to the total message size */ inline void add_int64(uint32_t field_id_size, int64_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size_ + if (value != 0) { + add_int64_repeated(field_id_size, value); } - // Delegate to repeated version - add_int64_repeated(field_id_size, value); } /** @@ -661,12 +652,9 @@ class ProtoSize { * @brief Calculates and adds the size of a uint64 field to the total message size */ inline void add_uint64(uint32_t field_id_size, uint64_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size_ + if (value != 0) { + add_uint64_repeated(field_id_size, value); } - // Delegate to repeated version - add_uint64_repeated(field_id_size, value); } /** @@ -684,12 +672,9 @@ class ProtoSize { * @brief Calculates and adds the size of a length-delimited field (string/bytes) to the total message size */ inline void add_length(uint32_t field_id_size, size_t len) { - // Skip calculation if length is zero - if (len == 0) { - return; // No need to update total_size_ + if (len != 0) { + add_length_repeated(field_id_size, len); } - // Delegate to repeated version - add_length_repeated(field_id_size, len); } /** From 32edc3f062d489565fccfb51ad81570b155b4ef7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 14:54:52 -1000 Subject: [PATCH 0049/1626] dry --- esphome/components/api/api_pb2.cpp | 66 +++++++++++++-------------- esphome/components/api/proto.h | 70 ++++++++++++++--------------- script/api_protobuf/api_protobuf.py | 10 ++--- 3 files changed, 73 insertions(+), 73 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index e3794ae56b..c91dc5ea04 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -167,10 +167,10 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const { size.add_bool(2, this->api_encryption_supported); #endif #ifdef USE_DEVICES - size.add_repeated_message(2, this->devices); + size.add_force_message(2, this->devices); #endif #ifdef USE_AREAS - size.add_repeated_message(2, this->areas); + size.add_force_message(2, this->areas); #endif #ifdef USE_AREAS size.add_message_object(2, this->area); @@ -353,7 +353,7 @@ void ListEntitiesFanResponse::calculate_size(ProtoSize &size) const { size.add_uint32(1, static_cast(this->entity_category)); if (!this->supported_preset_modes.empty()) { for (const auto &it : this->supported_preset_modes) { - size.add_length_repeated(1, it.size()); + size.add_length_force(1, it.size()); } } #ifdef USE_DEVICES @@ -470,14 +470,14 @@ void ListEntitiesLightResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->name_ref_.size()); if (!this->supported_color_modes.empty()) { for (const auto &it : this->supported_color_modes) { - size.add_uint32_repeated(1, static_cast(it)); + size.add_uint32_force(1, static_cast(it)); } } size.add_float(1, this->min_mireds); size.add_float(1, this->max_mireds); if (!this->effects.empty()) { for (const auto &it : this->effects) { - size.add_length_repeated(1, it.size()); + size.add_length_force(1, it.size()); } } size.add_bool(1, this->disabled_by_default); @@ -862,9 +862,9 @@ void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { } void HomeassistantServiceResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->service_ref_.size()); - size.add_repeated_message(1, this->data); - size.add_repeated_message(1, this->data_template); - size.add_repeated_message(1, this->variables); + size.add_force_message(1, this->data); + size.add_force_message(1, this->data_template); + size.add_force_message(1, this->variables); size.add_bool(1, this->is_event); } #ifdef USE_API_HOMEASSISTANT_STATES @@ -926,7 +926,7 @@ void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { void ListEntitiesServicesResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->name_ref_.size()); size.add_fixed32(1, this->key); - size.add_repeated_message(1, this->args); + size.add_force_message(1, this->args); } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1106,7 +1106,7 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->supports_two_point_target_temperature); if (!this->supported_modes.empty()) { for (const auto &it : this->supported_modes) { - size.add_uint32_repeated(1, static_cast(it)); + size.add_uint32_force(1, static_cast(it)); } } size.add_float(1, this->visual_min_temperature); @@ -1115,27 +1115,27 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->supports_action); if (!this->supported_fan_modes.empty()) { for (const auto &it : this->supported_fan_modes) { - size.add_uint32_repeated(1, static_cast(it)); + size.add_uint32_force(1, static_cast(it)); } } if (!this->supported_swing_modes.empty()) { for (const auto &it : this->supported_swing_modes) { - size.add_uint32_repeated(1, static_cast(it)); + size.add_uint32_force(1, static_cast(it)); } } if (!this->supported_custom_fan_modes.empty()) { for (const auto &it : this->supported_custom_fan_modes) { - size.add_length_repeated(1, it.size()); + size.add_length_force(1, it.size()); } } if (!this->supported_presets.empty()) { for (const auto &it : this->supported_presets) { - size.add_uint32_repeated(2, static_cast(it)); + size.add_uint32_force(2, static_cast(it)); } } if (!this->supported_custom_presets.empty()) { for (const auto &it : this->supported_custom_presets) { - size.add_length_repeated(2, it.size()); + size.add_length_force(2, it.size()); } } size.add_bool(2, this->disabled_by_default); @@ -1387,7 +1387,7 @@ void ListEntitiesSelectResponse::calculate_size(ProtoSize &size) const { #endif if (!this->options.empty()) { for (const auto &it : this->options) { - size.add_length_repeated(1, it.size()); + size.add_length_force(1, it.size()); } } size.add_bool(1, this->disabled_by_default); @@ -1474,7 +1474,7 @@ void ListEntitiesSirenResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->disabled_by_default); if (!this->tones.empty()) { for (const auto &it : this->tones) { - size.add_length_repeated(1, it.size()); + size.add_length_force(1, it.size()); } } size.add_bool(1, this->supports_duration); @@ -1734,7 +1734,7 @@ void ListEntitiesMediaPlayerResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); size.add_bool(1, this->supports_pause); - size.add_repeated_message(1, this->supported_formats); + size.add_force_message(1, this->supported_formats); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -1840,7 +1840,7 @@ void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer buffer) const } } void BluetoothLERawAdvertisementsResponse::calculate_size(ProtoSize &size) const { - size.add_repeated_message(1, this->advertisements); + size.add_force_message(1, this->advertisements); } bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1889,8 +1889,8 @@ void BluetoothGATTDescriptor::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->handle); } void BluetoothGATTDescriptor::calculate_size(ProtoSize &size) const { - size.add_uint64_repeated(1, this->uuid[0]); - size.add_uint64_repeated(1, this->uuid[1]); + size.add_uint64_force(1, this->uuid[0]); + size.add_uint64_force(1, this->uuid[1]); size.add_uint32(1, this->handle); } void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { @@ -1903,11 +1903,11 @@ void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { } } void BluetoothGATTCharacteristic::calculate_size(ProtoSize &size) const { - size.add_uint64_repeated(1, this->uuid[0]); - size.add_uint64_repeated(1, this->uuid[1]); + size.add_uint64_force(1, this->uuid[0]); + size.add_uint64_force(1, this->uuid[1]); size.add_uint32(1, this->handle); size.add_uint32(1, this->properties); - size.add_repeated_message(1, this->descriptors); + size.add_force_message(1, this->descriptors); } void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->uuid[0], true); @@ -1918,10 +1918,10 @@ void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { } } void BluetoothGATTService::calculate_size(ProtoSize &size) const { - size.add_uint64_repeated(1, this->uuid[0]); - size.add_uint64_repeated(1, this->uuid[1]); + size.add_uint64_force(1, this->uuid[0]); + size.add_uint64_force(1, this->uuid[1]); size.add_uint32(1, this->handle); - size.add_repeated_message(1, this->characteristics); + size.add_force_message(1, this->characteristics); } void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); @@ -1929,7 +1929,7 @@ void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { } void BluetoothGATTGetServicesResponse::calculate_size(ProtoSize &size) const { size.add_uint64(1, this->address); - size.add_message_object_repeated(1, this->services[0]); + size.add_message_object_force(1, this->services[0]); } void BluetoothGATTGetServicesDoneResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); @@ -2058,7 +2058,7 @@ void BluetoothConnectionsFreeResponse::calculate_size(ProtoSize &size) const { size.add_uint32(1, this->limit); if (!this->allocated.empty()) { for (const auto &it : this->allocated) { - size.add_uint64_repeated(1, it); + size.add_uint64_force(1, it); } } } @@ -2322,7 +2322,7 @@ void VoiceAssistantWakeWord::calculate_size(ProtoSize &size) const { size.add_length(1, this->wake_word_ref_.size()); if (!this->trained_languages.empty()) { for (const auto &it : this->trained_languages) { - size.add_length_repeated(1, it.size()); + size.add_length_force(1, it.size()); } } } @@ -2336,10 +2336,10 @@ void VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer buffer) const buffer.encode_uint32(3, this->max_active_wake_words); } void VoiceAssistantConfigurationResponse::calculate_size(ProtoSize &size) const { - size.add_repeated_message(1, this->available_wake_words); + size.add_force_message(1, this->available_wake_words); if (!this->active_wake_words.empty()) { for (const auto &it : this->active_wake_words) { - size.add_length_repeated(1, it.size()); + size.add_length_force(1, it.size()); } } size.add_uint32(1, this->max_active_wake_words); @@ -2710,7 +2710,7 @@ void ListEntitiesEventResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->device_class_ref_.size()); if (!this->event_types.empty()) { for (const auto &it : this->event_types) { - size.add_length_repeated(1, it.size()); + size.add_length_force(1, it.size()); } } #ifdef USE_DEVICES diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index b1dbcd1ac1..da7d236ff1 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -520,10 +520,10 @@ class ProtoSize { } /** - * @brief Calculates and adds the size of an int32 field to the total message size (repeated field version) + * @brief Calculates and adds the size of an int32 field to the total message size (force version) */ - inline void add_int32_repeated(uint32_t field_id_size, int32_t value) { - // Always calculate size for repeated fields + inline void add_int32_force(uint32_t field_id_size, int32_t value) { + // Always calculate size when force is true if (value < 0) { // Negative values are encoded as 10-byte varints in protobuf total_size_ += field_id_size + 10; @@ -538,15 +538,15 @@ class ProtoSize { */ inline void add_uint32(uint32_t field_id_size, uint32_t value) { if (value != 0) { - add_uint32_repeated(field_id_size, value); + add_uint32_force(field_id_size, value); } } /** - * @brief Calculates and adds the size of a uint32 field to the total message size (repeated field version) + * @brief Calculates and adds the size of a uint32 field to the total message size (force version) */ - inline void add_uint32_repeated(uint32_t field_id_size, uint32_t value) { - // Always calculate size for repeated fields + inline void add_uint32_force(uint32_t field_id_size, uint32_t value) { + // Always calculate size when force is true total_size_ += field_id_size + varint(value); } @@ -564,10 +564,10 @@ class ProtoSize { } /** - * @brief Calculates and adds the size of a boolean field to the total message size (repeated field version) + * @brief Calculates and adds the size of a boolean field to the total message size (force version) */ - inline void add_bool_repeated(uint32_t field_id_size, bool value) { - // Always calculate size for repeated fields + inline void add_bool_force(uint32_t field_id_size, bool value) { + // Always calculate size when force is true // Boolean fields always use 1 byte total_size_ += field_id_size + 1; } @@ -615,17 +615,17 @@ class ProtoSize { */ inline void add_sint32(uint32_t field_id_size, int32_t value) { if (value != 0) { - add_sint32_repeated(field_id_size, value); + add_sint32_force(field_id_size, value); } } /** - * @brief Calculates and adds the size of a sint32 field to the total message size (repeated field version) + * @brief Calculates and adds the size of a sint32 field to the total message size (force version) * * Sint32 fields use ZigZag encoding, which is more efficient for negative values. */ - inline void add_sint32_repeated(uint32_t field_id_size, int32_t value) { - // Always calculate size for repeated fields + inline void add_sint32_force(uint32_t field_id_size, int32_t value) { + // Always calculate size when force is true // ZigZag encoding for sint32: (n << 1) ^ (n >> 31) uint32_t zigzag = (static_cast(value) << 1) ^ (static_cast(value >> 31)); total_size_ += field_id_size + varint(zigzag); @@ -636,15 +636,15 @@ class ProtoSize { */ inline void add_int64(uint32_t field_id_size, int64_t value) { if (value != 0) { - add_int64_repeated(field_id_size, value); + add_int64_force(field_id_size, value); } } /** - * @brief Calculates and adds the size of an int64 field to the total message size (repeated field version) + * @brief Calculates and adds the size of an int64 field to the total message size (force version) */ - inline void add_int64_repeated(uint32_t field_id_size, int64_t value) { - // Always calculate size for repeated fields + inline void add_int64_force(uint32_t field_id_size, int64_t value) { + // Always calculate size when force is true total_size_ += field_id_size + varint(value); } @@ -653,19 +653,19 @@ class ProtoSize { */ inline void add_uint64(uint32_t field_id_size, uint64_t value) { if (value != 0) { - add_uint64_repeated(field_id_size, value); + add_uint64_force(field_id_size, value); } } /** - * @brief Calculates and adds the size of a uint64 field to the total message size (repeated field version) + * @brief Calculates and adds the size of a uint64 field to the total message size (force version) */ - inline void add_uint64_repeated(uint32_t field_id_size, uint64_t value) { - // Always calculate size for repeated fields + inline void add_uint64_force(uint32_t field_id_size, uint64_t value) { + // Always calculate size when force is true total_size_ += field_id_size + varint(value); } - // NOTE: sint64 support functions (add_sint64_field, add_sint64_field_repeated) removed + // NOTE: sint64 support functions (add_sint64_field, add_sint64_field_force) removed // sint64 type is not supported by ESPHome API to reduce overhead on embedded systems /** @@ -673,7 +673,7 @@ class ProtoSize { */ inline void add_length(uint32_t field_id_size, size_t len) { if (len != 0) { - add_length_repeated(field_id_size, len); + add_length_force(field_id_size, len); } } @@ -681,8 +681,8 @@ class ProtoSize { * @brief Calculates and adds the size of a length-delimited field (string/bytes) to the total message size (repeated * field version) */ - inline void add_length_repeated(uint32_t field_id_size, size_t len) { - // Always calculate size for repeated fields + inline void add_length_force(uint32_t field_id_size, size_t len) { + // Always calculate size when force is true // Field ID + length varint + data bytes total_size_ += field_id_size + varint(static_cast(len)) + static_cast(len); } @@ -717,12 +717,12 @@ class ProtoSize { } /** - * @brief Calculates and adds the size of a nested message field to the total message size (repeated field version) + * @brief Calculates and adds the size of a nested message field to the total message size (force version) * * @param nested_size The pre-calculated size of the nested message */ - inline void add_message_field_repeated(uint32_t field_id_size, uint32_t nested_size) { - // Always calculate size for repeated fields + inline void add_message_field_force(uint32_t field_id_size, uint32_t nested_size) { + // Always calculate size when force is true // Field ID + length varint + nested message content total_size_ += field_id_size + varint(nested_size) + nested_size; } @@ -747,18 +747,18 @@ class ProtoSize { } /** - * @brief Calculates and adds the size of a nested message field to the total message size (repeated field version) + * @brief Calculates and adds the size of a nested message field to the total message size (force version) * * @param message The nested message object */ - inline void add_message_object_repeated(uint32_t field_id_size, const ProtoMessage &message) { + inline void add_message_object_force(uint32_t field_id_size, const ProtoMessage &message) { // Calculate nested message size by creating a temporary ProtoSize ProtoSize nested_calc; message.calculate_size(nested_calc); uint32_t nested_size = nested_calc.get_size(); // Use the base implementation with the calculated nested_size - add_message_field_repeated(field_id_size, nested_size); + add_message_field_force(field_id_size, nested_size); } /** @@ -771,15 +771,15 @@ class ProtoSize { * @param messages Vector of message objects */ template - inline void add_repeated_message(uint32_t field_id_size, const std::vector &messages) { + inline void add_force_message(uint32_t field_id_size, const std::vector &messages) { // Skip if the vector is empty if (messages.empty()) { return; } - // Use the repeated field version for all messages + // Use the force version for all messages for (const auto &message : messages) { - add_message_object_repeated(field_id_size, message); + add_message_object_force(field_id_size, message); } } }; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index ce24b2a652..171468410f 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -279,7 +279,7 @@ class TypeInfo(ABC): value_expr: Optional value expression (defaults to name) """ field_id_size = self.calculate_field_id_size() - method = f"{base_method}_repeated" if force else base_method + method = f"{base_method}_force" if force else base_method value = value_expr if value_expr else name return f"size.{method}({field_id_size}, {value});" @@ -629,9 +629,9 @@ class StringType(TypeInfo): # Check if this is being called from a repeated field context # In that case, 'name' will be 'it' and we need to use the repeated version if name == "it": - # For repeated fields, we need to use add_length_repeated which includes field ID + # For repeated fields, we need to use add_length_force which includes field ID field_id_size = self.calculate_field_id_size() - return f"size.add_length_repeated({field_id_size}, it.size());" + return f"size.add_length_force({field_id_size}, it.size());" # For messages that need encoding, use the StringRef size field_id_size = self.calculate_field_id_size() @@ -846,7 +846,7 @@ class FixedArrayBytesType(TypeInfo): if force: # For repeated fields, always calculate size (no zero check) - return f"size.add_length_repeated({field_id_size}, {length_field});" + return f"size.add_length_force({field_id_size}, {length_field});" else: # For non-repeated fields, add_length already checks for zero return f"size.add_length({field_id_size}, {length_field});" @@ -1236,7 +1236,7 @@ class RepeatedTypeInfo(TypeInfo): if isinstance(self._ti, MessageType): # For repeated messages, use the dedicated helper that handles iteration internally field_id_size = self._ti.calculate_field_id_size() - o = f"size.add_repeated_message({field_id_size}, {name});" + o = f"size.add_force_message({field_id_size}, {name});" return o # For other repeated types, use the underlying type's size calculation with force=True From 0773fc320b3b4999150f961159d76ab31f93cb4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 14:57:42 -1000 Subject: [PATCH 0050/1626] dry --- esphome/components/api/proto.h | 4 ++-- script/api_protobuf/api_protobuf.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index da7d236ff1..52733aaf7e 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -771,13 +771,13 @@ class ProtoSize { * @param messages Vector of message objects */ template - inline void add_force_message(uint32_t field_id_size, const std::vector &messages) { + inline void add_repeated_message(uint32_t field_id_size, const std::vector &messages) { // Skip if the vector is empty if (messages.empty()) { return; } - // Use the force version for all messages + // Use the force version for all messages in the repeated field for (const auto &message : messages) { add_message_object_force(field_id_size, message); } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 171468410f..f0b636b775 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1236,7 +1236,7 @@ class RepeatedTypeInfo(TypeInfo): if isinstance(self._ti, MessageType): # For repeated messages, use the dedicated helper that handles iteration internally field_id_size = self._ti.calculate_field_id_size() - o = f"size.add_force_message({field_id_size}, {name});" + o = f"size.add_repeated_message({field_id_size}, {name});" return o # For other repeated types, use the underlying type's size calculation with force=True From 193a85eb1cce5d3d9d985105c0bed871f2c0872a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 14:57:50 -1000 Subject: [PATCH 0051/1626] dry --- esphome/components/api/api_pb2.cpp | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c91dc5ea04..96d134fa14 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -167,10 +167,10 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const { size.add_bool(2, this->api_encryption_supported); #endif #ifdef USE_DEVICES - size.add_force_message(2, this->devices); + size.add_repeated_message(2, this->devices); #endif #ifdef USE_AREAS - size.add_force_message(2, this->areas); + size.add_repeated_message(2, this->areas); #endif #ifdef USE_AREAS size.add_message_object(2, this->area); @@ -862,9 +862,9 @@ void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { } void HomeassistantServiceResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->service_ref_.size()); - size.add_force_message(1, this->data); - size.add_force_message(1, this->data_template); - size.add_force_message(1, this->variables); + size.add_repeated_message(1, this->data); + size.add_repeated_message(1, this->data_template); + size.add_repeated_message(1, this->variables); size.add_bool(1, this->is_event); } #ifdef USE_API_HOMEASSISTANT_STATES @@ -926,7 +926,7 @@ void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { void ListEntitiesServicesResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->name_ref_.size()); size.add_fixed32(1, this->key); - size.add_force_message(1, this->args); + size.add_repeated_message(1, this->args); } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1734,7 +1734,7 @@ void ListEntitiesMediaPlayerResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->disabled_by_default); size.add_uint32(1, static_cast(this->entity_category)); size.add_bool(1, this->supports_pause); - size.add_force_message(1, this->supported_formats); + size.add_repeated_message(1, this->supported_formats); #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif @@ -1840,7 +1840,7 @@ void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer buffer) const } } void BluetoothLERawAdvertisementsResponse::calculate_size(ProtoSize &size) const { - size.add_force_message(1, this->advertisements); + size.add_repeated_message(1, this->advertisements); } bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1907,7 +1907,7 @@ void BluetoothGATTCharacteristic::calculate_size(ProtoSize &size) const { size.add_uint64_force(1, this->uuid[1]); size.add_uint32(1, this->handle); size.add_uint32(1, this->properties); - size.add_force_message(1, this->descriptors); + size.add_repeated_message(1, this->descriptors); } void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->uuid[0], true); @@ -1921,7 +1921,7 @@ void BluetoothGATTService::calculate_size(ProtoSize &size) const { size.add_uint64_force(1, this->uuid[0]); size.add_uint64_force(1, this->uuid[1]); size.add_uint32(1, this->handle); - size.add_force_message(1, this->characteristics); + size.add_repeated_message(1, this->characteristics); } void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); @@ -2336,7 +2336,7 @@ void VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer buffer) const buffer.encode_uint32(3, this->max_active_wake_words); } void VoiceAssistantConfigurationResponse::calculate_size(ProtoSize &size) const { - size.add_force_message(1, this->available_wake_words); + size.add_repeated_message(1, this->available_wake_words); if (!this->active_wake_words.empty()) { for (const auto &it : this->active_wake_words) { size.add_length_force(1, it.size()); From a82b5fa87a9238670facef2ece72c9920ab9eb65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 15:01:07 -1000 Subject: [PATCH 0052/1626] dry --- esphome/components/api/proto.h | 33 ++++++++------------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 52733aaf7e..63fdcce126 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -504,18 +504,8 @@ class ProtoSize { * @brief Calculates and adds the size of an int32 field to the total message size */ inline void add_int32(uint32_t field_id_size, int32_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size_ - } - - // Calculate and directly add to total_size - if (value < 0) { - // Negative values are encoded as 10-byte varints in protobuf - total_size_ += field_id_size + 10; - } else { - // For non-negative values, use the standard varint size - total_size_ += field_id_size + varint(static_cast(value)); + if (value != 0) { + add_int32_force(field_id_size, value); } } @@ -554,13 +544,10 @@ class ProtoSize { * @brief Calculates and adds the size of a boolean field to the total message size */ inline void add_bool(uint32_t field_id_size, bool value) { - // Skip calculation if value is false - if (!value) { - return; // No need to update total_size_ + if (value) { + // Boolean fields always use 1 byte when true + total_size_ += field_id_size + 1; } - - // Boolean fields always use 1 byte when true - total_size_ += field_id_size + 1; } /** @@ -706,14 +693,10 @@ class ProtoSize { * @param nested_size The pre-calculated size of the nested message */ inline void add_message_field(uint32_t field_id_size, uint32_t nested_size) { - // Skip calculation if nested message is empty - if (nested_size == 0) { - return; // No need to update total_size_ + if (nested_size != 0) { + // Field ID + length varint + nested message content + total_size_ += field_id_size + varint(nested_size) + nested_size; } - - // Calculate and directly add to total_size - // Field ID + length varint + nested message content - total_size_ += field_id_size + varint(nested_size) + nested_size; } /** From 5ebce4a901f18ee93398308df4f04acfe5681974 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 15:01:45 -1000 Subject: [PATCH 0053/1626] dry --- esphome/components/api/proto.h | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 63fdcce126..bfca80960f 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -513,14 +513,9 @@ class ProtoSize { * @brief Calculates and adds the size of an int32 field to the total message size (force version) */ inline void add_int32_force(uint32_t field_id_size, int32_t value) { - // Always calculate size when force is true - if (value < 0) { - // Negative values are encoded as 10-byte varints in protobuf - total_size_ += field_id_size + 10; - } else { - // For non-negative values, use the standard varint size - total_size_ += field_id_size + varint(static_cast(value)); - } + // Always calculate size when forced + // Negative values are encoded as 10-byte varints in protobuf + total_size_ += field_id_size + (value < 0 ? 10 : varint(static_cast(value))); } /** From 6e345c5f23e7fa04ed74e9693e38c100fd611ce8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 15:03:36 -1000 Subject: [PATCH 0054/1626] dry --- esphome/components/api/proto.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index bfca80960f..3d66859bd3 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -689,8 +689,7 @@ class ProtoSize { */ inline void add_message_field(uint32_t field_id_size, uint32_t nested_size) { if (nested_size != 0) { - // Field ID + length varint + nested message content - total_size_ += field_id_size + varint(nested_size) + nested_size; + add_message_field_force(field_id_size, nested_size); } } From e00839a608bd0751e1a18800d140d3f227f9e3c4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 27 Jul 2025 11:51:57 +1000 Subject: [PATCH 0055/1626] [ci-custom] Report actual changes needed for absolute import (#9919) --- script/ci-custom.py | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/script/ci-custom.py b/script/ci-custom.py index e726fcefc0..6f3c513f42 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -197,7 +197,7 @@ def lint_content_find_check(find, only_first=False, **kwargs): find_ = find(fname, content) errs = [] for line, col in find_all(content, find_): - err = func(fname) + err = func(fname, line, col, content) errs.append((line + 1, col + 1, err)) if only_first: break @@ -264,12 +264,12 @@ def lint_executable_bit(fname): "esphome/dashboard/static/ext-searchbox.js", ], ) -def lint_tabs(fname): +def lint_tabs(fname, line, col, content): return "File contains tab character. Please convert tabs to spaces." @lint_content_find_check("\r", only_first=True) -def lint_newline(fname): +def lint_newline(fname, line, col, content): return "File contains Windows newline. Please set your editor to Unix newline mode." @@ -512,7 +512,7 @@ def relative_cpp_search_text(fname, content): @lint_content_find_check(relative_cpp_search_text, include=["esphome/components/*.cpp"]) -def lint_relative_cpp_import(fname): +def lint_relative_cpp_import(fname, line, col, content): return ( "Component contains absolute import - Components must always use " "relative imports.\n" @@ -529,6 +529,20 @@ def relative_py_search_text(fname, content): return f"esphome.components.{integration}" +def convert_path_to_relative(abspath, current): + """Convert an absolute path to a relative import path.""" + if abspath == current: + return "." + absparts = abspath.split(".") + curparts = current.split(".") + uplen = len(curparts) + while absparts and curparts and absparts[0] == curparts[0]: + absparts.pop(0) + curparts.pop(0) + uplen -= 1 + return "." * uplen + ".".join(absparts) + + @lint_content_find_check( relative_py_search_text, include=["esphome/components/*.py"], @@ -537,14 +551,19 @@ def relative_py_search_text(fname, content): "esphome/components/web_server/__init__.py", ], ) -def lint_relative_py_import(fname): +def lint_relative_py_import(fname, line, col, content): + import_line = content.splitlines()[line] + abspath = import_line[col:].split(" ")[0] + current = fname.removesuffix(".py").replace(os.path.sep, ".") + replacement = convert_path_to_relative(abspath, current) + newline = import_line.replace(abspath, replacement) return ( "Component contains absolute import - Components must always use " "relative imports within the integration.\n" "Change:\n" - ' from esphome.components.abc import abc_ns"\n' + f" {import_line}\n" "to:\n" - " from . import abc_ns\n\n" + f" {newline}\n" ) @@ -588,7 +607,7 @@ def lint_namespace(fname, content): @lint_content_find_check('"esphome.h"', include=cpp_include, exclude=["tests/custom.h"]) -def lint_esphome_h(fname): +def lint_esphome_h(fname, line, col, content): return ( "File contains reference to 'esphome.h' - This file is " "auto-generated and should only be used for *custom* " @@ -679,7 +698,7 @@ def lint_trailing_whitespace(fname, match): "tests/custom.h", ], ) -def lint_log_in_header(fname): +def lint_log_in_header(fname, line, col, content): return ( "Found reference to ESP_LOG in header file. Using ESP_LOG* in header files " "is currently not possible - please move the definition to a source file (.cpp)" From 5bdd8500127bea8694c5e4fa83d0752392a1d498 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 16:59:57 -1000 Subject: [PATCH 0056/1626] reduce light flash --- esphome/components/light/light_call.cpp | 83 +++++++++++++++---------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index a3ffe22591..9a620541dc 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -9,6 +9,11 @@ namespace light { static const char *const TAG = "light"; +// Helper function to reduce code size for validation warnings +static void log_validation_warning(const char *name, const char *param_name, float val, float min, float max) { + ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, param_name, val, min, max); +} + // Macro to reduce repetitive setter code #define IMPLEMENT_LIGHT_CALL_SETTER(name, type, flag) \ LightCall &LightCall::set_##name(optional(name)) { \ @@ -223,8 +228,7 @@ LightColorValues LightCall::validate_() { if (this->has_##name_()) { \ auto val = this->name_##_; \ if (val < (min) || val > (max)) { \ - ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_LITERAL(upper_name), val, \ - (min), (max)); \ + log_validation_warning(name, LOG_STR_LITERAL(upper_name), val, (min), (max)); \ this->name_##_ = clamp(val, (min), (max)); \ } \ } @@ -442,41 +446,56 @@ std::set LightCall::get_suitable_color_modes_() { bool has_rgb = (this->has_color_brightness() && this->color_brightness_ > 0.0f) || (this->has_red() || this->has_green() || this->has_blue()); + // Static sets that are only constructed once + static const std::set MODES_WHITE_ONLY = {ColorMode::WHITE, ColorMode::RGB_WHITE, + ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE, + ColorMode::RGB_COLD_WARM_WHITE}; + static const std::set MODES_CT_ONLY = {ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE, + ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}; + static const std::set MODES_WHITE_CT = {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, + ColorMode::RGB_COLD_WARM_WHITE}; + static const std::set MODES_CWWW_ONLY = {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}; + static const std::set MODES_NONE = { + ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE, ColorMode::RGB, + ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE}; + static const std::set MODES_RGB_WHITE = {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, + ColorMode::RGB_COLD_WARM_WHITE}; + static const std::set MODES_RGB_CT = {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; + static const std::set MODES_RGB_CWWW = {ColorMode::RGB_COLD_WARM_WHITE}; + static const std::set MODES_RGB_ONLY = {ColorMode::RGB, ColorMode::RGB_WHITE, + ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; + +// Build key from flags: [rgb][cwww][ct][white] #define KEY(white, ct, cwww, rgb) ((white) << 0 | (ct) << 1 | (cwww) << 2 | (rgb) << 3) -#define ENTRY(white, ct, cwww, rgb, ...) \ - std::make_tuple>(KEY(white, ct, cwww, rgb), __VA_ARGS__) - // Flag order: white, color temperature, cwww, rgb - std::array>, 10> lookup_table{ - ENTRY(true, false, false, false, - {ColorMode::WHITE, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE, - ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(false, true, false, false, - {ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE, - ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(true, true, false, false, - {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(false, false, true, false, {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(false, false, false, false, - {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE, ColorMode::RGB, - ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE}), - ENTRY(true, false, false, true, - {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(false, true, false, true, {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(true, true, false, true, {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(false, false, true, true, {ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(false, false, false, true, - {ColorMode::RGB, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}), - }; + uint8_t key = KEY(has_white, has_ct, has_cwww, has_rgb); - auto key = KEY(has_white, has_ct, has_cwww, has_rgb); - for (auto &item : lookup_table) { - if (std::get<0>(item) == key) - return std::get<1>(item); + switch (key) { + case KEY(true, false, false, false): + return MODES_WHITE_ONLY; + case KEY(false, true, false, false): + return MODES_CT_ONLY; + case KEY(true, true, false, false): + return MODES_WHITE_CT; + case KEY(false, false, true, false): + return MODES_CWWW_ONLY; + case KEY(false, false, false, false): + return MODES_NONE; + case KEY(true, false, false, true): + return MODES_RGB_WHITE; + case KEY(false, true, false, true): + return MODES_RGB_CT; + case KEY(true, true, false, true): + return MODES_RGB_CT; + case KEY(false, false, true, true): + return MODES_RGB_CWWW; + case KEY(false, false, false, true): + return MODES_RGB_ONLY; + default: + return {}; // conflicting flags } - // This happens if there are conflicting flags given. - return {}; +#undef KEY } LightCall &LightCall::set_effect(const std::string &effect) { From f333ab1fd71b42b1e72dfad00843087207ed23da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 17:01:02 -1000 Subject: [PATCH 0057/1626] cover --- tests/integration/test_light_calls.py | 63 +++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index 1c56bbbf9e..1a0a9e553f 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -180,6 +180,69 @@ async def test_light_calls( state = await wait_for_state_change(rgb_light.key) assert state.state is False + # Test color mode combinations to verify get_suitable_color_modes optimization + + # Test 22: White only mode + client.light_command(key=rgbcw_light.key, state=True, white=0.5) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + + # Test 23: Color temperature only mode + client.light_command(key=rgbcw_light.key, state=True, color_temperature=300) + state = await wait_for_state_change(rgbcw_light.key) + assert state.color_temperature == pytest.approx(300) + + # Test 24: Cold/warm white only mode + client.light_command( + key=rgbcw_light.key, state=True, cold_white=0.6, warm_white=0.4 + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.cold_white == pytest.approx(0.6) + assert state.warm_white == pytest.approx(0.4) + + # Test 25: RGB only mode + client.light_command(key=rgb_light.key, state=True, rgb=(0.5, 0.5, 0.5)) + state = await wait_for_state_change(rgb_light.key) + assert state.state is True + + # Test 26: RGB + white combination + client.light_command( + key=rgbcw_light.key, state=True, rgb=(0.3, 0.3, 0.3), white=0.5 + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + + # Test 27: RGB + color temperature combination + client.light_command( + key=rgbcw_light.key, state=True, rgb=(0.4, 0.4, 0.4), color_temperature=280 + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + + # Test 28: RGB + cold/warm white combination + client.light_command( + key=rgbcw_light.key, + state=True, + rgb=(0.2, 0.2, 0.2), + cold_white=0.5, + warm_white=0.5, + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + + # Test 29: White + color temperature combination + client.light_command( + key=rgbcw_light.key, state=True, white=0.6, color_temperature=320 + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + + # Test 30: No specific color parameters (tests default mode selection) + client.light_command(key=rgbcw_light.key, state=True, brightness=0.75) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(0.75) + # Final cleanup - turn all lights off for light in lights: client.light_command( From 79984a288e0e831b968041ec10499480ae62551f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 17:06:39 -1000 Subject: [PATCH 0058/1626] preen --- esphome/components/light/light_call.cpp | 62 +++++++++---------------- 1 file changed, 23 insertions(+), 39 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 9a620541dc..39b7bb50ed 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -446,51 +446,35 @@ std::set LightCall::get_suitable_color_modes_() { bool has_rgb = (this->has_color_brightness() && this->color_brightness_ > 0.0f) || (this->has_red() || this->has_green() || this->has_blue()); - // Static sets that are only constructed once - static const std::set MODES_WHITE_ONLY = {ColorMode::WHITE, ColorMode::RGB_WHITE, - ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE, - ColorMode::RGB_COLD_WARM_WHITE}; - static const std::set MODES_CT_ONLY = {ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE, - ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}; - static const std::set MODES_WHITE_CT = {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, - ColorMode::RGB_COLD_WARM_WHITE}; - static const std::set MODES_CWWW_ONLY = {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}; - static const std::set MODES_NONE = { - ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE, ColorMode::RGB, - ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE}; - static const std::set MODES_RGB_WHITE = {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, - ColorMode::RGB_COLD_WARM_WHITE}; - static const std::set MODES_RGB_CT = {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; - static const std::set MODES_RGB_CWWW = {ColorMode::RGB_COLD_WARM_WHITE}; - static const std::set MODES_RGB_ONLY = {ColorMode::RGB, ColorMode::RGB_WHITE, - ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; - // Build key from flags: [rgb][cwww][ct][white] #define KEY(white, ct, cwww, rgb) ((white) << 0 | (ct) << 1 | (cwww) << 2 | (rgb) << 3) uint8_t key = KEY(has_white, has_ct, has_cwww, has_rgb); switch (key) { - case KEY(true, false, false, false): - return MODES_WHITE_ONLY; - case KEY(false, true, false, false): - return MODES_CT_ONLY; - case KEY(true, true, false, false): - return MODES_WHITE_CT; - case KEY(false, false, true, false): - return MODES_CWWW_ONLY; - case KEY(false, false, false, false): - return MODES_NONE; - case KEY(true, false, false, true): - return MODES_RGB_WHITE; - case KEY(false, true, false, true): - return MODES_RGB_CT; - case KEY(true, true, false, true): - return MODES_RGB_CT; - case KEY(false, false, true, true): - return MODES_RGB_CWWW; - case KEY(false, false, false, true): - return MODES_RGB_ONLY; + case KEY(true, false, false, false): // white only + return {ColorMode::WHITE, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE, + ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(false, true, false, false): // ct only + return {ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE, + ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(true, true, false, false): // white + ct + return {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(false, false, true, false): // cwww only + return {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(false, false, false, false): // none + return {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE, ColorMode::RGB, + ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE}; + case KEY(true, false, false, true): // rgb + white + return {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(false, true, false, true): // rgb + ct + return {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(true, true, false, true): // rgb + white + ct + return {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(false, false, true, true): // rgb + cwww + return {ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(false, false, false, true): // rgb only + return {ColorMode::RGB, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; default: return {}; // conflicting flags } From 2d237d0f97a612aba9a15877fefe1bf5a0c7531d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 17:13:59 -1000 Subject: [PATCH 0059/1626] fixes --- esphome/components/light/light_call.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 39b7bb50ed..1b856ad580 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -468,8 +468,7 @@ std::set LightCall::get_suitable_color_modes_() { case KEY(true, false, false, true): // rgb + white return {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; case KEY(false, true, false, true): // rgb + ct - return {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; - case KEY(true, true, false, true): // rgb + white + ct + case KEY(true, true, false, true): // rgb + white + ct return {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; case KEY(false, false, true, true): // rgb + cwww return {ColorMode::RGB_COLD_WARM_WHITE}; From bcc56648c0482ca409b50d5deeee38c8f4cc7746 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 18:56:35 -1000 Subject: [PATCH 0060/1626] [light] Reduce flash memory usage by optimizing validation and color mode logic (#9921) --- esphome/components/light/light_call.cpp | 66 +++++++++++++------------ tests/integration/test_light_calls.py | 63 +++++++++++++++++++++++ 2 files changed, 97 insertions(+), 32 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index a3ffe22591..1b856ad580 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -9,6 +9,11 @@ namespace light { static const char *const TAG = "light"; +// Helper function to reduce code size for validation warnings +static void log_validation_warning(const char *name, const char *param_name, float val, float min, float max) { + ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, param_name, val, min, max); +} + // Macro to reduce repetitive setter code #define IMPLEMENT_LIGHT_CALL_SETTER(name, type, flag) \ LightCall &LightCall::set_##name(optional(name)) { \ @@ -223,8 +228,7 @@ LightColorValues LightCall::validate_() { if (this->has_##name_()) { \ auto val = this->name_##_; \ if (val < (min) || val > (max)) { \ - ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_LITERAL(upper_name), val, \ - (min), (max)); \ + log_validation_warning(name, LOG_STR_LITERAL(upper_name), val, (min), (max)); \ this->name_##_ = clamp(val, (min), (max)); \ } \ } @@ -442,41 +446,39 @@ std::set LightCall::get_suitable_color_modes_() { bool has_rgb = (this->has_color_brightness() && this->color_brightness_ > 0.0f) || (this->has_red() || this->has_green() || this->has_blue()); +// Build key from flags: [rgb][cwww][ct][white] #define KEY(white, ct, cwww, rgb) ((white) << 0 | (ct) << 1 | (cwww) << 2 | (rgb) << 3) -#define ENTRY(white, ct, cwww, rgb, ...) \ - std::make_tuple>(KEY(white, ct, cwww, rgb), __VA_ARGS__) - // Flag order: white, color temperature, cwww, rgb - std::array>, 10> lookup_table{ - ENTRY(true, false, false, false, - {ColorMode::WHITE, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE, - ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(false, true, false, false, - {ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE, - ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(true, true, false, false, - {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(false, false, true, false, {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(false, false, false, false, - {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE, ColorMode::RGB, - ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE}), - ENTRY(true, false, false, true, - {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(false, true, false, true, {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(true, true, false, true, {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(false, false, true, true, {ColorMode::RGB_COLD_WARM_WHITE}), - ENTRY(false, false, false, true, - {ColorMode::RGB, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}), - }; + uint8_t key = KEY(has_white, has_ct, has_cwww, has_rgb); - auto key = KEY(has_white, has_ct, has_cwww, has_rgb); - for (auto &item : lookup_table) { - if (std::get<0>(item) == key) - return std::get<1>(item); + switch (key) { + case KEY(true, false, false, false): // white only + return {ColorMode::WHITE, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE, + ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(false, true, false, false): // ct only + return {ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE, + ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(true, true, false, false): // white + ct + return {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(false, false, true, false): // cwww only + return {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(false, false, false, false): // none + return {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE, ColorMode::RGB, + ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE}; + case KEY(true, false, false, true): // rgb + white + return {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(false, true, false, true): // rgb + ct + case KEY(true, true, false, true): // rgb + white + ct + return {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(false, false, true, true): // rgb + cwww + return {ColorMode::RGB_COLD_WARM_WHITE}; + case KEY(false, false, false, true): // rgb only + return {ColorMode::RGB, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}; + default: + return {}; // conflicting flags } - // This happens if there are conflicting flags given. - return {}; +#undef KEY } LightCall &LightCall::set_effect(const std::string &effect) { diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index 1c56bbbf9e..1a0a9e553f 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -180,6 +180,69 @@ async def test_light_calls( state = await wait_for_state_change(rgb_light.key) assert state.state is False + # Test color mode combinations to verify get_suitable_color_modes optimization + + # Test 22: White only mode + client.light_command(key=rgbcw_light.key, state=True, white=0.5) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + + # Test 23: Color temperature only mode + client.light_command(key=rgbcw_light.key, state=True, color_temperature=300) + state = await wait_for_state_change(rgbcw_light.key) + assert state.color_temperature == pytest.approx(300) + + # Test 24: Cold/warm white only mode + client.light_command( + key=rgbcw_light.key, state=True, cold_white=0.6, warm_white=0.4 + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.cold_white == pytest.approx(0.6) + assert state.warm_white == pytest.approx(0.4) + + # Test 25: RGB only mode + client.light_command(key=rgb_light.key, state=True, rgb=(0.5, 0.5, 0.5)) + state = await wait_for_state_change(rgb_light.key) + assert state.state is True + + # Test 26: RGB + white combination + client.light_command( + key=rgbcw_light.key, state=True, rgb=(0.3, 0.3, 0.3), white=0.5 + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + + # Test 27: RGB + color temperature combination + client.light_command( + key=rgbcw_light.key, state=True, rgb=(0.4, 0.4, 0.4), color_temperature=280 + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + + # Test 28: RGB + cold/warm white combination + client.light_command( + key=rgbcw_light.key, + state=True, + rgb=(0.2, 0.2, 0.2), + cold_white=0.5, + warm_white=0.5, + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + + # Test 29: White + color temperature combination + client.light_command( + key=rgbcw_light.key, state=True, white=0.6, color_temperature=320 + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + + # Test 30: No specific color parameters (tests default mode selection) + client.light_command(key=rgbcw_light.key, state=True, brightness=0.75) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(0.75) + # Final cleanup - turn all lights off for light in lights: client.light_command( From 14862904ac569e06e5aaa6ac3d0bd8af476c9dfe Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sun, 27 Jul 2025 00:54:10 -0500 Subject: [PATCH 0061/1626] [power_supply] Optimize logging, reduce flash footprint (#9923) --- esphome/components/power_supply/power_supply.cpp | 15 +++++++-------- esphome/components/power_supply/power_supply.h | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/esphome/components/power_supply/power_supply.cpp b/esphome/components/power_supply/power_supply.cpp index 131fbdfa2e..5db2122412 100644 --- a/esphome/components/power_supply/power_supply.cpp +++ b/esphome/components/power_supply/power_supply.cpp @@ -13,14 +13,13 @@ void PowerSupply::setup() { this->request_high_power(); } void PowerSupply::dump_config() { - ESP_LOGCONFIG(TAG, "Power Supply:"); - LOG_PIN(" Pin: ", this->pin_); ESP_LOGCONFIG(TAG, + "Power Supply:\n" " Time to enable: %" PRIu32 " ms\n" - " Keep on time: %.1f s", - this->enable_time_, this->keep_on_time_ / 1000.0f); - if (this->enable_on_boot_) - ESP_LOGCONFIG(TAG, " Enabled at startup: True"); + " Keep on time: %" PRIu32 " s\n" + " Enable at startup: %s", + this->enable_time_, this->keep_on_time_ / 1000u, YESNO(this->enable_on_boot_)); + LOG_PIN(" Pin: ", this->pin_); } float PowerSupply::get_setup_priority() const { return setup_priority::IO; } @@ -30,7 +29,7 @@ bool PowerSupply::is_enabled() const { return this->active_requests_ != 0; } void PowerSupply::request_high_power() { if (this->active_requests_ == 0) { this->cancel_timeout("power-supply-off"); - ESP_LOGD(TAG, "Enabling power supply."); + ESP_LOGV(TAG, "Enabling"); this->pin_->digital_write(true); delay(this->enable_time_); } @@ -45,7 +44,7 @@ void PowerSupply::unrequest_high_power() { this->active_requests_--; if (this->active_requests_ == 0) { this->set_timeout("power-supply-off", this->keep_on_time_, [this]() { - ESP_LOGD(TAG, "Disabling power supply."); + ESP_LOGV(TAG, "Disabling"); this->pin_->digital_write(false); }); } diff --git a/esphome/components/power_supply/power_supply.h b/esphome/components/power_supply/power_supply.h index 3959f6f299..0387074eb8 100644 --- a/esphome/components/power_supply/power_supply.h +++ b/esphome/components/power_supply/power_supply.h @@ -36,10 +36,10 @@ class PowerSupply : public Component { protected: GPIOPin *pin_; - bool enable_on_boot_{false}; uint32_t enable_time_; uint32_t keep_on_time_; int16_t active_requests_{0}; // use signed integer to make catching negative requests easier. + bool enable_on_boot_{false}; }; class PowerSupplyRequester { From 92e9383164cb6df41c61fa2e3c235b7270613040 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 21:18:58 -1000 Subject: [PATCH 0062/1626] light_opt_part2 --- esphome/components/light/light_call.cpp | 153 +++++++++++------- esphome/components/light/light_color_values.h | 25 +-- .../components/light/light_json_schema.cpp | 130 ++++++++------- esphome/components/light/light_state.cpp | 17 +- 4 files changed, 189 insertions(+), 136 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 1b856ad580..ac5a2e9fde 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -9,11 +9,28 @@ namespace light { static const char *const TAG = "light"; -// Helper function to reduce code size for validation warnings +// Helper functions to reduce code size for logging +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN static void log_validation_warning(const char *name, const char *param_name, float val, float min, float max) { ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, param_name, val, min, max); } +static void log_feature_not_supported(const char *name, const char *feature) { + ESP_LOGW(TAG, "'%s': %s not supported", name, feature); +} + +static void log_color_mode_not_supported(const char *name, const char *feature) { + ESP_LOGW(TAG, "'%s': color mode does not support setting %s", name, feature); +} + +static void log_invalid_parameter(const char *name, const char *message) { ESP_LOGW(TAG, "'%s': %s", name, message); } +#else +#define log_validation_warning(name, param_name, val, min, max) +#define log_feature_not_supported(name, feature) +#define log_color_mode_not_supported(name, feature) +#define log_invalid_parameter(name, message) +#endif + // Macro to reduce repetitive setter code #define IMPLEMENT_LIGHT_CALL_SETTER(name, type, flag) \ LightCall &LightCall::set_##name(optional(name)) { \ @@ -49,11 +66,17 @@ static const LogString *color_mode_to_human(ColorMode color_mode) { return LOG_STR(""); } +// Helper to log percentage values +static inline void log_percent(const char *name, const char *param, float value) { + ESP_LOGD(TAG, " %s: %.0f%%", param, value * 100.0f); +} + void LightCall::perform() { const char *name = this->parent_->get_name().c_str(); LightColorValues v = this->validate_(); + const bool publish = this->get_publish_(); - if (this->get_publish_()) { + if (publish) { ESP_LOGD(TAG, "'%s' Setting:", name); // Only print color mode when it's being changed @@ -71,11 +94,11 @@ void LightCall::perform() { } if (this->has_brightness()) { - ESP_LOGD(TAG, " Brightness: %.0f%%", v.get_brightness() * 100.0f); + log_percent(name, "Brightness", v.get_brightness()); } if (this->has_color_brightness()) { - ESP_LOGD(TAG, " Color brightness: %.0f%%", v.get_color_brightness() * 100.0f); + log_percent(name, "Color brightness", v.get_color_brightness()); } if (this->has_red() || this->has_green() || this->has_blue()) { ESP_LOGD(TAG, " Red: %.0f%%, Green: %.0f%%, Blue: %.0f%%", v.get_red() * 100.0f, v.get_green() * 100.0f, @@ -83,7 +106,7 @@ void LightCall::perform() { } if (this->has_white()) { - ESP_LOGD(TAG, " White: %.0f%%", v.get_white() * 100.0f); + log_percent(name, "White", v.get_white()); } if (this->has_color_temperature()) { ESP_LOGD(TAG, " Color temperature: %.1f mireds", v.get_color_temperature()); @@ -97,26 +120,26 @@ void LightCall::perform() { if (this->has_flash_()) { // FLASH - if (this->get_publish_()) { + if (publish) { ESP_LOGD(TAG, " Flash length: %.1fs", this->flash_length_ / 1e3f); } - this->parent_->start_flash_(v, this->flash_length_, this->get_publish_()); + this->parent_->start_flash_(v, this->flash_length_, publish); } else if (this->has_transition_()) { // TRANSITION - if (this->get_publish_()) { + if (publish) { ESP_LOGD(TAG, " Transition length: %.1fs", this->transition_length_ / 1e3f); } // Special case: Transition and effect can be set when turning off if (this->has_effect_()) { - if (this->get_publish_()) { + if (publish) { ESP_LOGD(TAG, " Effect: 'None'"); } this->parent_->stop_effect_(); } - this->parent_->start_transition_(v, this->transition_length_, this->get_publish_()); + this->parent_->start_transition_(v, this->transition_length_, publish); } else if (this->has_effect_()) { // EFFECT @@ -127,7 +150,7 @@ void LightCall::perform() { effect_s = this->parent_->effects_[this->effect_ - 1]->get_name().c_str(); } - if (this->get_publish_()) { + if (publish) { ESP_LOGD(TAG, " Effect: '%s'", effect_s); } @@ -138,13 +161,13 @@ void LightCall::perform() { this->parent_->set_immediately_(v, true); } else { // INSTANT CHANGE - this->parent_->set_immediately_(v, this->get_publish_()); + this->parent_->set_immediately_(v, publish); } if (!this->has_transition_()) { this->parent_->target_state_reached_callback_.call(); } - if (this->get_publish_()) { + if (publish) { this->parent_->publish_state(); } if (this->get_save_()) { @@ -156,14 +179,27 @@ LightColorValues LightCall::validate_() { auto *name = this->parent_->get_name().c_str(); auto traits = this->parent_->get_traits(); + // Cache frequently used flags + const bool has_color_mode = this->has_color_mode(); + const bool has_state = this->has_state(); + const bool has_brightness = this->has_brightness(); + const bool has_color_brightness = this->has_color_brightness(); + const bool has_red = this->has_red(); + const bool has_green = this->has_green(); + const bool has_blue = this->has_blue(); + const bool has_white = this->has_white(); + const bool has_color_temperature = this->has_color_temperature(); + const bool has_cold_white = this->has_cold_white(); + const bool has_warm_white = this->has_warm_white(); + // Color mode check - if (this->has_color_mode() && !traits.supports_color_mode(this->color_mode_)) { + if (has_color_mode && !traits.supports_color_mode(this->color_mode_)) { ESP_LOGW(TAG, "'%s' does not support color mode %s", name, LOG_STR_ARG(color_mode_to_human(this->color_mode_))); this->set_flag_(FLAG_HAS_COLOR_MODE, false); } // Ensure there is always a color mode set - if (!this->has_color_mode()) { + if (!has_color_mode) { this->color_mode_ = this->compute_color_mode_(); this->set_flag_(FLAG_HAS_COLOR_MODE, true); } @@ -173,28 +209,27 @@ LightColorValues LightCall::validate_() { this->transform_parameters_(); // Brightness exists check - if (this->has_brightness() && this->brightness_ > 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) { - ESP_LOGW(TAG, "'%s': setting brightness not supported", name); + if (has_brightness && this->brightness_ > 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) { + log_feature_not_supported(name, "brightness"); this->set_flag_(FLAG_HAS_BRIGHTNESS, false); } // Transition length possible check if (this->has_transition_() && this->transition_length_ != 0 && !(color_mode & ColorCapability::BRIGHTNESS)) { - ESP_LOGW(TAG, "'%s': transitions not supported", name); + log_feature_not_supported(name, "transitions"); this->set_flag_(FLAG_HAS_TRANSITION, false); } // Color brightness exists check - if (this->has_color_brightness() && this->color_brightness_ > 0.0f && !(color_mode & ColorCapability::RGB)) { - ESP_LOGW(TAG, "'%s': color mode does not support setting RGB brightness", name); + if (has_color_brightness && this->color_brightness_ > 0.0f && !(color_mode & ColorCapability::RGB)) { + log_color_mode_not_supported(name, "RGB brightness"); this->set_flag_(FLAG_HAS_COLOR_BRIGHTNESS, false); } // RGB exists check - if ((this->has_red() && this->red_ > 0.0f) || (this->has_green() && this->green_ > 0.0f) || - (this->has_blue() && this->blue_ > 0.0f)) { + if ((has_red && this->red_ > 0.0f) || (has_green && this->green_ > 0.0f) || (has_blue && this->blue_ > 0.0f)) { if (!(color_mode & ColorCapability::RGB)) { - ESP_LOGW(TAG, "'%s': color mode does not support setting RGB color", name); + log_color_mode_not_supported(name, "RGB color"); this->set_flag_(FLAG_HAS_RED, false); this->set_flag_(FLAG_HAS_GREEN, false); this->set_flag_(FLAG_HAS_BLUE, false); @@ -202,23 +237,23 @@ LightColorValues LightCall::validate_() { } // White value exists check - if (this->has_white() && this->white_ > 0.0f && + if (has_white && this->white_ > 0.0f && !(color_mode & ColorCapability::WHITE || color_mode & ColorCapability::COLD_WARM_WHITE)) { - ESP_LOGW(TAG, "'%s': color mode does not support setting white value", name); + log_color_mode_not_supported(name, "white value"); this->set_flag_(FLAG_HAS_WHITE, false); } // Color temperature exists check - if (this->has_color_temperature() && + if (has_color_temperature && !(color_mode & ColorCapability::COLOR_TEMPERATURE || color_mode & ColorCapability::COLD_WARM_WHITE)) { - ESP_LOGW(TAG, "'%s': color mode does not support setting color temperature", name); + log_color_mode_not_supported(name, "color temperature"); this->set_flag_(FLAG_HAS_COLOR_TEMPERATURE, false); } // Cold/warm white value exists check - if ((this->has_cold_white() && this->cold_white_ > 0.0f) || (this->has_warm_white() && this->warm_white_ > 0.0f)) { + if ((has_cold_white && this->cold_white_ > 0.0f) || (has_warm_white && this->warm_white_ > 0.0f)) { if (!(color_mode & ColorCapability::COLD_WARM_WHITE)) { - ESP_LOGW(TAG, "'%s': color mode does not support setting cold/warm white value", name); + log_color_mode_not_supported(name, "cold/warm white value"); this->set_flag_(FLAG_HAS_COLD_WHITE, false); this->set_flag_(FLAG_HAS_WARM_WHITE, false); } @@ -246,18 +281,18 @@ LightColorValues LightCall::validate_() { VALIDATE_RANGE_(color_temperature, "Color temperature", traits.get_min_mireds(), traits.get_max_mireds()) // Flag whether an explicit turn off was requested, in which case we'll also stop the effect. - bool explicit_turn_off_request = this->has_state() && !this->state_; + bool explicit_turn_off_request = has_state && !this->state_; // Turn off when brightness is set to zero, and reset brightness (so that it has nonzero brightness when turned on). - if (this->has_brightness() && this->brightness_ == 0.0f) { + if (has_brightness && this->brightness_ == 0.0f) { this->state_ = false; this->set_flag_(FLAG_HAS_STATE, true); this->brightness_ = 1.0f; } // Set color brightness to 100% if currently zero and a color is set. - if (this->has_red() || this->has_green() || this->has_blue()) { - if (!this->has_color_brightness() && this->parent_->remote_values.get_color_brightness() == 0.0f) { + if (has_red || has_green || has_blue) { + if (!has_color_brightness && this->parent_->remote_values.get_color_brightness() == 0.0f) { this->color_brightness_ = 1.0f; this->set_flag_(FLAG_HAS_COLOR_BRIGHTNESS, true); } @@ -265,34 +300,34 @@ LightColorValues LightCall::validate_() { // Create color values for the light with this call applied. auto v = this->parent_->remote_values; - if (this->has_color_mode()) + if (has_color_mode) v.set_color_mode(this->color_mode_); - if (this->has_state()) + if (has_state) v.set_state(this->state_); - if (this->has_brightness()) + if (has_brightness) v.set_brightness(this->brightness_); - if (this->has_color_brightness()) + if (has_color_brightness) v.set_color_brightness(this->color_brightness_); - if (this->has_red()) + if (has_red) v.set_red(this->red_); - if (this->has_green()) + if (has_green) v.set_green(this->green_); - if (this->has_blue()) + if (has_blue) v.set_blue(this->blue_); - if (this->has_white()) + if (has_white) v.set_white(this->white_); - if (this->has_color_temperature()) + if (has_color_temperature) v.set_color_temperature(this->color_temperature_); - if (this->has_cold_white()) + if (has_cold_white) v.set_cold_white(this->cold_white_); - if (this->has_warm_white()) + if (has_warm_white) v.set_warm_white(this->warm_white_); v.normalize_color(); // Flash length check if (this->has_flash_() && this->flash_length_ == 0) { - ESP_LOGW(TAG, "'%s': flash length must be greater than zero", name); + log_invalid_parameter(name, "flash length must be greater than zero"); this->set_flag_(FLAG_HAS_FLASH, false); } @@ -311,13 +346,13 @@ LightColorValues LightCall::validate_() { } if (this->has_effect_() && (this->has_transition_() || this->has_flash_())) { - ESP_LOGW(TAG, "'%s': effect cannot be used with transition/flash", name); + log_invalid_parameter(name, "effect cannot be used with transition/flash"); this->set_flag_(FLAG_HAS_TRANSITION, false); this->set_flag_(FLAG_HAS_FLASH, false); } if (this->has_flash_() && this->has_transition_()) { - ESP_LOGW(TAG, "'%s': flash cannot be used with transition", name); + log_invalid_parameter(name, "flash cannot be used with transition"); this->set_flag_(FLAG_HAS_TRANSITION, false); } @@ -334,17 +369,17 @@ LightColorValues LightCall::validate_() { } if (this->has_transition_() && !supports_transition) { - ESP_LOGW(TAG, "'%s': transitions not supported", name); + log_feature_not_supported(name, "transitions"); this->set_flag_(FLAG_HAS_TRANSITION, false); } // If not a flash and turning the light off, then disable the light // Do not use light color values directly, so that effects can set 0% brightness // Reason: When user turns off the light in frontend, the effect should also stop - bool target_state = this->has_state() ? this->state_ : v.is_on(); + bool target_state = has_state ? this->state_ : v.is_on(); if (!this->has_flash_() && !target_state) { if (this->has_effect_()) { - ESP_LOGW(TAG, "'%s': cannot start effect when turning off", name); + log_invalid_parameter(name, "cannot start effect when turning off"); this->set_flag_(FLAG_HAS_EFFECT, false); } else if (this->parent_->active_effect_index_ != 0 && explicit_turn_off_request) { // Auto turn off effect @@ -368,21 +403,27 @@ void LightCall::transform_parameters_() { // - RGBWW lights with color_interlock=true, which also sets "brightness" and // "color_temperature" (without color_interlock, CW/WW are set directly) // - Legacy Home Assistant (pre-colormode), which sets "white" and "color_temperature" + + // Cache min/max mireds to avoid repeated calls + const float min_mireds = traits.get_min_mireds(); + const float max_mireds = traits.get_max_mireds(); + if (((this->has_white() && this->white_ > 0.0f) || this->has_color_temperature()) && // (this->color_mode_ & ColorCapability::COLD_WARM_WHITE) && // !(this->color_mode_ & ColorCapability::WHITE) && // !(this->color_mode_ & ColorCapability::COLOR_TEMPERATURE) && // - traits.get_min_mireds() > 0.0f && traits.get_max_mireds() > 0.0f) { + min_mireds > 0.0f && max_mireds > 0.0f) { ESP_LOGD(TAG, "'%s': setting cold/warm white channels using white/color temperature values", this->parent_->get_name().c_str()); if (this->has_color_temperature()) { - const float color_temp = clamp(this->color_temperature_, traits.get_min_mireds(), traits.get_max_mireds()); - const float ww_fraction = - (color_temp - traits.get_min_mireds()) / (traits.get_max_mireds() - traits.get_min_mireds()); + const float color_temp = clamp(this->color_temperature_, min_mireds, max_mireds); + const float range = max_mireds - min_mireds; + const float ww_fraction = (color_temp - min_mireds) / range; const float cw_fraction = 1.0f - ww_fraction; const float max_cw_ww = std::max(ww_fraction, cw_fraction); - this->cold_white_ = gamma_uncorrect(cw_fraction / max_cw_ww, this->parent_->get_gamma_correct()); - this->warm_white_ = gamma_uncorrect(ww_fraction / max_cw_ww, this->parent_->get_gamma_correct()); + const float gamma = this->parent_->get_gamma_correct(); + this->cold_white_ = gamma_uncorrect(cw_fraction / max_cw_ww, gamma); + this->warm_white_ = gamma_uncorrect(ww_fraction / max_cw_ww, gamma); this->set_flag_(FLAG_HAS_COLD_WHITE, true); this->set_flag_(FLAG_HAS_WARM_WHITE, true); } diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 5653a8d2a5..9a37f6b424 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -84,18 +84,21 @@ class LightColorValues { * @return The linearly interpolated LightColorValues. */ static LightColorValues lerp(const LightColorValues &start, const LightColorValues &end, float completion) { + // Directly interpolate the raw values to avoid getter/setter overhead + // Linear interpolation between two clamped values produces a clamped result, + // so we can skip the setters which include redundant clamping logic LightColorValues v; - v.set_color_mode(end.color_mode_); - v.set_state(std::lerp(start.get_state(), end.get_state(), completion)); - v.set_brightness(std::lerp(start.get_brightness(), end.get_brightness(), completion)); - v.set_color_brightness(std::lerp(start.get_color_brightness(), end.get_color_brightness(), completion)); - v.set_red(std::lerp(start.get_red(), end.get_red(), completion)); - v.set_green(std::lerp(start.get_green(), end.get_green(), completion)); - v.set_blue(std::lerp(start.get_blue(), end.get_blue(), completion)); - v.set_white(std::lerp(start.get_white(), end.get_white(), completion)); - v.set_color_temperature(std::lerp(start.get_color_temperature(), end.get_color_temperature(), completion)); - v.set_cold_white(std::lerp(start.get_cold_white(), end.get_cold_white(), completion)); - v.set_warm_white(std::lerp(start.get_warm_white(), end.get_warm_white(), completion)); + v.color_mode_ = end.color_mode_; + v.state_ = std::lerp(start.state_, end.state_, completion); + v.brightness_ = std::lerp(start.brightness_, end.brightness_, completion); + v.color_brightness_ = std::lerp(start.color_brightness_, end.color_brightness_, completion); + v.red_ = std::lerp(start.red_, end.red_, completion); + v.green_ = std::lerp(start.green_, end.green_, completion); + v.blue_ = std::lerp(start.blue_, end.blue_, completion); + v.white_ = std::lerp(start.white_, end.white_, completion); + v.color_temperature_ = std::lerp(start.color_temperature_, end.color_temperature_, completion); + v.cold_white_ = std::lerp(start.cold_white_, end.cold_white_, completion); + v.warm_white_ = std::lerp(start.warm_white_, end.warm_white_, completion); return v; } diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 26615bae5c..84e1ee9f1d 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -8,6 +8,46 @@ namespace light { // See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema +// Helper to convert float 0-1 to uint8_t 0-255 +static inline uint8_t to_uint8_scaled(float value) { return uint8_t(value * 255); } + +// Helper to parse color component from JSON +static float parse_color_component(JsonObject &color, const char *key, LightCall &call, + LightCall &(LightCall::*setter)(float) ) { + if (color[key].is()) { + float val = float(color[key]) / 255.0f; + (call.*setter)(val); + return val; + } + return 0.0f; +} + +// Lookup table for color mode strings +static const char *get_color_mode_json_str(ColorMode mode) { + switch (mode) { + case ColorMode::ON_OFF: + return "onoff"; + case ColorMode::BRIGHTNESS: + return "brightness"; + case ColorMode::WHITE: + return "white"; // not supported by HA in MQTT + case ColorMode::COLOR_TEMPERATURE: + return "color_temp"; + case ColorMode::COLD_WARM_WHITE: + return "cwww"; // not supported by HA + case ColorMode::RGB: + return "rgb"; + case ColorMode::RGB_WHITE: + return "rgbw"; + case ColorMode::RGB_COLOR_TEMPERATURE: + return "rgbct"; // not supported by HA + case ColorMode::RGB_COLD_WARM_WHITE: + return "rgbww"; + default: + return nullptr; + } +} + void LightJSONSchema::dump_json(LightState &state, JsonObject root) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson if (state.supports_effects()) @@ -16,60 +56,36 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { auto values = state.remote_values; auto traits = state.get_output()->get_traits(); - switch (values.get_color_mode()) { - case ColorMode::UNKNOWN: // don't need to set color mode if we don't know it - break; - case ColorMode::ON_OFF: - root["color_mode"] = "onoff"; - break; - case ColorMode::BRIGHTNESS: - root["color_mode"] = "brightness"; - break; - case ColorMode::WHITE: // not supported by HA in MQTT - root["color_mode"] = "white"; - break; - case ColorMode::COLOR_TEMPERATURE: - root["color_mode"] = "color_temp"; - break; - case ColorMode::COLD_WARM_WHITE: // not supported by HA - root["color_mode"] = "cwww"; - break; - case ColorMode::RGB: - root["color_mode"] = "rgb"; - break; - case ColorMode::RGB_WHITE: - root["color_mode"] = "rgbw"; - break; - case ColorMode::RGB_COLOR_TEMPERATURE: // not supported by HA - root["color_mode"] = "rgbct"; - break; - case ColorMode::RGB_COLD_WARM_WHITE: - root["color_mode"] = "rgbww"; - break; + const auto color_mode = values.get_color_mode(); + const char *mode_str = get_color_mode_json_str(color_mode); + if (mode_str != nullptr) { + root["color_mode"] = mode_str; } - if (values.get_color_mode() & ColorCapability::ON_OFF) + if (color_mode & ColorCapability::ON_OFF) root["state"] = (values.get_state() != 0.0f) ? "ON" : "OFF"; - if (values.get_color_mode() & ColorCapability::BRIGHTNESS) - root["brightness"] = uint8_t(values.get_brightness() * 255); + if (color_mode & ColorCapability::BRIGHTNESS) + root["brightness"] = to_uint8_scaled(values.get_brightness()); JsonObject color = root["color"].to(); - if (values.get_color_mode() & ColorCapability::RGB) { - color["r"] = uint8_t(values.get_color_brightness() * values.get_red() * 255); - color["g"] = uint8_t(values.get_color_brightness() * values.get_green() * 255); - color["b"] = uint8_t(values.get_color_brightness() * values.get_blue() * 255); + if (color_mode & ColorCapability::RGB) { + float color_brightness = values.get_color_brightness(); + color["r"] = to_uint8_scaled(color_brightness * values.get_red()); + color["g"] = to_uint8_scaled(color_brightness * values.get_green()); + color["b"] = to_uint8_scaled(color_brightness * values.get_blue()); } - if (values.get_color_mode() & ColorCapability::WHITE) { - color["w"] = uint8_t(values.get_white() * 255); - root["white_value"] = uint8_t(values.get_white() * 255); // legacy API + if (color_mode & ColorCapability::WHITE) { + uint8_t white_val = to_uint8_scaled(values.get_white()); + color["w"] = white_val; + root["white_value"] = white_val; // legacy API } - if (values.get_color_mode() & ColorCapability::COLOR_TEMPERATURE) { + if (color_mode & ColorCapability::COLOR_TEMPERATURE) { // this one isn't under the color subkey for some reason root["color_temp"] = uint32_t(values.get_color_temperature()); } - if (values.get_color_mode() & ColorCapability::COLD_WARM_WHITE) { - color["c"] = uint8_t(values.get_cold_white() * 255); - color["w"] = uint8_t(values.get_warm_white() * 255); + if (color_mode & ColorCapability::COLD_WARM_WHITE) { + color["c"] = to_uint8_scaled(values.get_cold_white()); + color["w"] = to_uint8_scaled(values.get_warm_white()); } } @@ -99,22 +115,16 @@ void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonO JsonObject color = root["color"]; // HA also encodes brightness information in the r, g, b values, so extract that and set it as color brightness. float max_rgb = 0.0f; - if (color["r"].is()) { - float r = float(color["r"]) / 255.0f; - max_rgb = fmaxf(max_rgb, r); - call.set_red(r); - } - if (color["g"].is()) { - float g = float(color["g"]) / 255.0f; - max_rgb = fmaxf(max_rgb, g); - call.set_green(g); - } - if (color["b"].is()) { - float b = float(color["b"]) / 255.0f; - max_rgb = fmaxf(max_rgb, b); - call.set_blue(b); - } - if (color["r"].is() || color["g"].is() || color["b"].is()) { + + float r = parse_color_component(color, "r", call, &LightCall::set_red); + float g = parse_color_component(color, "g", call, &LightCall::set_green); + float b = parse_color_component(color, "b", call, &LightCall::set_blue); + + max_rgb = fmaxf(max_rgb, r); + max_rgb = fmaxf(max_rgb, g); + max_rgb = fmaxf(max_rgb, b); + + if (max_rgb > 0.0f) { call.set_color_brightness(max_rgb); } diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index fd0aafe4c6..5b57707d6b 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -24,7 +24,8 @@ void LightState::setup() { } // When supported color temperature range is known, initialize color temperature setting within bounds. - float min_mireds = this->get_traits().get_min_mireds(); + auto traits = this->get_traits(); + float min_mireds = traits.get_min_mireds(); if (min_mireds > 0) { this->remote_values.set_color_temperature(min_mireds); this->current_values.set_color_temperature(min_mireds); @@ -43,11 +44,8 @@ void LightState::setup() { this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); // Attempt to load from preferences, else fall back to default values if (!this->rtc_.load(&recovered)) { - recovered.state = false; - if (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON || - this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON) { - recovered.state = true; - } + recovered.state = (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON || + this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON); } else if (this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_OFF || this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON) { // Inverted restore state @@ -88,17 +86,18 @@ void LightState::setup() { } void LightState::dump_config() { ESP_LOGCONFIG(TAG, "Light '%s'", this->get_name().c_str()); - if (this->get_traits().supports_color_capability(ColorCapability::BRIGHTNESS)) { + auto traits = this->get_traits(); + if (traits.supports_color_capability(ColorCapability::BRIGHTNESS)) { ESP_LOGCONFIG(TAG, " Default Transition Length: %.1fs\n" " Gamma Correct: %.2f", this->default_transition_length_ / 1e3f, this->gamma_correct_); } - if (this->get_traits().supports_color_capability(ColorCapability::COLOR_TEMPERATURE)) { + if (traits.supports_color_capability(ColorCapability::COLOR_TEMPERATURE)) { ESP_LOGCONFIG(TAG, " Min Mireds: %.1f\n" " Max Mireds: %.1f", - this->get_traits().get_min_mireds(), this->get_traits().get_max_mireds()); + traits.get_min_mireds(), traits.get_max_mireds()); } } void LightState::loop() { From 10434ac2a3c23fbef7991b21196b6ce22d5da3b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 21:24:24 -1000 Subject: [PATCH 0063/1626] fixes --- .../components/light/light_json_schema.cpp | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 84e1ee9f1d..20127fab33 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -12,14 +12,14 @@ namespace light { static inline uint8_t to_uint8_scaled(float value) { return uint8_t(value * 255); } // Helper to parse color component from JSON -static float parse_color_component(JsonObject &color, const char *key, LightCall &call, - LightCall &(LightCall::*setter)(float) ) { +static bool parse_color_component(JsonObject &color, const char *key, LightCall &call, + LightCall &(LightCall::*setter)(float), float &out_value) { if (color[key].is()) { - float val = float(color[key]) / 255.0f; - (call.*setter)(val); - return val; + out_value = float(color[key]) / 255.0f; + (call.*setter)(out_value); + return true; } - return 0.0f; + return false; } // Lookup table for color mode strings @@ -115,16 +115,23 @@ void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonO JsonObject color = root["color"]; // HA also encodes brightness information in the r, g, b values, so extract that and set it as color brightness. float max_rgb = 0.0f; + bool has_rgb = false; + float r, g, b; - float r = parse_color_component(color, "r", call, &LightCall::set_red); - float g = parse_color_component(color, "g", call, &LightCall::set_green); - float b = parse_color_component(color, "b", call, &LightCall::set_blue); + if (parse_color_component(color, "r", call, &LightCall::set_red, r)) { + max_rgb = fmaxf(max_rgb, r); + has_rgb = true; + } + if (parse_color_component(color, "g", call, &LightCall::set_green, g)) { + max_rgb = fmaxf(max_rgb, g); + has_rgb = true; + } + if (parse_color_component(color, "b", call, &LightCall::set_blue, b)) { + max_rgb = fmaxf(max_rgb, b); + has_rgb = true; + } - max_rgb = fmaxf(max_rgb, r); - max_rgb = fmaxf(max_rgb, g); - max_rgb = fmaxf(max_rgb, b); - - if (max_rgb > 0.0f) { + if (has_rgb) { call.set_color_brightness(max_rgb); } From 29e61c8913f2316e2c9180aeeb95cdcc49d4f64f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 21:27:44 -1000 Subject: [PATCH 0064/1626] revert --- .../components/light/light_json_schema.cpp | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 20127fab33..7af6dadd12 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -11,17 +11,6 @@ namespace light { // Helper to convert float 0-1 to uint8_t 0-255 static inline uint8_t to_uint8_scaled(float value) { return uint8_t(value * 255); } -// Helper to parse color component from JSON -static bool parse_color_component(JsonObject &color, const char *key, LightCall &call, - LightCall &(LightCall::*setter)(float), float &out_value) { - if (color[key].is()) { - out_value = float(color[key]) / 255.0f; - (call.*setter)(out_value); - return true; - } - return false; -} - // Lookup table for color mode strings static const char *get_color_mode_json_str(ColorMode mode) { switch (mode) { @@ -115,23 +104,22 @@ void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonO JsonObject color = root["color"]; // HA also encodes brightness information in the r, g, b values, so extract that and set it as color brightness. float max_rgb = 0.0f; - bool has_rgb = false; - float r, g, b; - - if (parse_color_component(color, "r", call, &LightCall::set_red, r)) { + if (color["r"].is()) { + float r = float(color["r"]) / 255.0f; max_rgb = fmaxf(max_rgb, r); - has_rgb = true; + call.set_red(r); } - if (parse_color_component(color, "g", call, &LightCall::set_green, g)) { + if (color["g"].is()) { + float g = float(color["g"]) / 255.0f; max_rgb = fmaxf(max_rgb, g); - has_rgb = true; + call.set_green(g); } - if (parse_color_component(color, "b", call, &LightCall::set_blue, b)) { + if (color["b"].is()) { + float b = float(color["b"]) / 255.0f; max_rgb = fmaxf(max_rgb, b); - has_rgb = true; + call.set_blue(b); } - - if (has_rgb) { + if (color["r"].is() || color["g"].is() || color["b"].is()) { call.set_color_brightness(max_rgb); } From 28dbf3bbcc3ef45bf60065db283b135811dbb8d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 21:32:34 -1000 Subject: [PATCH 0065/1626] revert --- esphome/components/light/light_color_values.h | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 9a37f6b424..04d7d1e7d8 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -84,9 +84,11 @@ class LightColorValues { * @return The linearly interpolated LightColorValues. */ static LightColorValues lerp(const LightColorValues &start, const LightColorValues &end, float completion) { - // Directly interpolate the raw values to avoid getter/setter overhead - // Linear interpolation between two clamped values produces a clamped result, - // so we can skip the setters which include redundant clamping logic + // Directly interpolate the raw values to avoid getter/setter overhead. + // This is safe because: + // - All LightColorValues have their values clamped when set via the setters + // - std::lerp guarantees output is in the same range as inputs + // - Therefore the output doesn't need clamping, so we can skip the setters LightColorValues v; v.color_mode_ = end.color_mode_; v.state_ = std::lerp(start.state_, end.state_, completion); From 51de85b1c150e306a4438dfb9543536f5b211fad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 21:37:46 -1000 Subject: [PATCH 0066/1626] merge --- esphome/components/light/light_call.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index ac5a2e9fde..6793777690 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -67,9 +67,13 @@ static const LogString *color_mode_to_human(ColorMode color_mode) { } // Helper to log percentage values +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG static inline void log_percent(const char *name, const char *param, float value) { ESP_LOGD(TAG, " %s: %.0f%%", param, value * 100.0f); } +#else +#define log_percent(name, param, value) +#endif void LightCall::perform() { const char *name = this->parent_->get_name().c_str(); From de3e9451dca7f02ec3d804a14a5b0c3e0b023632 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 21:43:53 -1000 Subject: [PATCH 0067/1626] missed existing helper --- esphome/components/light/light_json_schema.cpp | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 7af6dadd12..dac412655a 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -8,9 +8,6 @@ namespace light { // See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema -// Helper to convert float 0-1 to uint8_t 0-255 -static inline uint8_t to_uint8_scaled(float value) { return uint8_t(value * 255); } - // Lookup table for color mode strings static const char *get_color_mode_json_str(ColorMode mode) { switch (mode) { @@ -54,17 +51,17 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { if (color_mode & ColorCapability::ON_OFF) root["state"] = (values.get_state() != 0.0f) ? "ON" : "OFF"; if (color_mode & ColorCapability::BRIGHTNESS) - root["brightness"] = to_uint8_scaled(values.get_brightness()); + root["brightness"] = to_uint8_scale(values.get_brightness()); JsonObject color = root["color"].to(); if (color_mode & ColorCapability::RGB) { float color_brightness = values.get_color_brightness(); - color["r"] = to_uint8_scaled(color_brightness * values.get_red()); - color["g"] = to_uint8_scaled(color_brightness * values.get_green()); - color["b"] = to_uint8_scaled(color_brightness * values.get_blue()); + color["r"] = to_uint8_scale(color_brightness * values.get_red()); + color["g"] = to_uint8_scale(color_brightness * values.get_green()); + color["b"] = to_uint8_scale(color_brightness * values.get_blue()); } if (color_mode & ColorCapability::WHITE) { - uint8_t white_val = to_uint8_scaled(values.get_white()); + uint8_t white_val = to_uint8_scale(values.get_white()); color["w"] = white_val; root["white_value"] = white_val; // legacy API } @@ -73,8 +70,8 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { root["color_temp"] = uint32_t(values.get_color_temperature()); } if (color_mode & ColorCapability::COLD_WARM_WHITE) { - color["c"] = to_uint8_scaled(values.get_cold_white()); - color["w"] = to_uint8_scaled(values.get_warm_white()); + color["c"] = to_uint8_scale(values.get_cold_white()); + color["w"] = to_uint8_scale(values.get_warm_white()); } } From b7d48284acc90c990cb1dc37019e42e280e61409 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 21:44:31 -1000 Subject: [PATCH 0068/1626] missed existing helper --- esphome/components/light/light_json_schema.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index dac412655a..896b821705 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -9,7 +9,7 @@ namespace light { // See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema // Lookup table for color mode strings -static const char *get_color_mode_json_str(ColorMode mode) { +static constexpr const char *get_color_mode_json_str(ColorMode mode) { switch (mode) { case ColorMode::ON_OFF: return "onoff"; From e223a1008bf36a1c5be3b4db2028b38c29384428 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 21:46:54 -1000 Subject: [PATCH 0069/1626] missed existing helper --- esphome/components/light/light_call.cpp | 71 +++++++++++++------------ 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 6793777690..723dfb4667 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -184,26 +184,26 @@ LightColorValues LightCall::validate_() { auto traits = this->parent_->get_traits(); // Cache frequently used flags - const bool has_color_mode = this->has_color_mode(); - const bool has_state = this->has_state(); - const bool has_brightness = this->has_brightness(); - const bool has_color_brightness = this->has_color_brightness(); - const bool has_red = this->has_red(); - const bool has_green = this->has_green(); - const bool has_blue = this->has_blue(); - const bool has_white = this->has_white(); - const bool has_color_temperature = this->has_color_temperature(); - const bool has_cold_white = this->has_cold_white(); - const bool has_warm_white = this->has_warm_white(); + const bool has_color_mode_flag = this->has_color_mode(); + const bool has_state_flag = this->has_state(); + const bool has_brightness_flag = this->has_brightness(); + const bool has_color_brightness_flag = this->has_color_brightness(); + const bool has_red_flag = this->has_red(); + const bool has_green_flag = this->has_green(); + const bool has_blue_flag = this->has_blue(); + const bool has_white_flag = this->has_white(); + const bool has_color_temperature_flag = this->has_color_temperature(); + const bool has_cold_white_flag = this->has_cold_white(); + const bool has_warm_white_flag = this->has_warm_white(); // Color mode check - if (has_color_mode && !traits.supports_color_mode(this->color_mode_)) { + if (has_color_mode_flag && !traits.supports_color_mode(this->color_mode_)) { ESP_LOGW(TAG, "'%s' does not support color mode %s", name, LOG_STR_ARG(color_mode_to_human(this->color_mode_))); this->set_flag_(FLAG_HAS_COLOR_MODE, false); } // Ensure there is always a color mode set - if (!has_color_mode) { + if (!has_color_mode_flag) { this->color_mode_ = this->compute_color_mode_(); this->set_flag_(FLAG_HAS_COLOR_MODE, true); } @@ -213,7 +213,7 @@ LightColorValues LightCall::validate_() { this->transform_parameters_(); // Brightness exists check - if (has_brightness && this->brightness_ > 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) { + if (has_brightness_flag && this->brightness_ > 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) { log_feature_not_supported(name, "brightness"); this->set_flag_(FLAG_HAS_BRIGHTNESS, false); } @@ -225,13 +225,14 @@ LightColorValues LightCall::validate_() { } // Color brightness exists check - if (has_color_brightness && this->color_brightness_ > 0.0f && !(color_mode & ColorCapability::RGB)) { + if (has_color_brightness_flag && this->color_brightness_ > 0.0f && !(color_mode & ColorCapability::RGB)) { log_color_mode_not_supported(name, "RGB brightness"); this->set_flag_(FLAG_HAS_COLOR_BRIGHTNESS, false); } // RGB exists check - if ((has_red && this->red_ > 0.0f) || (has_green && this->green_ > 0.0f) || (has_blue && this->blue_ > 0.0f)) { + if ((has_red_flag && this->red_ > 0.0f) || (has_green_flag && this->green_ > 0.0f) || + (has_blue_flag && this->blue_ > 0.0f)) { if (!(color_mode & ColorCapability::RGB)) { log_color_mode_not_supported(name, "RGB color"); this->set_flag_(FLAG_HAS_RED, false); @@ -241,21 +242,21 @@ LightColorValues LightCall::validate_() { } // White value exists check - if (has_white && this->white_ > 0.0f && + if (has_white_flag && this->white_ > 0.0f && !(color_mode & ColorCapability::WHITE || color_mode & ColorCapability::COLD_WARM_WHITE)) { log_color_mode_not_supported(name, "white value"); this->set_flag_(FLAG_HAS_WHITE, false); } // Color temperature exists check - if (has_color_temperature && + if (has_color_temperature_flag && !(color_mode & ColorCapability::COLOR_TEMPERATURE || color_mode & ColorCapability::COLD_WARM_WHITE)) { log_color_mode_not_supported(name, "color temperature"); this->set_flag_(FLAG_HAS_COLOR_TEMPERATURE, false); } // Cold/warm white value exists check - if ((has_cold_white && this->cold_white_ > 0.0f) || (has_warm_white && this->warm_white_ > 0.0f)) { + if ((has_cold_white_flag && this->cold_white_ > 0.0f) || (has_warm_white_flag && this->warm_white_ > 0.0f)) { if (!(color_mode & ColorCapability::COLD_WARM_WHITE)) { log_color_mode_not_supported(name, "cold/warm white value"); this->set_flag_(FLAG_HAS_COLD_WHITE, false); @@ -285,18 +286,18 @@ LightColorValues LightCall::validate_() { VALIDATE_RANGE_(color_temperature, "Color temperature", traits.get_min_mireds(), traits.get_max_mireds()) // Flag whether an explicit turn off was requested, in which case we'll also stop the effect. - bool explicit_turn_off_request = has_state && !this->state_; + bool explicit_turn_off_request = has_state_flag && !this->state_; // Turn off when brightness is set to zero, and reset brightness (so that it has nonzero brightness when turned on). - if (has_brightness && this->brightness_ == 0.0f) { + if (has_brightness_flag && this->brightness_ == 0.0f) { this->state_ = false; this->set_flag_(FLAG_HAS_STATE, true); this->brightness_ = 1.0f; } // Set color brightness to 100% if currently zero and a color is set. - if (has_red || has_green || has_blue) { - if (!has_color_brightness && this->parent_->remote_values.get_color_brightness() == 0.0f) { + if (has_red_flag || has_green_flag || has_blue_flag) { + if (!has_color_brightness_flag && this->parent_->remote_values.get_color_brightness() == 0.0f) { this->color_brightness_ = 1.0f; this->set_flag_(FLAG_HAS_COLOR_BRIGHTNESS, true); } @@ -304,27 +305,27 @@ LightColorValues LightCall::validate_() { // Create color values for the light with this call applied. auto v = this->parent_->remote_values; - if (has_color_mode) + if (has_color_mode_flag) v.set_color_mode(this->color_mode_); - if (has_state) + if (has_state_flag) v.set_state(this->state_); - if (has_brightness) + if (has_brightness_flag) v.set_brightness(this->brightness_); - if (has_color_brightness) + if (has_color_brightness_flag) v.set_color_brightness(this->color_brightness_); - if (has_red) + if (has_red_flag) v.set_red(this->red_); - if (has_green) + if (has_green_flag) v.set_green(this->green_); - if (has_blue) + if (has_blue_flag) v.set_blue(this->blue_); - if (has_white) + if (has_white_flag) v.set_white(this->white_); - if (has_color_temperature) + if (has_color_temperature_flag) v.set_color_temperature(this->color_temperature_); - if (has_cold_white) + if (has_cold_white_flag) v.set_cold_white(this->cold_white_); - if (has_warm_white) + if (has_warm_white_flag) v.set_warm_white(this->warm_white_); v.normalize_color(); @@ -380,7 +381,7 @@ LightColorValues LightCall::validate_() { // If not a flash and turning the light off, then disable the light // Do not use light color values directly, so that effects can set 0% brightness // Reason: When user turns off the light in frontend, the effect should also stop - bool target_state = has_state ? this->state_ : v.is_on(); + bool target_state = has_state_flag ? this->state_ : v.is_on(); if (!this->has_flash_() && !target_state) { if (this->has_effect_()) { log_invalid_parameter(name, "cannot start effect when turning off"); From bcdfc744c6a1fe5b73e17f2f961c7a6fc4697814 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 21:48:53 -1000 Subject: [PATCH 0070/1626] missed existing helper --- esphome/components/light/light_call.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 723dfb4667..2b5781ca25 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -68,7 +68,7 @@ static const LogString *color_mode_to_human(ColorMode color_mode) { // Helper to log percentage values #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG -static inline void log_percent(const char *name, const char *param, float value) { +static void log_percent(const char *name, const char *param, float value) { ESP_LOGD(TAG, " %s: %.0f%%", param, value * 100.0f); } #else From a2ed20954275ca40514a60bcfce583a1c545b9dc Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 27 Jul 2025 17:57:37 +1000 Subject: [PATCH 0071/1626] [wifi] Disallow psram config with arduino (#9922) --- esphome/components/wifi/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 4e1d2998f6..26195694e7 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -6,6 +6,7 @@ from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_ from esphome.components.network import IPAddress from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv +from esphome.config_validation import only_with_esp_idf from esphome.const import ( CONF_AP, CONF_BSSID, @@ -336,7 +337,7 @@ CONFIG_SCHEMA = cv.All( single=True ), cv.Optional(CONF_USE_PSRAM): cv.All( - cv.requires_component("psram"), cv.boolean + only_with_esp_idf, cv.requires_component("psram"), cv.boolean ), } ), From 5769fbc3b63ecde6e8b3d40502b2b9298742bee7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 22:10:17 -1000 Subject: [PATCH 0072/1626] fix --- esphome/components/light/light_call.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 2b5781ca25..4179d0a7b3 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -184,7 +184,6 @@ LightColorValues LightCall::validate_() { auto traits = this->parent_->get_traits(); // Cache frequently used flags - const bool has_color_mode_flag = this->has_color_mode(); const bool has_state_flag = this->has_state(); const bool has_brightness_flag = this->has_brightness(); const bool has_color_brightness_flag = this->has_color_brightness(); @@ -197,13 +196,13 @@ LightColorValues LightCall::validate_() { const bool has_warm_white_flag = this->has_warm_white(); // Color mode check - if (has_color_mode_flag && !traits.supports_color_mode(this->color_mode_)) { + if (this->has_color_mode() && !traits.supports_color_mode(this->color_mode_)) { ESP_LOGW(TAG, "'%s' does not support color mode %s", name, LOG_STR_ARG(color_mode_to_human(this->color_mode_))); this->set_flag_(FLAG_HAS_COLOR_MODE, false); } // Ensure there is always a color mode set - if (!has_color_mode_flag) { + if (!this->has_color_mode()) { this->color_mode_ = this->compute_color_mode_(); this->set_flag_(FLAG_HAS_COLOR_MODE, true); } @@ -305,7 +304,7 @@ LightColorValues LightCall::validate_() { // Create color values for the light with this call applied. auto v = this->parent_->remote_values; - if (has_color_mode_flag) + if (this->has_color_mode()) v.set_color_mode(this->color_mode_); if (has_state_flag) v.set_state(this->state_); From 4f28aacf661b0703c13f9f53d1dfc018f791b951 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 22:11:48 -1000 Subject: [PATCH 0073/1626] fix --- esphome/components/light/light_call.cpp | 56 ++++++++++--------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 4179d0a7b3..60945531cf 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -183,18 +183,6 @@ LightColorValues LightCall::validate_() { auto *name = this->parent_->get_name().c_str(); auto traits = this->parent_->get_traits(); - // Cache frequently used flags - const bool has_state_flag = this->has_state(); - const bool has_brightness_flag = this->has_brightness(); - const bool has_color_brightness_flag = this->has_color_brightness(); - const bool has_red_flag = this->has_red(); - const bool has_green_flag = this->has_green(); - const bool has_blue_flag = this->has_blue(); - const bool has_white_flag = this->has_white(); - const bool has_color_temperature_flag = this->has_color_temperature(); - const bool has_cold_white_flag = this->has_cold_white(); - const bool has_warm_white_flag = this->has_warm_white(); - // Color mode check if (this->has_color_mode() && !traits.supports_color_mode(this->color_mode_)) { ESP_LOGW(TAG, "'%s' does not support color mode %s", name, LOG_STR_ARG(color_mode_to_human(this->color_mode_))); @@ -212,7 +200,7 @@ LightColorValues LightCall::validate_() { this->transform_parameters_(); // Brightness exists check - if (has_brightness_flag && this->brightness_ > 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) { + if (this->has_brightness() && this->brightness_ > 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) { log_feature_not_supported(name, "brightness"); this->set_flag_(FLAG_HAS_BRIGHTNESS, false); } @@ -224,14 +212,14 @@ LightColorValues LightCall::validate_() { } // Color brightness exists check - if (has_color_brightness_flag && this->color_brightness_ > 0.0f && !(color_mode & ColorCapability::RGB)) { + if (this->has_color_brightness() && this->color_brightness_ > 0.0f && !(color_mode & ColorCapability::RGB)) { log_color_mode_not_supported(name, "RGB brightness"); this->set_flag_(FLAG_HAS_COLOR_BRIGHTNESS, false); } // RGB exists check - if ((has_red_flag && this->red_ > 0.0f) || (has_green_flag && this->green_ > 0.0f) || - (has_blue_flag && this->blue_ > 0.0f)) { + if ((this->has_red() && this->red_ > 0.0f) || (this->has_green() && this->green_ > 0.0f) || + (this->has_blue() && this->blue_ > 0.0f)) { if (!(color_mode & ColorCapability::RGB)) { log_color_mode_not_supported(name, "RGB color"); this->set_flag_(FLAG_HAS_RED, false); @@ -241,21 +229,21 @@ LightColorValues LightCall::validate_() { } // White value exists check - if (has_white_flag && this->white_ > 0.0f && + if (this->has_white() && this->white_ > 0.0f && !(color_mode & ColorCapability::WHITE || color_mode & ColorCapability::COLD_WARM_WHITE)) { log_color_mode_not_supported(name, "white value"); this->set_flag_(FLAG_HAS_WHITE, false); } // Color temperature exists check - if (has_color_temperature_flag && + if (this->has_color_temperature() && !(color_mode & ColorCapability::COLOR_TEMPERATURE || color_mode & ColorCapability::COLD_WARM_WHITE)) { log_color_mode_not_supported(name, "color temperature"); this->set_flag_(FLAG_HAS_COLOR_TEMPERATURE, false); } // Cold/warm white value exists check - if ((has_cold_white_flag && this->cold_white_ > 0.0f) || (has_warm_white_flag && this->warm_white_ > 0.0f)) { + if ((this->has_cold_white() && this->cold_white_ > 0.0f) || (this->has_warm_white() && this->warm_white_ > 0.0f)) { if (!(color_mode & ColorCapability::COLD_WARM_WHITE)) { log_color_mode_not_supported(name, "cold/warm white value"); this->set_flag_(FLAG_HAS_COLD_WHITE, false); @@ -285,18 +273,18 @@ LightColorValues LightCall::validate_() { VALIDATE_RANGE_(color_temperature, "Color temperature", traits.get_min_mireds(), traits.get_max_mireds()) // Flag whether an explicit turn off was requested, in which case we'll also stop the effect. - bool explicit_turn_off_request = has_state_flag && !this->state_; + bool explicit_turn_off_request = this->has_state() && !this->state_; // Turn off when brightness is set to zero, and reset brightness (so that it has nonzero brightness when turned on). - if (has_brightness_flag && this->brightness_ == 0.0f) { + if (this->has_brightness() && this->brightness_ == 0.0f) { this->state_ = false; this->set_flag_(FLAG_HAS_STATE, true); this->brightness_ = 1.0f; } // Set color brightness to 100% if currently zero and a color is set. - if (has_red_flag || has_green_flag || has_blue_flag) { - if (!has_color_brightness_flag && this->parent_->remote_values.get_color_brightness() == 0.0f) { + if (this->has_red() || this->has_green() || this->has_blue()) { + if (!this->has_color_brightness() && this->parent_->remote_values.get_color_brightness() == 0.0f) { this->color_brightness_ = 1.0f; this->set_flag_(FLAG_HAS_COLOR_BRIGHTNESS, true); } @@ -306,25 +294,25 @@ LightColorValues LightCall::validate_() { auto v = this->parent_->remote_values; if (this->has_color_mode()) v.set_color_mode(this->color_mode_); - if (has_state_flag) + if (this->has_state()) v.set_state(this->state_); - if (has_brightness_flag) + if (this->has_brightness()) v.set_brightness(this->brightness_); - if (has_color_brightness_flag) + if (this->has_color_brightness()) v.set_color_brightness(this->color_brightness_); - if (has_red_flag) + if (this->has_red()) v.set_red(this->red_); - if (has_green_flag) + if (this->has_green()) v.set_green(this->green_); - if (has_blue_flag) + if (this->has_blue()) v.set_blue(this->blue_); - if (has_white_flag) + if (this->has_white()) v.set_white(this->white_); - if (has_color_temperature_flag) + if (this->has_color_temperature()) v.set_color_temperature(this->color_temperature_); - if (has_cold_white_flag) + if (this->has_cold_white()) v.set_cold_white(this->cold_white_); - if (has_warm_white_flag) + if (this->has_warm_white()) v.set_warm_white(this->warm_white_); v.normalize_color(); @@ -380,7 +368,7 @@ LightColorValues LightCall::validate_() { // If not a flash and turning the light off, then disable the light // Do not use light color values directly, so that effects can set 0% brightness // Reason: When user turns off the light in frontend, the effect should also stop - bool target_state = has_state_flag ? this->state_ : v.is_on(); + bool target_state = this->has_state() ? this->state_ : v.is_on(); if (!this->has_flash_() && !target_state) { if (this->has_effect_()) { log_invalid_parameter(name, "cannot start effect when turning off"); From a4026d6ba18043ddd977d12343bf4d8bdf08220e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jul 2025 08:34:43 -1000 Subject: [PATCH 0074/1626] [ruff] Enable RET and fix all violations --- esphome/automation.py | 6 ++-- .../alarm_control_panel/__init__.py | 12 +++---- esphome/components/ble_client/__init__.py | 10 ++---- esphome/components/esp32/__init__.py | 6 ++-- esphome/components/esp32/gpio.py | 3 +- .../components/esp32_ble_server/__init__.py | 3 +- esphome/components/haier/climate.py | 15 +++----- esphome/components/light/effects.py | 3 +- esphome/components/lvgl/automation.py | 6 ++-- esphome/components/lvgl/lv_validation.py | 3 +- esphome/components/lvgl/styles.py | 3 +- esphome/components/lvgl/widgets/__init__.py | 2 +- .../components/lvgl/widgets/buttonmatrix.py | 2 +- esphome/components/mqtt/__init__.py | 3 +- esphome/components/one_wire/__init__.py | 3 +- esphome/components/packages/__init__.py | 3 +- esphome/components/pmwcs3/sensor.py | 3 +- esphome/components/rf_bridge/__init__.py | 12 +++---- .../components/rp2040_pio_led_strip/light.py | 3 +- esphome/components/sim800l/__init__.py | 6 ++-- esphome/components/ufire_ec/sensor.py | 3 +- esphome/components/ufire_ise/sensor.py | 3 +- esphome/config_helpers.py | 3 +- esphome/config_validation.py | 2 +- esphome/cpp_helpers.py | 2 +- esphome/mqtt.py | 3 +- esphome/vscode.py | 3 +- esphome/yaml_util.py | 3 +- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 35 ++++++++----------- script/build_language_schema.py | 3 +- script/helpers_zephyr.py | 7 ++-- tests/dashboard/test_web_server.py | 3 +- tests/integration/conftest.py | 13 ++++--- .../loop_test_component/__init__.py | 6 ++-- tests/script/test_clang_tidy_hash.py | 2 +- tests/script/test_helpers.py | 5 ++- tests/unit_tests/test_substitutions.py | 5 ++- tests/unit_tests/test_vscode.py | 3 +- 39 files changed, 79 insertions(+), 133 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 34159561c2..99d4362845 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -391,8 +391,7 @@ async def build_action(full_config, template_arg, args): ) action_id = full_config[CONF_TYPE_ID] builder = registry_entry.coroutine_fun - ret = await builder(config, action_id, template_arg, args) - return ret + return await builder(config, action_id, template_arg, args) async def build_action_list(config, templ, arg_type): @@ -409,8 +408,7 @@ async def build_condition(full_config, template_arg, args): ) action_id = full_config[CONF_TYPE_ID] builder = registry_entry.coroutine_fun - ret = await builder(config, action_id, template_arg, args) - return ret + return await builder(config, action_id, template_arg, args) async def build_condition_list(config, templ, args): diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 6d37d53a4c..b076175eb8 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -301,8 +301,7 @@ async def alarm_action_disarm_to_code(config, action_id, template_arg, args): ) async def alarm_action_pending_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -310,8 +309,7 @@ async def alarm_action_pending_to_code(config, action_id, template_arg, args): ) async def alarm_action_trigger_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -319,8 +317,7 @@ async def alarm_action_trigger_to_code(config, action_id, template_arg, args): ) async def alarm_action_chime_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -333,8 +330,7 @@ async def alarm_action_chime_to_code(config, action_id, template_arg, args): ) async def alarm_action_ready_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_condition( diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index a88172ca87..0f3869c23b 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -175,8 +175,7 @@ BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema( ) async def ble_disconnect_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) @automation.register_action( @@ -184,8 +183,7 @@ async def ble_disconnect_to_code(config, action_id, template_arg, args): ) async def ble_connect_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) @automation.register_action( @@ -282,9 +280,7 @@ async def passkey_reply_to_code(config, action_id, template_arg, args): ) async def remove_bond_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - - return var + return cg.new_Pvariable(action_id, template_arg, parent) async def to_code(config): diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 2b4c4ff043..2dd5b8ec78 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -872,7 +872,7 @@ def get_arduino_partition_csv(flash_size): eeprom_partition_start = app1_partition_start + app_partition_size spiffs_partition_start = eeprom_partition_start + eeprom_partition_size - partition_csv = f"""\ + return f"""\ nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xE000, 0x2000, app0, app, ota_0, 0x{app0_partition_start:X}, 0x{app_partition_size:X}, @@ -880,20 +880,18 @@ app1, app, ota_1, 0x{app1_partition_start:X}, 0x{app_partition_size:X}, eeprom, data, 0x99, 0x{eeprom_partition_start:X}, 0x{eeprom_partition_size:X}, spiffs, data, spiffs, 0x{spiffs_partition_start:X}, 0x{spiffs_partition_size:X} """ - return partition_csv def get_idf_partition_csv(flash_size): app_partition_size = APP_PARTITION_SIZES[flash_size] - partition_csv = f"""\ + return f"""\ otadata, data, ota, , 0x2000, phy_init, data, phy, , 0x1000, app0, app, ota_0, , 0x{app_partition_size:X}, app1, app, ota_1, , 0x{app_partition_size:X}, nvs, data, nvs, , 0x6D000, """ - return partition_csv def _format_sdkconfig_val(value: SdkconfigValueType) -> str: diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index c35e5c2215..513f463d57 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -187,8 +187,7 @@ def validate_supports(value): "Open-drain only works with output mode", [CONF_MODE, CONF_OPEN_DRAIN] ) - value = _esp32_validations[variant].usage_validation(value) - return value + return _esp32_validations[variant].usage_validation(value) # https://docs.espressif.com/projects/esp-idf/en/v3.3.5/api-reference/peripherals/gpio.html#_CPPv416gpio_drive_cap_t diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 19f466eb7b..6f16d76a32 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -628,5 +628,4 @@ async def ble_server_descriptor_set_value(config, action_id, template_arg, args) ) async def ble_server_characteristic_notify(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 0393c263d4..8c3649058f 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -330,8 +330,7 @@ HAIER_HON_BASE_ACTION_SCHEMA = automation.maybe_simple_id( ) async def display_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -342,8 +341,7 @@ async def display_action_to_code(config, action_id, template_arg, args): ) async def beeper_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) # Start self cleaning or steri-cleaning action action @@ -359,8 +357,7 @@ async def beeper_action_to_code(config, action_id, template_arg, args): ) async def start_cleaning_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) # Set vertical airflow direction action @@ -417,8 +414,7 @@ async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, ) async def health_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -432,8 +428,7 @@ async def health_action_to_code(config, action_id, template_arg, args): ) async def power_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) def _final_validate(config): diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index f5749a17ab..6c8fd86225 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -353,10 +353,9 @@ async def addressable_lambda_effect_to_code(config, effect_id): (bool, "initial_run"), ] lambda_ = await cg.process_lambda(config[CONF_LAMBDA], args, return_type=cg.void) - var = cg.new_Pvariable( + return cg.new_Pvariable( effect_id, config[CONF_NAME], lambda_, config[CONF_UPDATE_INTERVAL] ) - return var @register_addressable_effect( diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index cc0f833ced..fc70b0f682 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -85,8 +85,7 @@ async def action_to_code( async with LambdaContext(parameters=args, where=action_id) as context: for widget in widgets: await action(widget) - var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) - return var + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) async def update_to_code(config, action_id, template_arg, args): @@ -354,8 +353,7 @@ async def widget_focus(config, action_id, template_arg, args): if config[CONF_FREEZE]: lv.group_focus_freeze(group, True) - var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) - return var + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) @automation.register_action( diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 92fe74eb52..5a1b99cf7c 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -271,8 +271,7 @@ padding = LValidator(padding_validator, int32, retmapper=literal) def zoom_validator(value): - value = cv.float_range(0.1, 10.0)(value) - return value + return cv.float_range(0.1, 10.0)(value) def zoom_retmapper(value): diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 11d7bca5fa..3969c9f388 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -66,8 +66,7 @@ async def style_update_to_code(config, action_id, template_arg, args): async with LambdaContext(parameters=args, where=action_id) as context: await style_set(style, config) - var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) - return var + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) async def theme_to_code(config): diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index a8cb8dce33..d12464fe71 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -189,7 +189,7 @@ class Widget: for matrix buttons :return: """ - return None + return def get_max(self): return self.type.get_max(self.config) diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index aa33be722c..c6b6d2440f 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -193,7 +193,7 @@ class ButtonMatrixType(WidgetType): async def to_code(self, w: Widget, config): lvgl_components_required.add("BUTTONMATRIX") if CONF_ROWS not in config: - return [] + return text_list, ctrl_list, width_list, key_list = await get_button_data( config[CONF_ROWS], w ) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 1a6fcabf42..52d3181780 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -312,14 +312,13 @@ CONFIG_SCHEMA = cv.All( def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) - exp = cg.StructInitializer( + return cg.StructInitializer( MQTTMessage, ("topic", config[CONF_TOPIC]), ("payload", config.get(CONF_PAYLOAD, "")), ("qos", config[CONF_QOS]), ("retain", config[CONF_RETAIN]), ) - return exp @coroutine_with_priority(40.0) diff --git a/esphome/components/one_wire/__init__.py b/esphome/components/one_wire/__init__.py index 99a1ccd1eb..6d95b8fd33 100644 --- a/esphome/components/one_wire/__init__.py +++ b/esphome/components/one_wire/__init__.py @@ -18,13 +18,12 @@ def one_wire_device_schema(): :return: The 1-wire device schema, `extend` this in your config schema. """ - schema = cv.Schema( + return cv.Schema( { cv.GenerateID(CONF_ONE_WIRE_ID): cv.use_id(OneWireBus), cv.Optional(CONF_ADDRESS): cv.hex_uint64_t, } ) - return schema async def register_one_wire_device(var, config): diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 0db7841db2..2e7dc0e197 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -186,8 +186,7 @@ def _process_package(package_config, config): package_config = _process_base_package(package_config) if isinstance(package_config, dict): recursive_package = do_packages_pass(package_config) - config = merge_config(recursive_package, config) - return config + return merge_config(recursive_package, config) def do_packages_pass(config: dict): diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index d42338ab6f..075b9b00b5 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -114,8 +114,7 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( ) async def pmwcs3_calibration_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 5ccca823de..b4770726b4 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -136,8 +136,7 @@ RFBRIDGE_ID_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(RFBridgeComponent)}) @automation.register_action("rf_bridge.learn", RFBridgeLearnAction, RFBRIDGE_ID_SCHEMA) async def rf_bridge_learnx_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - return var + return cg.new_Pvariable(action_id, template_args, paren) @automation.register_action( @@ -149,8 +148,7 @@ async def rf_bridge_start_advanced_sniffing_to_code( config, action_id, template_args, args ): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - return var + return cg.new_Pvariable(action_id, template_args, paren) @automation.register_action( @@ -162,8 +160,7 @@ async def rf_bridge_stop_advanced_sniffing_to_code( config, action_id, template_args, args ): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - return var + return cg.new_Pvariable(action_id, template_args, paren) @automation.register_action( @@ -175,8 +172,7 @@ async def rf_bridge_start_bucket_sniffing_to_code( config, action_id, template_args, args ): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - return var + return cg.new_Pvariable(action_id, template_args, paren) RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA = cv.Schema( diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 9107db9b7f..62f7fffdc9 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -125,8 +125,7 @@ writezero: def time_to_cycles(time_us): cycles_per_us = 57.5 - cycles = round(float(time_us) * cycles_per_us) - return cycles + return round(float(time_us) * cycles_per_us) CONF_PIO = "pio" diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index 2ca9127d3f..c48a3c63c4 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -171,8 +171,7 @@ async def sim800l_dial_to_code(config, action_id, template_arg, args): ) async def sim800l_connect_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) SIM800L_SEND_USSD_SCHEMA = cv.Schema( @@ -201,5 +200,4 @@ async def sim800l_send_ussd_to_code(config, action_id, template_arg, args): ) async def sim800l_disconnect_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 944fdfdee9..9edf0f89ff 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -122,5 +122,4 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( ) async def ufire_ec_reset_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index e57a1155a4..8009cdaa6a 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -123,5 +123,4 @@ UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent ) async def ufire_ise_reset_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index 50ce4e8e34..00cd8f9818 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -111,8 +111,7 @@ def merge_config(full_old, full_new): else: ids[new_id] = len(res) res.append(v) - res = [v for i, v in enumerate(res) if i not in ids_to_delete] - return res + return [v for i, v in enumerate(res) if i not in ids_to_delete] if new is None: return old diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 1a4976e235..11a67b9478 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1866,7 +1866,7 @@ def validate_registry_entry(name, registry): def none(value): if value in ("none", "None"): - return None + return raise Invalid("Must be none") diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 3f64be6154..b61b215bdc 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -115,7 +115,7 @@ async def build_registry_list(registry, config): async def past_safe_mode(): if CONF_SAFE_MODE not in CORE.config: - return + return None def _safe_mode_generator(): while True: diff --git a/esphome/mqtt.py b/esphome/mqtt.py index acfa8a0926..f1c631697a 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -36,7 +36,7 @@ _LOGGER = logging.getLogger(__name__) def config_from_env(): - config = { + return { CONF_MQTT: { CONF_USERNAME: get_str_env("ESPHOME_DASHBOARD_MQTT_USERNAME"), CONF_PASSWORD: get_str_env("ESPHOME_DASHBOARD_MQTT_PASSWORD"), @@ -44,7 +44,6 @@ def config_from_env(): CONF_PORT: get_int_env("ESPHOME_DASHBOARD_MQTT_PORT", 1883), }, } - return config def initialize( diff --git a/esphome/vscode.py b/esphome/vscode.py index d8cfe91938..f5e2a20b97 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -81,8 +81,7 @@ def _print_file_read_event(path: str) -> None: def _request_and_get_stream_on_stdin(fname: str) -> StringIO: _print_file_read_event(fname) - raw_yaml_stream = StringIO(_read_file_content_from_json_on_stdin()) - return raw_yaml_stream + return StringIO(_read_file_content_from_json_on_stdin()) def _vscode_loader(fname: str) -> dict[str, Any]: diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 33a56fc158..f26bc0502d 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -305,8 +305,7 @@ class ESPHomeLoaderMixin: result = self.yaml_loader(self._rel_path(file)) if not vars: vars = {} - result = substitute_vars(result, vars) - return result + return substitute_vars(result, vars) @_add_data_ref def construct_include_dir_list(self, node: yaml.Node) -> list[dict[str, Any]]: diff --git a/pyproject.toml b/pyproject.toml index 200f51a873..4943c48eb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,7 @@ select = [ "PERF", # performance "PL", # pylint "SIM", # flake8-simplify + "RET", # flake8-ret "UP", # pyupgrade ] diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 92c85d2366..22adc76288 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -539,8 +539,7 @@ class BoolType(TypeInfo): wire_type = WireType.VARINT # Uses wire type 0 def dump(self, name: str) -> str: - o = f"out.append(YESNO({name}));" - return o + return f"out.append(YESNO({name}));" def get_size_calculation(self, name: str, force: bool = False) -> str: return self._get_simple_size_calculation(name, force, "add_bool_field") @@ -680,8 +679,7 @@ class MessageType(TypeInfo): return f"case {self.number}: value.decode_to_message(this->{self.field_name}); break;" def dump(self, name: str) -> str: - o = f"{name}.dump_to(out);" - return o + return f"{name}.dump_to(out);" @property def dump_content(self) -> str: @@ -829,8 +827,7 @@ class FixedArrayBytesType(TypeInfo): return f"buffer.encode_bytes({self.number}, this->{self.field_name}, this->{self.field_name}_len);" def dump(self, name: str) -> str: - o = f"out.append(format_hex_pretty({name}, {name}_len));" - return o + return f"out.append(format_hex_pretty({name}, {name}_len));" @property def dump_content(self) -> str: @@ -847,13 +844,12 @@ class FixedArrayBytesType(TypeInfo): if force: # For repeated fields, always calculate size return f"total_size += {field_id_size} + ProtoSize::varint(static_cast({length_field})) + {length_field};" - else: - # For non-repeated fields, skip if length is 0 (matching encode_string behavior) - return ( - f"if ({length_field} != 0) {{\n" - f" total_size += {field_id_size} + ProtoSize::varint(static_cast({length_field})) + {length_field};\n" - f"}}" - ) + # For non-repeated fields, skip if length is 0 (matching encode_string behavior) + return ( + f"if ({length_field} != 0) {{\n" + f" total_size += {field_id_size} + ProtoSize::varint(static_cast({length_field})) + {length_field};\n" + f"}}" + ) def get_estimated_size(self) -> int: # Estimate based on typical BLE advertisement size @@ -908,8 +904,7 @@ class EnumType(TypeInfo): return f"buffer.{self.encode_func}({self.number}, static_cast(this->{self.field_name}));" def dump(self, name: str) -> str: - o = f"out.append(proto_enum_to_string<{self.cpp_type}>({name}));" - return o + return f"out.append(proto_enum_to_string<{self.cpp_type}>({name}));" def dump_field_value(self, value: str) -> str: # Enums need explicit cast for the template @@ -1078,13 +1073,12 @@ class FixedArrayRepeatedType(TypeInfo): def encode_element(element: str) -> str: if isinstance(self._ti, EnumType): return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" - else: - return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" + return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" # Unroll small arrays for efficiency if self.array_size == 1: return encode_element(f"this->{self.field_name}[0]") - elif self.array_size == 2: + if self.array_size == 2: return ( encode_element(f"this->{self.field_name}[0]") + "\n " @@ -1240,8 +1234,9 @@ class RepeatedTypeInfo(TypeInfo): if isinstance(self._ti, MessageType): # For repeated messages, use the dedicated helper that handles iteration internally field_id_size = self._ti.calculate_field_id_size() - o = f"ProtoSize::add_repeated_message(total_size, {field_id_size}, {name});" - return o + return ( + f"ProtoSize::add_repeated_message(total_size, {field_id_size}, {name});" + ) # For other repeated types, use the underlying type's size calculation with force=True o = f"if (!{name}.empty()) {{\n" diff --git a/script/build_language_schema.py b/script/build_language_schema.py index c114d15315..ff6e898902 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -444,8 +444,7 @@ def get_str_path_schema(strPath): if len(parts) > 2: parts[0] += "." + parts[1] parts[1] = parts[2] - s1 = output.get(parts[0], {}).get(S_SCHEMAS, {}).get(parts[1], {}) - return s1 + return output.get(parts[0], {}).get(S_SCHEMAS, {}).get(parts[1], {}) def pop_str_path_schema(strPath): diff --git a/script/helpers_zephyr.py b/script/helpers_zephyr.py index 305ca00c0c..09a0850cbf 100644 --- a/script/helpers_zephyr.py +++ b/script/helpers_zephyr.py @@ -42,12 +42,11 @@ CONFIG_NEWLIB_LIBC=y def extract_defines(command): define_pattern = re.compile(r"-D\s*([^\s]+)") - defines = [ + return [ match for match in define_pattern.findall(command) if match not in ("_ASMLANGUAGE") ] - return defines def find_cxx_path(commands): for entry in commands: @@ -56,6 +55,7 @@ CONFIG_NEWLIB_LIBC=y if not cxx_path.endswith("++"): continue return cxx_path + return None def get_builtin_include_paths(compiler): result = subprocess.run( @@ -83,11 +83,10 @@ CONFIG_NEWLIB_LIBC=y flag_pattern = re.compile( r"(-O[0-3s]|-g|-std=[^\s]+|-Wall|-Wextra|-Werror|--[^\s]+|-f[^\s]+|-m[^\s]+|-imacros\s*[^\s]+)" ) - flags = [ + return [ match.replace("-imacros ", "-imacros") for match in flag_pattern.findall(command) ] - return flags def transform_to_idedata_format(compile_commands): cxx_path = find_cxx_path(compile_commands) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index cd02200d0b..b77ab7a7a3 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -31,8 +31,7 @@ class DashboardTestHelper: else: url = f"http://127.0.0.1:{self.port}{path}" future = self.client.fetch(url, raise_error=True, **kwargs) - result = await future - return result + return await future @pytest_asyncio.fixture() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 46eb6c88e2..55bf0b97a7 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -251,19 +251,18 @@ async def compile_esphome( if proc.returncode == 0: # Success! break - elif proc.returncode == -11 and attempt < max_retries - 1: + if proc.returncode == -11 and attempt < max_retries - 1: # Segfault (-11 = SIGSEGV), retry print( f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..." ) await asyncio.sleep(1) # Brief pause before retry continue - else: - # Other error or final retry - raise RuntimeError( - f"Failed to compile {config_path}, return code: {proc.returncode}. " - f"Run with 'pytest -s' to see compilation output." - ) + # Other error or final retry + raise RuntimeError( + f"Failed to compile {config_path}, return code: {proc.returncode}. " + f"Run with 'pytest -s' to see compilation output." + ) # Load the config to get idedata (blocking call, must use executor) loop = asyncio.get_running_loop() diff --git a/tests/integration/fixtures/external_components/loop_test_component/__init__.py b/tests/integration/fixtures/external_components/loop_test_component/__init__.py index 3f3a40db09..a0b0f8c65a 100644 --- a/tests/integration/fixtures/external_components/loop_test_component/__init__.py +++ b/tests/integration/fixtures/external_components/loop_test_component/__init__.py @@ -72,8 +72,7 @@ DisableAction = loop_test_component_ns.class_("DisableAction", automation.Action ) async def enable_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) @automation.register_action( @@ -87,8 +86,7 @@ async def enable_to_code(config, action_id, template_arg, args): ) async def disable_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) async def to_code(config): diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index 7b66a69adb..2f84d11a0d 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -69,7 +69,7 @@ def test_calculate_clang_tidy_hash() -> None: def read_file_mock(path: Path) -> bytes: if ".clang-tidy" in str(path): return clang_tidy_content - elif "platformio.ini" in str(path): + if "platformio.ini" in str(path): return platformio_content return b"" diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 423e2d3c30..9730efd366 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -315,9 +315,8 @@ def test_local_development_no_remotes_configured(monkeypatch: MonkeyPatch) -> No def side_effect_func(*args): if args == ("git", "remote"): return "origin\nupstream\n" - else: - # All merge-base attempts fail - raise Exception("Command failed") + # All merge-base attempts fail + raise Exception("Command failed") mock_output.side_effect = side_effect_func diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index b65fecb26e..b2b7cb1ea4 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -18,11 +18,10 @@ def sort_dicts(obj): """Recursively sort dictionaries for order-insensitive comparison.""" if isinstance(obj, dict): return {k: sort_dicts(obj[k]) for k in sorted(obj)} - elif isinstance(obj, list): + if isinstance(obj, list): # Lists are not sorted; we preserve order return [sort_dicts(i) for i in obj] - else: - return obj + return obj def dict_diff(a, b, path=""): diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 6e0bde23b2..4b28a2215b 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -22,8 +22,7 @@ def _run_repl_test(input_data): call[0][0] for call in mock_stdout.write.call_args_list ).strip() splitted_output = full_output.split("\n") - remove_version = splitted_output[1:] # remove first entry with version info - return remove_version + return splitted_output[1:] # remove first entry with version info def _validate(file_path: str): From 6c8df02d9c05dedc86e76b393b466ece8f994205 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jul 2025 10:45:35 -1000 Subject: [PATCH 0075/1626] [core] Optimize scheduler retry mechanism to reduce flash usage --- esphome/core/scheduler.cpp | 61 +++++++++++++++++++++++++++----------- esphome/core/scheduler.h | 32 +++++++++++++++----- 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a2c16c41fb..6269a66543 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -83,6 +83,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type item->type = type; item->callback = std::move(func); item->remove = false; + item->is_retry = is_retry; #ifndef ESPHOME_THREAD_SINGLE // Special handling for defer() (delay = 0, type = TIMEOUT) @@ -134,8 +135,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // For retries, check if there's a cancelled timeout first if (is_retry && name_cstr != nullptr && type == SchedulerItem::TIMEOUT && - (has_cancelled_timeout_in_container_(this->items_, component, name_cstr) || - has_cancelled_timeout_in_container_(this->to_add_, component, name_cstr))) { + (has_cancelled_timeout_in_container_(this->items_, component, name_cstr, /* match_retry= */ true) || + has_cancelled_timeout_in_container_(this->to_add_, component, name_cstr, /* match_retry= */ true))) { // Skip scheduling - the retry was cancelled #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", name_cstr); @@ -198,25 +199,27 @@ void retry_handler(const std::shared_ptr &args) { // second execution of `func` happens after `initial_wait_time` args->scheduler->set_timer_common_( args->component, Scheduler::SchedulerItem::TIMEOUT, false, &args->name, args->current_interval, - [args]() { retry_handler(args); }, true); + [args]() { retry_handler(args); }, /* is_retry= */ true); // backoff_increase_factor applied to third & later executions args->current_interval *= args->backoff_increase_factor; } -void HOT Scheduler::set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, - uint8_t max_attempts, std::function func, - float backoff_increase_factor) { - if (!name.empty()) - this->cancel_retry(component, name); +void HOT Scheduler::set_retry_common_(Component *component, bool is_static_string, const void *name_ptr, + uint32_t initial_wait_time, uint8_t max_attempts, + std::function func, float backoff_increase_factor) { + const char *name_cstr = this->get_name_cstr_(is_static_string, name_ptr); + + if (name_cstr != nullptr) + this->cancel_retry(component, name_cstr); if (initial_wait_time == SCHEDULER_DONT_RUN) return; ESP_LOGVV(TAG, "set_retry(name='%s', initial_wait_time=%" PRIu32 ", max_attempts=%u, backoff_factor=%0.1f)", - name.c_str(), initial_wait_time, max_attempts, backoff_increase_factor); + name_cstr ? name_cstr : "", initial_wait_time, max_attempts, backoff_increase_factor); if (backoff_increase_factor < 0.0001) { - ESP_LOGE(TAG, "backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor, name.c_str()); + ESP_LOGE(TAG, "backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor, name_cstr ? name_cstr : ""); backoff_increase_factor = 1; } @@ -225,15 +228,36 @@ void HOT Scheduler::set_retry(Component *component, const std::string &name, uin args->retry_countdown = max_attempts; args->current_interval = initial_wait_time; args->component = component; - args->name = "retry$" + name; + args->name = name_cstr ? name_cstr : ""; // Convert to std::string for RetryArgs args->backoff_increase_factor = backoff_increase_factor; args->scheduler = this; - // First execution of `func` immediately - this->set_timeout(component, args->name, 0, [args]() { retry_handler(args); }); + // First execution of `func` immediately - use set_timer_common_ with is_retry=true + this->set_timer_common_( + component, SchedulerItem::TIMEOUT, false, &args->name, 0, [args]() { retry_handler(args); }, + /* is_retry= */ true); +} + +void HOT Scheduler::set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, + uint8_t max_attempts, std::function func, + float backoff_increase_factor) { + this->set_retry_common_(component, false, &name, initial_wait_time, max_attempts, std::move(func), + backoff_increase_factor); +} + +void HOT Scheduler::set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts, + std::function func, float backoff_increase_factor) { + this->set_retry_common_(component, true, name, initial_wait_time, max_attempts, std::move(func), + backoff_increase_factor); } bool HOT Scheduler::cancel_retry(Component *component, const std::string &name) { - return this->cancel_timeout(component, "retry$" + name); + return this->cancel_retry(component, name.c_str()); +} + +bool HOT Scheduler::cancel_retry(Component *component, const char *name) { + // Cancel timeouts that have is_retry flag set + LockGuard guard{this->lock_}; + return this->cancel_item_locked_(component, name, SchedulerItem::TIMEOUT, /* match_retry= */ true); } optional HOT Scheduler::next_schedule_in(uint32_t now) { @@ -479,7 +503,8 @@ bool HOT Scheduler::cancel_item_(Component *component, bool is_static_string, co } // Helper to cancel items by name - must be called with lock held -bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_cstr, SchedulerItem::Type type) { +bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_cstr, SchedulerItem::Type type, + bool match_retry) { // Early return if name is invalid - no items to cancel if (name_cstr == nullptr) { return false; @@ -492,7 +517,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Only check defer queue for timeouts (intervals never go there) if (type == SchedulerItem::TIMEOUT) { for (auto &item : this->defer_queue_) { - if (this->matches_item_(item, component, name_cstr, type)) { + if (this->matches_item_(item, component, name_cstr, type, match_retry)) { item->remove = true; total_cancelled++; } @@ -502,7 +527,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Cancel items in the main heap for (auto &item : this->items_) { - if (this->matches_item_(item, component, name_cstr, type)) { + if (this->matches_item_(item, component, name_cstr, type, match_retry)) { item->remove = true; total_cancelled++; this->to_remove_++; // Track removals for heap items @@ -511,7 +536,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Cancel items in to_add_ for (auto &item : this->to_add_) { - if (this->matches_item_(item, component, name_cstr, type)) { + if (this->matches_item_(item, component, name_cstr, type, match_retry)) { item->remove = true; total_cancelled++; // Don't track removals for to_add_ items diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index fa189bacf7..a6092e1b1e 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -61,7 +61,10 @@ class Scheduler { bool cancel_interval(Component *component, const char *name); void set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, std::function func, float backoff_increase_factor = 1.0f); + void set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts, + std::function func, float backoff_increase_factor = 1.0f); bool cancel_retry(Component *component, const std::string &name); + bool cancel_retry(Component *component, const char *name); // Calculate when the next scheduled item should run // @param now Fresh timestamp from millis() - must not be stale/cached @@ -98,11 +101,18 @@ class Scheduler { enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; bool remove : 1; bool name_is_dynamic : 1; // True if name was dynamically allocated (needs delete[]) - // 5 bits padding + bool is_retry : 1; // True if this is a retry timeout + // 4 bits padding // Constructor SchedulerItem() - : component(nullptr), interval(0), next_execution_(0), type(TIMEOUT), remove(false), name_is_dynamic(false) { + : component(nullptr), + interval(0), + next_execution_(0), + type(TIMEOUT), + remove(false), + name_is_dynamic(false), + is_retry(false) { name_.static_name = nullptr; } @@ -156,6 +166,10 @@ class Scheduler { void set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, const void *name_ptr, uint32_t delay, std::function func, bool is_retry = false); + // Common implementation for retry + void set_retry_common_(Component *component, bool is_static_string, const void *name_ptr, uint32_t initial_wait_time, + uint8_t max_attempts, std::function func, float backoff_increase_factor); + uint64_t millis_64_(uint32_t now); // Cleanup logically deleted items from the scheduler // Returns the number of items remaining after cleanup @@ -165,7 +179,7 @@ class Scheduler { private: // Helper to cancel items by name - must be called with lock held - bool cancel_item_locked_(Component *component, const char *name, SchedulerItem::Type type); + bool cancel_item_locked_(Component *component, const char *name, SchedulerItem::Type type, bool match_retry = false); // Helper to extract name as const char* from either static string or std::string inline const char *get_name_cstr_(bool is_static_string, const void *name_ptr) { @@ -177,8 +191,9 @@ class Scheduler { // Helper function to check if item matches criteria for cancellation inline bool HOT matches_item_(const std::unique_ptr &item, Component *component, const char *name_cstr, - SchedulerItem::Type type, bool skip_removed = true) const { - if (item->component != component || item->type != type || (skip_removed && item->remove)) { + SchedulerItem::Type type, bool match_retry, bool skip_removed = true) const { + if (item->component != component || item->type != type || (skip_removed && item->remove) || + (match_retry && !item->is_retry)) { return false; } const char *item_name = item->get_name(); @@ -206,10 +221,11 @@ class Scheduler { // Template helper to check if any item in a container matches our criteria template - bool has_cancelled_timeout_in_container_(const Container &container, Component *component, - const char *name_cstr) const { + bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr, + bool match_retry) const { for (const auto &item : container) { - if (item->remove && this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, false)) { + if (item->remove && this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry, + /* skip_removed= */ false)) { return true; } } From 4fc6ef6d3edb22de11e595b146e69b362555f06b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jul 2025 10:54:57 -1000 Subject: [PATCH 0076/1626] cover --- .../fixtures/scheduler_retry_test.yaml | 62 ++++++++++++++++++ .../integration/test_scheduler_retry_test.py | 64 ++++++++++++++++++- 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/tests/integration/fixtures/scheduler_retry_test.yaml b/tests/integration/fixtures/scheduler_retry_test.yaml index c6fcc53f8c..11fff6c395 100644 --- a/tests/integration/fixtures/scheduler_retry_test.yaml +++ b/tests/integration/fixtures/scheduler_retry_test.yaml @@ -37,6 +37,15 @@ globals: - id: multiple_same_name_counter type: int initial_value: '0' + - id: const_char_retry_counter + type: int + initial_value: '0' + - id: static_char_retry_counter + type: int + initial_value: '0' + - id: mixed_cancel_result + type: bool + initial_value: 'false' # Using different component types for each test to ensure isolation sensor: @@ -229,6 +238,56 @@ script: return RetryResult::RETRY; }); + # Test 8: Const char* overloads + - logger.log: "=== Test 8: Const char* overloads ===" + - lambda: |- + auto *component = id(simple_retry_sensor); + + // Test 8a: Direct string literal + App.scheduler.set_retry(component, "const_char_test", 30, 2, + [](uint8_t retry_countdown) { + id(const_char_retry_counter)++; + ESP_LOGI("test", "Const char retry %d", id(const_char_retry_counter)); + return RetryResult::DONE; + }); + + # Test 9: Static const char* variable + - logger.log: "=== Test 9: Static const char* ===" + - lambda: |- + auto *component = id(backoff_retry_sensor); + + static const char* STATIC_NAME = "static_retry_test"; + App.scheduler.set_retry(component, STATIC_NAME, 20, 1, + [](uint8_t retry_countdown) { + id(static_char_retry_counter)++; + ESP_LOGI("test", "Static const char retry %d", id(static_char_retry_counter)); + return RetryResult::DONE; + }); + + // Cancel with same static const char* + App.scheduler.set_timeout(component, "static_cancel", 10, []() { + static const char* STATIC_NAME = "static_retry_test"; + bool result = App.scheduler.cancel_retry(id(backoff_retry_sensor), STATIC_NAME); + ESP_LOGI("test", "Static cancel result: %s", result ? "true" : "false"); + }); + + # Test 10: Mix string and const char* cancel + - logger.log: "=== Test 10: Mixed string/const char* ===" + - lambda: |- + auto *component = id(immediate_done_sensor); + + // Set with std::string + std::string str_name = "mixed_retry"; + App.scheduler.set_retry(component, str_name, 40, 3, + [](uint8_t retry_countdown) { + ESP_LOGI("test", "Mixed retry - should be cancelled"); + return RetryResult::RETRY; + }); + + // Cancel with const char* + id(mixed_cancel_result) = App.scheduler.cancel_retry(component, "mixed_retry"); + ESP_LOGI("test", "Mixed cancel result: %s", id(mixed_cancel_result) ? "true" : "false"); + # Wait for all tests to complete before reporting - delay: 500ms @@ -242,4 +301,7 @@ script: ESP_LOGI("test", "Empty name retry counter: %d (expected 1-2)", id(empty_name_retry_counter)); ESP_LOGI("test", "Component retry counter: %d (expected 2)", id(script_retry_counter)); ESP_LOGI("test", "Multiple same name counter: %d (expected 20+)", id(multiple_same_name_counter)); + ESP_LOGI("test", "Const char retry counter: %d (expected 1)", id(const_char_retry_counter)); + ESP_LOGI("test", "Static char retry counter: %d (expected 1)", id(static_char_retry_counter)); + ESP_LOGI("test", "Mixed cancel result: %s (expected true)", id(mixed_cancel_result) ? "true" : "false"); ESP_LOGI("test", "All retry tests completed"); diff --git a/tests/integration/test_scheduler_retry_test.py b/tests/integration/test_scheduler_retry_test.py index 1a469fcff1..c04b7197c9 100644 --- a/tests/integration/test_scheduler_retry_test.py +++ b/tests/integration/test_scheduler_retry_test.py @@ -23,6 +23,9 @@ async def test_scheduler_retry_test( empty_name_retry_done = asyncio.Event() component_retry_done = asyncio.Event() multiple_name_done = asyncio.Event() + const_char_done = asyncio.Event() + static_char_done = asyncio.Event() + mixed_cancel_done = asyncio.Event() test_complete = asyncio.Event() # Track retry counts @@ -33,16 +36,20 @@ async def test_scheduler_retry_test( empty_name_retry_count = 0 component_retry_count = 0 multiple_name_count = 0 + const_char_retry_count = 0 + static_char_retry_count = 0 # Track specific test results cancel_result = None empty_cancel_result = None + mixed_cancel_result = None backoff_intervals = [] def on_log_line(line: str) -> None: nonlocal simple_retry_count, backoff_retry_count, immediate_done_count nonlocal cancel_retry_count, empty_name_retry_count, component_retry_count - nonlocal multiple_name_count, cancel_result, empty_cancel_result + nonlocal multiple_name_count, const_char_retry_count, static_char_retry_count + nonlocal cancel_result, empty_cancel_result, mixed_cancel_result # Strip ANSI color codes clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) @@ -106,6 +113,27 @@ async def test_scheduler_retry_test( if multiple_name_count >= 20: multiple_name_done.set() + # Const char retry test + elif "Const char retry" in clean_line: + if match := re.search(r"Const char retry (\d+)", clean_line): + const_char_retry_count = int(match.group(1)) + const_char_done.set() + + # Static const char retry test + elif "Static const char retry" in clean_line: + if match := re.search(r"Static const char retry (\d+)", clean_line): + static_char_retry_count = int(match.group(1)) + static_char_done.set() + + elif "Static cancel result:" in clean_line: + # This is part of test 9, but we don't track it separately + pass + + # Mixed cancel test + elif "Mixed cancel result:" in clean_line: + mixed_cancel_result = "true" in clean_line + mixed_cancel_done.set() + # Test completion elif "All retry tests completed" in clean_line: test_complete.set() @@ -227,6 +255,40 @@ async def test_scheduler_retry_test( f"Expected multiple name count >= 20 (second retry only), got {multiple_name_count}" ) + # Wait for const char retry test + try: + await asyncio.wait_for(const_char_done.wait(), timeout=1.0) + except TimeoutError: + pytest.fail( + f"Const char retry test did not complete. Count: {const_char_retry_count}" + ) + + assert const_char_retry_count == 1, ( + f"Expected 1 const char retry call, got {const_char_retry_count}" + ) + + # Wait for static char retry test + try: + await asyncio.wait_for(static_char_done.wait(), timeout=1.0) + except TimeoutError: + pytest.fail( + f"Static char retry test did not complete. Count: {static_char_retry_count}" + ) + + assert static_char_retry_count == 1, ( + f"Expected 1 static char retry call, got {static_char_retry_count}" + ) + + # Wait for mixed cancel test + try: + await asyncio.wait_for(mixed_cancel_done.wait(), timeout=1.0) + except TimeoutError: + pytest.fail("Mixed cancel test did not complete") + + assert mixed_cancel_result is True, ( + "Mixed string/const char cancel should have succeeded" + ) + # Wait for test completion try: await asyncio.wait_for(test_complete.wait(), timeout=1.0) From 0fa97046752ab27106e6e288105f655b0aa9db47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jul 2025 11:25:42 -1000 Subject: [PATCH 0077/1626] [core] Use nullptr defaults in status_set_error/warning to reduce flash usage --- esphome/core/component.cpp | 12 +++++++----- esphome/core/component.h | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 42b0a71d79..513b0a7ba2 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -16,6 +16,7 @@ namespace esphome { static const char *const TAG = "component"; +static const char *const UNSPECIFIED_MESSAGE = "unspecified"; // Global vectors for component data that doesn't belong in every instance. // Using vector instead of unordered_map for both because: @@ -132,7 +133,7 @@ void Component::call_dump_config() { this->dump_config(); if (this->is_failed()) { // Look up error message from global vector - const char *error_msg = "unspecified"; + const char *error_msg = nullptr; if (component_error_messages) { for (const auto &pair : *component_error_messages) { if (pair.first == this) { @@ -141,7 +142,8 @@ void Component::call_dump_config() { } } } - ESP_LOGE(TAG, " %s is marked FAILED: %s", this->get_component_source(), error_msg); + ESP_LOGE(TAG, " %s is marked FAILED: %s", this->get_component_source(), + error_msg ? error_msg : UNSPECIFIED_MESSAGE); } } @@ -284,15 +286,15 @@ void Component::status_set_warning(const char *message) { return; this->component_state_ |= STATUS_LED_WARNING; App.app_state_ |= STATUS_LED_WARNING; - ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), message); + ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), message ? message : UNSPECIFIED_MESSAGE); } void Component::status_set_error(const char *message) { if ((this->component_state_ & STATUS_LED_ERROR) != 0) return; this->component_state_ |= STATUS_LED_ERROR; App.app_state_ |= STATUS_LED_ERROR; - ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), message); - if (strcmp(message, "unspecified") != 0) { + ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), message ? message : UNSPECIFIED_MESSAGE); + if (message != nullptr) { // Lazy allocate the error messages vector if needed if (!component_error_messages) { component_error_messages = std::make_unique>>(); diff --git a/esphome/core/component.h b/esphome/core/component.h index 5f17c1c22a..096c6f9c69 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -202,9 +202,9 @@ class Component { bool status_has_error() const; - void status_set_warning(const char *message = "unspecified"); + void status_set_warning(const char *message = nullptr); - void status_set_error(const char *message = "unspecified"); + void status_set_error(const char *message = nullptr); void status_clear_warning(); From 20b61d4bdb83c695b068667bd7e17079ce2c7190 Mon Sep 17 00:00:00 2001 From: cryptk <421501+cryptk@users.noreply.github.com> Date: Sun, 27 Jul 2025 21:20:51 -0500 Subject: [PATCH 0078/1626] Add seed flag when running setup with uv present (#9932) --- script/setup | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/setup b/script/setup index b17d3235a7..1bd7c44575 100755 --- a/script/setup +++ b/script/setup @@ -6,7 +6,7 @@ set -e cd "$(dirname "$0")/.." if [ ! -n "$VIRTUAL_ENV" ]; then if [ -x "$(command -v uv)" ]; then - uv venv venv + uv venv --seed venv else python3 -m venv venv fi From a3690422bf08550873d03e34b8ad75b5024bb910 Mon Sep 17 00:00:00 2001 From: Jimmy Hedman Date: Mon, 28 Jul 2025 05:19:17 +0200 Subject: [PATCH 0079/1626] Fail with old lerp (#9914) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/core/helpers.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index f67f13b71f..5204804e1e 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -68,7 +68,10 @@ To bit_cast(const From &src) { return dst; } #endif -using std::lerp; + +// clang-format off +inline float lerp(float completion, float start, float end) = delete; // Please use std::lerp. Notice that it has different order on arguments! +// clang-format on // std::byteswap from C++23 template constexpr T byteswap(T n) { From 6ed9214465dd6ddc573a619a46221e4319e6ad43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jul 2025 17:20:30 -1000 Subject: [PATCH 0080/1626] [core] Use nullptr defaults in status_set_error/warning to reduce flash usage (#9931) --- esphome/core/component.cpp | 12 +++++++----- esphome/core/component.h | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 42b0a71d79..513b0a7ba2 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -16,6 +16,7 @@ namespace esphome { static const char *const TAG = "component"; +static const char *const UNSPECIFIED_MESSAGE = "unspecified"; // Global vectors for component data that doesn't belong in every instance. // Using vector instead of unordered_map for both because: @@ -132,7 +133,7 @@ void Component::call_dump_config() { this->dump_config(); if (this->is_failed()) { // Look up error message from global vector - const char *error_msg = "unspecified"; + const char *error_msg = nullptr; if (component_error_messages) { for (const auto &pair : *component_error_messages) { if (pair.first == this) { @@ -141,7 +142,8 @@ void Component::call_dump_config() { } } } - ESP_LOGE(TAG, " %s is marked FAILED: %s", this->get_component_source(), error_msg); + ESP_LOGE(TAG, " %s is marked FAILED: %s", this->get_component_source(), + error_msg ? error_msg : UNSPECIFIED_MESSAGE); } } @@ -284,15 +286,15 @@ void Component::status_set_warning(const char *message) { return; this->component_state_ |= STATUS_LED_WARNING; App.app_state_ |= STATUS_LED_WARNING; - ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), message); + ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), message ? message : UNSPECIFIED_MESSAGE); } void Component::status_set_error(const char *message) { if ((this->component_state_ & STATUS_LED_ERROR) != 0) return; this->component_state_ |= STATUS_LED_ERROR; App.app_state_ |= STATUS_LED_ERROR; - ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), message); - if (strcmp(message, "unspecified") != 0) { + ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), message ? message : UNSPECIFIED_MESSAGE); + if (message != nullptr) { // Lazy allocate the error messages vector if needed if (!component_error_messages) { component_error_messages = std::make_unique>>(); diff --git a/esphome/core/component.h b/esphome/core/component.h index 5f17c1c22a..096c6f9c69 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -202,9 +202,9 @@ class Component { bool status_has_error() const; - void status_set_warning(const char *message = "unspecified"); + void status_set_warning(const char *message = nullptr); - void status_set_error(const char *message = "unspecified"); + void status_set_error(const char *message = nullptr); void status_clear_warning(); From 84ed1bcf34b95d4774d7f4218eeb98e859f761ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jul 2025 17:22:56 -1000 Subject: [PATCH 0081/1626] [light] Reduce flash usage by 832 bytes through code optimization (#9924) --- esphome/components/light/light_call.cpp | 95 +++++++++++++------ esphome/components/light/light_color_values.h | 27 +++--- .../components/light/light_json_schema.cpp | 90 +++++++++--------- esphome/components/light/light_state.cpp | 17 ++-- 4 files changed, 134 insertions(+), 95 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 1b856ad580..60945531cf 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -9,11 +9,28 @@ namespace light { static const char *const TAG = "light"; -// Helper function to reduce code size for validation warnings +// Helper functions to reduce code size for logging +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN static void log_validation_warning(const char *name, const char *param_name, float val, float min, float max) { ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, param_name, val, min, max); } +static void log_feature_not_supported(const char *name, const char *feature) { + ESP_LOGW(TAG, "'%s': %s not supported", name, feature); +} + +static void log_color_mode_not_supported(const char *name, const char *feature) { + ESP_LOGW(TAG, "'%s': color mode does not support setting %s", name, feature); +} + +static void log_invalid_parameter(const char *name, const char *message) { ESP_LOGW(TAG, "'%s': %s", name, message); } +#else +#define log_validation_warning(name, param_name, val, min, max) +#define log_feature_not_supported(name, feature) +#define log_color_mode_not_supported(name, feature) +#define log_invalid_parameter(name, message) +#endif + // Macro to reduce repetitive setter code #define IMPLEMENT_LIGHT_CALL_SETTER(name, type, flag) \ LightCall &LightCall::set_##name(optional(name)) { \ @@ -49,11 +66,21 @@ static const LogString *color_mode_to_human(ColorMode color_mode) { return LOG_STR(""); } +// Helper to log percentage values +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG +static void log_percent(const char *name, const char *param, float value) { + ESP_LOGD(TAG, " %s: %.0f%%", param, value * 100.0f); +} +#else +#define log_percent(name, param, value) +#endif + void LightCall::perform() { const char *name = this->parent_->get_name().c_str(); LightColorValues v = this->validate_(); + const bool publish = this->get_publish_(); - if (this->get_publish_()) { + if (publish) { ESP_LOGD(TAG, "'%s' Setting:", name); // Only print color mode when it's being changed @@ -71,11 +98,11 @@ void LightCall::perform() { } if (this->has_brightness()) { - ESP_LOGD(TAG, " Brightness: %.0f%%", v.get_brightness() * 100.0f); + log_percent(name, "Brightness", v.get_brightness()); } if (this->has_color_brightness()) { - ESP_LOGD(TAG, " Color brightness: %.0f%%", v.get_color_brightness() * 100.0f); + log_percent(name, "Color brightness", v.get_color_brightness()); } if (this->has_red() || this->has_green() || this->has_blue()) { ESP_LOGD(TAG, " Red: %.0f%%, Green: %.0f%%, Blue: %.0f%%", v.get_red() * 100.0f, v.get_green() * 100.0f, @@ -83,7 +110,7 @@ void LightCall::perform() { } if (this->has_white()) { - ESP_LOGD(TAG, " White: %.0f%%", v.get_white() * 100.0f); + log_percent(name, "White", v.get_white()); } if (this->has_color_temperature()) { ESP_LOGD(TAG, " Color temperature: %.1f mireds", v.get_color_temperature()); @@ -97,26 +124,26 @@ void LightCall::perform() { if (this->has_flash_()) { // FLASH - if (this->get_publish_()) { + if (publish) { ESP_LOGD(TAG, " Flash length: %.1fs", this->flash_length_ / 1e3f); } - this->parent_->start_flash_(v, this->flash_length_, this->get_publish_()); + this->parent_->start_flash_(v, this->flash_length_, publish); } else if (this->has_transition_()) { // TRANSITION - if (this->get_publish_()) { + if (publish) { ESP_LOGD(TAG, " Transition length: %.1fs", this->transition_length_ / 1e3f); } // Special case: Transition and effect can be set when turning off if (this->has_effect_()) { - if (this->get_publish_()) { + if (publish) { ESP_LOGD(TAG, " Effect: 'None'"); } this->parent_->stop_effect_(); } - this->parent_->start_transition_(v, this->transition_length_, this->get_publish_()); + this->parent_->start_transition_(v, this->transition_length_, publish); } else if (this->has_effect_()) { // EFFECT @@ -127,7 +154,7 @@ void LightCall::perform() { effect_s = this->parent_->effects_[this->effect_ - 1]->get_name().c_str(); } - if (this->get_publish_()) { + if (publish) { ESP_LOGD(TAG, " Effect: '%s'", effect_s); } @@ -138,13 +165,13 @@ void LightCall::perform() { this->parent_->set_immediately_(v, true); } else { // INSTANT CHANGE - this->parent_->set_immediately_(v, this->get_publish_()); + this->parent_->set_immediately_(v, publish); } if (!this->has_transition_()) { this->parent_->target_state_reached_callback_.call(); } - if (this->get_publish_()) { + if (publish) { this->parent_->publish_state(); } if (this->get_save_()) { @@ -174,19 +201,19 @@ LightColorValues LightCall::validate_() { // Brightness exists check if (this->has_brightness() && this->brightness_ > 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) { - ESP_LOGW(TAG, "'%s': setting brightness not supported", name); + log_feature_not_supported(name, "brightness"); this->set_flag_(FLAG_HAS_BRIGHTNESS, false); } // Transition length possible check if (this->has_transition_() && this->transition_length_ != 0 && !(color_mode & ColorCapability::BRIGHTNESS)) { - ESP_LOGW(TAG, "'%s': transitions not supported", name); + log_feature_not_supported(name, "transitions"); this->set_flag_(FLAG_HAS_TRANSITION, false); } // Color brightness exists check if (this->has_color_brightness() && this->color_brightness_ > 0.0f && !(color_mode & ColorCapability::RGB)) { - ESP_LOGW(TAG, "'%s': color mode does not support setting RGB brightness", name); + log_color_mode_not_supported(name, "RGB brightness"); this->set_flag_(FLAG_HAS_COLOR_BRIGHTNESS, false); } @@ -194,7 +221,7 @@ LightColorValues LightCall::validate_() { if ((this->has_red() && this->red_ > 0.0f) || (this->has_green() && this->green_ > 0.0f) || (this->has_blue() && this->blue_ > 0.0f)) { if (!(color_mode & ColorCapability::RGB)) { - ESP_LOGW(TAG, "'%s': color mode does not support setting RGB color", name); + log_color_mode_not_supported(name, "RGB color"); this->set_flag_(FLAG_HAS_RED, false); this->set_flag_(FLAG_HAS_GREEN, false); this->set_flag_(FLAG_HAS_BLUE, false); @@ -204,21 +231,21 @@ LightColorValues LightCall::validate_() { // White value exists check if (this->has_white() && this->white_ > 0.0f && !(color_mode & ColorCapability::WHITE || color_mode & ColorCapability::COLD_WARM_WHITE)) { - ESP_LOGW(TAG, "'%s': color mode does not support setting white value", name); + log_color_mode_not_supported(name, "white value"); this->set_flag_(FLAG_HAS_WHITE, false); } // Color temperature exists check if (this->has_color_temperature() && !(color_mode & ColorCapability::COLOR_TEMPERATURE || color_mode & ColorCapability::COLD_WARM_WHITE)) { - ESP_LOGW(TAG, "'%s': color mode does not support setting color temperature", name); + log_color_mode_not_supported(name, "color temperature"); this->set_flag_(FLAG_HAS_COLOR_TEMPERATURE, false); } // Cold/warm white value exists check if ((this->has_cold_white() && this->cold_white_ > 0.0f) || (this->has_warm_white() && this->warm_white_ > 0.0f)) { if (!(color_mode & ColorCapability::COLD_WARM_WHITE)) { - ESP_LOGW(TAG, "'%s': color mode does not support setting cold/warm white value", name); + log_color_mode_not_supported(name, "cold/warm white value"); this->set_flag_(FLAG_HAS_COLD_WHITE, false); this->set_flag_(FLAG_HAS_WARM_WHITE, false); } @@ -292,7 +319,7 @@ LightColorValues LightCall::validate_() { // Flash length check if (this->has_flash_() && this->flash_length_ == 0) { - ESP_LOGW(TAG, "'%s': flash length must be greater than zero", name); + log_invalid_parameter(name, "flash length must be greater than zero"); this->set_flag_(FLAG_HAS_FLASH, false); } @@ -311,13 +338,13 @@ LightColorValues LightCall::validate_() { } if (this->has_effect_() && (this->has_transition_() || this->has_flash_())) { - ESP_LOGW(TAG, "'%s': effect cannot be used with transition/flash", name); + log_invalid_parameter(name, "effect cannot be used with transition/flash"); this->set_flag_(FLAG_HAS_TRANSITION, false); this->set_flag_(FLAG_HAS_FLASH, false); } if (this->has_flash_() && this->has_transition_()) { - ESP_LOGW(TAG, "'%s': flash cannot be used with transition", name); + log_invalid_parameter(name, "flash cannot be used with transition"); this->set_flag_(FLAG_HAS_TRANSITION, false); } @@ -334,7 +361,7 @@ LightColorValues LightCall::validate_() { } if (this->has_transition_() && !supports_transition) { - ESP_LOGW(TAG, "'%s': transitions not supported", name); + log_feature_not_supported(name, "transitions"); this->set_flag_(FLAG_HAS_TRANSITION, false); } @@ -344,7 +371,7 @@ LightColorValues LightCall::validate_() { bool target_state = this->has_state() ? this->state_ : v.is_on(); if (!this->has_flash_() && !target_state) { if (this->has_effect_()) { - ESP_LOGW(TAG, "'%s': cannot start effect when turning off", name); + log_invalid_parameter(name, "cannot start effect when turning off"); this->set_flag_(FLAG_HAS_EFFECT, false); } else if (this->parent_->active_effect_index_ != 0 && explicit_turn_off_request) { // Auto turn off effect @@ -368,21 +395,27 @@ void LightCall::transform_parameters_() { // - RGBWW lights with color_interlock=true, which also sets "brightness" and // "color_temperature" (without color_interlock, CW/WW are set directly) // - Legacy Home Assistant (pre-colormode), which sets "white" and "color_temperature" + + // Cache min/max mireds to avoid repeated calls + const float min_mireds = traits.get_min_mireds(); + const float max_mireds = traits.get_max_mireds(); + if (((this->has_white() && this->white_ > 0.0f) || this->has_color_temperature()) && // (this->color_mode_ & ColorCapability::COLD_WARM_WHITE) && // !(this->color_mode_ & ColorCapability::WHITE) && // !(this->color_mode_ & ColorCapability::COLOR_TEMPERATURE) && // - traits.get_min_mireds() > 0.0f && traits.get_max_mireds() > 0.0f) { + min_mireds > 0.0f && max_mireds > 0.0f) { ESP_LOGD(TAG, "'%s': setting cold/warm white channels using white/color temperature values", this->parent_->get_name().c_str()); if (this->has_color_temperature()) { - const float color_temp = clamp(this->color_temperature_, traits.get_min_mireds(), traits.get_max_mireds()); - const float ww_fraction = - (color_temp - traits.get_min_mireds()) / (traits.get_max_mireds() - traits.get_min_mireds()); + const float color_temp = clamp(this->color_temperature_, min_mireds, max_mireds); + const float range = max_mireds - min_mireds; + const float ww_fraction = (color_temp - min_mireds) / range; const float cw_fraction = 1.0f - ww_fraction; const float max_cw_ww = std::max(ww_fraction, cw_fraction); - this->cold_white_ = gamma_uncorrect(cw_fraction / max_cw_ww, this->parent_->get_gamma_correct()); - this->warm_white_ = gamma_uncorrect(ww_fraction / max_cw_ww, this->parent_->get_gamma_correct()); + const float gamma = this->parent_->get_gamma_correct(); + this->cold_white_ = gamma_uncorrect(cw_fraction / max_cw_ww, gamma); + this->warm_white_ = gamma_uncorrect(ww_fraction / max_cw_ww, gamma); this->set_flag_(FLAG_HAS_COLD_WHITE, true); this->set_flag_(FLAG_HAS_WARM_WHITE, true); } diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 5653a8d2a5..04d7d1e7d8 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -84,18 +84,23 @@ class LightColorValues { * @return The linearly interpolated LightColorValues. */ static LightColorValues lerp(const LightColorValues &start, const LightColorValues &end, float completion) { + // Directly interpolate the raw values to avoid getter/setter overhead. + // This is safe because: + // - All LightColorValues have their values clamped when set via the setters + // - std::lerp guarantees output is in the same range as inputs + // - Therefore the output doesn't need clamping, so we can skip the setters LightColorValues v; - v.set_color_mode(end.color_mode_); - v.set_state(std::lerp(start.get_state(), end.get_state(), completion)); - v.set_brightness(std::lerp(start.get_brightness(), end.get_brightness(), completion)); - v.set_color_brightness(std::lerp(start.get_color_brightness(), end.get_color_brightness(), completion)); - v.set_red(std::lerp(start.get_red(), end.get_red(), completion)); - v.set_green(std::lerp(start.get_green(), end.get_green(), completion)); - v.set_blue(std::lerp(start.get_blue(), end.get_blue(), completion)); - v.set_white(std::lerp(start.get_white(), end.get_white(), completion)); - v.set_color_temperature(std::lerp(start.get_color_temperature(), end.get_color_temperature(), completion)); - v.set_cold_white(std::lerp(start.get_cold_white(), end.get_cold_white(), completion)); - v.set_warm_white(std::lerp(start.get_warm_white(), end.get_warm_white(), completion)); + v.color_mode_ = end.color_mode_; + v.state_ = std::lerp(start.state_, end.state_, completion); + v.brightness_ = std::lerp(start.brightness_, end.brightness_, completion); + v.color_brightness_ = std::lerp(start.color_brightness_, end.color_brightness_, completion); + v.red_ = std::lerp(start.red_, end.red_, completion); + v.green_ = std::lerp(start.green_, end.green_, completion); + v.blue_ = std::lerp(start.blue_, end.blue_, completion); + v.white_ = std::lerp(start.white_, end.white_, completion); + v.color_temperature_ = std::lerp(start.color_temperature_, end.color_temperature_, completion); + v.cold_white_ = std::lerp(start.cold_white_, end.cold_white_, completion); + v.warm_white_ = std::lerp(start.warm_white_, end.warm_white_, completion); return v; } diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 26615bae5c..896b821705 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -8,6 +8,32 @@ namespace light { // See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema +// Lookup table for color mode strings +static constexpr const char *get_color_mode_json_str(ColorMode mode) { + switch (mode) { + case ColorMode::ON_OFF: + return "onoff"; + case ColorMode::BRIGHTNESS: + return "brightness"; + case ColorMode::WHITE: + return "white"; // not supported by HA in MQTT + case ColorMode::COLOR_TEMPERATURE: + return "color_temp"; + case ColorMode::COLD_WARM_WHITE: + return "cwww"; // not supported by HA + case ColorMode::RGB: + return "rgb"; + case ColorMode::RGB_WHITE: + return "rgbw"; + case ColorMode::RGB_COLOR_TEMPERATURE: + return "rgbct"; // not supported by HA + case ColorMode::RGB_COLD_WARM_WHITE: + return "rgbww"; + default: + return nullptr; + } +} + void LightJSONSchema::dump_json(LightState &state, JsonObject root) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson if (state.supports_effects()) @@ -16,60 +42,36 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { auto values = state.remote_values; auto traits = state.get_output()->get_traits(); - switch (values.get_color_mode()) { - case ColorMode::UNKNOWN: // don't need to set color mode if we don't know it - break; - case ColorMode::ON_OFF: - root["color_mode"] = "onoff"; - break; - case ColorMode::BRIGHTNESS: - root["color_mode"] = "brightness"; - break; - case ColorMode::WHITE: // not supported by HA in MQTT - root["color_mode"] = "white"; - break; - case ColorMode::COLOR_TEMPERATURE: - root["color_mode"] = "color_temp"; - break; - case ColorMode::COLD_WARM_WHITE: // not supported by HA - root["color_mode"] = "cwww"; - break; - case ColorMode::RGB: - root["color_mode"] = "rgb"; - break; - case ColorMode::RGB_WHITE: - root["color_mode"] = "rgbw"; - break; - case ColorMode::RGB_COLOR_TEMPERATURE: // not supported by HA - root["color_mode"] = "rgbct"; - break; - case ColorMode::RGB_COLD_WARM_WHITE: - root["color_mode"] = "rgbww"; - break; + const auto color_mode = values.get_color_mode(); + const char *mode_str = get_color_mode_json_str(color_mode); + if (mode_str != nullptr) { + root["color_mode"] = mode_str; } - if (values.get_color_mode() & ColorCapability::ON_OFF) + if (color_mode & ColorCapability::ON_OFF) root["state"] = (values.get_state() != 0.0f) ? "ON" : "OFF"; - if (values.get_color_mode() & ColorCapability::BRIGHTNESS) - root["brightness"] = uint8_t(values.get_brightness() * 255); + if (color_mode & ColorCapability::BRIGHTNESS) + root["brightness"] = to_uint8_scale(values.get_brightness()); JsonObject color = root["color"].to(); - if (values.get_color_mode() & ColorCapability::RGB) { - color["r"] = uint8_t(values.get_color_brightness() * values.get_red() * 255); - color["g"] = uint8_t(values.get_color_brightness() * values.get_green() * 255); - color["b"] = uint8_t(values.get_color_brightness() * values.get_blue() * 255); + if (color_mode & ColorCapability::RGB) { + float color_brightness = values.get_color_brightness(); + color["r"] = to_uint8_scale(color_brightness * values.get_red()); + color["g"] = to_uint8_scale(color_brightness * values.get_green()); + color["b"] = to_uint8_scale(color_brightness * values.get_blue()); } - if (values.get_color_mode() & ColorCapability::WHITE) { - color["w"] = uint8_t(values.get_white() * 255); - root["white_value"] = uint8_t(values.get_white() * 255); // legacy API + if (color_mode & ColorCapability::WHITE) { + uint8_t white_val = to_uint8_scale(values.get_white()); + color["w"] = white_val; + root["white_value"] = white_val; // legacy API } - if (values.get_color_mode() & ColorCapability::COLOR_TEMPERATURE) { + if (color_mode & ColorCapability::COLOR_TEMPERATURE) { // this one isn't under the color subkey for some reason root["color_temp"] = uint32_t(values.get_color_temperature()); } - if (values.get_color_mode() & ColorCapability::COLD_WARM_WHITE) { - color["c"] = uint8_t(values.get_cold_white() * 255); - color["w"] = uint8_t(values.get_warm_white() * 255); + if (color_mode & ColorCapability::COLD_WARM_WHITE) { + color["c"] = to_uint8_scale(values.get_cold_white()); + color["w"] = to_uint8_scale(values.get_warm_white()); } } diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index fd0aafe4c6..5b57707d6b 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -24,7 +24,8 @@ void LightState::setup() { } // When supported color temperature range is known, initialize color temperature setting within bounds. - float min_mireds = this->get_traits().get_min_mireds(); + auto traits = this->get_traits(); + float min_mireds = traits.get_min_mireds(); if (min_mireds > 0) { this->remote_values.set_color_temperature(min_mireds); this->current_values.set_color_temperature(min_mireds); @@ -43,11 +44,8 @@ void LightState::setup() { this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); // Attempt to load from preferences, else fall back to default values if (!this->rtc_.load(&recovered)) { - recovered.state = false; - if (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON || - this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON) { - recovered.state = true; - } + recovered.state = (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON || + this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON); } else if (this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_OFF || this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON) { // Inverted restore state @@ -88,17 +86,18 @@ void LightState::setup() { } void LightState::dump_config() { ESP_LOGCONFIG(TAG, "Light '%s'", this->get_name().c_str()); - if (this->get_traits().supports_color_capability(ColorCapability::BRIGHTNESS)) { + auto traits = this->get_traits(); + if (traits.supports_color_capability(ColorCapability::BRIGHTNESS)) { ESP_LOGCONFIG(TAG, " Default Transition Length: %.1fs\n" " Gamma Correct: %.2f", this->default_transition_length_ / 1e3f, this->gamma_correct_); } - if (this->get_traits().supports_color_capability(ColorCapability::COLOR_TEMPERATURE)) { + if (traits.supports_color_capability(ColorCapability::COLOR_TEMPERATURE)) { ESP_LOGCONFIG(TAG, " Min Mireds: %.1f\n" " Max Mireds: %.1f", - this->get_traits().get_min_mireds(), this->get_traits().get_max_mireds()); + traits.get_min_mireds(), traits.get_max_mireds()); } } void LightState::loop() { From 7f0c66f8350b1ee32c933fbb524163359afb94b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jul 2025 17:24:15 -1000 Subject: [PATCH 0082/1626] [api] Reduce code duplication in send_noise_encryption_set_key_response (#9918) --- esphome/components/api/api_connection.cpp | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0d3b99cd41..e0d4ec0cc8 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1538,19 +1538,18 @@ void APIConnection::execute_service(const ExecuteServiceRequest &msg) { #endif #ifdef USE_API_NOISE bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryptionSetKeyRequest &msg) { - psk_t psk{}; NoiseEncryptionSetKeyResponse resp; + resp.success = false; + + psk_t psk{}; if (base64_decode(msg.key, psk.data(), msg.key.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); - resp.success = false; - return this->send_message(resp, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE); - } - if (!this->parent_->save_noise_psk(psk, true)) { + } else if (!this->parent_->save_noise_psk(psk, true)) { ESP_LOGW(TAG, "Failed to save encryption key"); - resp.success = false; - return this->send_message(resp, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE); + } else { + resp.success = true; } - resp.success = true; + return this->send_message(resp, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE); } #endif From 087970bca8ed71b994b795acf3e473562cb3e3c1 Mon Sep 17 00:00:00 2001 From: Cornelius Mosch <16503500+cmaxl@users.noreply.github.com> Date: Mon, 28 Jul 2025 05:25:32 +0200 Subject: [PATCH 0083/1626] replace os.getlogin() with getpass.getuser() (#9928) --- esphome/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index c9f5037890..341c1fa893 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2,6 +2,7 @@ import argparse from datetime import datetime import functools +import getpass import importlib import logging import os @@ -335,7 +336,7 @@ def check_permissions(port): raise EsphomeError( "You do not have read or write permission on the selected serial port. " "To resolve this issue, you can add your user to the dialout group " - f"by running the following command: sudo usermod -a -G dialout {os.getlogin()}. " + f"by running the following command: sudo usermod -a -G dialout {getpass.getuser()}. " "You will need to log out & back in or reboot to activate the new group access." ) From 5029e248ebb68eaf175cef6f66ec03cd06b938ea Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 28 Jul 2025 05:28:27 +0200 Subject: [PATCH 0084/1626] [packages] add example from documentation to component tests (#9891) --- tests/components/packages/garage-door.yaml | 5 +++++ .../packages/test-vars.esp32-idf.yaml | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 tests/components/packages/garage-door.yaml create mode 100644 tests/components/packages/test-vars.esp32-idf.yaml diff --git a/tests/components/packages/garage-door.yaml b/tests/components/packages/garage-door.yaml new file mode 100644 index 0000000000..e16265d1e1 --- /dev/null +++ b/tests/components/packages/garage-door.yaml @@ -0,0 +1,5 @@ +switch: + - name: ${door_name} Garage Door Switch + platform: gpio + pin: ${door_pin} + id: ${door_id} diff --git a/tests/components/packages/test-vars.esp32-idf.yaml b/tests/components/packages/test-vars.esp32-idf.yaml new file mode 100644 index 0000000000..f12467d9f9 --- /dev/null +++ b/tests/components/packages/test-vars.esp32-idf.yaml @@ -0,0 +1,19 @@ +packages: + left_garage_door: !include + file: garage-door.yaml + vars: + door_name: Left + door_pin: 1 + door_id: left_garage_door + middle_garage_door: !include + file: garage-door.yaml + vars: + door_name: Middle + door_pin: 2 + door_id: middle_garage_door + right_garage_door: !include + file: garage-door.yaml + vars: + door_name: Right + door_pin: 3 + door_id: right_garage_door From c9793f374192dfa575a6ee34ec9475b1dd12bca3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 22 Jul 2025 22:57:42 -0400 Subject: [PATCH 0085/1626] [remote_receiver] Fix idle validation (#9819) --- esphome/components/remote_receiver/__init__.py | 18 +++++++++++++++++- .../remote_receiver/remote_receiver_esp32.cpp | 3 +-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index dffc088085..9095016b55 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -60,6 +60,20 @@ RemoteReceiverComponent = remote_receiver_ns.class_( ) +def validate_config(config): + if CORE.is_esp32: + variant = esp32.get_esp32_variant() + if variant in (esp32.const.VARIANT_ESP32, esp32.const.VARIANT_ESP32S2): + max_idle = 65535 + else: + max_idle = 32767 + if CONF_CLOCK_RESOLUTION in config: + max_idle = int(max_idle * 1000000 / config[CONF_CLOCK_RESOLUTION]) + if config[CONF_IDLE].total_microseconds > max_idle: + raise cv.Invalid(f"config 'idle' exceeds the maximum value of {max_idle}us") + return config + + def validate_tolerance(value): if isinstance(value, dict): return TOLERANCE_SCHEMA(value) @@ -136,7 +150,9 @@ CONFIG_SCHEMA = remote_base.validate_triggers( cv.boolean, ), } - ).extend(cv.COMPONENT_SCHEMA) + ) + .extend(cv.COMPONENT_SCHEMA) + .add_extra(validate_config) ) diff --git a/esphome/components/remote_receiver/remote_receiver_esp32.cpp b/esphome/components/remote_receiver/remote_receiver_esp32.cpp index 3d6346baec..3e6172c6d6 100644 --- a/esphome/components/remote_receiver/remote_receiver_esp32.cpp +++ b/esphome/components/remote_receiver/remote_receiver_esp32.cpp @@ -86,10 +86,9 @@ void RemoteReceiverComponent::setup() { uint32_t event_size = sizeof(rmt_rx_done_event_data_t); uint32_t max_filter_ns = 255u * 1000 / (RMT_CLK_FREQ / 1000000); - uint32_t max_idle_ns = 65535u * 1000; memset(&this->store_.config, 0, sizeof(this->store_.config)); this->store_.config.signal_range_min_ns = std::min(this->filter_us_ * 1000, max_filter_ns); - this->store_.config.signal_range_max_ns = std::min(this->idle_us_ * 1000, max_idle_ns); + this->store_.config.signal_range_max_ns = this->idle_us_ * 1000; this->store_.filter_symbols = this->filter_symbols_; this->store_.receive_size = this->receive_symbols_ * sizeof(rmt_symbol_word_t); this->store_.buffer_size = std::max((event_size + this->store_.receive_size) * 2, this->buffer_size_); From 93028a4d908b08e62fb206848c0dc42fdfe3eb77 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 24 Jul 2025 07:52:07 +1000 Subject: [PATCH 0086/1626] [gt911] i2c fixes (#9822) --- .../gt911/touchscreen/gt911_touchscreen.cpp | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 1cead70181..5c540effd0 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -8,6 +8,8 @@ namespace gt911 { static const char *const TAG = "gt911.touchscreen"; +static const uint8_t PRIMARY_ADDRESS = 0x5D; // default I2C address for GT911 +static const uint8_t SECONDARY_ADDRESS = 0x14; // secondary I2C address for GT911 static const uint8_t GET_TOUCH_STATE[2] = {0x81, 0x4E}; static const uint8_t CLEAR_TOUCH_STATE[3] = {0x81, 0x4E, 0x00}; static const uint8_t GET_TOUCHES[2] = {0x81, 0x4F}; @@ -18,8 +20,7 @@ static const size_t MAX_BUTTONS = 4; // max number of buttons scanned #define ERROR_CHECK(err) \ if ((err) != i2c::ERROR_OK) { \ - ESP_LOGE(TAG, "Failed to communicate!"); \ - this->status_set_warning(); \ + this->status_set_warning("Communication failure"); \ return; \ } @@ -30,31 +31,31 @@ void GT911Touchscreen::setup() { this->reset_pin_->setup(); this->reset_pin_->digital_write(false); if (this->interrupt_pin_ != nullptr) { - // The interrupt pin is used as an input during reset to select the I2C address. + // temporarily set the interrupt pin to output to control address selection this->interrupt_pin_->pin_mode(gpio::FLAG_OUTPUT); - this->interrupt_pin_->setup(); this->interrupt_pin_->digital_write(false); } delay(2); this->reset_pin_->digital_write(true); delay(50); // NOLINT - if (this->interrupt_pin_ != nullptr) { - this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT); - this->interrupt_pin_->setup(); - } + } + if (this->interrupt_pin_ != nullptr) { + // set pre-configured input mode + this->interrupt_pin_->setup(); } // check the configuration of the int line. uint8_t data[4]; - err = this->write(GET_SWITCHES, 2); + err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES)); + if (err != i2c::ERROR_OK && this->address_ == PRIMARY_ADDRESS) { + this->address_ = SECONDARY_ADDRESS; + err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES)); + } if (err == i2c::ERROR_OK) { err = this->read(data, 1); if (err == i2c::ERROR_OK) { - ESP_LOGD(TAG, "Read from switches: 0x%02X", data[0]); + ESP_LOGD(TAG, "Read from switches at address 0x%02X: 0x%02X", this->address_, data[0]); if (this->interrupt_pin_ != nullptr) { - // datasheet says NOT to use pullup/down on the int line. - this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT); - this->interrupt_pin_->setup(); this->attach_interrupt_(this->interrupt_pin_, (data[0] & 1) ? gpio::INTERRUPT_FALLING_EDGE : gpio::INTERRUPT_RISING_EDGE); } @@ -63,7 +64,7 @@ void GT911Touchscreen::setup() { if (this->x_raw_max_ == 0 || this->y_raw_max_ == 0) { // no calibration? Attempt to read the max values from the touchscreen. if (err == i2c::ERROR_OK) { - err = this->write(GET_MAX_VALUES, 2); + err = this->write(GET_MAX_VALUES, sizeof(GET_MAX_VALUES)); if (err == i2c::ERROR_OK) { err = this->read(data, sizeof(data)); if (err == i2c::ERROR_OK) { @@ -75,15 +76,12 @@ void GT911Touchscreen::setup() { } } if (err != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Failed to read calibration values from touchscreen!"); - this->mark_failed(); + this->mark_failed("Failed to read calibration"); return; } } if (err != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Failed to communicate!"); - this->mark_failed(); - return; + this->mark_failed("Failed to communicate"); } ESP_LOGCONFIG(TAG, "GT911 Touchscreen setup complete"); @@ -94,7 +92,7 @@ void GT911Touchscreen::update_touches() { uint8_t touch_state = 0; uint8_t data[MAX_TOUCHES + 1][8]; // 8 bytes each for each point, plus extra space for the key byte - err = this->write(GET_TOUCH_STATE, sizeof(GET_TOUCH_STATE), false); + err = this->write(GET_TOUCH_STATE, sizeof(GET_TOUCH_STATE)); ERROR_CHECK(err); err = this->read(&touch_state, 1); ERROR_CHECK(err); @@ -106,7 +104,7 @@ void GT911Touchscreen::update_touches() { return; } - err = this->write(GET_TOUCHES, sizeof(GET_TOUCHES), false); + err = this->write(GET_TOUCHES, sizeof(GET_TOUCHES)); ERROR_CHECK(err); // num_of_touches is guaranteed to be 0..5. Also read the key data err = this->read(data[0], sizeof(data[0]) * num_of_touches + 1); @@ -132,6 +130,7 @@ void GT911Touchscreen::dump_config() { ESP_LOGCONFIG(TAG, "GT911 Touchscreen:"); LOG_I2C_DEVICE(this); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); } } // namespace gt911 From 8b0ad3072f94b7614eed2c63217fd8a3f993087a Mon Sep 17 00:00:00 2001 From: Eric Hoffmann Date: Wed, 23 Jul 2025 23:55:31 +0200 Subject: [PATCH 0087/1626] fix: non-optional x/y target calculation for ld2450 (#9849) --- esphome/components/ld2450/ld2450.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 8f3b3a3f21..09761b2937 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -477,10 +477,11 @@ void LD2450Component::handle_periodic_data_() { // X start = TARGET_X + index * 8; is_moving = false; + // tx is used for further calculations, so always needs to be populated + val = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); + tx = val; sensor::Sensor *sx = this->move_x_sensors_[index]; if (sx != nullptr) { - val = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); - tx = val; if (this->cached_target_data_[index].x != val) { sx->publish_state(val); this->cached_target_data_[index].x = val; @@ -488,10 +489,11 @@ void LD2450Component::handle_periodic_data_() { } // Y start = TARGET_Y + index * 8; + // ty is used for further calculations, so always needs to be populated + val = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); + ty = val; sensor::Sensor *sy = this->move_y_sensors_[index]; if (sy != nullptr) { - val = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); - ty = val; if (this->cached_target_data_[index].y != val) { sy->publish_state(val); this->cached_target_data_[index].y = val; From 8dce7b0905640aa448aa50bc47132733d9cf6844 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 26 Jul 2025 16:23:36 +1200 Subject: [PATCH 0088/1626] [logger] Don't allow ``logger.log`` actions without configuring the ``logger`` (#9821) --- esphome/components/logger/__init__.py | 1 + tests/component_tests/text/test_text.yaml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index cf2af17677..618f5bcc7a 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -400,6 +400,7 @@ CONF_LOGGER_LOG = "logger.log" LOGGER_LOG_ACTION_SCHEMA = cv.All( cv.maybe_simple_value( { + cv.GenerateID(CONF_LOGGER_ID): cv.use_id(Logger), cv.Required(CONF_FORMAT): cv.string, cv.Optional(CONF_ARGS, default=list): cv.ensure_list(cv.lambda_), cv.Optional(CONF_LEVEL, default="DEBUG"): cv.one_of( diff --git a/tests/component_tests/text/test_text.yaml b/tests/component_tests/text/test_text.yaml index d81c909f9d..9b05d59349 100644 --- a/tests/component_tests/text/test_text.yaml +++ b/tests/component_tests/text/test_text.yaml @@ -4,6 +4,8 @@ esphome: esp32: board: esp32dev +logger: + text: - platform: template name: "test 1 text" From 2f9475a9275a2a7a4db5091d5fb466d6eea76510 Mon Sep 17 00:00:00 2001 From: cryptk <421501+cryptk@users.noreply.github.com> Date: Sun, 27 Jul 2025 21:20:51 -0500 Subject: [PATCH 0089/1626] Add seed flag when running setup with uv present (#9932) --- script/setup | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/setup b/script/setup index b17d3235a7..1bd7c44575 100755 --- a/script/setup +++ b/script/setup @@ -6,7 +6,7 @@ set -e cd "$(dirname "$0")/.." if [ ! -n "$VIRTUAL_ENV" ]; then if [ -x "$(command -v uv)" ]; then - uv venv venv + uv venv --seed venv else python3 -m venv venv fi From 3a6cc0ea3d95a3c4fce5e56c0f75ad086878dc20 Mon Sep 17 00:00:00 2001 From: Jimmy Hedman Date: Mon, 28 Jul 2025 05:19:17 +0200 Subject: [PATCH 0090/1626] Fail with old lerp (#9914) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/core/helpers.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 53b79a00b7..4a4c52bb06 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -67,7 +67,10 @@ To bit_cast(const From &src) { return dst; } #endif -using std::lerp; + +// clang-format off +inline float lerp(float completion, float start, float end) = delete; // Please use std::lerp. Notice that it has different order on arguments! +// clang-format on // std::byteswap from C++23 template constexpr T byteswap(T n) { From 573dad17365746b8053087b3047bbfbaa13f5c8a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 28 Jul 2025 15:55:07 +1200 Subject: [PATCH 0091/1626] Bump version to 2025.7.4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index ae1e519030..afd3b582e5 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.7.3 +PROJECT_NUMBER = 2025.7.4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 476059af62..6ed0119411 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.7.3" +__version__ = "2025.7.4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 05f6d01cbe32ebf505b61d5d14607a8309aabe0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jul 2025 18:35:35 -1000 Subject: [PATCH 0092/1626] [api] Add conditional compilation for Home Assistant service subscriptions (#9900) --- esphome/components/api/__init__.py | 7 +++++++ esphome/components/api/api.proto | 2 ++ esphome/components/api/api_connection.h | 4 ++++ esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 2 ++ esphome/components/api/api_pb2_dump.cpp | 2 ++ esphome/components/api/api_pb2_service.cpp | 4 ++++ esphome/components/api/api_pb2_service.h | 6 ++++++ esphome/components/api/api_server.cpp | 2 ++ esphome/components/api/api_server.h | 2 ++ esphome/components/api/custom_api_device.h | 2 ++ esphome/components/api/homeassistant_service.h | 2 ++ esphome/components/homeassistant/number/__init__.py | 1 + esphome/components/homeassistant/switch/__init__.py | 1 + esphome/core/defines.h | 1 + 15 files changed, 40 insertions(+) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index f48a7d93be..5d398a4e23 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -53,6 +53,7 @@ SERVICE_ARG_NATIVE_TYPES = { CONF_ENCRYPTION = "encryption" CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" +CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" @@ -119,6 +120,7 @@ CONFIG_SCHEMA = cv.All( cv.Range(max=cv.TimePeriod(milliseconds=65535)), ), cv.Optional(CONF_CUSTOM_SERVICES, default=False): cv.boolean, + cv.Optional(CONF_HOMEASSISTANT_SERVICES, default=False): cv.boolean, cv.Optional(CONF_HOMEASSISTANT_STATES, default=False): cv.boolean, cv.Optional(CONF_ON_CLIENT_CONNECTED): automation.validate_automation( single=True @@ -148,6 +150,9 @@ async def to_code(config): if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: cg.add_define("USE_API_SERVICES") + if config[CONF_HOMEASSISTANT_SERVICES]: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + if config[CONF_HOMEASSISTANT_STATES]: cg.add_define("USE_API_HOMEASSISTANT_STATES") @@ -240,6 +245,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( HOMEASSISTANT_ACTION_ACTION_SCHEMA, ) async def homeassistant_service_to_code(config, action_id, template_arg, args): + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, False) templ = await cg.templatable(config[CONF_ACTION], args, None) @@ -283,6 +289,7 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( HOMEASSISTANT_EVENT_ACTION_SCHEMA, ) async def homeassistant_event_to_code(config, action_id, template_arg, args): + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) templ = await cg.templatable(config[CONF_EVENT], args, None) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 5956c8b0b9..eb6ccbfb91 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -755,6 +755,7 @@ message NoiseEncryptionSetKeyResponse { message SubscribeHomeassistantServicesRequest { option (id) = 34; option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_API_HOMEASSISTANT_SERVICES"; } message HomeassistantServiceMap { @@ -766,6 +767,7 @@ message HomeassistantServiceResponse { option (id) = 35; option (source) = SOURCE_SERVER; option (no_delay) = true; + option (ifdef) = "USE_API_HOMEASSISTANT_SERVICES"; string service = 1; repeated HomeassistantServiceMap data = 2; diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4f4b44b0ad..3c446c431b 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -131,11 +131,13 @@ class APIConnection : public APIServerConnection { void media_player_command(const MediaPlayerCommandRequest &msg) override; #endif bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len); +#ifdef USE_API_HOMEASSISTANT_SERVICES void send_homeassistant_service_call(const HomeassistantServiceResponse &call) { if (!this->flags_.service_call_subscription) return; this->send_message(call, HomeassistantServiceResponse::MESSAGE_TYPE); } +#endif #ifdef USE_BLUETOOTH_PROXY void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) override; void unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) override; @@ -209,9 +211,11 @@ class APIConnection : public APIServerConnection { if (msg.dump_config) App.schedule_dump_config(); } +#ifdef USE_API_HOMEASSISTANT_SERVICES void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) override { this->flags_.service_call_subscription = true; } +#endif #ifdef USE_API_HOMEASSISTANT_STATES void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override; #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index e2d1cfebd4..89177b233a 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -843,6 +843,7 @@ void NoiseEncryptionSetKeyResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_bool_field(total_size, 1, this->success); } #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES void HomeassistantServiceMap::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->key_ref_); buffer.encode_string(2, this->value_ref_); @@ -871,6 +872,7 @@ void HomeassistantServiceResponse::calculate_size(uint32_t &total_size) const { ProtoSize::add_repeated_message(total_size, 1, this->variables); ProtoSize::add_bool_field(total_size, 1, this->is_event); } +#endif #ifdef USE_API_HOMEASSISTANT_STATES void SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->entity_id_ref_); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index b7d8945e8e..65623144d1 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1044,6 +1044,7 @@ class NoiseEncryptionSetKeyResponse : public ProtoMessage { protected: }; #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES class SubscribeHomeassistantServicesRequest : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 34; @@ -1092,6 +1093,7 @@ class HomeassistantServiceResponse : public ProtoMessage { protected: }; +#endif #ifdef USE_API_HOMEASSISTANT_STATES class SubscribeHomeAssistantStatesRequest : public ProtoDecodableMessage { public: diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index d7f9f63f5f..c18425f369 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1038,6 +1038,7 @@ void NoiseEncryptionSetKeyRequest::dump_to(std::string &out) const { } void NoiseEncryptionSetKeyResponse::dump_to(std::string &out) const { dump_field(out, "success", this->success); } #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES void SubscribeHomeassistantServicesRequest::dump_to(std::string &out) const { out.append("SubscribeHomeassistantServicesRequest {}"); } @@ -1066,6 +1067,7 @@ void HomeassistantServiceResponse::dump_to(std::string &out) const { } dump_field(out, "is_event", this->is_event); } +#endif #ifdef USE_API_HOMEASSISTANT_STATES void SubscribeHomeAssistantStatesRequest::dump_to(std::string &out) const { out.append("SubscribeHomeAssistantStatesRequest {}"); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 4674d04f66..26d5b12d00 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -149,6 +149,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES case SubscribeHomeassistantServicesRequest::MESSAGE_TYPE: { SubscribeHomeassistantServicesRequest msg; msg.decode(msg_data, msg_size); @@ -158,6 +159,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_subscribe_homeassistant_services_request(msg); break; } +#endif case GetTimeRequest::MESSAGE_TYPE: { GetTimeRequest msg; msg.decode(msg_data, msg_size); @@ -639,12 +641,14 @@ void APIServerConnection::on_subscribe_logs_request(const SubscribeLogsRequest & this->subscribe_logs(msg); } } +#ifdef USE_API_HOMEASSISTANT_SERVICES void APIServerConnection::on_subscribe_homeassistant_services_request( const SubscribeHomeassistantServicesRequest &msg) { if (this->check_authenticated_()) { this->subscribe_homeassistant_services(msg); } } +#endif #ifdef USE_API_HOMEASSISTANT_STATES void APIServerConnection::on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) { if (this->check_authenticated_()) { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 19ed85aa0b..6172e33bf6 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -60,7 +60,9 @@ class APIServerConnectionBase : public ProtoService { virtual void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &value){}; #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES virtual void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &value){}; +#endif #ifdef USE_API_HOMEASSISTANT_STATES virtual void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &value){}; @@ -218,7 +220,9 @@ class APIServerConnection : public APIServerConnectionBase { virtual void list_entities(const ListEntitiesRequest &msg) = 0; virtual void subscribe_states(const SubscribeStatesRequest &msg) = 0; virtual void subscribe_logs(const SubscribeLogsRequest &msg) = 0; +#ifdef USE_API_HOMEASSISTANT_SERVICES virtual void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) = 0; +#endif #ifdef USE_API_HOMEASSISTANT_STATES virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; #endif @@ -338,7 +342,9 @@ class APIServerConnection : public APIServerConnectionBase { void on_list_entities_request(const ListEntitiesRequest &msg) override; void on_subscribe_states_request(const SubscribeStatesRequest &msg) 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; +#endif #ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 0454315760..1f38f4a31a 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -369,11 +369,13 @@ void APIServer::set_password(const std::string &password) { this->password_ = pa void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; } +#ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_service_call(const HomeassistantServiceResponse &call) { for (auto &client : this->clients_) { client->send_homeassistant_service_call(call); } } +#endif #ifdef USE_API_HOMEASSISTANT_STATES void APIServer::subscribe_home_assistant_state(std::string entity_id, optional attribute, diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 22e9573d7e..8b5e624df2 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -106,7 +106,9 @@ class APIServer : public Component, public Controller { #ifdef USE_MEDIA_PLAYER void on_media_player_update(media_player::MediaPlayer *obj) override; #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES void send_homeassistant_service_call(const HomeassistantServiceResponse &call); +#endif #ifdef USE_API_SERVICES void register_user_service(UserServiceDescriptor *descriptor) { this->user_services_.push_back(descriptor); } #endif diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index e9e39a0772..2f85a40614 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -137,6 +137,7 @@ class CustomAPIDevice { } #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES /** Call a Home Assistant service from ESPHome. * * Usage: @@ -221,6 +222,7 @@ class CustomAPIDevice { } global_api_server->send_homeassistant_service_call(resp); } +#endif }; } // namespace esphome::api diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 212b3b22d6..ec17c0c7a4 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -2,6 +2,7 @@ #include "api_server.h" #ifdef USE_API +#ifdef USE_API_HOMEASSISTANT_SERVICES #include "api_pb2.h" #include "esphome/core/automation.h" #include "esphome/core/helpers.h" @@ -100,3 +101,4 @@ template class HomeAssistantServiceCallAction : public Action Date: Sun, 27 Jul 2025 18:39:25 -1000 Subject: [PATCH 0093/1626] [api] Fix string lifetime issue in Home Assistant service calls with templated values (#9909) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 4 +- esphome/components/api/api_pb2.h | 3 +- esphome/components/api/api_pb2_dump.cpp | 2 +- esphome/components/api/custom_api_device.h | 4 +- .../components/api/homeassistant_service.h | 9 +- .../number/homeassistant_number.cpp | 6 +- .../switch/homeassistant_switch.cpp | 2 +- script/api_protobuf/api_protobuf.py | 46 ++- .../fixtures/api_homeassistant.yaml | 311 ++++++++++++++++++ tests/integration/test_api_homeassistant.py | 305 +++++++++++++++++ 12 files changed, 670 insertions(+), 25 deletions(-) create mode 100644 tests/integration/fixtures/api_homeassistant.yaml create mode 100644 tests/integration/test_api_homeassistant.py diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index eb6ccbfb91..e0e1602fcb 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -760,7 +760,7 @@ message SubscribeHomeassistantServicesRequest { message HomeassistantServiceMap { string key = 1; - string value = 2; + string value = 2 [(no_zero_copy) = true]; } message HomeassistantServiceResponse { diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index bb3947e8a3..4f0f52fc6f 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -27,4 +27,5 @@ extend google.protobuf.MessageOptions { extend google.protobuf.FieldOptions { optional string field_ifdef = 1042; optional uint32 fixed_array_size = 50007; + optional bool no_zero_copy = 50008 [default=false]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 89177b233a..b587ee5f03 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -846,11 +846,11 @@ void NoiseEncryptionSetKeyResponse::calculate_size(uint32_t &total_size) const { #ifdef USE_API_HOMEASSISTANT_SERVICES void HomeassistantServiceMap::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->key_ref_); - buffer.encode_string(2, this->value_ref_); + buffer.encode_string(2, this->value); } void HomeassistantServiceMap::calculate_size(uint32_t &total_size) const { ProtoSize::add_string_field(total_size, 1, this->key_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->value_ref_.size()); + ProtoSize::add_string_field(total_size, 1, this->value.size()); } void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->service_ref_); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 65623144d1..fb8174f988 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1062,8 +1062,7 @@ class HomeassistantServiceMap : public ProtoMessage { public: StringRef key_ref_{}; void set_key(const StringRef &ref) { this->key_ref_ = ref; } - StringRef value_ref_{}; - void set_value(const StringRef &ref) { this->value_ref_ = ref; } + std::string value{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(uint32_t &total_size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index c18425f369..aca60464a3 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1045,7 +1045,7 @@ void SubscribeHomeassistantServicesRequest::dump_to(std::string &out) const { void HomeassistantServiceMap::dump_to(std::string &out) const { MessageDumpHelper helper(out, "HomeassistantServiceMap"); dump_field(out, "key", this->key_ref_); - dump_field(out, "value", this->value_ref_); + dump_field(out, "value", this->value); } void HomeassistantServiceResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "HomeassistantServiceResponse"); diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index 2f85a40614..a39947e725 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -175,7 +175,7 @@ class CustomAPIDevice { resp.data.emplace_back(); auto &kv = resp.data.back(); kv.set_key(StringRef(it.first)); - kv.set_value(StringRef(it.second)); + kv.value = it.second; } global_api_server->send_homeassistant_service_call(resp); } @@ -218,7 +218,7 @@ class CustomAPIDevice { resp.data.emplace_back(); auto &kv = resp.data.back(); kv.set_key(StringRef(it.first)); - kv.set_value(StringRef(it.second)); + kv.value = it.second; } global_api_server->send_homeassistant_service_call(resp); } diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index ec17c0c7a4..5df9c7c792 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -70,22 +70,19 @@ template class HomeAssistantServiceCallAction : public Actiondata_template_) { resp.data_template.emplace_back(); auto &kv = resp.data_template.back(); kv.set_key(StringRef(it.key)); - std::string value = it.value.value(x...); - kv.set_value(StringRef(value)); + kv.value = it.value.value(x...); } for (auto &it : this->variables_) { resp.variables.emplace_back(); auto &kv = resp.variables.back(); kv.set_key(StringRef(it.key)); - std::string value = it.value.value(x...); - kv.set_value(StringRef(value)); + kv.value = it.value.value(x...); } this->parent_->send_homeassistant_service_call(resp); } diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index ffb352c969..87bf6727f2 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -93,14 +93,12 @@ void HomeassistantNumber::control(float value) { resp.data.emplace_back(); auto &entity_id = resp.data.back(); entity_id.set_key(ENTITY_ID_KEY); - entity_id.set_value(StringRef(this->entity_id_)); + entity_id.value = this->entity_id_; resp.data.emplace_back(); auto &entity_value = resp.data.back(); entity_value.set_key(VALUE_KEY); - // to_string() returns a temporary - must store it to avoid dangling reference - std::string value_str = to_string(value); - entity_value.set_value(StringRef(value_str)); + entity_value.value = to_string(value); api::global_api_server->send_homeassistant_service_call(resp); } diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 0fe609bf43..b3300335b9 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -54,7 +54,7 @@ void HomeassistantSwitch::write_state(bool state) { resp.data.emplace_back(); auto &entity_id_kv = resp.data.back(); entity_id_kv.set_key(ENTITY_ID_KEY); - entity_id_kv.set_value(StringRef(this->entity_id_)); + entity_id_kv.value = this->entity_id_; api::global_api_server->send_homeassistant_service_call(resp); } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 92c85d2366..4b9a61383d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -562,11 +562,16 @@ class StringType(TypeInfo): @property def public_content(self) -> list[str]: content: list[str] = [] - # Add std::string storage if message needs decoding - if self._needs_decode: + + # Check if no_zero_copy option is set + no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False) + + # Add std::string storage if message needs decoding OR if no_zero_copy is set + if self._needs_decode or no_zero_copy: content.append(f"std::string {self.field_name}{{}};") - if self._needs_encode: + # Only add StringRef if encoding is needed AND no_zero_copy is not set + if self._needs_encode and not no_zero_copy: content.extend( [ # Add StringRef field if message needs encoding @@ -581,13 +586,28 @@ class StringType(TypeInfo): @property def encode_content(self) -> str: - return f"buffer.encode_string({self.number}, this->{self.field_name}_ref_);" + # Check if no_zero_copy option is set + no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False) + + if no_zero_copy: + # Use the std::string directly + return f"buffer.encode_string({self.number}, this->{self.field_name});" + else: + # Use the StringRef + return f"buffer.encode_string({self.number}, this->{self.field_name}_ref_);" def dump(self, name): + # Check if no_zero_copy option is set + no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False) + # If name is 'it', this is a repeated field element - always use string if name == "it": return "append_quoted_string(out, StringRef(it));" + # If no_zero_copy is set, always use std::string + if no_zero_copy: + return f'out.append("\'").append(this->{self.field_name}).append("\'");' + # For SOURCE_CLIENT only, always use std::string if not self._needs_encode: return f'out.append("\'").append(this->{self.field_name}).append("\'");' @@ -607,6 +627,13 @@ class StringType(TypeInfo): @property def dump_content(self) -> str: + # Check if no_zero_copy option is set + no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False) + + # If no_zero_copy is set, always use std::string + if no_zero_copy: + return f'dump_field(out, "{self.name}", this->{self.field_name});' + # For SOURCE_CLIENT only, use std::string if not self._needs_encode: return f'dump_field(out, "{self.name}", this->{self.field_name});' @@ -622,8 +649,15 @@ class StringType(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - # For SOURCE_CLIENT only messages, use the string field directly - if not self._needs_encode: + # Check if no_zero_copy option is set + no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False) + + # For SOURCE_CLIENT only messages or no_zero_copy, use the string field directly + if not self._needs_encode or no_zero_copy: + # For no_zero_copy, we need to use .size() on the string + if no_zero_copy and name != "it": + field_id_size = self.calculate_field_id_size() + return f"ProtoSize::add_string_field(total_size, {field_id_size}, this->{self.field_name}.size());" return self._get_simple_size_calculation(name, force, "add_string_field") # Check if this is being called from a repeated field context diff --git a/tests/integration/fixtures/api_homeassistant.yaml b/tests/integration/fixtures/api_homeassistant.yaml new file mode 100644 index 0000000000..ce8628977a --- /dev/null +++ b/tests/integration/fixtures/api_homeassistant.yaml @@ -0,0 +1,311 @@ +esphome: + name: test-ha-api + friendly_name: Home Assistant API Test + +host: + +api: + services: + - service: trigger_all_tests + then: + - logger.log: "=== Starting Home Assistant API Tests ===" + - button.press: test_basic_service + - button.press: test_templated_service + - button.press: test_empty_string_service + - button.press: test_multiple_fields_service + - button.press: test_complex_lambda_service + - button.press: test_all_empty_service + - button.press: test_rapid_service_calls + - button.press: test_read_ha_states + - number.set: + id: ha_number + value: 42.5 + - switch.turn_on: ha_switch + - switch.turn_off: ha_switch + - logger.log: "=== All tests completed ===" + +logger: + level: DEBUG + +# Time component for templated values +time: + - platform: homeassistant + id: homeassistant_time + +# Global variables for testing +globals: + - id: test_brightness + type: int + initial_value: '75' + - id: test_string + type: std::string + initial_value: '"test_value"' + +# Sensors for testing state reading +sensor: + - platform: template + name: "Test Sensor" + id: test_sensor + lambda: return 42.0; + update_interval: 0.1s + + # Home Assistant sensor that reads external state + - platform: homeassistant + name: "HA Temperature" + entity_id: sensor.external_temperature + id: ha_temperature + on_value: + then: + - logger.log: + format: "HA Temperature state updated: %.1f" + args: ['x'] + + # Test multiple HA state sensors + - platform: homeassistant + name: "HA Humidity" + entity_id: sensor.external_humidity + id: ha_humidity + on_value: + then: + - logger.log: + format: "HA Humidity state updated: %.1f" + args: ['x'] + +# Binary sensor from Home Assistant +binary_sensor: + - platform: homeassistant + name: "HA Motion" + entity_id: binary_sensor.external_motion + id: ha_motion + on_state: + then: + - logger.log: + format: "HA Motion state changed: %s" + args: ['x ? "ON" : "OFF"'] + +# Text sensor from Home Assistant +text_sensor: + - platform: homeassistant + name: "HA Weather" + entity_id: weather.home + attribute: condition + id: ha_weather + on_value: + then: + - logger.log: + format: "HA Weather condition updated: %s" + args: ['x.c_str()'] + + # Test empty state handling + - platform: homeassistant + name: "HA Empty State" + entity_id: sensor.nonexistent_sensor + id: ha_empty_state + on_value: + then: + - logger.log: + format: "HA Empty state updated: %s" + args: ['x.c_str()'] + +# Number component for testing HA number control +number: + - platform: template + name: "HA Controlled Number" + id: ha_number + min_value: 0 + max_value: 100 + step: 1 + optimistic: true + set_action: + - logger.log: + format: "Setting HA number to: %.1f" + args: ['x'] + - homeassistant.action: + action: input_number.set_value + data: + entity_id: input_number.test_number + value: !lambda 'return to_string(x);' + +# Switch component for testing HA switch control +switch: + - platform: template + name: "HA Controlled Switch" + id: ha_switch + optimistic: true + turn_on_action: + - logger.log: "Toggling HA switch: switch.test_switch ON" + - homeassistant.action: + action: switch.turn_on + data: + entity_id: switch.test_switch + turn_off_action: + - logger.log: "Toggling HA switch: switch.test_switch OFF" + - homeassistant.action: + action: switch.turn_off + data: + entity_id: switch.test_switch + +# Buttons for testing various service call scenarios +button: + # Test 1: Basic service call with static values + - platform: template + name: "Test Basic Service" + id: test_basic_service + on_press: + - logger.log: "Sending HomeAssistant service call: light.turn_off" + - homeassistant.action: + action: light.turn_off + data: + entity_id: light.test_light + - logger.log: "Service data: entity_id=light.test_light" + + # Test 2: Service call with templated/lambda values (main bug fix test) + - platform: template + name: "Test Templated Service" + id: test_templated_service + on_press: + - logger.log: "Testing templated service call" + - lambda: |- + int brightness_percent = id(test_brightness); + std::string computed = to_string(brightness_percent * 255 / 100); + ESP_LOGI("test", "Lambda computed value: %s", computed.c_str()); + - homeassistant.action: + action: light.turn_on + data: + entity_id: light.test_light + # This creates a temporary string - the main test case + brightness: !lambda 'return to_string(id(test_brightness) * 255 / 100);' + data_template: + color_name: !lambda 'return id(test_string);' + variables: + transition: !lambda 'return "2.5";' + + # Test 3: Service call with empty string values + - platform: template + name: "Test Empty String Service" + id: test_empty_string_service + on_press: + - logger.log: "Testing empty string values" + - homeassistant.action: + action: notify.test + data: + message: "Test message" + title: "" + data_template: + target: !lambda 'return "";' + variables: + sound: !lambda 'return "";' + + - logger.log: "Empty value for key: title" + - logger.log: "Empty value for key: target" + - logger.log: "Empty value for key: sound" + + # Test 4: Service call with multiple data fields + - platform: template + name: "Test Multiple Fields Service" + id: test_multiple_fields_service + on_press: + - logger.log: "Testing multiple data fields" + - homeassistant.action: + action: climate.set_temperature + data: + entity_id: climate.test_climate + temperature: "22" + hvac_mode: "heat" + data_template: + target_temp_high: !lambda 'return "24";' + target_temp_low: !lambda 'return "20";' + variables: + preset_mode: !lambda 'return "comfort";' + + # Test 5: Complex lambda with string operations + - platform: template + name: "Test Complex Lambda Service" + id: test_complex_lambda_service + on_press: + - logger.log: "Testing complex lambda expressions" + - homeassistant.action: + action: script.test_script + data: + entity_id: !lambda |- + std::string base = "light."; + std::string room = "living_room"; + return base + room; + brightness_pct: !lambda |- + float sensor_val = id(test_sensor).state; + int pct = (int)(sensor_val * 2.38); // 42 * 2.38 ≈ 100 + return to_string(pct); + data_template: + message: !lambda |- + char buffer[50]; + snprintf(buffer, sizeof(buffer), "Sensor: %.1f, Time: %02d:%02d", + id(test_sensor).state, + id(homeassistant_time).now().hour, + id(homeassistant_time).now().minute); + return std::string(buffer); + + # Test 6: Service with only empty strings to verify size calculation + - platform: template + name: "Test All Empty Service" + id: test_all_empty_service + on_press: + - logger.log: "Testing all empty string values" + - homeassistant.action: + action: test.empty + data: + field1: "" + field2: "" + data_template: + field3: !lambda 'return "";' + variables: + field4: !lambda 'return "";' + - logger.log: "All empty service call completed" + + # Test 7: Rapid successive service calls + - platform: template + name: "Test Rapid Service Calls" + id: test_rapid_service_calls + on_press: + - logger.log: "Testing rapid service calls" + - repeat: + count: 5 + then: + - homeassistant.action: + action: counter.increment + data: + entity_id: counter.test_counter + - delay: 10ms + - logger.log: "Rapid service calls completed" + + # Test 8: Log current HA states + - platform: template + name: "Test Read HA States" + id: test_read_ha_states + on_press: + - logger.log: "Reading current HA states" + - lambda: |- + if (id(ha_temperature).has_state()) { + ESP_LOGI("test", "Current HA Temperature: %.1f", id(ha_temperature).state); + } else { + ESP_LOGI("test", "HA Temperature has no state"); + } + + if (id(ha_humidity).has_state()) { + ESP_LOGI("test", "Current HA Humidity: %.1f", id(ha_humidity).state); + } else { + ESP_LOGI("test", "HA Humidity has no state"); + } + + ESP_LOGI("test", "Current HA Motion: %s", id(ha_motion).state ? "ON" : "OFF"); + + if (id(ha_weather).has_state()) { + ESP_LOGI("test", "Current HA Weather: %s", id(ha_weather).state.c_str()); + } else { + ESP_LOGI("test", "HA Weather has no state"); + } + + if (id(ha_empty_state).has_state()) { + ESP_LOGI("test", "HA Empty State value: %s", id(ha_empty_state).state.c_str()); + } else { + ESP_LOGI("test", "HA Empty State has no value (expected)"); + } diff --git a/tests/integration/test_api_homeassistant.py b/tests/integration/test_api_homeassistant.py new file mode 100644 index 0000000000..f69838396d --- /dev/null +++ b/tests/integration/test_api_homeassistant.py @@ -0,0 +1,305 @@ +"""Integration test for Home Assistant API functionality. + +Tests: +- Home Assistant service calls with templated values (main bug fix) +- Service calls with empty string values +- Home Assistant state reading (sensors, binary sensors, text sensors) +- Home Assistant number and switch component control +- Complex lambda expressions and string handling +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import HomeassistantServiceCall +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_homeassistant( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Comprehensive test for Home Assistant API functionality.""" + loop = asyncio.get_running_loop() + + # Create futures for patterns that capture values + lambda_computed_future = loop.create_future() + ha_temp_state_future = loop.create_future() + ha_humidity_state_future = loop.create_future() + ha_motion_state_future = loop.create_future() + ha_weather_state_future = loop.create_future() + + # State update futures + temp_update_future = loop.create_future() + humidity_update_future = loop.create_future() + motion_update_future = loop.create_future() + weather_update_future = loop.create_future() + + # Number future + ha_number_future = loop.create_future() + + tests_complete_future = loop.create_future() + + # Patterns to match in logs - only keeping patterns that capture values + lambda_computed_pattern = re.compile(r"Lambda computed value: (\d+)") + ha_temp_state_pattern = re.compile(r"Current HA Temperature: ([\d.]+)") + ha_humidity_state_pattern = re.compile(r"Current HA Humidity: ([\d.]+)") + ha_motion_state_pattern = re.compile(r"Current HA Motion: (ON|OFF)") + ha_weather_state_pattern = re.compile(r"Current HA Weather: (\w+)") + + # State update patterns + temp_update_pattern = re.compile(r"HA Temperature state updated: ([\d.]+)") + humidity_update_pattern = re.compile(r"HA Humidity state updated: ([\d.]+)") + motion_update_pattern = re.compile(r"HA Motion state changed: (ON|OFF)") + weather_update_pattern = re.compile(r"HA Weather condition updated: (\w+)") + + # Number pattern + ha_number_pattern = re.compile(r"Setting HA number to: ([\d.]+)") + + tests_complete_pattern = re.compile(r"=== All tests completed ===") + + # Track all log lines for debugging + log_lines: list[str] = [] + + # Track HomeAssistant service calls + ha_service_calls: list[HomeassistantServiceCall] = [] + + # Service call futures organized by service name + service_call_futures = { + "light.turn_off": loop.create_future(), # basic_service_call + "light.turn_on": loop.create_future(), # templated_service_call + "notify.test": loop.create_future(), # empty_string_service_call + "climate.set_temperature": loop.create_future(), # multiple_fields_service_call + "script.test_script": loop.create_future(), # complex_lambda_service_call + "test.empty": loop.create_future(), # all_empty_service_call + "input_number.set_value": loop.create_future(), # ha_number_service_call + "switch.turn_on": loop.create_future(), # ha_switch_on_service_call + "switch.turn_off": loop.create_future(), # ha_switch_off_service_call + } + + def on_service_call(service_call: HomeassistantServiceCall) -> None: + """Capture HomeAssistant service calls.""" + ha_service_calls.append(service_call) + + # Check if this service call is one we're waiting for + if service_call.service in service_call_futures: + future = service_call_futures[service_call.service] + if not future.done(): + future.set_result(service_call) + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + log_lines.append(line) + + # Check for patterns that capture values + if not lambda_computed_future.done(): + match = lambda_computed_pattern.search(line) + if match: + lambda_computed_future.set_result(match.group(1)) + elif not ha_temp_state_future.done() and ha_temp_state_pattern.search(line): + ha_temp_state_future.set_result(line) + elif not ha_humidity_state_future.done() and ha_humidity_state_pattern.search( + line + ): + ha_humidity_state_future.set_result(line) + elif not ha_motion_state_future.done() and ha_motion_state_pattern.search(line): + ha_motion_state_future.set_result(line) + elif not ha_weather_state_future.done() and ha_weather_state_pattern.search( + line + ): + ha_weather_state_future.set_result(line) + + # Check state update patterns + elif not temp_update_future.done() and temp_update_pattern.search(line): + temp_update_future.set_result(line) + elif not humidity_update_future.done() and humidity_update_pattern.search(line): + humidity_update_future.set_result(line) + elif not motion_update_future.done() and motion_update_pattern.search(line): + motion_update_future.set_result(line) + elif not weather_update_future.done() and weather_update_pattern.search(line): + weather_update_future.set_result(line) + + # Check number pattern + elif not ha_number_future.done() and ha_number_pattern.search(line): + match = ha_number_pattern.search(line) + if match: + ha_number_future.set_result(match.group(1)) + + elif not tests_complete_future.done() and tests_complete_pattern.search(line): + tests_complete_future.set_result(True) + + # Run with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-ha-api" + + # Subscribe to HomeAssistant service calls + client.subscribe_service_calls(on_service_call) + + # Send some Home Assistant states for our sensors to read + client.send_home_assistant_state("sensor.external_temperature", "", "22.5") + client.send_home_assistant_state("sensor.external_humidity", "", "65.0") + client.send_home_assistant_state("binary_sensor.external_motion", "", "ON") + client.send_home_assistant_state("weather.home", "condition", "sunny") + + # List entities and services + _, services = await client.list_entities_services() + + # Find the trigger service + trigger_service = next( + (s for s in services if s.name == "trigger_all_tests"), None + ) + assert trigger_service is not None, "trigger_all_tests service not found" + + # Execute all tests + client.execute_service(trigger_service, {}) + + # Wait for all tests to complete with appropriate timeouts + try: + # Templated service test - the main bug fix + computed_value = await asyncio.wait_for(lambda_computed_future, timeout=5.0) + # Verify the computed value is reasonable (75 * 255 / 100 = 191.25 -> 191) + assert computed_value in ["191", "192"], ( + f"Unexpected computed value: {computed_value}" + ) + + # Check state reads - verify we received the mocked values + temp_line = await asyncio.wait_for(ha_temp_state_future, timeout=5.0) + assert "Current HA Temperature: 22.5" in temp_line + + humidity_line = await asyncio.wait_for( + ha_humidity_state_future, timeout=5.0 + ) + assert "Current HA Humidity: 65.0" in humidity_line + + motion_line = await asyncio.wait_for(ha_motion_state_future, timeout=5.0) + assert "Current HA Motion: ON" in motion_line + + weather_line = await asyncio.wait_for(ha_weather_state_future, timeout=5.0) + assert "Current HA Weather: sunny" in weather_line + + # Number test + number_value = await asyncio.wait_for(ha_number_future, timeout=5.0) + assert number_value == "42.5", f"Unexpected number value: {number_value}" + + # Wait for completion + await asyncio.wait_for(tests_complete_future, timeout=5.0) + + # Now verify the protobuf messages + # 1. Basic service call + basic_call = await asyncio.wait_for( + service_call_futures["light.turn_off"], timeout=2.0 + ) + assert basic_call.service == "light.turn_off" + assert "entity_id" in basic_call.data, ( + f"entity_id not found in data: {basic_call.data}" + ) + assert basic_call.data["entity_id"] == "light.test_light", ( + f"Wrong entity_id: {basic_call.data['entity_id']}" + ) + + # 2. Templated service call - verify the temporary string issue is fixed + templated_call = await asyncio.wait_for( + service_call_futures["light.turn_on"], timeout=2.0 + ) + assert templated_call.service == "light.turn_on" + # Check the computed brightness value + assert "brightness" in templated_call.data + assert templated_call.data["brightness"] in ["191", "192"] # 75 * 255 / 100 + # Check data_template + assert "color_name" in templated_call.data_template + assert templated_call.data_template["color_name"] == "test_value" + # Check variables + assert "transition" in templated_call.variables + assert templated_call.variables["transition"] == "2.5" + + # 3. Empty string service call + empty_call = await asyncio.wait_for( + service_call_futures["notify.test"], timeout=2.0 + ) + assert empty_call.service == "notify.test" + # Verify empty strings are properly handled + assert "title" in empty_call.data and empty_call.data["title"] == "" + assert ( + "target" in empty_call.data_template + and empty_call.data_template["target"] == "" + ) + assert ( + "sound" in empty_call.variables and empty_call.variables["sound"] == "" + ) + + # 4. Multiple fields service call + multi_call = await asyncio.wait_for( + service_call_futures["climate.set_temperature"], timeout=2.0 + ) + assert multi_call.service == "climate.set_temperature" + assert multi_call.data["temperature"] == "22" + assert multi_call.data["hvac_mode"] == "heat" + assert multi_call.data_template["target_temp_high"] == "24" + assert multi_call.variables["preset_mode"] == "comfort" + + # 5. Complex lambda service call + complex_call = await asyncio.wait_for( + service_call_futures["script.test_script"], timeout=2.0 + ) + assert complex_call.service == "script.test_script" + assert complex_call.data["entity_id"] == "light.living_room" + assert complex_call.data["brightness_pct"] == "99" # 42 * 2.38 ≈ 99 + # Check message includes sensor value + assert "message" in complex_call.data_template + assert "Sensor: 42.0" in complex_call.data_template["message"] + + # 6. All empty service call + all_empty_call = await asyncio.wait_for( + service_call_futures["test.empty"], timeout=2.0 + ) + assert all_empty_call.service == "test.empty" + # All fields should be empty strings + assert all(v == "" for v in all_empty_call.data.values()) + assert all(v == "" for v in all_empty_call.data_template.values()) + assert all(v == "" for v in all_empty_call.variables.values()) + + # 7. HA Number service call + number_call = await asyncio.wait_for( + service_call_futures["input_number.set_value"], timeout=2.0 + ) + assert number_call.service == "input_number.set_value" + assert number_call.data["entity_id"] == "input_number.test_number" + # The value might be formatted with trailing zeros + assert float(number_call.data["value"]) == 42.5 + + # 8. HA Switch service calls + switch_on_call = await asyncio.wait_for( + service_call_futures["switch.turn_on"], timeout=2.0 + ) + assert switch_on_call.service == "switch.turn_on" + assert switch_on_call.data["entity_id"] == "switch.test_switch" + + switch_off_call = await asyncio.wait_for( + service_call_futures["switch.turn_off"], timeout=2.0 + ) + assert switch_off_call.service == "switch.turn_off" + assert switch_off_call.data["entity_id"] == "switch.test_switch" + + except TimeoutError as e: + # Show recent log lines for debugging + recent_logs = "\n".join(log_lines[-20:]) + service_calls_summary = "\n".join( + f"- {call.service}" for call in ha_service_calls + ) + pytest.fail( + f"Test timed out waiting for expected log pattern or service call. Error: {e}\n\n" + f"Recent log lines:\n{recent_logs}\n\n" + f"Received service calls:\n{service_calls_summary}" + ) From 4933ef780b7d921bd5ddb18853de8611586d5fec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jul 2025 18:50:17 -1000 Subject: [PATCH 0094/1626] [bluetooth_proxy] Fix service discovery cache pollution and descriptor count parameter bug (#9902) --- .../bluetooth_proxy/bluetooth_connection.cpp | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 4b84257e27..b3484032b2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -80,15 +80,10 @@ void BluetoothConnection::send_service_for_discovery_() { &service_result, &service_count, this->send_service_); this->send_service_++; - if (service_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service error at offset=%d, status=%d", this->connection_index_, - this->address_str().c_str(), this->send_service_ - 1, service_status); - return; - } - - if (service_count == 0) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service missing, service_count=%d", this->connection_index_, - this->address_str().c_str(), service_count); + if (service_status != ESP_GATT_OK || service_count == 0) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", + this->connection_index_, this->address_str().c_str(), service_status != ESP_GATT_OK ? "error" : "missing", + service_status, service_count, this->send_service_ - 1); return; } @@ -104,15 +99,20 @@ void BluetoothConnection::send_service_for_discovery_() { esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, service_result.start_handle, service_result.end_handle, 0, &total_char_count); - if (char_count_status == ESP_GATT_OK && total_char_count > 0) { - // Only reserve if we successfully got a count - service_resp.characteristics.reserve(total_char_count); - } else if (char_count_status != ESP_GATT_OK) { + if (char_count_status != ESP_GATT_OK) { ESP_LOGW(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, this->address_str().c_str(), char_count_status); + return; } - // Now process characteristics + if (total_char_count == 0) { + // No characteristics, just send the service response + api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); + return; + } + + // Reserve space and process characteristics + service_resp.characteristics.reserve(total_char_count); uint16_t char_offset = 0; esp_gattc_char_elem_t char_result; while (true) { // characteristics @@ -126,7 +126,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (char_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, this->address_str().c_str(), char_status); - break; + return; } if (char_count == 0) { break; @@ -141,19 +141,21 @@ void BluetoothConnection::send_service_for_discovery_() { // Get the number of descriptors directly with one call uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = - esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, char_result.char_handle, - service_result.end_handle, 0, &total_desc_count); + esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( + this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); - if (desc_count_status == ESP_GATT_OK && total_desc_count > 0) { - // Only reserve if we successfully got a count - characteristic_resp.descriptors.reserve(total_desc_count); - } else if (desc_count_status != ESP_GATT_OK) { + if (desc_count_status != ESP_GATT_OK) { ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, this->address_str().c_str(), char_result.char_handle, desc_count_status); + return; + } + if (total_desc_count == 0) { + // No descriptors, continue to next characteristic + continue; } - // Now process descriptors + // Reserve space and process descriptors + characteristic_resp.descriptors.reserve(total_desc_count); uint16_t desc_offset = 0; esp_gattc_descr_elem_t desc_result; while (true) { // descriptors @@ -166,10 +168,10 @@ void BluetoothConnection::send_service_for_discovery_() { if (desc_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, this->address_str().c_str(), desc_status); - break; + return; } if (desc_count == 0) { - break; + break; // No more descriptors } characteristic_resp.descriptors.emplace_back(); From eecdaa516315544092d93bbec96207648d7ec359 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:23:35 +1000 Subject: [PATCH 0095/1626] [config_validation] extend should combine extra validations (#9939) --- esphome/voluptuous_schema.py | 7 +-- .../config_validation/test_config.py | 51 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/config_validation/test_config.py diff --git a/esphome/voluptuous_schema.py b/esphome/voluptuous_schema.py index 8fb966e3b2..7220fb307f 100644 --- a/esphome/voluptuous_schema.py +++ b/esphome/voluptuous_schema.py @@ -225,9 +225,10 @@ class _Schema(vol.Schema): return ret schema = schemas[0] + extra_schemas = self._extra_schemas.copy() + if isinstance(schema, _Schema): + extra_schemas.extend(schema._extra_schemas) if isinstance(schema, vol.Schema): schema = schema.schema ret = super().extend(schema, extra=extra) - return _Schema( - ret.schema, extra=ret.extra, extra_schemas=self._extra_schemas.copy() - ) + return _Schema(ret.schema, extra=ret.extra, extra_schemas=extra_schemas) diff --git a/tests/component_tests/config_validation/test_config.py b/tests/component_tests/config_validation/test_config.py new file mode 100644 index 0000000000..1a9b9bc1f3 --- /dev/null +++ b/tests/component_tests/config_validation/test_config.py @@ -0,0 +1,51 @@ +""" +Test schema.extend functionality in esphome.config_validation. +""" + +from typing import Any + +import esphome.config_validation as cv + + +def test_config_extend() -> None: + """Test that schema.extend correctly merges schemas with extras.""" + + def func1(data: dict[str, Any]) -> dict[str, Any]: + data["extra_1"] = "value1" + return data + + def func2(data: dict[str, Any]) -> dict[str, Any]: + data["extra_2"] = "value2" + return data + + schema1 = cv.Schema( + { + cv.Required("key1"): cv.string, + } + ) + schema1.add_extra(func1) + schema2 = cv.Schema( + { + cv.Required("key2"): cv.string, + } + ) + schema2.add_extra(func2) + extended_schema = schema1.extend(schema2) + config = { + "key1": "initial_value1", + "key2": "initial_value2", + } + validated = extended_schema(config) + assert validated["key1"] == "initial_value1" + assert validated["key2"] == "initial_value2" + assert validated["extra_1"] == "value1" + assert validated["extra_2"] == "value2" + + # Check the opposite order of extension + extended_schema = schema2.extend(schema1) + + validated = extended_schema(config) + assert validated["key1"] == "initial_value1" + assert validated["key2"] == "initial_value2" + assert validated["extra_1"] == "value1" + assert validated["extra_2"] == "value2" From f9453f96421ab0493fa824d36a3d0342398d7a35 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 29 Jul 2025 06:43:22 +1000 Subject: [PATCH 0096/1626] [lvgl] Bugfix for tileview (#9938) --- esphome/components/lvgl/widgets/tileview.py | 22 ++++++++++++++------- tests/components/lvgl/lvgl-package.yaml | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/widgets/tileview.py b/esphome/components/lvgl/widgets/tileview.py index 3865d404e2..5e3a95f017 100644 --- a/esphome/components/lvgl/widgets/tileview.py +++ b/esphome/components/lvgl/widgets/tileview.py @@ -15,7 +15,7 @@ from ..defines import ( TILE_DIRECTIONS, literal, ) -from ..lv_validation import animated, lv_int +from ..lv_validation import animated, lv_int, lv_pct from ..lvcode import lv, lv_assign, lv_expr, lv_obj, lv_Pvariable from ..schemas import container_schema from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr @@ -41,8 +41,8 @@ TILEVIEW_SCHEMA = cv.Schema( container_schema( obj_spec, { - cv.Required(CONF_ROW): lv_int, - cv.Required(CONF_COLUMN): lv_int, + cv.Required(CONF_ROW): cv.positive_int, + cv.Required(CONF_COLUMN): cv.positive_int, cv.GenerateID(): cv.declare_id(lv_tile_t), cv.Optional(CONF_DIR, default="ALL"): TILE_DIRECTIONS.several_of, }, @@ -63,21 +63,29 @@ class TileviewType(WidgetType): ) async def to_code(self, w: Widget, config: dict): - for tile_conf in config.get(CONF_TILES, ()): + tiles = config[CONF_TILES] + for tile_conf in tiles: w_id = tile_conf[CONF_ID] tile_obj = lv_Pvariable(lv_obj_t, w_id) tile = Widget.create(w_id, tile_obj, tile_spec, tile_conf) dirs = tile_conf[CONF_DIR] if isinstance(dirs, list): dirs = "|".join(dirs) + row_pos = tile_conf[CONF_ROW] + col_pos = tile_conf[CONF_COLUMN] lv_assign( tile_obj, - lv_expr.tileview_add_tile( - w.obj, tile_conf[CONF_COLUMN], tile_conf[CONF_ROW], literal(dirs) - ), + lv_expr.tileview_add_tile(w.obj, col_pos, row_pos, literal(dirs)), ) + # Bugfix for LVGL 8.x + lv_obj.set_pos(tile_obj, lv_pct(col_pos * 100), lv_pct(row_pos * 100)) await set_obj_properties(tile, tile_conf) await add_widgets(tile, tile_conf) + if tiles: + # Set the first tile as active + lv_obj.set_tile_id( + w.obj, tiles[0][CONF_COLUMN], tiles[0][CONF_ROW], literal("LV_ANIM_OFF") + ) tileview_spec = TileviewType() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 46341c266d..853466c9cc 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -738,7 +738,7 @@ lvgl: id: bar_id value: !lambda return (int)((float)rand() / RAND_MAX * 100); start_value: !lambda return (int)((float)rand() / RAND_MAX * 100); - mode: symmetrical + mode: range - logger.log: format: "bar value %f" args: [x] From 85435e6b5f695b37a0efd688c1848373762f191e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 10:54:16 -1000 Subject: [PATCH 0097/1626] [scheduler] Eliminate more runtime string allocations from retry (#9930) --- esphome/core/scheduler.cpp | 61 ++++++++++++------ esphome/core/scheduler.h | 32 +++++++--- .../fixtures/scheduler_retry_test.yaml | 62 ++++++++++++++++++ .../integration/test_scheduler_retry_test.py | 64 ++++++++++++++++++- 4 files changed, 192 insertions(+), 27 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a2c16c41fb..6269a66543 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -83,6 +83,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type item->type = type; item->callback = std::move(func); item->remove = false; + item->is_retry = is_retry; #ifndef ESPHOME_THREAD_SINGLE // Special handling for defer() (delay = 0, type = TIMEOUT) @@ -134,8 +135,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // For retries, check if there's a cancelled timeout first if (is_retry && name_cstr != nullptr && type == SchedulerItem::TIMEOUT && - (has_cancelled_timeout_in_container_(this->items_, component, name_cstr) || - has_cancelled_timeout_in_container_(this->to_add_, component, name_cstr))) { + (has_cancelled_timeout_in_container_(this->items_, component, name_cstr, /* match_retry= */ true) || + has_cancelled_timeout_in_container_(this->to_add_, component, name_cstr, /* match_retry= */ true))) { // Skip scheduling - the retry was cancelled #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", name_cstr); @@ -198,25 +199,27 @@ void retry_handler(const std::shared_ptr &args) { // second execution of `func` happens after `initial_wait_time` args->scheduler->set_timer_common_( args->component, Scheduler::SchedulerItem::TIMEOUT, false, &args->name, args->current_interval, - [args]() { retry_handler(args); }, true); + [args]() { retry_handler(args); }, /* is_retry= */ true); // backoff_increase_factor applied to third & later executions args->current_interval *= args->backoff_increase_factor; } -void HOT Scheduler::set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, - uint8_t max_attempts, std::function func, - float backoff_increase_factor) { - if (!name.empty()) - this->cancel_retry(component, name); +void HOT Scheduler::set_retry_common_(Component *component, bool is_static_string, const void *name_ptr, + uint32_t initial_wait_time, uint8_t max_attempts, + std::function func, float backoff_increase_factor) { + const char *name_cstr = this->get_name_cstr_(is_static_string, name_ptr); + + if (name_cstr != nullptr) + this->cancel_retry(component, name_cstr); if (initial_wait_time == SCHEDULER_DONT_RUN) return; ESP_LOGVV(TAG, "set_retry(name='%s', initial_wait_time=%" PRIu32 ", max_attempts=%u, backoff_factor=%0.1f)", - name.c_str(), initial_wait_time, max_attempts, backoff_increase_factor); + name_cstr ? name_cstr : "", initial_wait_time, max_attempts, backoff_increase_factor); if (backoff_increase_factor < 0.0001) { - ESP_LOGE(TAG, "backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor, name.c_str()); + ESP_LOGE(TAG, "backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor, name_cstr ? name_cstr : ""); backoff_increase_factor = 1; } @@ -225,15 +228,36 @@ void HOT Scheduler::set_retry(Component *component, const std::string &name, uin args->retry_countdown = max_attempts; args->current_interval = initial_wait_time; args->component = component; - args->name = "retry$" + name; + args->name = name_cstr ? name_cstr : ""; // Convert to std::string for RetryArgs args->backoff_increase_factor = backoff_increase_factor; args->scheduler = this; - // First execution of `func` immediately - this->set_timeout(component, args->name, 0, [args]() { retry_handler(args); }); + // First execution of `func` immediately - use set_timer_common_ with is_retry=true + this->set_timer_common_( + component, SchedulerItem::TIMEOUT, false, &args->name, 0, [args]() { retry_handler(args); }, + /* is_retry= */ true); +} + +void HOT Scheduler::set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, + uint8_t max_attempts, std::function func, + float backoff_increase_factor) { + this->set_retry_common_(component, false, &name, initial_wait_time, max_attempts, std::move(func), + backoff_increase_factor); +} + +void HOT Scheduler::set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts, + std::function func, float backoff_increase_factor) { + this->set_retry_common_(component, true, name, initial_wait_time, max_attempts, std::move(func), + backoff_increase_factor); } bool HOT Scheduler::cancel_retry(Component *component, const std::string &name) { - return this->cancel_timeout(component, "retry$" + name); + return this->cancel_retry(component, name.c_str()); +} + +bool HOT Scheduler::cancel_retry(Component *component, const char *name) { + // Cancel timeouts that have is_retry flag set + LockGuard guard{this->lock_}; + return this->cancel_item_locked_(component, name, SchedulerItem::TIMEOUT, /* match_retry= */ true); } optional HOT Scheduler::next_schedule_in(uint32_t now) { @@ -479,7 +503,8 @@ bool HOT Scheduler::cancel_item_(Component *component, bool is_static_string, co } // Helper to cancel items by name - must be called with lock held -bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_cstr, SchedulerItem::Type type) { +bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_cstr, SchedulerItem::Type type, + bool match_retry) { // Early return if name is invalid - no items to cancel if (name_cstr == nullptr) { return false; @@ -492,7 +517,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Only check defer queue for timeouts (intervals never go there) if (type == SchedulerItem::TIMEOUT) { for (auto &item : this->defer_queue_) { - if (this->matches_item_(item, component, name_cstr, type)) { + if (this->matches_item_(item, component, name_cstr, type, match_retry)) { item->remove = true; total_cancelled++; } @@ -502,7 +527,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Cancel items in the main heap for (auto &item : this->items_) { - if (this->matches_item_(item, component, name_cstr, type)) { + if (this->matches_item_(item, component, name_cstr, type, match_retry)) { item->remove = true; total_cancelled++; this->to_remove_++; // Track removals for heap items @@ -511,7 +536,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Cancel items in to_add_ for (auto &item : this->to_add_) { - if (this->matches_item_(item, component, name_cstr, type)) { + if (this->matches_item_(item, component, name_cstr, type, match_retry)) { item->remove = true; total_cancelled++; // Don't track removals for to_add_ items diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index fa189bacf7..a6092e1b1e 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -61,7 +61,10 @@ class Scheduler { bool cancel_interval(Component *component, const char *name); void set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, std::function func, float backoff_increase_factor = 1.0f); + void set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts, + std::function func, float backoff_increase_factor = 1.0f); bool cancel_retry(Component *component, const std::string &name); + bool cancel_retry(Component *component, const char *name); // Calculate when the next scheduled item should run // @param now Fresh timestamp from millis() - must not be stale/cached @@ -98,11 +101,18 @@ class Scheduler { enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; bool remove : 1; bool name_is_dynamic : 1; // True if name was dynamically allocated (needs delete[]) - // 5 bits padding + bool is_retry : 1; // True if this is a retry timeout + // 4 bits padding // Constructor SchedulerItem() - : component(nullptr), interval(0), next_execution_(0), type(TIMEOUT), remove(false), name_is_dynamic(false) { + : component(nullptr), + interval(0), + next_execution_(0), + type(TIMEOUT), + remove(false), + name_is_dynamic(false), + is_retry(false) { name_.static_name = nullptr; } @@ -156,6 +166,10 @@ class Scheduler { void set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, const void *name_ptr, uint32_t delay, std::function func, bool is_retry = false); + // Common implementation for retry + void set_retry_common_(Component *component, bool is_static_string, const void *name_ptr, uint32_t initial_wait_time, + uint8_t max_attempts, std::function func, float backoff_increase_factor); + uint64_t millis_64_(uint32_t now); // Cleanup logically deleted items from the scheduler // Returns the number of items remaining after cleanup @@ -165,7 +179,7 @@ class Scheduler { private: // Helper to cancel items by name - must be called with lock held - bool cancel_item_locked_(Component *component, const char *name, SchedulerItem::Type type); + bool cancel_item_locked_(Component *component, const char *name, SchedulerItem::Type type, bool match_retry = false); // Helper to extract name as const char* from either static string or std::string inline const char *get_name_cstr_(bool is_static_string, const void *name_ptr) { @@ -177,8 +191,9 @@ class Scheduler { // Helper function to check if item matches criteria for cancellation inline bool HOT matches_item_(const std::unique_ptr &item, Component *component, const char *name_cstr, - SchedulerItem::Type type, bool skip_removed = true) const { - if (item->component != component || item->type != type || (skip_removed && item->remove)) { + SchedulerItem::Type type, bool match_retry, bool skip_removed = true) const { + if (item->component != component || item->type != type || (skip_removed && item->remove) || + (match_retry && !item->is_retry)) { return false; } const char *item_name = item->get_name(); @@ -206,10 +221,11 @@ class Scheduler { // Template helper to check if any item in a container matches our criteria template - bool has_cancelled_timeout_in_container_(const Container &container, Component *component, - const char *name_cstr) const { + bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr, + bool match_retry) const { for (const auto &item : container) { - if (item->remove && this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, false)) { + if (item->remove && this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry, + /* skip_removed= */ false)) { return true; } } diff --git a/tests/integration/fixtures/scheduler_retry_test.yaml b/tests/integration/fixtures/scheduler_retry_test.yaml index c6fcc53f8c..11fff6c395 100644 --- a/tests/integration/fixtures/scheduler_retry_test.yaml +++ b/tests/integration/fixtures/scheduler_retry_test.yaml @@ -37,6 +37,15 @@ globals: - id: multiple_same_name_counter type: int initial_value: '0' + - id: const_char_retry_counter + type: int + initial_value: '0' + - id: static_char_retry_counter + type: int + initial_value: '0' + - id: mixed_cancel_result + type: bool + initial_value: 'false' # Using different component types for each test to ensure isolation sensor: @@ -229,6 +238,56 @@ script: return RetryResult::RETRY; }); + # Test 8: Const char* overloads + - logger.log: "=== Test 8: Const char* overloads ===" + - lambda: |- + auto *component = id(simple_retry_sensor); + + // Test 8a: Direct string literal + App.scheduler.set_retry(component, "const_char_test", 30, 2, + [](uint8_t retry_countdown) { + id(const_char_retry_counter)++; + ESP_LOGI("test", "Const char retry %d", id(const_char_retry_counter)); + return RetryResult::DONE; + }); + + # Test 9: Static const char* variable + - logger.log: "=== Test 9: Static const char* ===" + - lambda: |- + auto *component = id(backoff_retry_sensor); + + static const char* STATIC_NAME = "static_retry_test"; + App.scheduler.set_retry(component, STATIC_NAME, 20, 1, + [](uint8_t retry_countdown) { + id(static_char_retry_counter)++; + ESP_LOGI("test", "Static const char retry %d", id(static_char_retry_counter)); + return RetryResult::DONE; + }); + + // Cancel with same static const char* + App.scheduler.set_timeout(component, "static_cancel", 10, []() { + static const char* STATIC_NAME = "static_retry_test"; + bool result = App.scheduler.cancel_retry(id(backoff_retry_sensor), STATIC_NAME); + ESP_LOGI("test", "Static cancel result: %s", result ? "true" : "false"); + }); + + # Test 10: Mix string and const char* cancel + - logger.log: "=== Test 10: Mixed string/const char* ===" + - lambda: |- + auto *component = id(immediate_done_sensor); + + // Set with std::string + std::string str_name = "mixed_retry"; + App.scheduler.set_retry(component, str_name, 40, 3, + [](uint8_t retry_countdown) { + ESP_LOGI("test", "Mixed retry - should be cancelled"); + return RetryResult::RETRY; + }); + + // Cancel with const char* + id(mixed_cancel_result) = App.scheduler.cancel_retry(component, "mixed_retry"); + ESP_LOGI("test", "Mixed cancel result: %s", id(mixed_cancel_result) ? "true" : "false"); + # Wait for all tests to complete before reporting - delay: 500ms @@ -242,4 +301,7 @@ script: ESP_LOGI("test", "Empty name retry counter: %d (expected 1-2)", id(empty_name_retry_counter)); ESP_LOGI("test", "Component retry counter: %d (expected 2)", id(script_retry_counter)); ESP_LOGI("test", "Multiple same name counter: %d (expected 20+)", id(multiple_same_name_counter)); + ESP_LOGI("test", "Const char retry counter: %d (expected 1)", id(const_char_retry_counter)); + ESP_LOGI("test", "Static char retry counter: %d (expected 1)", id(static_char_retry_counter)); + ESP_LOGI("test", "Mixed cancel result: %s (expected true)", id(mixed_cancel_result) ? "true" : "false"); ESP_LOGI("test", "All retry tests completed"); diff --git a/tests/integration/test_scheduler_retry_test.py b/tests/integration/test_scheduler_retry_test.py index 1a469fcff1..c04b7197c9 100644 --- a/tests/integration/test_scheduler_retry_test.py +++ b/tests/integration/test_scheduler_retry_test.py @@ -23,6 +23,9 @@ async def test_scheduler_retry_test( empty_name_retry_done = asyncio.Event() component_retry_done = asyncio.Event() multiple_name_done = asyncio.Event() + const_char_done = asyncio.Event() + static_char_done = asyncio.Event() + mixed_cancel_done = asyncio.Event() test_complete = asyncio.Event() # Track retry counts @@ -33,16 +36,20 @@ async def test_scheduler_retry_test( empty_name_retry_count = 0 component_retry_count = 0 multiple_name_count = 0 + const_char_retry_count = 0 + static_char_retry_count = 0 # Track specific test results cancel_result = None empty_cancel_result = None + mixed_cancel_result = None backoff_intervals = [] def on_log_line(line: str) -> None: nonlocal simple_retry_count, backoff_retry_count, immediate_done_count nonlocal cancel_retry_count, empty_name_retry_count, component_retry_count - nonlocal multiple_name_count, cancel_result, empty_cancel_result + nonlocal multiple_name_count, const_char_retry_count, static_char_retry_count + nonlocal cancel_result, empty_cancel_result, mixed_cancel_result # Strip ANSI color codes clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) @@ -106,6 +113,27 @@ async def test_scheduler_retry_test( if multiple_name_count >= 20: multiple_name_done.set() + # Const char retry test + elif "Const char retry" in clean_line: + if match := re.search(r"Const char retry (\d+)", clean_line): + const_char_retry_count = int(match.group(1)) + const_char_done.set() + + # Static const char retry test + elif "Static const char retry" in clean_line: + if match := re.search(r"Static const char retry (\d+)", clean_line): + static_char_retry_count = int(match.group(1)) + static_char_done.set() + + elif "Static cancel result:" in clean_line: + # This is part of test 9, but we don't track it separately + pass + + # Mixed cancel test + elif "Mixed cancel result:" in clean_line: + mixed_cancel_result = "true" in clean_line + mixed_cancel_done.set() + # Test completion elif "All retry tests completed" in clean_line: test_complete.set() @@ -227,6 +255,40 @@ async def test_scheduler_retry_test( f"Expected multiple name count >= 20 (second retry only), got {multiple_name_count}" ) + # Wait for const char retry test + try: + await asyncio.wait_for(const_char_done.wait(), timeout=1.0) + except TimeoutError: + pytest.fail( + f"Const char retry test did not complete. Count: {const_char_retry_count}" + ) + + assert const_char_retry_count == 1, ( + f"Expected 1 const char retry call, got {const_char_retry_count}" + ) + + # Wait for static char retry test + try: + await asyncio.wait_for(static_char_done.wait(), timeout=1.0) + except TimeoutError: + pytest.fail( + f"Static char retry test did not complete. Count: {static_char_retry_count}" + ) + + assert static_char_retry_count == 1, ( + f"Expected 1 static char retry call, got {static_char_retry_count}" + ) + + # Wait for mixed cancel test + try: + await asyncio.wait_for(mixed_cancel_done.wait(), timeout=1.0) + except TimeoutError: + pytest.fail("Mixed cancel test did not complete") + + assert mixed_cancel_result is True, ( + "Mixed string/const char cancel should have succeeded" + ) + # Wait for test completion try: await asyncio.wait_for(test_complete.wait(), timeout=1.0) From 59d466a6c8f88a470e24dbde4c8e7b797e75e5e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 10:55:41 -1000 Subject: [PATCH 0098/1626] [api] Remove unnecessary string copies from optional access (#9897) --- esphome/components/api/api_connection.cpp | 46 ++++++++-------- esphome/components/api/api_connection.h | 4 ++ esphome/components/api/proto.h | 5 +- .../fixtures/host_mode_many_entities.yaml | 53 +++++++++++++++++++ .../test_host_mode_many_entities.py | 21 +++++++- 5 files changed, 104 insertions(+), 25 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index e0d4ec0cc8..d5e9f61427 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -244,21 +244,7 @@ void APIConnection::loop() { #ifdef USE_API_HOMEASSISTANT_STATES if (state_subs_at_ >= 0) { - const auto &subs = this->parent_->get_state_subs(); - if (state_subs_at_ < static_cast(subs.size())) { - auto &it = subs[state_subs_at_]; - SubscribeHomeAssistantStateResponse resp; - resp.set_entity_id(StringRef(it.entity_id)); - // attribute.value() returns temporary - must store it - std::string attribute_value = it.attribute.value(); - resp.set_attribute(StringRef(attribute_value)); - resp.once = it.once; - if (this->send_message(resp, SubscribeHomeAssistantStateResponse::MESSAGE_TYPE)) { - state_subs_at_++; - } - } else { - state_subs_at_ = -1; - } + this->process_state_subscriptions_(); } #endif } @@ -644,17 +630,13 @@ uint16_t APIConnection::try_send_climate_state(EntityBase *entity, APIConnection if (traits.get_supports_fan_modes() && climate->fan_mode.has_value()) resp.fan_mode = static_cast(climate->fan_mode.value()); if (!traits.get_supported_custom_fan_modes().empty() && climate->custom_fan_mode.has_value()) { - // custom_fan_mode.value() returns temporary - must store it - std::string custom_fan_mode = climate->custom_fan_mode.value(); - resp.set_custom_fan_mode(StringRef(custom_fan_mode)); + resp.set_custom_fan_mode(StringRef(climate->custom_fan_mode.value())); } if (traits.get_supports_presets() && climate->preset.has_value()) { resp.preset = static_cast(climate->preset.value()); } if (!traits.get_supported_custom_presets().empty() && climate->custom_preset.has_value()) { - // custom_preset.value() returns temporary - must store it - std::string custom_preset = climate->custom_preset.value(); - resp.set_custom_preset(StringRef(custom_preset)); + resp.set_custom_preset(StringRef(climate->custom_preset.value())); } if (traits.get_supports_swing_modes()) resp.swing_mode = static_cast(climate->swing_mode); @@ -1843,5 +1825,27 @@ uint16_t APIConnection::try_send_ping_request(EntityBase *entity, APIConnection return encode_message_to_buffer(req, PingRequest::MESSAGE_TYPE, conn, remaining_size, is_single); } +#ifdef USE_API_HOMEASSISTANT_STATES +void APIConnection::process_state_subscriptions_() { + const auto &subs = this->parent_->get_state_subs(); + if (this->state_subs_at_ >= static_cast(subs.size())) { + this->state_subs_at_ = -1; + return; + } + + const auto &it = subs[this->state_subs_at_]; + SubscribeHomeAssistantStateResponse resp; + resp.set_entity_id(StringRef(it.entity_id)); + + // Avoid string copy by directly using the optional's value if it exists + resp.set_attribute(it.attribute.has_value() ? StringRef(it.attribute.value()) : StringRef("")); + + resp.once = it.once; + if (this->send_message(resp, SubscribeHomeAssistantStateResponse::MESSAGE_TYPE)) { + this->state_subs_at_++; + } +} +#endif // USE_API_HOMEASSISTANT_STATES + } // namespace esphome::api #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3c446c431b..f57d37f5a5 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -298,6 +298,10 @@ class APIConnection : public APIServerConnection { // Helper function to handle authentication completion void complete_authentication_(); +#ifdef USE_API_HOMEASSISTANT_STATES + void process_state_subscriptions_(); +#endif + // Non-template helper to encode any ProtoMessage static uint16_t encode_message_to_buffer(ProtoMessage &msg, uint8_t message_type, APIConnection *conn, uint32_t remaining_size, bool is_single); diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 771eaa98d1..cea93f928f 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -35,11 +35,10 @@ namespace esphome::api { * * Unsafe Patterns (WILL cause crashes/corruption): * 1. Temporaries: msg.set_field(StringRef(obj.get_string())) // get_string() returns by value - * 2. Optional values: msg.set_field(StringRef(optional.value())) // value() returns a copy - * 3. Concatenation: msg.set_field(StringRef(str1 + str2)) // Result is temporary + * 2. Concatenation: msg.set_field(StringRef(str1 + str2)) // Result is temporary * * For unsafe patterns, store in a local variable first: - * std::string temp = optional.value(); // or get_string() or str1 + str2 + * std::string temp = get_string(); // or str1 + str2 * msg.set_field(StringRef(temp)); * * The send_*_response pattern ensures proper lifetime management by encoding diff --git a/tests/integration/fixtures/host_mode_many_entities.yaml b/tests/integration/fixtures/host_mode_many_entities.yaml index 3d1aa36196..5e085a15c9 100644 --- a/tests/integration/fixtures/host_mode_many_entities.yaml +++ b/tests/integration/fixtures/host_mode_many_entities.yaml @@ -210,6 +210,15 @@ sensor: name: "Test Sensor 50" lambda: return 50.0; update_interval: 0.1s + # Temperature sensor for the thermostat + - platform: template + name: "Temperature Sensor" + id: temp_sensor + lambda: return 22.5; + unit_of_measurement: "°C" + device_class: temperature + state_class: measurement + update_interval: 5s # Mixed entity types for comprehensive batching test binary_sensor: @@ -285,6 +294,50 @@ valve: stop_action: - logger.log: "Valve stopping" +output: + - platform: template + id: heater_output + type: binary + write_action: + - logger.log: "Heater output changed" + - platform: template + id: cooler_output + type: binary + write_action: + - logger.log: "Cooler output changed" + +climate: + - platform: thermostat + name: "Test Thermostat" + sensor: temp_sensor + default_preset: Home + on_boot_restore_from: default_preset + min_heating_off_time: 1s + min_heating_run_time: 1s + min_cooling_off_time: 1s + min_cooling_run_time: 1s + min_idle_time: 1s + heat_action: + - output.turn_on: heater_output + cool_action: + - output.turn_on: cooler_output + idle_action: + - output.turn_off: heater_output + - output.turn_off: cooler_output + preset: + - name: Home + default_target_temperature_low: 20 + default_target_temperature_high: 24 + mode: heat_cool + - name: Away + default_target_temperature_low: 16 + default_target_temperature_high: 26 + mode: heat_cool + - name: Sleep + default_target_temperature_low: 18 + default_target_temperature_high: 22 + mode: heat_cool + alarm_control_panel: - platform: template name: "Test Alarm" diff --git a/tests/integration/test_host_mode_many_entities.py b/tests/integration/test_host_mode_many_entities.py index ce9e157a88..aaca4555f6 100644 --- a/tests/integration/test_host_mode_many_entities.py +++ b/tests/integration/test_host_mode_many_entities.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from aioesphomeapi import EntityState, SensorState +from aioesphomeapi import ClimateInfo, EntityState, SensorState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -70,3 +70,22 @@ async def test_host_mode_many_entities( assert len(sensor_states) >= 50, ( f"Expected at least 50 sensor states, got {len(sensor_states)}" ) + + # Get entity info to verify climate entity details + entities = await client.list_entities_services() + climate_infos = [e for e in entities[0] if isinstance(e, ClimateInfo)] + assert len(climate_infos) >= 1, "Expected at least 1 climate entity" + + climate_info = climate_infos[0] + # Verify the thermostat has presets + assert len(climate_info.supported_presets) > 0, ( + "Expected climate to have presets" + ) + # The thermostat platform uses standard presets (Home, Away, Sleep) + # which should be transmitted properly without string copies + + # Verify specific presets exist + preset_names = [p.name for p in climate_info.supported_presets] + assert "HOME" in preset_names, f"Expected 'HOME' preset, got {preset_names}" + assert "AWAY" in preset_names, f"Expected 'AWAY' preset, got {preset_names}" + assert "SLEEP" in preset_names, f"Expected 'SLEEP' preset, got {preset_names}" From 08defd7360e21ccb2acc6a3d46b061e3c67ca017 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 11:02:53 -1000 Subject: [PATCH 0099/1626] Bump aioesphomeapi from 37.1.2 to 37.1.3 (#9943) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 80bd470f02..9889e83910 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.9.0 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.1.2 +aioesphomeapi==37.1.3 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 189d20a822e782495c572ea0bf47dd32b322b799 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 29 Jul 2025 09:21:53 +1200 Subject: [PATCH 0100/1626] [heatpumpir] Bump library to 1.0.37 (#9944) --- .clang-tidy.hash | 2 +- esphome/components/heatpumpir/climate.py | 2 +- platformio.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 316f43e706..02b528b0d6 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -32b0db73b3ae01ba18c9cbb1dabbd8156bc14dded500471919bd0a3dc33916e0 +b7056e39f1484500ca2d237068670b789fe9241786b48da0681d646b25af05d5 diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 0f9f146ae9..d2b907d437 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -126,6 +126,6 @@ async def to_code(config): cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) - cg.add_library("tonia/HeatpumpIR", "1.0.35") + cg.add_library("tonia/HeatpumpIR", "1.0.37") if CORE.is_libretiny: CORE.add_platformio_option("lib_ignore", "IRremoteESP8266") diff --git a/platformio.ini b/platformio.ini index bf0754ead3..5956ae8117 100644 --- a/platformio.ini +++ b/platformio.ini @@ -78,7 +78,7 @@ lib_deps = glmnet/Dsmr@0.7 ; dsmr rweather/Crypto@0.4.0 ; dsmr dudanov/MideaUART@1.1.9 ; midea - tonia/HeatpumpIR@1.0.35 ; heatpumpir + tonia/HeatpumpIR@1.0.37 ; heatpumpir build_flags = ${common.build_flags} -DUSE_ARDUINO From 68f388f78e14d9374156caa37b858fba2cbf90c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 12:25:07 -1000 Subject: [PATCH 0101/1626] [api] Optimize protobuf empty message handling to reduce flash and runtime overhead (#9908) --- esphome/components/api/api_pb2.h | 26 +++++++++++----------- esphome/components/api/api_pb2_service.cpp | 26 +++++++++++----------- script/api_protobuf/api_protobuf.py | 18 ++++++++++++--- 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index fb8174f988..47bdaead23 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -378,7 +378,7 @@ class ConnectResponse : public ProtoMessage { protected: }; -class DisconnectRequest : public ProtoDecodableMessage { +class DisconnectRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 5; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -391,7 +391,7 @@ class DisconnectRequest : public ProtoDecodableMessage { protected: }; -class DisconnectResponse : public ProtoDecodableMessage { +class DisconnectResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 6; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -404,7 +404,7 @@ class DisconnectResponse : public ProtoDecodableMessage { protected: }; -class PingRequest : public ProtoDecodableMessage { +class PingRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 7; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -417,7 +417,7 @@ class PingRequest : public ProtoDecodableMessage { protected: }; -class PingResponse : public ProtoDecodableMessage { +class PingResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 8; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -430,7 +430,7 @@ class PingResponse : public ProtoDecodableMessage { protected: }; -class DeviceInfoRequest : public ProtoDecodableMessage { +class DeviceInfoRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 9; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -546,7 +546,7 @@ class DeviceInfoResponse : public ProtoMessage { protected: }; -class ListEntitiesRequest : public ProtoDecodableMessage { +class ListEntitiesRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 11; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -572,7 +572,7 @@ class ListEntitiesDoneResponse : public ProtoMessage { protected: }; -class SubscribeStatesRequest : public ProtoDecodableMessage { +class SubscribeStatesRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 20; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -1045,7 +1045,7 @@ class NoiseEncryptionSetKeyResponse : public ProtoMessage { }; #endif #ifdef USE_API_HOMEASSISTANT_SERVICES -class SubscribeHomeassistantServicesRequest : public ProtoDecodableMessage { +class SubscribeHomeassistantServicesRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 34; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -1094,7 +1094,7 @@ class HomeassistantServiceResponse : public ProtoMessage { }; #endif #ifdef USE_API_HOMEASSISTANT_STATES -class SubscribeHomeAssistantStatesRequest : public ProtoDecodableMessage { +class SubscribeHomeAssistantStatesRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 38; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -1145,7 +1145,7 @@ class HomeAssistantStateResponse : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; #endif -class GetTimeRequest : public ProtoDecodableMessage { +class GetTimeRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 36; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -2043,7 +2043,7 @@ class BluetoothGATTNotifyDataResponse : public ProtoMessage { protected: }; -class SubscribeBluetoothConnectionsFreeRequest : public ProtoDecodableMessage { +class SubscribeBluetoothConnectionsFreeRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 80; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -2162,7 +2162,7 @@ class BluetoothDeviceUnpairingResponse : public ProtoMessage { protected: }; -class UnsubscribeBluetoothLEAdvertisementsRequest : public ProtoDecodableMessage { +class UnsubscribeBluetoothLEAdvertisementsRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 87; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -2418,7 +2418,7 @@ class VoiceAssistantWakeWord : public ProtoMessage { protected: }; -class VoiceAssistantConfigurationRequest : public ProtoDecodableMessage { +class VoiceAssistantConfigurationRequest : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 121; static constexpr uint8_t ESTIMATED_SIZE = 0; diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 26d5b12d00..6b7b8b9ebd 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -35,7 +35,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case DisconnectRequest::MESSAGE_TYPE: { DisconnectRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_disconnect_request: %s", msg.dump().c_str()); #endif @@ -44,7 +44,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case DisconnectResponse::MESSAGE_TYPE: { DisconnectResponse msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_disconnect_response: %s", msg.dump().c_str()); #endif @@ -53,7 +53,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case PingRequest::MESSAGE_TYPE: { PingRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_ping_request: %s", msg.dump().c_str()); #endif @@ -62,7 +62,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case PingResponse::MESSAGE_TYPE: { PingResponse msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_ping_response: %s", msg.dump().c_str()); #endif @@ -71,7 +71,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case DeviceInfoRequest::MESSAGE_TYPE: { DeviceInfoRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_device_info_request: %s", msg.dump().c_str()); #endif @@ -80,7 +80,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case ListEntitiesRequest::MESSAGE_TYPE: { ListEntitiesRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_list_entities_request: %s", msg.dump().c_str()); #endif @@ -89,7 +89,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } case SubscribeStatesRequest::MESSAGE_TYPE: { SubscribeStatesRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_subscribe_states_request: %s", msg.dump().c_str()); #endif @@ -152,7 +152,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #ifdef USE_API_HOMEASSISTANT_SERVICES case SubscribeHomeassistantServicesRequest::MESSAGE_TYPE: { SubscribeHomeassistantServicesRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_subscribe_homeassistant_services_request: %s", msg.dump().c_str()); #endif @@ -162,7 +162,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #endif case GetTimeRequest::MESSAGE_TYPE: { GetTimeRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_get_time_request: %s", msg.dump().c_str()); #endif @@ -181,7 +181,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #ifdef USE_API_HOMEASSISTANT_STATES case SubscribeHomeAssistantStatesRequest::MESSAGE_TYPE: { SubscribeHomeAssistantStatesRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_subscribe_home_assistant_states_request: %s", msg.dump().c_str()); #endif @@ -390,7 +390,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #ifdef USE_BLUETOOTH_PROXY case SubscribeBluetoothConnectionsFreeRequest::MESSAGE_TYPE: { SubscribeBluetoothConnectionsFreeRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_subscribe_bluetooth_connections_free_request: %s", msg.dump().c_str()); #endif @@ -401,7 +401,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #ifdef USE_BLUETOOTH_PROXY case UnsubscribeBluetoothLEAdvertisementsRequest::MESSAGE_TYPE: { UnsubscribeBluetoothLEAdvertisementsRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_unsubscribe_bluetooth_le_advertisements_request: %s", msg.dump().c_str()); #endif @@ -555,7 +555,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #ifdef USE_VOICE_ASSISTANT case VoiceAssistantConfigurationRequest::MESSAGE_TYPE: { VoiceAssistantConfigurationRequest msg; - msg.decode(msg_data, msg_size); + // Empty message: no decode needed #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_voice_assistant_configuration_request: %s", msg.dump().c_str()); #endif diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 4b9a61383d..d346b66aef 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1772,8 +1772,13 @@ def build_message_type( if base_class: out = f"class {desc.name} : public {base_class} {{\n" else: - # Determine inheritance based on whether the message needs decoding - base_class = "ProtoDecodableMessage" if needs_decode else "ProtoMessage" + # Check if message has any non-deprecated fields + has_fields = any(not field.options.deprecated for field in desc.field) + # Determine inheritance based on whether the message needs decoding and has fields + if needs_decode and has_fields: + base_class = "ProtoDecodableMessage" + else: + base_class = "ProtoMessage" out = f"class {desc.name} : public {base_class} {{\n" out += " public:\n" out += indent("\n".join(public_content)) + "\n" @@ -2039,7 +2044,14 @@ def build_service_message_type( hout += f"virtual void {func}(const {mt.name} &value){{}};\n" case = "" case += f"{mt.name} msg;\n" - case += "msg.decode(msg_data, msg_size);\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 + 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'ESP_LOGVV(TAG, "{func}: %s", msg.dump().c_str());\n' From 2c9987869e6533dc3f47936368b3dcac822d82fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 12:28:32 -1000 Subject: [PATCH 0102/1626] [api] Align ProtoSize API design with ProtoWriteBuffer pattern (#9920) --- esphome/components/api/api_connection.cpp | 5 +- esphome/components/api/api_pb2.cpp | 1118 ++++++++++----------- esphome/components/api/api_pb2.h | 166 +-- esphome/components/api/proto.h | 293 +++--- script/api_protobuf/api_protobuf.py | 78 +- 5 files changed, 795 insertions(+), 865 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d5e9f61427..cd27087fe8 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -276,8 +276,9 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess #endif // Calculate size - uint32_t calculated_size = 0; - msg.calculate_size(calculated_size); + ProtoSize size_calc; + msg.calculate_size(size_calc); + uint32_t calculated_size = size_calc.get_size(); // Cache frame sizes to avoid repeated virtual calls const uint8_t header_padding = conn->helper_->frame_header_padding(); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index b587ee5f03..f6f39f901f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -36,11 +36,11 @@ void HelloResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(3, this->server_info_ref_); buffer.encode_string(4, this->name_ref_); } -void HelloResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint32_field(total_size, 1, this->api_version_major); - ProtoSize::add_uint32_field(total_size, 1, this->api_version_minor); - ProtoSize::add_string_field(total_size, 1, this->server_info_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void HelloResponse::calculate_size(ProtoSize &size) const { + size.add_uint32(1, this->api_version_major); + size.add_uint32(1, this->api_version_minor); + size.add_length(1, this->server_info_ref_.size()); + size.add_length(1, this->name_ref_.size()); } bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { @@ -53,17 +53,15 @@ bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value return true; } void ConnectResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->invalid_password); } -void ConnectResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_bool_field(total_size, 1, this->invalid_password); -} +void ConnectResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->invalid_password); } #ifdef USE_AREAS void AreaInfo::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->area_id); buffer.encode_string(2, this->name_ref_); } -void AreaInfo::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint32_field(total_size, 1, this->area_id); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void AreaInfo::calculate_size(ProtoSize &size) const { + size.add_uint32(1, this->area_id); + size.add_length(1, this->name_ref_.size()); } #endif #ifdef USE_DEVICES @@ -72,10 +70,10 @@ void DeviceInfo::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(2, this->name_ref_); buffer.encode_uint32(3, this->area_id); } -void DeviceInfo::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint32_field(total_size, 1, this->device_id); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_uint32_field(total_size, 1, this->area_id); +void DeviceInfo::calculate_size(ProtoSize &size) const { + size.add_uint32(1, this->device_id); + size.add_length(1, this->name_ref_.size()); + size.add_uint32(1, this->area_id); } #endif void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { @@ -130,52 +128,52 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_message(22, this->area); #endif } -void DeviceInfoResponse::calculate_size(uint32_t &total_size) const { +void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_API_PASSWORD - ProtoSize::add_bool_field(total_size, 1, this->uses_password); + size.add_bool(1, this->uses_password); #endif - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->mac_address_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->esphome_version_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->compilation_time_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->model_ref_.size()); + size.add_length(1, this->name_ref_.size()); + size.add_length(1, this->mac_address_ref_.size()); + size.add_length(1, this->esphome_version_ref_.size()); + size.add_length(1, this->compilation_time_ref_.size()); + size.add_length(1, this->model_ref_.size()); #ifdef USE_DEEP_SLEEP - ProtoSize::add_bool_field(total_size, 1, this->has_deep_sleep); + size.add_bool(1, this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoSize::add_string_field(total_size, 1, this->project_name_ref_.size()); + size.add_length(1, this->project_name_ref_.size()); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoSize::add_string_field(total_size, 1, this->project_version_ref_.size()); + size.add_length(1, this->project_version_ref_.size()); #endif #ifdef USE_WEBSERVER - ProtoSize::add_uint32_field(total_size, 1, this->webserver_port); + size.add_uint32(1, this->webserver_port); #endif #ifdef USE_BLUETOOTH_PROXY - ProtoSize::add_uint32_field(total_size, 1, this->bluetooth_proxy_feature_flags); + size.add_uint32(1, this->bluetooth_proxy_feature_flags); #endif - ProtoSize::add_string_field(total_size, 1, this->manufacturer_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->friendly_name_ref_.size()); + size.add_length(1, this->manufacturer_ref_.size()); + size.add_length(1, this->friendly_name_ref_.size()); #ifdef USE_VOICE_ASSISTANT - ProtoSize::add_uint32_field(total_size, 2, this->voice_assistant_feature_flags); + size.add_uint32(2, this->voice_assistant_feature_flags); #endif #ifdef USE_AREAS - ProtoSize::add_string_field(total_size, 2, this->suggested_area_ref_.size()); + size.add_length(2, this->suggested_area_ref_.size()); #endif #ifdef USE_BLUETOOTH_PROXY - ProtoSize::add_string_field(total_size, 2, this->bluetooth_mac_address_ref_.size()); + size.add_length(2, this->bluetooth_mac_address_ref_.size()); #endif #ifdef USE_API_NOISE - ProtoSize::add_bool_field(total_size, 2, this->api_encryption_supported); + size.add_bool(2, this->api_encryption_supported); #endif #ifdef USE_DEVICES - ProtoSize::add_repeated_message(total_size, 2, this->devices); + size.add_repeated_message(2, this->devices); #endif #ifdef USE_AREAS - ProtoSize::add_repeated_message(total_size, 2, this->areas); + size.add_repeated_message(2, this->areas); #endif #ifdef USE_AREAS - ProtoSize::add_message_object(total_size, 2, this->area); + size.add_message_object(2, this->area); #endif } #ifdef USE_BINARY_SENSOR @@ -194,19 +192,19 @@ void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(10, this->device_id); #endif } -void ListEntitiesBinarySensorResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->is_status_binary_sensor); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); +void ListEntitiesBinarySensorResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); + size.add_length(1, this->device_class_ref_.size()); + size.add_bool(1, this->is_status_binary_sensor); + size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void BinarySensorStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -217,12 +215,12 @@ void BinarySensorStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void BinarySensorStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->state); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); +void BinarySensorStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_bool(1, this->state); + size.add_bool(1, this->missing_state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } #endif @@ -245,22 +243,22 @@ void ListEntitiesCoverResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(13, this->device_id); #endif } -void ListEntitiesCoverResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->assumed_state); - ProtoSize::add_bool_field(total_size, 1, this->supports_position); - ProtoSize::add_bool_field(total_size, 1, this->supports_tilt); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); +void ListEntitiesCoverResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); + size.add_bool(1, this->assumed_state); + size.add_bool(1, this->supports_position); + size.add_bool(1, this->supports_tilt); + size.add_length(1, this->device_class_ref_.size()); + size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_bool_field(total_size, 1, this->supports_stop); + size.add_uint32(1, static_cast(this->entity_category)); + size.add_bool(1, this->supports_stop); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void CoverStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -272,13 +270,13 @@ void CoverStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(6, this->device_id); #endif } -void CoverStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_float_field(total_size, 1, this->position); - ProtoSize::add_float_field(total_size, 1, this->tilt); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->current_operation)); +void CoverStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_float(1, this->position); + size.add_float(1, this->tilt); + size.add_uint32(1, static_cast(this->current_operation)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool CoverCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -340,26 +338,26 @@ void ListEntitiesFanResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(13, this->device_id); #endif } -void ListEntitiesFanResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->supports_oscillation); - ProtoSize::add_bool_field(total_size, 1, this->supports_speed); - ProtoSize::add_bool_field(total_size, 1, this->supports_direction); - ProtoSize::add_int32_field(total_size, 1, this->supported_speed_count); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); +void ListEntitiesFanResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); + size.add_bool(1, this->supports_oscillation); + size.add_bool(1, this->supports_speed); + size.add_bool(1, this->supports_direction); + size.add_int32(1, this->supported_speed_count); + size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); if (!this->supported_preset_modes.empty()) { for (const auto &it : this->supported_preset_modes) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_length_force(1, it.size()); } } #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void FanStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -373,15 +371,15 @@ void FanStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(8, this->device_id); #endif } -void FanStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->state); - ProtoSize::add_bool_field(total_size, 1, this->oscillating); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->direction)); - ProtoSize::add_int32_field(total_size, 1, this->speed_level); - ProtoSize::add_string_field(total_size, 1, this->preset_mode_ref_.size()); +void FanStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_bool(1, this->state); + size.add_bool(1, this->oscillating); + size.add_uint32(1, static_cast(this->direction)); + size.add_int32(1, this->speed_level); + size.add_length(1, this->preset_mode_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool FanCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -466,29 +464,29 @@ void ListEntitiesLightResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(16, this->device_id); #endif } -void ListEntitiesLightResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesLightResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); if (!this->supported_color_modes.empty()) { for (const auto &it : this->supported_color_modes) { - ProtoSize::add_enum_field_repeated(total_size, 1, static_cast(it)); + size.add_uint32_force(1, static_cast(it)); } } - ProtoSize::add_float_field(total_size, 1, this->min_mireds); - ProtoSize::add_float_field(total_size, 1, this->max_mireds); + size.add_float(1, this->min_mireds); + size.add_float(1, this->max_mireds); if (!this->effects.empty()) { for (const auto &it : this->effects) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_length_force(1, it.size()); } } - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); + size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 2, this->device_id); + size.add_uint32(2, this->device_id); #endif } void LightStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -509,22 +507,22 @@ void LightStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(14, this->device_id); #endif } -void LightStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->state); - ProtoSize::add_float_field(total_size, 1, this->brightness); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->color_mode)); - ProtoSize::add_float_field(total_size, 1, this->color_brightness); - ProtoSize::add_float_field(total_size, 1, this->red); - ProtoSize::add_float_field(total_size, 1, this->green); - ProtoSize::add_float_field(total_size, 1, this->blue); - ProtoSize::add_float_field(total_size, 1, this->white); - ProtoSize::add_float_field(total_size, 1, this->color_temperature); - ProtoSize::add_float_field(total_size, 1, this->cold_white); - ProtoSize::add_float_field(total_size, 1, this->warm_white); - ProtoSize::add_string_field(total_size, 1, this->effect_ref_.size()); +void LightStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_bool(1, this->state); + size.add_float(1, this->brightness); + size.add_uint32(1, static_cast(this->color_mode)); + size.add_float(1, this->color_brightness); + size.add_float(1, this->red); + size.add_float(1, this->green); + size.add_float(1, this->blue); + size.add_float(1, this->white); + size.add_float(1, this->color_temperature); + size.add_float(1, this->cold_white); + size.add_float(1, this->warm_white); + size.add_length(1, this->effect_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool LightCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -654,22 +652,22 @@ void ListEntitiesSensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(14, this->device_id); #endif } -void ListEntitiesSensorResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesSensorResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_string_field(total_size, 1, this->unit_of_measurement_ref_.size()); - ProtoSize::add_int32_field(total_size, 1, this->accuracy_decimals); - ProtoSize::add_bool_field(total_size, 1, this->force_update); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->state_class)); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_length(1, this->unit_of_measurement_ref_.size()); + size.add_int32(1, this->accuracy_decimals); + size.add_bool(1, this->force_update); + size.add_length(1, this->device_class_ref_.size()); + size.add_uint32(1, static_cast(this->state_class)); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void SensorStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -680,12 +678,12 @@ void SensorStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void SensorStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_float_field(total_size, 1, this->state); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); +void SensorStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_float(1, this->state); + size.add_bool(1, this->missing_state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } #endif @@ -705,19 +703,19 @@ void ListEntitiesSwitchResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(10, this->device_id); #endif } -void ListEntitiesSwitchResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesSwitchResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->assumed_state); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); + size.add_bool(1, this->assumed_state); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); + size.add_length(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void SwitchStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -727,11 +725,11 @@ void SwitchStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->device_id); #endif } -void SwitchStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->state); +void SwitchStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_bool(1, this->state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool SwitchCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -775,18 +773,18 @@ void ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(9, this->device_id); #endif } -void ListEntitiesTextSensorResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesTextSensorResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); + size.add_length(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void TextSensorStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -797,12 +795,12 @@ void TextSensorStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void TextSensorStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->state_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); +void TextSensorStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_length(1, this->state_ref_.size()); + size.add_bool(1, this->missing_state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } #endif @@ -823,9 +821,9 @@ void SubscribeLogsResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, static_cast(this->level)); buffer.encode_bytes(3, this->message_ptr_, this->message_len_); } -void SubscribeLogsResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_enum_field(total_size, 1, static_cast(this->level)); - ProtoSize::add_bytes_field(total_size, 1, this->message_len_); +void SubscribeLogsResponse::calculate_size(ProtoSize &size) const { + size.add_uint32(1, static_cast(this->level)); + size.add_length(1, this->message_len_); } #ifdef USE_API_NOISE bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { @@ -839,18 +837,16 @@ bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthD return true; } void NoiseEncryptionSetKeyResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->success); } -void NoiseEncryptionSetKeyResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_bool_field(total_size, 1, this->success); -} +void NoiseEncryptionSetKeyResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->success); } #endif #ifdef USE_API_HOMEASSISTANT_SERVICES void HomeassistantServiceMap::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->key_ref_); buffer.encode_string(2, this->value); } -void HomeassistantServiceMap::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->key_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->value.size()); +void HomeassistantServiceMap::calculate_size(ProtoSize &size) const { + size.add_length(1, this->key_ref_.size()); + size.add_length(1, this->value.size()); } void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->service_ref_); @@ -865,12 +861,12 @@ void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { } buffer.encode_bool(5, this->is_event); } -void HomeassistantServiceResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->service_ref_.size()); - ProtoSize::add_repeated_message(total_size, 1, this->data); - ProtoSize::add_repeated_message(total_size, 1, this->data_template); - ProtoSize::add_repeated_message(total_size, 1, this->variables); - ProtoSize::add_bool_field(total_size, 1, this->is_event); +void HomeassistantServiceResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->service_ref_.size()); + size.add_repeated_message(1, this->data); + size.add_repeated_message(1, this->data_template); + size.add_repeated_message(1, this->variables); + size.add_bool(1, this->is_event); } #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -879,10 +875,10 @@ void SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer buffer) const buffer.encode_string(2, this->attribute_ref_); buffer.encode_bool(3, this->once); } -void SubscribeHomeAssistantStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->entity_id_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->attribute_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->once); +void SubscribeHomeAssistantStateResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->entity_id_ref_.size()); + size.add_length(1, this->attribute_ref_.size()); + size.add_bool(1, this->once); } bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { @@ -912,17 +908,15 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { return true; } void GetTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->epoch_seconds); } -void GetTimeResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->epoch_seconds); -} +void GetTimeResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->epoch_seconds); } #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); buffer.encode_uint32(2, static_cast(this->type)); } -void ListEntitiesServicesArgument::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->type)); +void ListEntitiesServicesArgument::calculate_size(ProtoSize &size) const { + size.add_length(1, this->name_ref_.size()); + size.add_uint32(1, static_cast(this->type)); } void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); @@ -931,10 +925,10 @@ void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_message(3, it, true); } } -void ListEntitiesServicesResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_repeated_message(total_size, 1, this->args); +void ListEntitiesServicesResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->name_ref_.size()); + size.add_fixed32(1, this->key); + size.add_repeated_message(1, this->args); } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1020,17 +1014,17 @@ void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(8, this->device_id); #endif } -void ListEntitiesCameraResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); +void ListEntitiesCameraResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); + size.add_bool(1, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void CameraImageResponse::encode(ProtoWriteBuffer buffer) const { @@ -1041,12 +1035,12 @@ void CameraImageResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void CameraImageResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bytes_field(total_size, 1, this->data_len_); - ProtoSize::add_bool_field(total_size, 1, this->done); +void CameraImageResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_length(1, this->data_len_); + size.add_bool(1, this->done); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool CameraImageRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1106,58 +1100,58 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(26, this->device_id); #endif } -void ListEntitiesClimateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->supports_current_temperature); - ProtoSize::add_bool_field(total_size, 1, this->supports_two_point_target_temperature); +void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); + size.add_bool(1, this->supports_current_temperature); + size.add_bool(1, this->supports_two_point_target_temperature); if (!this->supported_modes.empty()) { for (const auto &it : this->supported_modes) { - ProtoSize::add_enum_field_repeated(total_size, 1, static_cast(it)); + size.add_uint32_force(1, static_cast(it)); } } - ProtoSize::add_float_field(total_size, 1, this->visual_min_temperature); - ProtoSize::add_float_field(total_size, 1, this->visual_max_temperature); - ProtoSize::add_float_field(total_size, 1, this->visual_target_temperature_step); - ProtoSize::add_bool_field(total_size, 1, this->supports_action); + size.add_float(1, this->visual_min_temperature); + size.add_float(1, this->visual_max_temperature); + size.add_float(1, this->visual_target_temperature_step); + size.add_bool(1, this->supports_action); if (!this->supported_fan_modes.empty()) { for (const auto &it : this->supported_fan_modes) { - ProtoSize::add_enum_field_repeated(total_size, 1, static_cast(it)); + size.add_uint32_force(1, static_cast(it)); } } if (!this->supported_swing_modes.empty()) { for (const auto &it : this->supported_swing_modes) { - ProtoSize::add_enum_field_repeated(total_size, 1, static_cast(it)); + size.add_uint32_force(1, static_cast(it)); } } if (!this->supported_custom_fan_modes.empty()) { for (const auto &it : this->supported_custom_fan_modes) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_length_force(1, it.size()); } } if (!this->supported_presets.empty()) { for (const auto &it : this->supported_presets) { - ProtoSize::add_enum_field_repeated(total_size, 2, static_cast(it)); + size.add_uint32_force(2, static_cast(it)); } } if (!this->supported_custom_presets.empty()) { for (const auto &it : this->supported_custom_presets) { - ProtoSize::add_string_field_repeated(total_size, 2, it); + size.add_length_force(2, it.size()); } } - ProtoSize::add_bool_field(total_size, 2, this->disabled_by_default); + size.add_bool(2, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 2, this->icon_ref_.size()); + size.add_length(2, this->icon_ref_.size()); #endif - ProtoSize::add_enum_field(total_size, 2, static_cast(this->entity_category)); - ProtoSize::add_float_field(total_size, 2, this->visual_current_temperature_step); - ProtoSize::add_bool_field(total_size, 2, this->supports_current_humidity); - ProtoSize::add_bool_field(total_size, 2, this->supports_target_humidity); - ProtoSize::add_float_field(total_size, 2, this->visual_min_humidity); - ProtoSize::add_float_field(total_size, 2, this->visual_max_humidity); + size.add_uint32(2, static_cast(this->entity_category)); + size.add_float(2, this->visual_current_temperature_step); + size.add_bool(2, this->supports_current_humidity); + size.add_bool(2, this->supports_target_humidity); + size.add_float(2, this->visual_min_humidity); + size.add_float(2, this->visual_max_humidity); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 2, this->device_id); + size.add_uint32(2, this->device_id); #endif } void ClimateStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1179,23 +1173,23 @@ void ClimateStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(16, this->device_id); #endif } -void ClimateStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->mode)); - ProtoSize::add_float_field(total_size, 1, this->current_temperature); - ProtoSize::add_float_field(total_size, 1, this->target_temperature); - ProtoSize::add_float_field(total_size, 1, this->target_temperature_low); - ProtoSize::add_float_field(total_size, 1, this->target_temperature_high); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->action)); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->fan_mode)); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->swing_mode)); - ProtoSize::add_string_field(total_size, 1, this->custom_fan_mode_ref_.size()); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->preset)); - ProtoSize::add_string_field(total_size, 1, this->custom_preset_ref_.size()); - ProtoSize::add_float_field(total_size, 1, this->current_humidity); - ProtoSize::add_float_field(total_size, 1, this->target_humidity); +void ClimateStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_uint32(1, static_cast(this->mode)); + size.add_float(1, this->current_temperature); + size.add_float(1, this->target_temperature); + size.add_float(1, this->target_temperature_low); + size.add_float(1, this->target_temperature_high); + size.add_uint32(1, static_cast(this->action)); + size.add_uint32(1, static_cast(this->fan_mode)); + size.add_uint32(1, static_cast(this->swing_mode)); + size.add_length(1, this->custom_fan_mode_ref_.size()); + size.add_uint32(1, static_cast(this->preset)); + size.add_length(1, this->custom_preset_ref_.size()); + size.add_float(1, this->current_humidity); + size.add_float(1, this->target_humidity); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 2, this->device_id); + size.add_uint32(2, this->device_id); #endif } bool ClimateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1308,23 +1302,23 @@ void ListEntitiesNumberResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(14, this->device_id); #endif } -void ListEntitiesNumberResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesNumberResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_float_field(total_size, 1, this->min_value); - ProtoSize::add_float_field(total_size, 1, this->max_value); - ProtoSize::add_float_field(total_size, 1, this->step); - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->unit_of_measurement_ref_.size()); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->mode)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); + size.add_float(1, this->min_value); + size.add_float(1, this->max_value); + size.add_float(1, this->step); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); + size.add_length(1, this->unit_of_measurement_ref_.size()); + size.add_uint32(1, static_cast(this->mode)); + size.add_length(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void NumberStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1335,12 +1329,12 @@ void NumberStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void NumberStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_float_field(total_size, 1, this->state); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); +void NumberStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_float(1, this->state); + size.add_bool(1, this->missing_state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool NumberCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1386,22 +1380,22 @@ void ListEntitiesSelectResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(9, this->device_id); #endif } -void ListEntitiesSelectResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesSelectResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif if (!this->options.empty()) { for (const auto &it : this->options) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_length_force(1, it.size()); } } - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void SelectStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1412,12 +1406,12 @@ void SelectStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void SelectStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->state_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); +void SelectStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_length(1, this->state_ref_.size()); + size.add_bool(1, this->missing_state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool SelectCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1472,24 +1466,24 @@ void ListEntitiesSirenResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(11, this->device_id); #endif } -void ListEntitiesSirenResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesSirenResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); + size.add_bool(1, this->disabled_by_default); if (!this->tones.empty()) { for (const auto &it : this->tones) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_length_force(1, it.size()); } } - ProtoSize::add_bool_field(total_size, 1, this->supports_duration); - ProtoSize::add_bool_field(total_size, 1, this->supports_volume); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_bool(1, this->supports_duration); + size.add_bool(1, this->supports_volume); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void SirenStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1499,11 +1493,11 @@ void SirenStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->device_id); #endif } -void SirenStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->state); +void SirenStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_bool(1, this->state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool SirenCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1578,21 +1572,21 @@ void ListEntitiesLockResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(12, this->device_id); #endif } -void ListEntitiesLockResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesLockResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_bool_field(total_size, 1, this->assumed_state); - ProtoSize::add_bool_field(total_size, 1, this->supports_open); - ProtoSize::add_bool_field(total_size, 1, this->requires_code); - ProtoSize::add_string_field(total_size, 1, this->code_format_ref_.size()); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); + size.add_bool(1, this->assumed_state); + size.add_bool(1, this->supports_open); + size.add_bool(1, this->requires_code); + size.add_length(1, this->code_format_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void LockStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1602,11 +1596,11 @@ void LockStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->device_id); #endif } -void LockStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->state)); +void LockStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_uint32(1, static_cast(this->state)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool LockCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1663,18 +1657,18 @@ void ListEntitiesButtonResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(9, this->device_id); #endif } -void ListEntitiesButtonResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesButtonResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); + size.add_length(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool ButtonCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1708,12 +1702,12 @@ void MediaPlayerSupportedFormat::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, static_cast(this->purpose)); buffer.encode_uint32(5, this->sample_bytes); } -void MediaPlayerSupportedFormat::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->format_ref_.size()); - ProtoSize::add_uint32_field(total_size, 1, this->sample_rate); - ProtoSize::add_uint32_field(total_size, 1, this->num_channels); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->purpose)); - ProtoSize::add_uint32_field(total_size, 1, this->sample_bytes); +void MediaPlayerSupportedFormat::calculate_size(ProtoSize &size) const { + size.add_length(1, this->format_ref_.size()); + size.add_uint32(1, this->sample_rate); + size.add_uint32(1, this->num_channels); + size.add_uint32(1, static_cast(this->purpose)); + size.add_uint32(1, this->sample_bytes); } void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->object_id_ref_); @@ -1732,19 +1726,19 @@ void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(10, this->device_id); #endif } -void ListEntitiesMediaPlayerResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesMediaPlayerResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_bool_field(total_size, 1, this->supports_pause); - ProtoSize::add_repeated_message(total_size, 1, this->supported_formats); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); + size.add_bool(1, this->supports_pause); + size.add_repeated_message(1, this->supported_formats); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void MediaPlayerStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -1756,13 +1750,13 @@ void MediaPlayerStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(5, this->device_id); #endif } -void MediaPlayerStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->state)); - ProtoSize::add_float_field(total_size, 1, this->volume); - ProtoSize::add_bool_field(total_size, 1, this->muted); +void MediaPlayerStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_uint32(1, static_cast(this->state)); + size.add_float(1, this->volume); + size.add_bool(1, this->muted); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -1836,21 +1830,19 @@ void BluetoothLERawAdvertisement::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->address_type); buffer.encode_bytes(4, this->data, this->data_len); } -void BluetoothLERawAdvertisement::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_sint32_field(total_size, 1, this->rssi); - ProtoSize::add_uint32_field(total_size, 1, this->address_type); - if (this->data_len != 0) { - total_size += 1 + ProtoSize::varint(static_cast(this->data_len)) + this->data_len; - } +void BluetoothLERawAdvertisement::calculate_size(ProtoSize &size) const { + size.add_uint64(1, this->address); + size.add_sint32(1, this->rssi); + size.add_uint32(1, this->address_type); + size.add_length(1, this->data_len); } void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->advertisements) { buffer.encode_message(1, it, true); } } -void BluetoothLERawAdvertisementsResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_repeated_message(total_size, 1, this->advertisements); +void BluetoothLERawAdvertisementsResponse::calculate_size(ProtoSize &size) const { + size.add_repeated_message(1, this->advertisements); } bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1877,11 +1869,11 @@ void BluetoothDeviceConnectionResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->mtu); buffer.encode_int32(4, this->error); } -void BluetoothDeviceConnectionResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_bool_field(total_size, 1, this->connected); - ProtoSize::add_uint32_field(total_size, 1, this->mtu); - ProtoSize::add_int32_field(total_size, 1, this->error); +void BluetoothDeviceConnectionResponse::calculate_size(ProtoSize &size) const { + size.add_uint64(1, this->address); + size.add_bool(1, this->connected); + size.add_uint32(1, this->mtu); + size.add_int32(1, this->error); } bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1898,10 +1890,10 @@ void BluetoothGATTDescriptor::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->uuid[1], true); buffer.encode_uint32(2, this->handle); } -void BluetoothGATTDescriptor::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field_repeated(total_size, 1, this->uuid[0]); - ProtoSize::add_uint64_field_repeated(total_size, 1, this->uuid[1]); - ProtoSize::add_uint32_field(total_size, 1, this->handle); +void BluetoothGATTDescriptor::calculate_size(ProtoSize &size) const { + size.add_uint64_force(1, this->uuid[0]); + size.add_uint64_force(1, this->uuid[1]); + size.add_uint32(1, this->handle); } void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->uuid[0], true); @@ -1912,12 +1904,12 @@ void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { buffer.encode_message(4, it, true); } } -void BluetoothGATTCharacteristic::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field_repeated(total_size, 1, this->uuid[0]); - ProtoSize::add_uint64_field_repeated(total_size, 1, this->uuid[1]); - ProtoSize::add_uint32_field(total_size, 1, this->handle); - ProtoSize::add_uint32_field(total_size, 1, this->properties); - ProtoSize::add_repeated_message(total_size, 1, this->descriptors); +void BluetoothGATTCharacteristic::calculate_size(ProtoSize &size) const { + size.add_uint64_force(1, this->uuid[0]); + size.add_uint64_force(1, this->uuid[1]); + size.add_uint32(1, this->handle); + size.add_uint32(1, this->properties); + size.add_repeated_message(1, this->descriptors); } void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->uuid[0], true); @@ -1927,26 +1919,24 @@ void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { buffer.encode_message(3, it, true); } } -void BluetoothGATTService::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field_repeated(total_size, 1, this->uuid[0]); - ProtoSize::add_uint64_field_repeated(total_size, 1, this->uuid[1]); - ProtoSize::add_uint32_field(total_size, 1, this->handle); - ProtoSize::add_repeated_message(total_size, 1, this->characteristics); +void BluetoothGATTService::calculate_size(ProtoSize &size) const { + size.add_uint64_force(1, this->uuid[0]); + size.add_uint64_force(1, this->uuid[1]); + size.add_uint32(1, this->handle); + size.add_repeated_message(1, this->characteristics); } void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_message(2, this->services[0], true); } -void BluetoothGATTGetServicesResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_message_object_repeated(total_size, 1, this->services[0]); +void BluetoothGATTGetServicesResponse::calculate_size(ProtoSize &size) const { + size.add_uint64(1, this->address); + size.add_message_object_force(1, this->services[0]); } void BluetoothGATTGetServicesDoneResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); } -void BluetoothGATTGetServicesDoneResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); -} +void BluetoothGATTGetServicesDoneResponse::calculate_size(ProtoSize &size) const { size.add_uint64(1, this->address); } bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { case 1: @@ -1965,10 +1955,10 @@ void BluetoothGATTReadResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->handle); buffer.encode_bytes(3, this->data_ptr_, this->data_len_); } -void BluetoothGATTReadResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_uint32_field(total_size, 1, this->handle); - ProtoSize::add_bytes_field(total_size, 1, this->data_len_); +void BluetoothGATTReadResponse::calculate_size(ProtoSize &size) const { + size.add_uint64(1, this->address); + size.add_uint32(1, this->handle); + size.add_length(1, this->data_len_); } bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2053,10 +2043,10 @@ void BluetoothGATTNotifyDataResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->handle); buffer.encode_bytes(3, this->data_ptr_, this->data_len_); } -void BluetoothGATTNotifyDataResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_uint32_field(total_size, 1, this->handle); - ProtoSize::add_bytes_field(total_size, 1, this->data_len_); +void BluetoothGATTNotifyDataResponse::calculate_size(ProtoSize &size) const { + size.add_uint64(1, this->address); + size.add_uint32(1, this->handle); + size.add_length(1, this->data_len_); } void BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->free); @@ -2065,12 +2055,12 @@ void BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(3, it, true); } } -void BluetoothConnectionsFreeResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint32_field(total_size, 1, this->free); - ProtoSize::add_uint32_field(total_size, 1, this->limit); +void BluetoothConnectionsFreeResponse::calculate_size(ProtoSize &size) const { + size.add_uint32(1, this->free); + size.add_uint32(1, this->limit); if (!this->allocated.empty()) { for (const auto &it : this->allocated) { - ProtoSize::add_uint64_field_repeated(total_size, 1, it); + size.add_uint64_force(1, it); } } } @@ -2079,64 +2069,64 @@ void BluetoothGATTErrorResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->handle); buffer.encode_int32(3, this->error); } -void BluetoothGATTErrorResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_uint32_field(total_size, 1, this->handle); - ProtoSize::add_int32_field(total_size, 1, this->error); +void BluetoothGATTErrorResponse::calculate_size(ProtoSize &size) const { + size.add_uint64(1, this->address); + size.add_uint32(1, this->handle); + size.add_int32(1, this->error); } void BluetoothGATTWriteResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_uint32(2, this->handle); } -void BluetoothGATTWriteResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_uint32_field(total_size, 1, this->handle); +void BluetoothGATTWriteResponse::calculate_size(ProtoSize &size) const { + size.add_uint64(1, this->address); + size.add_uint32(1, this->handle); } void BluetoothGATTNotifyResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_uint32(2, this->handle); } -void BluetoothGATTNotifyResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_uint32_field(total_size, 1, this->handle); +void BluetoothGATTNotifyResponse::calculate_size(ProtoSize &size) const { + size.add_uint64(1, this->address); + size.add_uint32(1, this->handle); } void BluetoothDevicePairingResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_bool(2, this->paired); buffer.encode_int32(3, this->error); } -void BluetoothDevicePairingResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_bool_field(total_size, 1, this->paired); - ProtoSize::add_int32_field(total_size, 1, this->error); +void BluetoothDevicePairingResponse::calculate_size(ProtoSize &size) const { + size.add_uint64(1, this->address); + size.add_bool(1, this->paired); + size.add_int32(1, this->error); } void BluetoothDeviceUnpairingResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_bool(2, this->success); buffer.encode_int32(3, this->error); } -void BluetoothDeviceUnpairingResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_bool_field(total_size, 1, this->success); - ProtoSize::add_int32_field(total_size, 1, this->error); +void BluetoothDeviceUnpairingResponse::calculate_size(ProtoSize &size) const { + size.add_uint64(1, this->address); + size.add_bool(1, this->success); + size.add_int32(1, this->error); } void BluetoothDeviceClearCacheResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); buffer.encode_bool(2, this->success); buffer.encode_int32(3, this->error); } -void BluetoothDeviceClearCacheResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint64_field(total_size, 1, this->address); - ProtoSize::add_bool_field(total_size, 1, this->success); - ProtoSize::add_int32_field(total_size, 1, this->error); +void BluetoothDeviceClearCacheResponse::calculate_size(ProtoSize &size) const { + size.add_uint64(1, this->address); + size.add_bool(1, this->success); + size.add_int32(1, this->error); } void BluetoothScannerStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, static_cast(this->state)); buffer.encode_uint32(2, static_cast(this->mode)); } -void BluetoothScannerStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_enum_field(total_size, 1, static_cast(this->state)); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->mode)); +void BluetoothScannerStateResponse::calculate_size(ProtoSize &size) const { + size.add_uint32(1, static_cast(this->state)); + size.add_uint32(1, static_cast(this->mode)); } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2168,10 +2158,10 @@ void VoiceAssistantAudioSettings::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->auto_gain); buffer.encode_float(3, this->volume_multiplier); } -void VoiceAssistantAudioSettings::calculate_size(uint32_t &total_size) const { - ProtoSize::add_uint32_field(total_size, 1, this->noise_suppression_level); - ProtoSize::add_uint32_field(total_size, 1, this->auto_gain); - ProtoSize::add_float_field(total_size, 1, this->volume_multiplier); +void VoiceAssistantAudioSettings::calculate_size(ProtoSize &size) const { + size.add_uint32(1, this->noise_suppression_level); + size.add_uint32(1, this->auto_gain); + size.add_float(1, this->volume_multiplier); } void VoiceAssistantRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->start); @@ -2180,12 +2170,12 @@ void VoiceAssistantRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_message(4, this->audio_settings); buffer.encode_string(5, this->wake_word_phrase_ref_); } -void VoiceAssistantRequest::calculate_size(uint32_t &total_size) const { - ProtoSize::add_bool_field(total_size, 1, this->start); - ProtoSize::add_string_field(total_size, 1, this->conversation_id_ref_.size()); - ProtoSize::add_uint32_field(total_size, 1, this->flags); - ProtoSize::add_message_object(total_size, 1, this->audio_settings); - ProtoSize::add_string_field(total_size, 1, this->wake_word_phrase_ref_.size()); +void VoiceAssistantRequest::calculate_size(ProtoSize &size) const { + size.add_bool(1, this->start); + size.add_length(1, this->conversation_id_ref_.size()); + size.add_uint32(1, this->flags); + size.add_message_object(1, this->audio_settings); + size.add_length(1, this->wake_word_phrase_ref_.size()); } bool VoiceAssistantResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2258,9 +2248,9 @@ void VoiceAssistantAudio::encode(ProtoWriteBuffer buffer) const { buffer.encode_bytes(1, this->data_ptr_, this->data_len_); buffer.encode_bool(2, this->end); } -void VoiceAssistantAudio::calculate_size(uint32_t &total_size) const { - ProtoSize::add_bytes_field(total_size, 1, this->data_len_); - ProtoSize::add_bool_field(total_size, 1, this->end); +void VoiceAssistantAudio::calculate_size(ProtoSize &size) const { + size.add_length(1, this->data_len_); + size.add_bool(1, this->end); } bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -2321,9 +2311,7 @@ bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLength return true; } void VoiceAssistantAnnounceFinished::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->success); } -void VoiceAssistantAnnounceFinished::calculate_size(uint32_t &total_size) const { - ProtoSize::add_bool_field(total_size, 1, this->success); -} +void VoiceAssistantAnnounceFinished::calculate_size(ProtoSize &size) const { size.add_bool(1, this->success); } void VoiceAssistantWakeWord::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->id_ref_); buffer.encode_string(2, this->wake_word_ref_); @@ -2331,12 +2319,12 @@ void VoiceAssistantWakeWord::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(3, it, true); } } -void VoiceAssistantWakeWord::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->id_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->wake_word_ref_.size()); +void VoiceAssistantWakeWord::calculate_size(ProtoSize &size) const { + size.add_length(1, this->id_ref_.size()); + size.add_length(1, this->wake_word_ref_.size()); if (!this->trained_languages.empty()) { for (const auto &it : this->trained_languages) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_length_force(1, it.size()); } } } @@ -2349,14 +2337,14 @@ void VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer buffer) const } buffer.encode_uint32(3, this->max_active_wake_words); } -void VoiceAssistantConfigurationResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_repeated_message(total_size, 1, this->available_wake_words); +void VoiceAssistantConfigurationResponse::calculate_size(ProtoSize &size) const { + size.add_repeated_message(1, this->available_wake_words); if (!this->active_wake_words.empty()) { for (const auto &it : this->active_wake_words) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_length_force(1, it.size()); } } - ProtoSize::add_uint32_field(total_size, 1, this->max_active_wake_words); + size.add_uint32(1, this->max_active_wake_words); } bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { @@ -2386,20 +2374,20 @@ void ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer buffer) cons buffer.encode_uint32(11, this->device_id); #endif } -void ListEntitiesAlarmControlPanelResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesAlarmControlPanelResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_uint32_field(total_size, 1, this->supported_features); - ProtoSize::add_bool_field(total_size, 1, this->requires_code); - ProtoSize::add_bool_field(total_size, 1, this->requires_code_to_arm); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); + size.add_uint32(1, this->supported_features); + size.add_bool(1, this->requires_code); + size.add_bool(1, this->requires_code_to_arm); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void AlarmControlPanelStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2409,11 +2397,11 @@ void AlarmControlPanelStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->device_id); #endif } -void AlarmControlPanelStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->state)); +void AlarmControlPanelStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_uint32(1, static_cast(this->state)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2470,21 +2458,21 @@ void ListEntitiesTextResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(12, this->device_id); #endif } -void ListEntitiesTextResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesTextResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_uint32_field(total_size, 1, this->min_length); - ProtoSize::add_uint32_field(total_size, 1, this->max_length); - ProtoSize::add_string_field(total_size, 1, this->pattern_ref_.size()); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->mode)); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); + size.add_uint32(1, this->min_length); + size.add_uint32(1, this->max_length); + size.add_length(1, this->pattern_ref_.size()); + size.add_uint32(1, static_cast(this->mode)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void TextStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2495,12 +2483,12 @@ void TextStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void TextStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->state_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); +void TextStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_length(1, this->state_ref_.size()); + size.add_bool(1, this->missing_state); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool TextCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2550,17 +2538,17 @@ void ListEntitiesDateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(8, this->device_id); #endif } -void ListEntitiesDateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesDateResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void DateStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2573,14 +2561,14 @@ void DateStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(6, this->device_id); #endif } -void DateStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); - ProtoSize::add_uint32_field(total_size, 1, this->year); - ProtoSize::add_uint32_field(total_size, 1, this->month); - ProtoSize::add_uint32_field(total_size, 1, this->day); +void DateStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_bool(1, this->missing_state); + size.add_uint32(1, this->year); + size.add_uint32(1, this->month); + size.add_uint32(1, this->day); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool DateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2629,17 +2617,17 @@ void ListEntitiesTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(8, this->device_id); #endif } -void ListEntitiesTimeResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesTimeResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void TimeStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2652,14 +2640,14 @@ void TimeStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(6, this->device_id); #endif } -void TimeStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); - ProtoSize::add_uint32_field(total_size, 1, this->hour); - ProtoSize::add_uint32_field(total_size, 1, this->minute); - ProtoSize::add_uint32_field(total_size, 1, this->second); +void TimeStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_bool(1, this->missing_state); + size.add_uint32(1, this->hour); + size.add_uint32(1, this->minute); + size.add_uint32(1, this->second); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool TimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2712,23 +2700,23 @@ void ListEntitiesEventResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(10, this->device_id); #endif } -void ListEntitiesEventResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesEventResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); + size.add_length(1, this->device_class_ref_.size()); if (!this->event_types.empty()) { for (const auto &it : this->event_types) { - ProtoSize::add_string_field_repeated(total_size, 1, it); + size.add_length_force(1, it.size()); } } #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void EventResponse::encode(ProtoWriteBuffer buffer) const { @@ -2738,11 +2726,11 @@ void EventResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(3, this->device_id); #endif } -void EventResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->event_type_ref_.size()); +void EventResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_length(1, this->event_type_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } #endif @@ -2764,21 +2752,21 @@ void ListEntitiesValveResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(12, this->device_id); #endif } -void ListEntitiesValveResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesValveResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); - ProtoSize::add_bool_field(total_size, 1, this->assumed_state); - ProtoSize::add_bool_field(total_size, 1, this->supports_position); - ProtoSize::add_bool_field(total_size, 1, this->supports_stop); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); + size.add_length(1, this->device_class_ref_.size()); + size.add_bool(1, this->assumed_state); + size.add_bool(1, this->supports_position); + size.add_bool(1, this->supports_stop); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void ValveStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2789,12 +2777,12 @@ void ValveStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void ValveStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_float_field(total_size, 1, this->position); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->current_operation)); +void ValveStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_float(1, this->position); + size.add_uint32(1, static_cast(this->current_operation)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool ValveCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2843,17 +2831,17 @@ void ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(8, this->device_id); #endif } -void ListEntitiesDateTimeResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesDateTimeResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void DateTimeStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2864,12 +2852,12 @@ void DateTimeStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->device_id); #endif } -void DateTimeStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); - ProtoSize::add_fixed32_field(total_size, 1, this->epoch_seconds); +void DateTimeStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_bool(1, this->missing_state); + size.add_fixed32(1, this->epoch_seconds); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool DateTimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { @@ -2913,18 +2901,18 @@ void ListEntitiesUpdateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(9, this->device_id); #endif } -void ListEntitiesUpdateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_string_field(total_size, 1, this->object_id_ref_.size()); - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_string_field(total_size, 1, this->name_ref_.size()); +void ListEntitiesUpdateResponse::calculate_size(ProtoSize &size) const { + size.add_length(1, this->object_id_ref_.size()); + size.add_fixed32(1, this->key); + size.add_length(1, this->name_ref_.size()); #ifdef USE_ENTITY_ICON - ProtoSize::add_string_field(total_size, 1, this->icon_ref_.size()); + size.add_length(1, this->icon_ref_.size()); #endif - ProtoSize::add_bool_field(total_size, 1, this->disabled_by_default); - ProtoSize::add_enum_field(total_size, 1, static_cast(this->entity_category)); - ProtoSize::add_string_field(total_size, 1, this->device_class_ref_.size()); + size.add_bool(1, this->disabled_by_default); + size.add_uint32(1, static_cast(this->entity_category)); + size.add_length(1, this->device_class_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } void UpdateStateResponse::encode(ProtoWriteBuffer buffer) const { @@ -2942,19 +2930,19 @@ void UpdateStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(11, this->device_id); #endif } -void UpdateStateResponse::calculate_size(uint32_t &total_size) const { - ProtoSize::add_fixed32_field(total_size, 1, this->key); - ProtoSize::add_bool_field(total_size, 1, this->missing_state); - ProtoSize::add_bool_field(total_size, 1, this->in_progress); - ProtoSize::add_bool_field(total_size, 1, this->has_progress); - ProtoSize::add_float_field(total_size, 1, this->progress); - ProtoSize::add_string_field(total_size, 1, this->current_version_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->latest_version_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->title_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->release_summary_ref_.size()); - ProtoSize::add_string_field(total_size, 1, this->release_url_ref_.size()); +void UpdateStateResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->key); + size.add_bool(1, this->missing_state); + size.add_bool(1, this->in_progress); + size.add_bool(1, this->has_progress); + size.add_float(1, this->progress); + size.add_length(1, this->current_version_ref_.size()); + size.add_length(1, this->latest_version_ref_.size()); + size.add_length(1, this->title_ref_.size()); + size.add_length(1, this->release_summary_ref_.size()); + size.add_length(1, this->release_url_ref_.size()); #ifdef USE_DEVICES - ProtoSize::add_uint32_field(total_size, 1, this->device_id); + size.add_uint32(1, this->device_id); #endif } bool UpdateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 47bdaead23..f637e44df3 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -340,7 +340,7 @@ class HelloResponse : public ProtoMessage { StringRef name_ref_{}; void set_name(const StringRef &ref) { this->name_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -371,7 +371,7 @@ class ConnectResponse : public ProtoMessage { #endif bool invalid_password{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -450,7 +450,7 @@ class AreaInfo : public ProtoMessage { StringRef name_ref_{}; void set_name(const StringRef &ref) { this->name_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -466,7 +466,7 @@ class DeviceInfo : public ProtoMessage { void set_name(const StringRef &ref) { this->name_ref_ = ref; } uint32_t area_id{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -539,7 +539,7 @@ class DeviceInfoResponse : public ProtoMessage { AreaInfo area{}; #endif void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -597,7 +597,7 @@ class ListEntitiesBinarySensorResponse : public InfoResponseProtoMessage { void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } bool is_status_binary_sensor{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -614,7 +614,7 @@ class BinarySensorStateResponse : public StateResponseProtoMessage { bool state{false}; bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -637,7 +637,7 @@ class ListEntitiesCoverResponse : public InfoResponseProtoMessage { void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } bool supports_stop{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -655,7 +655,7 @@ class CoverStateResponse : public StateResponseProtoMessage { float tilt{0.0f}; enums::CoverOperation current_operation{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -697,7 +697,7 @@ class ListEntitiesFanResponse : public InfoResponseProtoMessage { int32_t supported_speed_count{0}; std::vector supported_preset_modes{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -718,7 +718,7 @@ class FanStateResponse : public StateResponseProtoMessage { StringRef preset_mode_ref_{}; void set_preset_mode(const StringRef &ref) { this->preset_mode_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -765,7 +765,7 @@ class ListEntitiesLightResponse : public InfoResponseProtoMessage { float max_mireds{0.0f}; std::vector effects{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -793,7 +793,7 @@ class LightStateResponse : public StateResponseProtoMessage { StringRef effect_ref_{}; void set_effect(const StringRef &ref) { this->effect_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -859,7 +859,7 @@ class ListEntitiesSensorResponse : public InfoResponseProtoMessage { void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } enums::SensorStateClass state_class{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -876,7 +876,7 @@ class SensorStateResponse : public StateResponseProtoMessage { float state{0.0f}; bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -896,7 +896,7 @@ class ListEntitiesSwitchResponse : public InfoResponseProtoMessage { StringRef device_class_ref_{}; void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -912,7 +912,7 @@ class SwitchStateResponse : public StateResponseProtoMessage { #endif bool state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -947,7 +947,7 @@ class ListEntitiesTextSensorResponse : public InfoResponseProtoMessage { StringRef device_class_ref_{}; void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -965,7 +965,7 @@ class TextSensorStateResponse : public StateResponseProtoMessage { void set_state(const StringRef &ref) { this->state_ref_ = ref; } bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1004,7 +1004,7 @@ class SubscribeLogsResponse : public ProtoMessage { this->message_len_ = len; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1036,7 +1036,7 @@ class NoiseEncryptionSetKeyResponse : public ProtoMessage { #endif bool success{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1064,7 +1064,7 @@ class HomeassistantServiceMap : public ProtoMessage { void set_key(const StringRef &ref) { this->key_ref_ = ref; } std::string value{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1085,7 +1085,7 @@ class HomeassistantServiceResponse : public ProtoMessage { std::vector variables{}; bool is_event{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1120,7 +1120,7 @@ class SubscribeHomeAssistantStateResponse : public ProtoMessage { void set_attribute(const StringRef &ref) { this->attribute_ref_ = ref; } bool once{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1167,7 +1167,7 @@ class GetTimeResponse : public ProtoDecodableMessage { #endif uint32_t epoch_seconds{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1182,7 +1182,7 @@ class ListEntitiesServicesArgument : public ProtoMessage { void set_name(const StringRef &ref) { this->name_ref_ = ref; } enums::ServiceArgType type{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1201,7 +1201,7 @@ class ListEntitiesServicesResponse : public ProtoMessage { uint32_t key{0}; std::vector args{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1255,7 +1255,7 @@ class ListEntitiesCameraResponse : public InfoResponseProtoMessage { const char *message_name() const override { return "list_entities_camera_response"; } #endif void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1277,7 +1277,7 @@ class CameraImageResponse : public StateResponseProtoMessage { } bool done{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1327,7 +1327,7 @@ class ListEntitiesClimateResponse : public InfoResponseProtoMessage { float visual_min_humidity{0.0f}; float visual_max_humidity{0.0f}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1357,7 +1357,7 @@ class ClimateStateResponse : public StateResponseProtoMessage { float current_humidity{0.0f}; float target_humidity{0.0f}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1418,7 +1418,7 @@ class ListEntitiesNumberResponse : public InfoResponseProtoMessage { StringRef device_class_ref_{}; void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1435,7 +1435,7 @@ class NumberStateResponse : public StateResponseProtoMessage { float state{0.0f}; bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1469,7 +1469,7 @@ class ListEntitiesSelectResponse : public InfoResponseProtoMessage { #endif std::vector options{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1487,7 +1487,7 @@ class SelectStateResponse : public StateResponseProtoMessage { void set_state(const StringRef &ref) { this->state_ref_ = ref; } bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1524,7 +1524,7 @@ class ListEntitiesSirenResponse : public InfoResponseProtoMessage { bool supports_duration{false}; bool supports_volume{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1540,7 +1540,7 @@ class SirenStateResponse : public StateResponseProtoMessage { #endif bool state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1586,7 +1586,7 @@ class ListEntitiesLockResponse : public InfoResponseProtoMessage { StringRef code_format_ref_{}; void set_code_format(const StringRef &ref) { this->code_format_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1602,7 +1602,7 @@ class LockStateResponse : public StateResponseProtoMessage { #endif enums::LockState state{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1640,7 +1640,7 @@ class ListEntitiesButtonResponse : public InfoResponseProtoMessage { StringRef device_class_ref_{}; void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1673,7 +1673,7 @@ class MediaPlayerSupportedFormat : public ProtoMessage { enums::MediaPlayerFormatPurpose purpose{}; uint32_t sample_bytes{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1690,7 +1690,7 @@ class ListEntitiesMediaPlayerResponse : public InfoResponseProtoMessage { bool supports_pause{false}; std::vector supported_formats{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1708,7 +1708,7 @@ class MediaPlayerStateResponse : public StateResponseProtoMessage { float volume{0.0f}; bool muted{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1764,7 +1764,7 @@ class BluetoothLERawAdvertisement : public ProtoMessage { uint8_t data[62]{}; uint8_t data_len{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1780,7 +1780,7 @@ class BluetoothLERawAdvertisementsResponse : public ProtoMessage { #endif std::vector advertisements{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1817,7 +1817,7 @@ class BluetoothDeviceConnectionResponse : public ProtoMessage { uint32_t mtu{0}; int32_t error{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1844,7 +1844,7 @@ class BluetoothGATTDescriptor : public ProtoMessage { std::array uuid{}; uint32_t handle{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1858,7 +1858,7 @@ class BluetoothGATTCharacteristic : public ProtoMessage { uint32_t properties{0}; std::vector descriptors{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1871,7 +1871,7 @@ class BluetoothGATTService : public ProtoMessage { uint32_t handle{0}; std::vector characteristics{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1888,7 +1888,7 @@ class BluetoothGATTGetServicesResponse : public ProtoMessage { uint64_t address{0}; std::array services{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1904,7 +1904,7 @@ class BluetoothGATTGetServicesDoneResponse : public ProtoMessage { #endif uint64_t address{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1943,7 +1943,7 @@ class BluetoothGATTReadResponse : public ProtoMessage { this->data_len_ = len; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2036,7 +2036,7 @@ class BluetoothGATTNotifyDataResponse : public ProtoMessage { this->data_len_ = len; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2067,7 +2067,7 @@ class BluetoothConnectionsFreeResponse : public ProtoMessage { uint32_t limit{0}; std::vector allocated{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2085,7 +2085,7 @@ class BluetoothGATTErrorResponse : public ProtoMessage { uint32_t handle{0}; int32_t error{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2102,7 +2102,7 @@ class BluetoothGATTWriteResponse : public ProtoMessage { uint64_t address{0}; uint32_t handle{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2119,7 +2119,7 @@ class BluetoothGATTNotifyResponse : public ProtoMessage { uint64_t address{0}; uint32_t handle{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2137,7 +2137,7 @@ class BluetoothDevicePairingResponse : public ProtoMessage { bool paired{false}; int32_t error{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2155,7 +2155,7 @@ class BluetoothDeviceUnpairingResponse : public ProtoMessage { bool success{false}; int32_t error{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2186,7 +2186,7 @@ class BluetoothDeviceClearCacheResponse : public ProtoMessage { bool success{false}; int32_t error{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2203,7 +2203,7 @@ class BluetoothScannerStateResponse : public ProtoMessage { enums::BluetoothScannerState state{}; enums::BluetoothScannerMode mode{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2249,7 +2249,7 @@ class VoiceAssistantAudioSettings : public ProtoMessage { uint32_t auto_gain{0}; float volume_multiplier{0.0f}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2271,7 +2271,7 @@ class VoiceAssistantRequest : public ProtoMessage { StringRef wake_word_phrase_ref_{}; void set_wake_word_phrase(const StringRef &ref) { this->wake_word_phrase_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2338,7 +2338,7 @@ class VoiceAssistantAudio : public ProtoDecodableMessage { } bool end{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2396,7 +2396,7 @@ class VoiceAssistantAnnounceFinished : public ProtoMessage { #endif bool success{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2411,7 +2411,7 @@ class VoiceAssistantWakeWord : public ProtoMessage { void set_wake_word(const StringRef &ref) { this->wake_word_ref_ = ref; } std::vector trained_languages{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2442,7 +2442,7 @@ class VoiceAssistantConfigurationResponse : public ProtoMessage { std::vector active_wake_words{}; uint32_t max_active_wake_words{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2477,7 +2477,7 @@ class ListEntitiesAlarmControlPanelResponse : public InfoResponseProtoMessage { bool requires_code{false}; bool requires_code_to_arm{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2493,7 +2493,7 @@ class AlarmControlPanelStateResponse : public StateResponseProtoMessage { #endif enums::AlarmControlPanelState state{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2533,7 +2533,7 @@ class ListEntitiesTextResponse : public InfoResponseProtoMessage { void set_pattern(const StringRef &ref) { this->pattern_ref_ = ref; } enums::TextMode mode{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2551,7 +2551,7 @@ class TextStateResponse : public StateResponseProtoMessage { void set_state(const StringRef &ref) { this->state_ref_ = ref; } bool missing_state{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2585,7 +2585,7 @@ class ListEntitiesDateResponse : public InfoResponseProtoMessage { const char *message_name() const override { return "list_entities_date_response"; } #endif void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2604,7 +2604,7 @@ class DateStateResponse : public StateResponseProtoMessage { uint32_t month{0}; uint32_t day{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2639,7 +2639,7 @@ class ListEntitiesTimeResponse : public InfoResponseProtoMessage { const char *message_name() const override { return "list_entities_time_response"; } #endif void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2658,7 +2658,7 @@ class TimeStateResponse : public StateResponseProtoMessage { uint32_t minute{0}; uint32_t second{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2696,7 +2696,7 @@ class ListEntitiesEventResponse : public InfoResponseProtoMessage { void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } std::vector event_types{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2713,7 +2713,7 @@ class EventResponse : public StateResponseProtoMessage { StringRef event_type_ref_{}; void set_event_type(const StringRef &ref) { this->event_type_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2735,7 +2735,7 @@ class ListEntitiesValveResponse : public InfoResponseProtoMessage { bool supports_position{false}; bool supports_stop{false}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2752,7 +2752,7 @@ class ValveStateResponse : public StateResponseProtoMessage { float position{0.0f}; enums::ValveOperation current_operation{}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2787,7 +2787,7 @@ class ListEntitiesDateTimeResponse : public InfoResponseProtoMessage { const char *message_name() const override { return "list_entities_date_time_response"; } #endif void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2804,7 +2804,7 @@ class DateTimeStateResponse : public StateResponseProtoMessage { bool missing_state{false}; uint32_t epoch_seconds{0}; void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2839,7 +2839,7 @@ class ListEntitiesUpdateResponse : public InfoResponseProtoMessage { StringRef device_class_ref_{}; void set_device_class(const StringRef &ref) { this->device_class_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2868,7 +2868,7 @@ class UpdateStateResponse : public StateResponseProtoMessage { StringRef release_url_ref_{}; void set_release_url(const StringRef &ref) { this->release_url_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(uint32_t &total_size) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index cea93f928f..5c174b679c 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -333,13 +333,16 @@ class ProtoWriteBuffer { std::vector *buffer_; }; +// Forward declaration +class ProtoSize; + class ProtoMessage { public: virtual ~ProtoMessage() = default; // Default implementation for messages with no fields virtual void encode(ProtoWriteBuffer buffer) const {} // Default implementation for messages with no fields - virtual void calculate_size(uint32_t &total_size) const {} + virtual void calculate_size(ProtoSize &size) const {} #ifdef HAS_PROTO_MESSAGE_DUMP std::string dump() const; virtual void dump_to(std::string &out) const = 0; @@ -360,24 +363,32 @@ class ProtoDecodableMessage : public ProtoMessage { }; class ProtoSize { + private: + uint32_t total_size_ = 0; + public: /** * @brief ProtoSize class for Protocol Buffer serialization size calculation * - * This class provides static methods to calculate the exact byte counts needed - * for encoding various Protocol Buffer field types. All methods are designed to be - * efficient for the common case where many fields have default values. + * This class provides methods to calculate the exact byte counts needed + * for encoding various Protocol Buffer field types. The class now uses an + * object-based approach to reduce parameter passing overhead while keeping + * varint calculation methods static for external use. * * Implements Protocol Buffer encoding size calculation according to: * https://protobuf.dev/programming-guides/encoding/ * * Key features: + * - Object-based approach reduces flash usage by eliminating parameter passing * - Early-return optimization for zero/default values - * - Direct total_size updates to avoid unnecessary additions + * - Static varint methods for external callers * - Specialized handling for different field types according to protobuf spec - * - Templated helpers for repeated fields and messages */ + ProtoSize() = default; + + uint32_t get_size() const { return total_size_; } + /** * @brief Calculates the size in bytes needed to encode a uint32_t value as a varint * @@ -478,9 +489,7 @@ class ProtoSize { * @brief Common parameters for all add_*_field methods * * All add_*_field methods follow these common patterns: - * - * @param total_size Reference to the total message size to update - * @param field_id_size Pre-calculated size of the field ID in bytes + * * @param field_id_size Pre-calculated size of the field ID in bytes * @param value The value to calculate size for (type varies) * @param force Whether to calculate size even if the value is default/zero/empty * @@ -493,85 +502,63 @@ class ProtoSize { /** * @brief Calculates and adds the size of an int32 field to the total message size */ - static inline void add_int32_field(uint32_t &total_size, uint32_t field_id_size, int32_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size - } - - // Calculate and directly add to total_size - if (value < 0) { - // Negative values are encoded as 10-byte varints in protobuf - total_size += field_id_size + 10; - } else { - // For non-negative values, use the standard varint size - total_size += field_id_size + varint(static_cast(value)); + inline void add_int32(uint32_t field_id_size, int32_t value) { + if (value != 0) { + add_int32_force(field_id_size, value); } } /** - * @brief Calculates and adds the size of an int32 field to the total message size (repeated field version) + * @brief Calculates and adds the size of an int32 field to the total message size (force version) */ - static inline void add_int32_field_repeated(uint32_t &total_size, uint32_t field_id_size, int32_t value) { - // Always calculate size for repeated fields - if (value < 0) { - // Negative values are encoded as 10-byte varints in protobuf - total_size += field_id_size + 10; - } else { - // For non-negative values, use the standard varint size - total_size += field_id_size + varint(static_cast(value)); - } + inline void add_int32_force(uint32_t field_id_size, int32_t value) { + // Always calculate size when forced + // Negative values are encoded as 10-byte varints in protobuf + total_size_ += field_id_size + (value < 0 ? 10 : varint(static_cast(value))); } /** * @brief Calculates and adds the size of a uint32 field to the total message size */ - static inline void add_uint32_field(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size + inline void add_uint32(uint32_t field_id_size, uint32_t value) { + if (value != 0) { + add_uint32_force(field_id_size, value); } - - // Calculate and directly add to total_size - total_size += field_id_size + varint(value); } /** - * @brief Calculates and adds the size of a uint32 field to the total message size (repeated field version) + * @brief Calculates and adds the size of a uint32 field to the total message size (force version) */ - static inline void add_uint32_field_repeated(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { - // Always calculate size for repeated fields - total_size += field_id_size + varint(value); + inline void add_uint32_force(uint32_t field_id_size, uint32_t value) { + // Always calculate size when force is true + total_size_ += field_id_size + varint(value); } /** * @brief Calculates and adds the size of a boolean field to the total message size */ - static inline void add_bool_field(uint32_t &total_size, uint32_t field_id_size, bool value) { - // Skip calculation if value is false - if (!value) { - return; // No need to update total_size + inline void add_bool(uint32_t field_id_size, bool value) { + if (value) { + // Boolean fields always use 1 byte when true + total_size_ += field_id_size + 1; } - - // Boolean fields always use 1 byte when true - total_size += field_id_size + 1; } /** - * @brief Calculates and adds the size of a boolean field to the total message size (repeated field version) + * @brief Calculates and adds the size of a boolean field to the total message size (force version) */ - static inline void add_bool_field_repeated(uint32_t &total_size, uint32_t field_id_size, bool value) { - // Always calculate size for repeated fields + inline void add_bool_force(uint32_t field_id_size, bool value) { + // Always calculate size when force is true // Boolean fields always use 1 byte - total_size += field_id_size + 1; + total_size_ += field_id_size + 1; } /** * @brief Calculates and adds the size of a float field to the total message size */ - static inline void add_float_field(uint32_t &total_size, uint32_t field_id_size, float value) { + inline void add_float(uint32_t field_id_size, float value) { if (value != 0.0f) { - total_size += field_id_size + 4; + total_size_ += field_id_size + 4; } } @@ -581,9 +568,9 @@ class ProtoSize { /** * @brief Calculates and adds the size of a fixed32 field to the total message size */ - static inline void add_fixed32_field(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { + inline void add_fixed32(uint32_t field_id_size, uint32_t value) { if (value != 0) { - total_size += field_id_size + 4; + total_size_ += field_id_size + 4; } } @@ -593,149 +580,104 @@ class ProtoSize { /** * @brief Calculates and adds the size of a sfixed32 field to the total message size */ - static inline void add_sfixed32_field(uint32_t &total_size, uint32_t field_id_size, int32_t value) { + inline void add_sfixed32(uint32_t field_id_size, int32_t value) { if (value != 0) { - total_size += field_id_size + 4; + total_size_ += field_id_size + 4; } } // NOTE: add_sfixed64_field removed - wire type 1 (64-bit: sfixed64) not supported // to reduce overhead on embedded systems - /** - * @brief Calculates and adds the size of an enum field to the total message size - * - * Enum fields are encoded as uint32 varints. - */ - static inline void add_enum_field(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size - } - - // Enums are encoded as uint32 - total_size += field_id_size + varint(value); - } - - /** - * @brief Calculates and adds the size of an enum field to the total message size (repeated field version) - * - * Enum fields are encoded as uint32 varints. - */ - static inline void add_enum_field_repeated(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { - // Always calculate size for repeated fields - // Enums are encoded as uint32 - total_size += field_id_size + varint(value); - } - /** * @brief Calculates and adds the size of a sint32 field to the total message size * * Sint32 fields use ZigZag encoding, which is more efficient for negative values. */ - static inline void add_sint32_field(uint32_t &total_size, uint32_t field_id_size, int32_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size + inline void add_sint32(uint32_t field_id_size, int32_t value) { + if (value != 0) { + add_sint32_force(field_id_size, value); } - - // ZigZag encoding for sint32: (n << 1) ^ (n >> 31) - uint32_t zigzag = (static_cast(value) << 1) ^ (static_cast(value >> 31)); - total_size += field_id_size + varint(zigzag); } /** - * @brief Calculates and adds the size of a sint32 field to the total message size (repeated field version) + * @brief Calculates and adds the size of a sint32 field to the total message size (force version) * * Sint32 fields use ZigZag encoding, which is more efficient for negative values. */ - static inline void add_sint32_field_repeated(uint32_t &total_size, uint32_t field_id_size, int32_t value) { - // Always calculate size for repeated fields + inline void add_sint32_force(uint32_t field_id_size, int32_t value) { + // Always calculate size when force is true // ZigZag encoding for sint32: (n << 1) ^ (n >> 31) uint32_t zigzag = (static_cast(value) << 1) ^ (static_cast(value >> 31)); - total_size += field_id_size + varint(zigzag); + total_size_ += field_id_size + varint(zigzag); } /** * @brief Calculates and adds the size of an int64 field to the total message size */ - static inline void add_int64_field(uint32_t &total_size, uint32_t field_id_size, int64_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size + inline void add_int64(uint32_t field_id_size, int64_t value) { + if (value != 0) { + add_int64_force(field_id_size, value); } - - // Calculate and directly add to total_size - total_size += field_id_size + varint(value); } /** - * @brief Calculates and adds the size of an int64 field to the total message size (repeated field version) + * @brief Calculates and adds the size of an int64 field to the total message size (force version) */ - static inline void add_int64_field_repeated(uint32_t &total_size, uint32_t field_id_size, int64_t value) { - // Always calculate size for repeated fields - total_size += field_id_size + varint(value); + inline void add_int64_force(uint32_t field_id_size, int64_t value) { + // Always calculate size when force is true + total_size_ += field_id_size + varint(value); } /** * @brief Calculates and adds the size of a uint64 field to the total message size */ - static inline void add_uint64_field(uint32_t &total_size, uint32_t field_id_size, uint64_t value) { - // Skip calculation if value is zero - if (value == 0) { - return; // No need to update total_size + inline void add_uint64(uint32_t field_id_size, uint64_t value) { + if (value != 0) { + add_uint64_force(field_id_size, value); } - - // Calculate and directly add to total_size - total_size += field_id_size + varint(value); } /** - * @brief Calculates and adds the size of a uint64 field to the total message size (repeated field version) + * @brief Calculates and adds the size of a uint64 field to the total message size (force version) */ - static inline void add_uint64_field_repeated(uint32_t &total_size, uint32_t field_id_size, uint64_t value) { - // Always calculate size for repeated fields - total_size += field_id_size + varint(value); + inline void add_uint64_force(uint32_t field_id_size, uint64_t value) { + // Always calculate size when force is true + total_size_ += field_id_size + varint(value); } - // NOTE: sint64 support functions (add_sint64_field, add_sint64_field_repeated) removed + // NOTE: sint64 support functions (add_sint64_field, add_sint64_field_force) removed // sint64 type is not supported by ESPHome API to reduce overhead on embedded systems /** - * @brief Calculates and adds the size of a string field using length + * @brief Calculates and adds the size of a length-delimited field (string/bytes) to the total message size */ - static inline void add_string_field(uint32_t &total_size, uint32_t field_id_size, size_t len) { - // Skip calculation if string is empty - if (len == 0) { - return; // No need to update total_size + inline void add_length(uint32_t field_id_size, size_t len) { + if (len != 0) { + add_length_force(field_id_size, len); } - - // Field ID + length varint + string bytes - total_size += field_id_size + varint(static_cast(len)) + static_cast(len); } /** - * @brief Calculates and adds the size of a string/bytes field to the total message size (repeated field version) + * @brief Calculates and adds the size of a length-delimited field (string/bytes) to the total message size (repeated + * field version) */ - static inline void add_string_field_repeated(uint32_t &total_size, uint32_t field_id_size, const std::string &str) { - // Always calculate size for repeated fields - const uint32_t str_size = static_cast(str.size()); - total_size += field_id_size + varint(str_size) + str_size; - } - - /** - * @brief Calculates and adds the size of a bytes field to the total message size - */ - static inline void add_bytes_field(uint32_t &total_size, uint32_t field_id_size, size_t len) { - // Skip calculation if bytes is empty - if (len == 0) { - return; // No need to update total_size - } - + inline void add_length_force(uint32_t field_id_size, size_t len) { + // Always calculate size when force is true // Field ID + length varint + data bytes - total_size += field_id_size + varint(static_cast(len)) + static_cast(len); + total_size_ += field_id_size + varint(static_cast(len)) + static_cast(len); } + /** + * @brief Adds a pre-calculated size directly to the total + * + * This is used when we can calculate the total size by multiplying the number + * of elements by the bytes per element (for repeated fixed-size types like float, fixed32, etc.) + * + * @param size The pre-calculated total size to add + */ + inline void add_precalculated_size(uint32_t size) { total_size_ += size; } + /** * @brief Calculates and adds the size of a nested message field to the total message size * @@ -744,26 +686,21 @@ class ProtoSize { * * @param nested_size The pre-calculated size of the nested message */ - static inline void add_message_field(uint32_t &total_size, uint32_t field_id_size, uint32_t nested_size) { - // Skip calculation if nested message is empty - if (nested_size == 0) { - return; // No need to update total_size + inline void add_message_field(uint32_t field_id_size, uint32_t nested_size) { + if (nested_size != 0) { + add_message_field_force(field_id_size, nested_size); } - - // Calculate and directly add to total_size - // Field ID + length varint + nested message content - total_size += field_id_size + varint(nested_size) + nested_size; } /** - * @brief Calculates and adds the size of a nested message field to the total message size (repeated field version) + * @brief Calculates and adds the size of a nested message field to the total message size (force version) * * @param nested_size The pre-calculated size of the nested message */ - static inline void add_message_field_repeated(uint32_t &total_size, uint32_t field_id_size, uint32_t nested_size) { - // Always calculate size for repeated fields + inline void add_message_field_force(uint32_t field_id_size, uint32_t nested_size) { + // Always calculate size when force is true // Field ID + length varint + nested message content - total_size += field_id_size + varint(nested_size) + nested_size; + total_size_ += field_id_size + varint(nested_size) + nested_size; } /** @@ -775,26 +712,29 @@ class ProtoSize { * * @param message The nested message object */ - static inline void add_message_object(uint32_t &total_size, uint32_t field_id_size, const ProtoMessage &message) { - uint32_t nested_size = 0; - message.calculate_size(nested_size); + inline void add_message_object(uint32_t field_id_size, const ProtoMessage &message) { + // Calculate nested message size by creating a temporary ProtoSize + ProtoSize nested_calc; + message.calculate_size(nested_calc); + uint32_t nested_size = nested_calc.get_size(); // Use the base implementation with the calculated nested_size - add_message_field(total_size, field_id_size, nested_size); + add_message_field(field_id_size, nested_size); } /** - * @brief Calculates and adds the size of a nested message field to the total message size (repeated field version) + * @brief Calculates and adds the size of a nested message field to the total message size (force version) * * @param message The nested message object */ - static inline void add_message_object_repeated(uint32_t &total_size, uint32_t field_id_size, - const ProtoMessage &message) { - uint32_t nested_size = 0; - message.calculate_size(nested_size); + inline void add_message_object_force(uint32_t field_id_size, const ProtoMessage &message) { + // Calculate nested message size by creating a temporary ProtoSize + ProtoSize nested_calc; + message.calculate_size(nested_calc); + uint32_t nested_size = nested_calc.get_size(); // Use the base implementation with the calculated nested_size - add_message_field_repeated(total_size, field_id_size, nested_size); + add_message_field_force(field_id_size, nested_size); } /** @@ -807,16 +747,15 @@ class ProtoSize { * @param messages Vector of message objects */ template - static inline void add_repeated_message(uint32_t &total_size, uint32_t field_id_size, - const std::vector &messages) { + inline void add_repeated_message(uint32_t field_id_size, const std::vector &messages) { // Skip if the vector is empty if (messages.empty()) { return; } - // Use the repeated field version for all messages + // Use the force version for all messages in the repeated field for (const auto &message : messages) { - add_message_object_repeated(total_size, field_id_size, message); + add_message_object_force(field_id_size, message); } } }; @@ -826,8 +765,9 @@ inline void ProtoWriteBuffer::encode_message(uint32_t field_id, const ProtoMessa this->encode_field_raw(field_id, 2); // type 2: Length-delimited message // Calculate the message size first - uint32_t msg_length_bytes = 0; - value.calculate_size(msg_length_bytes); + ProtoSize msg_size; + value.calculate_size(msg_size); + uint32_t msg_length_bytes = msg_size.get_size(); // Calculate how many bytes the length varint needs uint32_t varint_length_bytes = ProtoSize::varint(msg_length_bytes); @@ -876,8 +816,9 @@ class ProtoService { // Optimized method that pre-allocates buffer based on message size bool send_message_(const ProtoMessage &msg, uint8_t message_type) { - uint32_t msg_size = 0; - msg.calculate_size(msg_size); + ProtoSize size; + msg.calculate_size(size); + uint32_t msg_size = size.get_size(); // Create a pre-sized buffer auto buffer = this->create_buffer(msg_size); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index d346b66aef..275c7ffc9e 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -275,13 +275,13 @@ class TypeInfo(ABC): Args: name: Field name force: Whether this is for a repeated field - base_method: Base method name (e.g., "add_int32_field") + base_method: Base method name (e.g., "add_int32") value_expr: Optional value expression (defaults to name) """ field_id_size = self.calculate_field_id_size() - method = f"{base_method}_repeated" if force else base_method + method = f"{base_method}_force" if force else base_method value = value_expr if value_expr else name - return f"ProtoSize::{method}(total_size, {field_id_size}, {value});" + return f"size.{method}({field_id_size}, {value});" @abstractmethod def get_size_calculation(self, name: str, force: bool = False) -> str: @@ -389,7 +389,7 @@ class DoubleType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_double_field(total_size, {field_id_size}, {name});" + return f"size.add_double({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 8 @@ -413,7 +413,7 @@ class FloatType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_float_field(total_size, {field_id_size}, {name});" + return f"size.add_float({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 4 @@ -436,7 +436,7 @@ class Int64Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_int64_field") + return self._get_simple_size_calculation(name, force, "add_int64") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -456,7 +456,7 @@ class UInt64Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_uint64_field") + return self._get_simple_size_calculation(name, force, "add_uint64") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -476,7 +476,7 @@ class Int32Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_int32_field") + return self._get_simple_size_calculation(name, force, "add_int32") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -497,7 +497,7 @@ class Fixed64Type(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_fixed64_field(total_size, {field_id_size}, {name});" + return f"size.add_fixed64({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 8 @@ -521,7 +521,7 @@ class Fixed32Type(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_fixed32_field(total_size, {field_id_size}, {name});" + return f"size.add_fixed32({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 4 @@ -543,7 +543,7 @@ class BoolType(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_bool_field") + return self._get_simple_size_calculation(name, force, "add_bool") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 1 # field ID + 1 byte @@ -657,19 +657,21 @@ class StringType(TypeInfo): # For no_zero_copy, we need to use .size() on the string if no_zero_copy and name != "it": field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_string_field(total_size, {field_id_size}, this->{self.field_name}.size());" - return self._get_simple_size_calculation(name, force, "add_string_field") + return ( + f"size.add_length({field_id_size}, this->{self.field_name}.size());" + ) + return self._get_simple_size_calculation(name, force, "add_length") # Check if this is being called from a repeated field context # In that case, 'name' will be 'it' and we need to use the repeated version if name == "it": - # For repeated fields, we need to use add_string_field_repeated which includes field ID + # For repeated fields, we need to use add_length_force which includes field ID field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_string_field_repeated(total_size, {field_id_size}, it);" + return f"size.add_length_force({field_id_size}, it.size());" # For messages that need encoding, use the StringRef size field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_string_field(total_size, {field_id_size}, this->{self.field_name}_ref_.size());" + return f"size.add_length({field_id_size}, this->{self.field_name}_ref_.size());" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical string @@ -804,7 +806,7 @@ class BytesType(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"ProtoSize::add_bytes_field(total_size, {self.calculate_field_id_size()}, this->{self.field_name}_len_);" + return f"size.add_length({self.calculate_field_id_size()}, this->{self.field_name}_len_);" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes @@ -879,15 +881,11 @@ class FixedArrayBytesType(TypeInfo): field_id_size = self.calculate_field_id_size() if force: - # For repeated fields, always calculate size - return f"total_size += {field_id_size} + ProtoSize::varint(static_cast({length_field})) + {length_field};" + # For repeated fields, always calculate size (no zero check) + return f"size.add_length_force({field_id_size}, {length_field});" else: - # For non-repeated fields, skip if length is 0 (matching encode_string behavior) - return ( - f"if ({length_field} != 0) {{\n" - f" total_size += {field_id_size} + ProtoSize::varint(static_cast({length_field})) + {length_field};\n" - f"}}" - ) + # For non-repeated fields, add_length already checks for zero + return f"size.add_length({field_id_size}, {length_field});" def get_estimated_size(self) -> int: # Estimate based on typical BLE advertisement size @@ -914,7 +912,7 @@ class UInt32Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_uint32_field") + return self._get_simple_size_calculation(name, force, "add_uint32") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -951,7 +949,7 @@ class EnumType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: return self._get_simple_size_calculation( - name, force, "add_enum_field", f"static_cast({name})" + name, force, "add_uint32", f"static_cast({name})" ) def get_estimated_size(self) -> int: @@ -973,7 +971,7 @@ class SFixed32Type(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_sfixed32_field(total_size, {field_id_size}, {name});" + return f"size.add_sfixed32({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 4 @@ -997,7 +995,7 @@ class SFixed64Type(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() - return f"ProtoSize::add_sfixed64_field(total_size, {field_id_size}, {name});" + return f"size.add_sfixed64({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 8 @@ -1020,7 +1018,7 @@ class SInt32Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_sint32_field") + return self._get_simple_size_calculation(name, force, "add_sint32") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -1040,7 +1038,7 @@ class SInt64Type(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_simple_size_calculation(name, force, "add_sint64_field") + return self._get_simple_size_calculation(name, force, "add_sint64") def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -1274,7 +1272,7 @@ class RepeatedTypeInfo(TypeInfo): if isinstance(self._ti, MessageType): # For repeated messages, use the dedicated helper that handles iteration internally field_id_size = self._ti.calculate_field_id_size() - o = f"ProtoSize::add_repeated_message(total_size, {field_id_size}, {name});" + o = f"size.add_repeated_message({field_id_size}, {name});" return o # For other repeated types, use the underlying type's size calculation with force=True @@ -1287,7 +1285,9 @@ class RepeatedTypeInfo(TypeInfo): field_id_size = self._ti.calculate_field_id_size() # Pre-calculate the total bytes per element bytes_per_element = field_id_size + num_bytes - o += f" total_size += {name}.size() * {bytes_per_element};\n" + o += ( + f" size.add_precalculated_size({name}.size() * {bytes_per_element});\n" + ) else: # Other types need the actual value o += f" for (const auto {'' if self._ti_is_bool else '&'}it : {name}) {{\n" @@ -1719,11 +1719,11 @@ def build_message_type( if needs_encode and encode: o = f"void {desc.name}::encode(ProtoWriteBuffer buffer) const {{" if len(encode) == 1 and len(encode[0]) + len(o) + 3 < 120: - o += f" {encode[0]} " + o += f" {encode[0]} }}\n" else: o += "\n" o += indent("\n".join(encode)) + "\n" - o += "}\n" + o += "}\n" cpp += o prot = "void encode(ProtoWriteBuffer buffer) const override;" public_content.append(prot) @@ -1731,17 +1731,17 @@ def build_message_type( # Add calculate_size method only if this message needs encoding and has fields if needs_encode and size_calc: - o = f"void {desc.name}::calculate_size(uint32_t &total_size) const {{" + o = f"void {desc.name}::calculate_size(ProtoSize &size) const {{" # For a single field, just inline it for simplicity if len(size_calc) == 1 and len(size_calc[0]) + len(o) + 3 < 120: - o += f" {size_calc[0]} " + o += f" {size_calc[0]} }}\n" else: # For multiple fields o += "\n" o += indent("\n".join(size_calc)) + "\n" - o += "}\n" + o += "}\n" cpp += o - prot = "void calculate_size(uint32_t &total_size) const override;" + prot = "void calculate_size(ProtoSize &size) const override;" public_content.append(prot) # If no fields to calculate size for or message doesn't need encoding, the default implementation in ProtoMessage will be used From 224ea51cd7a1b5cffa5ca35a0a3c7f9df910f1ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jul 2025 22:17:56 -1000 Subject: [PATCH 0103/1626] zero copy vectors --- esphome/components/api/api.proto | 20 ++--- esphome/components/api/api_connection.cpp | 31 +++----- esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 60 +++++++------- esphome/components/api/api_pb2.h | 23 +++--- esphome/components/api/api_pb2_dump.cpp | 20 ++--- esphome/components/api/api_pb2_includes.h | 30 +++++++ esphome/components/fan/fan_traits.h | 2 +- esphome/components/select/select_traits.cpp | 2 +- esphome/components/select/select_traits.h | 2 +- script/api_protobuf/api_protobuf.py | 88 ++++++++++++++++----- 11 files changed, 175 insertions(+), 104 deletions(-) create mode 100644 esphome/components/api/api_pb2_includes.h diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index e0e1602fcb..e5d9bb38c9 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -419,7 +419,7 @@ message ListEntitiesFanResponse { bool disabled_by_default = 9; string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON"]; EntityCategory entity_category = 11; - repeated string supported_preset_modes = 12; + repeated string supported_preset_modes = 12 [(container_pointer) = "std::set"]; uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"]; } // Deprecated in API version 1.6 - only used in deprecated fields @@ -500,7 +500,7 @@ message ListEntitiesLightResponse { string name = 3; reserved 4; // Deprecated: was string unique_id - repeated ColorMode supported_color_modes = 12; + repeated ColorMode supported_color_modes = 12 [(container_pointer) = "std::set"]; // next four supports_* are for legacy clients, newer clients should use color modes // Deprecated in API version 1.6 bool legacy_supports_brightness = 5 [deprecated=true]; @@ -966,7 +966,7 @@ message ListEntitiesClimateResponse { bool supports_current_temperature = 5; bool supports_two_point_target_temperature = 6; - repeated ClimateMode supported_modes = 7; + repeated ClimateMode supported_modes = 7 [(container_pointer) = "std::set"]; float visual_min_temperature = 8; float visual_max_temperature = 9; float visual_target_temperature_step = 10; @@ -975,11 +975,11 @@ message ListEntitiesClimateResponse { // Deprecated in API version 1.5 bool legacy_supports_away = 11 [deprecated=true]; bool supports_action = 12; - repeated ClimateFanMode supported_fan_modes = 13; - repeated ClimateSwingMode supported_swing_modes = 14; - repeated string supported_custom_fan_modes = 15; - repeated ClimatePreset supported_presets = 16; - repeated string supported_custom_presets = 17; + repeated ClimateFanMode supported_fan_modes = 13 [(container_pointer) = "std::set"]; + repeated ClimateSwingMode supported_swing_modes = 14 [(container_pointer) = "std::set"]; + repeated string supported_custom_fan_modes = 15 [(container_pointer) = "std::set"]; + repeated ClimatePreset supported_presets = 16 [(container_pointer) = "std::set"]; + repeated string supported_custom_presets = 17 [(container_pointer) = "std::set"]; bool disabled_by_default = 18; string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON"]; EntityCategory entity_category = 20; @@ -1119,7 +1119,7 @@ message ListEntitiesSelectResponse { reserved 4; // Deprecated: was string unique_id string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; - repeated string options = 6; + repeated string options = 6 [(container_pointer) = "std::vector"]; bool disabled_by_default = 7; EntityCategory entity_category = 8; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; @@ -1834,7 +1834,7 @@ message VoiceAssistantConfigurationResponse { option (ifdef) = "USE_VOICE_ASSISTANT"; repeated VoiceAssistantWakeWord available_wake_words = 1; - repeated string active_wake_words = 2; + repeated string active_wake_words = 2 [(container_pointer) = "std::vector"]; uint32 max_active_wake_words = 3; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cd27087fe8..5576f915e2 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -413,8 +413,7 @@ uint16_t APIConnection::try_send_fan_info(EntityBase *entity, APIConnection *con msg.supports_speed = traits.supports_speed(); msg.supports_direction = traits.supports_direction(); msg.supported_speed_count = traits.supported_speed_count(); - for (auto const &preset : traits.supported_preset_modes()) - msg.supported_preset_modes.push_back(preset); + msg.supported_preset_modes = &traits.supported_preset_modes(); return fill_and_encode_entity_info(fan, msg, ListEntitiesFanResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } void APIConnection::fan_command(const FanCommandRequest &msg) { @@ -470,8 +469,7 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c auto *light = static_cast(entity); ListEntitiesLightResponse msg; auto traits = light->get_traits(); - for (auto mode : traits.get_supported_color_modes()) - msg.supported_color_modes.push_back(static_cast(mode)); + msg.supported_color_modes = &traits.get_supported_color_modes(); if (traits.supports_color_capability(light::ColorCapability::COLOR_TEMPERATURE) || traits.supports_color_capability(light::ColorCapability::COLD_WARM_WHITE)) { msg.min_mireds = traits.get_min_mireds(); @@ -657,8 +655,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.supports_current_humidity = traits.get_supports_current_humidity(); msg.supports_two_point_target_temperature = traits.get_supports_two_point_target_temperature(); msg.supports_target_humidity = traits.get_supports_target_humidity(); - for (auto mode : traits.get_supported_modes()) - msg.supported_modes.push_back(static_cast(mode)); + msg.supported_modes = &traits.get_supported_modes(); msg.visual_min_temperature = traits.get_visual_min_temperature(); msg.visual_max_temperature = traits.get_visual_max_temperature(); msg.visual_target_temperature_step = traits.get_visual_target_temperature_step(); @@ -666,16 +663,11 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.visual_min_humidity = traits.get_visual_min_humidity(); msg.visual_max_humidity = traits.get_visual_max_humidity(); msg.supports_action = traits.get_supports_action(); - for (auto fan_mode : traits.get_supported_fan_modes()) - msg.supported_fan_modes.push_back(static_cast(fan_mode)); - for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) - msg.supported_custom_fan_modes.push_back(custom_fan_mode); - for (auto preset : traits.get_supported_presets()) - msg.supported_presets.push_back(static_cast(preset)); - for (auto const &custom_preset : traits.get_supported_custom_presets()) - msg.supported_custom_presets.push_back(custom_preset); - for (auto swing_mode : traits.get_supported_swing_modes()) - msg.supported_swing_modes.push_back(static_cast(swing_mode)); + msg.supported_fan_modes = &traits.get_supported_fan_modes(); + msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes(); + msg.supported_presets = &traits.get_supported_presets(); + msg.supported_custom_presets = &traits.get_supported_custom_presets(); + msg.supported_swing_modes = &traits.get_supported_swing_modes(); return fill_and_encode_entity_info(climate, msg, ListEntitiesClimateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -881,8 +873,7 @@ uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection * bool is_single) { auto *select = static_cast(entity); ListEntitiesSelectResponse msg; - for (const auto &option : select->traits.get_options()) - msg.options.push_back(option); + msg.options = &select->traits.get_options(); return fill_and_encode_entity_info(select, msg, ListEntitiesSelectResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -1196,9 +1187,7 @@ bool APIConnection::send_voice_assistant_get_configuration_response(const VoiceA resp_wake_word.trained_languages.push_back(lang); } } - for (auto &wake_word_id : config.active_wake_words) { - resp.active_wake_words.push_back(wake_word_id); - } + resp.active_wake_words = &config.active_wake_words; resp.max_active_wake_words = config.max_active_wake_words; return this->send_message(resp, VoiceAssistantConfigurationResponse::MESSAGE_TYPE); } diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 4f0f52fc6f..e7f65e08b0 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -28,4 +28,5 @@ extend google.protobuf.FieldOptions { optional string field_ifdef = 1042; optional uint32 fixed_array_size = 50007; optional bool no_zero_copy = 50008 [default=false]; + optional string container_pointer = 50001; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index f6f39f901f..990c322d9c 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -331,7 +331,7 @@ void ListEntitiesFanResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(10, this->icon_ref_); #endif buffer.encode_uint32(11, static_cast(this->entity_category)); - for (auto &it : this->supported_preset_modes) { + for (const auto &it : *this->supported_preset_modes) { buffer.encode_string(12, it, true); } #ifdef USE_DEVICES @@ -351,8 +351,8 @@ void ListEntitiesFanResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->icon_ref_.size()); #endif size.add_uint32(1, static_cast(this->entity_category)); - if (!this->supported_preset_modes.empty()) { - for (const auto &it : this->supported_preset_modes) { + if (!this->supported_preset_modes->empty()) { + for (const auto &it : *this->supported_preset_modes) { size.add_length_force(1, it.size()); } } @@ -447,7 +447,7 @@ void ListEntitiesLightResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->object_id_ref_); buffer.encode_fixed32(2, this->key); buffer.encode_string(3, this->name_ref_); - for (auto &it : this->supported_color_modes) { + for (const auto &it : *this->supported_color_modes) { buffer.encode_uint32(12, static_cast(it), true); } buffer.encode_float(9, this->min_mireds); @@ -468,8 +468,8 @@ void ListEntitiesLightResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); size.add_length(1, this->name_ref_.size()); - if (!this->supported_color_modes.empty()) { - for (const auto &it : this->supported_color_modes) { + if (!this->supported_color_modes->empty()) { + for (const auto &it : *this->supported_color_modes) { size.add_uint32_force(1, static_cast(it)); } } @@ -1064,26 +1064,26 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(3, this->name_ref_); buffer.encode_bool(5, this->supports_current_temperature); buffer.encode_bool(6, this->supports_two_point_target_temperature); - for (auto &it : this->supported_modes) { + for (const auto &it : *this->supported_modes) { buffer.encode_uint32(7, static_cast(it), true); } buffer.encode_float(8, this->visual_min_temperature); buffer.encode_float(9, this->visual_max_temperature); buffer.encode_float(10, this->visual_target_temperature_step); buffer.encode_bool(12, this->supports_action); - for (auto &it : this->supported_fan_modes) { + for (const auto &it : *this->supported_fan_modes) { buffer.encode_uint32(13, static_cast(it), true); } - for (auto &it : this->supported_swing_modes) { + for (const auto &it : *this->supported_swing_modes) { buffer.encode_uint32(14, static_cast(it), true); } - for (auto &it : this->supported_custom_fan_modes) { + for (const auto &it : *this->supported_custom_fan_modes) { buffer.encode_string(15, it, true); } - for (auto &it : this->supported_presets) { + for (const auto &it : *this->supported_presets) { buffer.encode_uint32(16, static_cast(it), true); } - for (auto &it : this->supported_custom_presets) { + for (const auto &it : *this->supported_custom_presets) { buffer.encode_string(17, it, true); } buffer.encode_bool(18, this->disabled_by_default); @@ -1106,8 +1106,8 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->name_ref_.size()); size.add_bool(1, this->supports_current_temperature); size.add_bool(1, this->supports_two_point_target_temperature); - if (!this->supported_modes.empty()) { - for (const auto &it : this->supported_modes) { + if (!this->supported_modes->empty()) { + for (const auto &it : *this->supported_modes) { size.add_uint32_force(1, static_cast(it)); } } @@ -1115,28 +1115,28 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { size.add_float(1, this->visual_max_temperature); size.add_float(1, this->visual_target_temperature_step); size.add_bool(1, this->supports_action); - if (!this->supported_fan_modes.empty()) { - for (const auto &it : this->supported_fan_modes) { + if (!this->supported_fan_modes->empty()) { + for (const auto &it : *this->supported_fan_modes) { size.add_uint32_force(1, static_cast(it)); } } - if (!this->supported_swing_modes.empty()) { - for (const auto &it : this->supported_swing_modes) { + if (!this->supported_swing_modes->empty()) { + for (const auto &it : *this->supported_swing_modes) { size.add_uint32_force(1, static_cast(it)); } } - if (!this->supported_custom_fan_modes.empty()) { - for (const auto &it : this->supported_custom_fan_modes) { + if (!this->supported_custom_fan_modes->empty()) { + for (const auto &it : *this->supported_custom_fan_modes) { size.add_length_force(1, it.size()); } } - if (!this->supported_presets.empty()) { - for (const auto &it : this->supported_presets) { + if (!this->supported_presets->empty()) { + for (const auto &it : *this->supported_presets) { size.add_uint32_force(2, static_cast(it)); } } - if (!this->supported_custom_presets.empty()) { - for (const auto &it : this->supported_custom_presets) { + if (!this->supported_custom_presets->empty()) { + for (const auto &it : *this->supported_custom_presets) { size.add_length_force(2, it.size()); } } @@ -1371,7 +1371,7 @@ void ListEntitiesSelectResponse::encode(ProtoWriteBuffer buffer) const { #ifdef USE_ENTITY_ICON buffer.encode_string(5, this->icon_ref_); #endif - for (auto &it : this->options) { + for (const auto &it : *this->options) { buffer.encode_string(6, it, true); } buffer.encode_bool(7, this->disabled_by_default); @@ -1387,8 +1387,8 @@ void ListEntitiesSelectResponse::calculate_size(ProtoSize &size) const { #ifdef USE_ENTITY_ICON size.add_length(1, this->icon_ref_.size()); #endif - if (!this->options.empty()) { - for (const auto &it : this->options) { + if (!this->options->empty()) { + for (const auto &it : *this->options) { size.add_length_force(1, it.size()); } } @@ -2332,15 +2332,15 @@ void VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer buffer) const for (auto &it : this->available_wake_words) { buffer.encode_message(1, it, true); } - for (auto &it : this->active_wake_words) { + for (const auto &it : *this->active_wake_words) { buffer.encode_string(2, it, true); } buffer.encode_uint32(3, this->max_active_wake_words); } void VoiceAssistantConfigurationResponse::calculate_size(ProtoSize &size) const { size.add_repeated_message(1, this->available_wake_words); - if (!this->active_wake_words.empty()) { - for (const auto &it : this->active_wake_words) { + if (!this->active_wake_words->empty()) { + for (const auto &it : *this->active_wake_words) { size.add_length_force(1, it.size()); } } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index f637e44df3..78b4e7a3cc 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -6,6 +6,9 @@ #include "esphome/core/string_ref.h" #include "proto.h" +#include "api_pb2_includes.h" + +#include namespace esphome::api { @@ -695,7 +698,7 @@ class ListEntitiesFanResponse : public InfoResponseProtoMessage { bool supports_speed{false}; bool supports_direction{false}; int32_t supported_speed_count{0}; - std::vector supported_preset_modes{}; + const std::set *supported_preset_modes{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -760,7 +763,7 @@ class ListEntitiesLightResponse : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_light_response"; } #endif - std::vector supported_color_modes{}; + const std::set *supported_color_modes{}; float min_mireds{0.0f}; float max_mireds{0.0f}; std::vector effects{}; @@ -1311,16 +1314,16 @@ class ListEntitiesClimateResponse : public InfoResponseProtoMessage { #endif bool supports_current_temperature{false}; bool supports_two_point_target_temperature{false}; - std::vector supported_modes{}; + const std::set *supported_modes{}; float visual_min_temperature{0.0f}; float visual_max_temperature{0.0f}; float visual_target_temperature_step{0.0f}; bool supports_action{false}; - std::vector supported_fan_modes{}; - std::vector supported_swing_modes{}; - std::vector supported_custom_fan_modes{}; - std::vector supported_presets{}; - std::vector supported_custom_presets{}; + const std::set *supported_fan_modes{}; + const std::set *supported_swing_modes{}; + const std::set *supported_custom_fan_modes{}; + const std::set *supported_presets{}; + const std::set *supported_custom_presets{}; float visual_current_temperature_step{0.0f}; bool supports_current_humidity{false}; bool supports_target_humidity{false}; @@ -1467,7 +1470,7 @@ class ListEntitiesSelectResponse : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_select_response"; } #endif - std::vector options{}; + const std::vector *options{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2439,7 +2442,7 @@ class VoiceAssistantConfigurationResponse : public ProtoMessage { const char *message_name() const override { return "voice_assistant_configuration_response"; } #endif std::vector available_wake_words{}; - std::vector active_wake_words{}; + const std::vector *active_wake_words{}; uint32_t max_active_wake_words{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index aca60464a3..bde484ce59 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -814,7 +814,7 @@ void ListEntitiesFanResponse::dump_to(std::string &out) const { dump_field(out, "icon", this->icon_ref_); #endif dump_field(out, "entity_category", static_cast(this->entity_category)); - for (const auto &it : this->supported_preset_modes) { + for (const auto &it : *this->supported_preset_modes) { dump_field(out, "supported_preset_modes", it, 4); } #ifdef USE_DEVICES @@ -857,7 +857,7 @@ void ListEntitiesLightResponse::dump_to(std::string &out) const { dump_field(out, "object_id", this->object_id_ref_); dump_field(out, "key", this->key); dump_field(out, "name", this->name_ref_); - for (const auto &it : this->supported_color_modes) { + for (const auto &it : *this->supported_color_modes) { dump_field(out, "supported_color_modes", static_cast(it), 4); } dump_field(out, "min_mireds", this->min_mireds); @@ -1173,26 +1173,26 @@ void ListEntitiesClimateResponse::dump_to(std::string &out) const { dump_field(out, "name", this->name_ref_); dump_field(out, "supports_current_temperature", this->supports_current_temperature); dump_field(out, "supports_two_point_target_temperature", this->supports_two_point_target_temperature); - for (const auto &it : this->supported_modes) { + for (const auto &it : *this->supported_modes) { dump_field(out, "supported_modes", static_cast(it), 4); } dump_field(out, "visual_min_temperature", this->visual_min_temperature); dump_field(out, "visual_max_temperature", this->visual_max_temperature); dump_field(out, "visual_target_temperature_step", this->visual_target_temperature_step); dump_field(out, "supports_action", this->supports_action); - for (const auto &it : this->supported_fan_modes) { + for (const auto &it : *this->supported_fan_modes) { dump_field(out, "supported_fan_modes", static_cast(it), 4); } - for (const auto &it : this->supported_swing_modes) { + for (const auto &it : *this->supported_swing_modes) { dump_field(out, "supported_swing_modes", static_cast(it), 4); } - for (const auto &it : this->supported_custom_fan_modes) { + for (const auto &it : *this->supported_custom_fan_modes) { dump_field(out, "supported_custom_fan_modes", it, 4); } - for (const auto &it : this->supported_presets) { + for (const auto &it : *this->supported_presets) { dump_field(out, "supported_presets", static_cast(it), 4); } - for (const auto &it : this->supported_custom_presets) { + for (const auto &it : *this->supported_custom_presets) { dump_field(out, "supported_custom_presets", it, 4); } dump_field(out, "disabled_by_default", this->disabled_by_default); @@ -1305,7 +1305,7 @@ void ListEntitiesSelectResponse::dump_to(std::string &out) const { #ifdef USE_ENTITY_ICON dump_field(out, "icon", this->icon_ref_); #endif - for (const auto &it : this->options) { + for (const auto &it : *this->options) { dump_field(out, "options", it, 4); } dump_field(out, "disabled_by_default", this->disabled_by_default); @@ -1769,7 +1769,7 @@ void VoiceAssistantConfigurationResponse::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } - for (const auto &it : this->active_wake_words) { + for (const auto &it : *this->active_wake_words) { dump_field(out, "active_wake_words", it, 4); } dump_field(out, "max_active_wake_words", this->max_active_wake_words); diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h new file mode 100644 index 0000000000..d165956b23 --- /dev/null +++ b/esphome/components/api/api_pb2_includes.h @@ -0,0 +1,30 @@ +#pragma once + +// This file provides includes needed by the generated protobuf code +// when using pointer optimizations for component-specific types + +#ifdef USE_CLIMATE +#include "esphome/components/climate/climate_mode.h" +#include "esphome/components/climate/climate_traits.h" +#endif + +#ifdef USE_LIGHT +#include "esphome/components/light/light_traits.h" +#endif + +#ifdef USE_FAN +#include "esphome/components/fan/fan_traits.h" +#endif + +#ifdef USE_SELECT +#include "esphome/components/select/select_traits.h" +#endif + +#ifdef USE_MEDIA_PLAYER +#include "esphome/components/media_player/media_player_traits.h" +#endif + +// Standard library includes that might be needed +#include +#include +#include diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index 2ef6f8b7cc..d3010cb39b 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -29,7 +29,7 @@ class FanTraits { /// Set whether this fan supports changing direction void set_direction(bool direction) { this->direction_ = direction; } /// Return the preset modes supported by the fan. - std::set supported_preset_modes() const { return this->preset_modes_; } + const std::set &supported_preset_modes() const { return this->preset_modes_; } /// Set the preset modes supported by the fan. void set_supported_preset_modes(const std::set &preset_modes) { this->preset_modes_ = preset_modes; } /// Return if preset modes are supported diff --git a/esphome/components/select/select_traits.cpp b/esphome/components/select/select_traits.cpp index 89da30c405..a8cd4290c8 100644 --- a/esphome/components/select/select_traits.cpp +++ b/esphome/components/select/select_traits.cpp @@ -5,7 +5,7 @@ namespace select { void SelectTraits::set_options(std::vector options) { this->options_ = std::move(options); } -std::vector SelectTraits::get_options() const { return this->options_; } +const std::vector &SelectTraits::get_options() const { return this->options_; } } // namespace select } // namespace esphome diff --git a/esphome/components/select/select_traits.h b/esphome/components/select/select_traits.h index ccf23dc6d0..128066dd6b 100644 --- a/esphome/components/select/select_traits.h +++ b/esphome/components/select/select_traits.h @@ -9,7 +9,7 @@ namespace select { class SelectTraits { public: void set_options(std::vector options); - std::vector get_options() const; + const std::vector &get_options() const; protected: std::vector options_; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 275c7ffc9e..40804ae37e 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -388,8 +388,7 @@ class DoubleType(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - field_id_size = self.calculate_field_id_size() - return f"size.add_double({field_id_size}, {name});" + return self._get_fixed_size_calculation(name, "add_double") def get_fixed_size_bytes(self) -> int: return 8 @@ -1170,6 +1169,10 @@ class FixedArrayRepeatedType(TypeInfo): class RepeatedTypeInfo(TypeInfo): def __init__(self, field: descriptor.FieldDescriptorProto) -> None: super().__init__(field) + # Check if this is a pointer field by looking for container_pointer option + self._container_type = get_field_opt(field, pb.container_pointer, "") + self._use_pointer = bool(self._container_type) + # For repeated fields, we need to get the base type info # but we can't call create_field_type_info as it would cause recursion # So we extract just the type creation logic @@ -1185,6 +1188,14 @@ class RepeatedTypeInfo(TypeInfo): @property def cpp_type(self) -> str: + if self._use_pointer and self._container_type: + # For pointer fields, use the specified container type + # If the container type already includes the element type (e.g., std::set) + # use it as-is, otherwise append the element type + if "<" in self._container_type and ">" in self._container_type: + return f"const {self._container_type}*" + else: + return f"const {self._container_type}<{self._ti.cpp_type}>*" return f"std::vector<{self._ti.cpp_type}>" @property @@ -1205,6 +1216,9 @@ class RepeatedTypeInfo(TypeInfo): @property def decode_varint_content(self) -> str: + # Pointer fields don't support decoding + if self._use_pointer: + return None content = self._ti.decode_varint if content is None: return None @@ -1214,6 +1228,9 @@ class RepeatedTypeInfo(TypeInfo): @property def decode_length_content(self) -> str: + # Pointer fields don't support decoding + if self._use_pointer: + return None content = self._ti.decode_length if content is None and isinstance(self._ti, MessageType): # Special handling for non-template message decoding @@ -1226,6 +1243,9 @@ class RepeatedTypeInfo(TypeInfo): @property def decode_32bit_content(self) -> str: + # Pointer fields don't support decoding + if self._use_pointer: + return None content = self._ti.decode_32bit if content is None: return None @@ -1235,6 +1255,9 @@ class RepeatedTypeInfo(TypeInfo): @property def decode_64bit_content(self) -> str: + # Pointer fields don't support decoding + if self._use_pointer: + return None content = self._ti.decode_64bit if content is None: return None @@ -1249,16 +1272,31 @@ class RepeatedTypeInfo(TypeInfo): @property def encode_content(self) -> str: - o = f"for (auto {'' if self._ti_is_bool else '&'}it : this->{self.field_name}) {{\n" - if isinstance(self._ti, EnumType): - o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" + if self._use_pointer: + # For pointer fields, just dereference (pointer should never be null in our use case) + o = f"for (const auto &it : *this->{self.field_name}) {{\n" + if isinstance(self._ti, EnumType): + o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" + else: + o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" + o += "}" + return o else: - o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" - o += "}" - return o + o = f"for (auto {'' if self._ti_is_bool else '&'}it : this->{self.field_name}) {{\n" + if isinstance(self._ti, EnumType): + o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" + else: + o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" + o += "}" + return o @property def dump_content(self) -> str: + if self._use_pointer: + # For pointer fields, dereference and use the existing helper + return _generate_array_dump_content( + self._ti, f"*this->{self.field_name}", self.name, is_bool=False + ) return _generate_array_dump_content( self._ti, f"this->{self.field_name}", self.name, is_bool=self._ti_is_bool ) @@ -1269,30 +1307,34 @@ class RepeatedTypeInfo(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: # For repeated fields, we always need to pass force=True to the underlying type's calculation # This is because the encode method always sets force=true for repeated fields + + # Handle message types separately as they use a dedicated helper if isinstance(self._ti, MessageType): - # For repeated messages, use the dedicated helper that handles iteration internally field_id_size = self._ti.calculate_field_id_size() - o = f"size.add_repeated_message({field_id_size}, {name});" - return o + container = f"*{name}" if self._use_pointer else name + return f"size.add_repeated_message({field_id_size}, {container});" - # For other repeated types, use the underlying type's size calculation with force=True - o = f"if (!{name}.empty()) {{\n" + # For non-message types, generate size calculation with iteration + container_ref = f"*{name}" if self._use_pointer else name + empty_check = f"{name}->empty()" if self._use_pointer else f"{name}.empty()" - # Check if this is a fixed-size type by seeing if it has a fixed byte count + o = f"if (!{empty_check}) {{\n" + + # Check if this is a fixed-size type num_bytes = self._ti.get_fixed_size_bytes() if num_bytes is not None: - # Fixed types have constant size per element, so we can multiply + # Fixed types have constant size per element field_id_size = self._ti.calculate_field_id_size() - # Pre-calculate the total bytes per element bytes_per_element = field_id_size + num_bytes - o += ( - f" size.add_precalculated_size({name}.size() * {bytes_per_element});\n" - ) + size_expr = f"{name}->size()" if self._use_pointer else f"{name}.size()" + o += f" size.add_precalculated_size({size_expr} * {bytes_per_element});\n" else: # Other types need the actual value - o += f" for (const auto {'' if self._ti_is_bool else '&'}it : {name}) {{\n" + auto_ref = "" if self._ti_is_bool else "&" + o += f" for (const auto {auto_ref}it : {container_ref}) {{\n" o += f" {self._ti.get_size_calculation('it', True)}\n" o += " }\n" + o += "}" return o @@ -2080,6 +2122,7 @@ def main() -> None: d = descriptor.FileDescriptorSet.FromString(proto_content) file = d.file[0] + content = FILE_HEADER content += """\ #pragma once @@ -2088,7 +2131,12 @@ def main() -> None: #include "esphome/core/string_ref.h" #include "proto.h" +#include "api_pb2_includes.h" +#include +""" + + content += """ namespace esphome::api { """ From 4e565202e47efc40cc1a86cfec25447013f9e4b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 12:42:46 -1000 Subject: [PATCH 0104/1626] preen --- esphome/components/api/api_pb2_includes.h | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index d165956b23..ad1a906f0c 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -1,5 +1,7 @@ #pragma once +#include "esphome/core/defines.h" + // This file provides includes needed by the generated protobuf code // when using pointer optimizations for component-specific types @@ -28,3 +30,9 @@ #include #include #include + +namespace esphome::api { + +// This file only provides includes, no actual code + +} // namespace esphome::api From 5b7085287f04352516ec4af30fb6006f0ef20da2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 12:43:50 -1000 Subject: [PATCH 0105/1626] preen --- script/api_protobuf/api_protobuf.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 40804ae37e..40e288ca80 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2132,8 +2132,6 @@ def main() -> None: #include "proto.h" #include "api_pb2_includes.h" - -#include """ content += """ From 7ab8cc49c6de5216c87d6cebc8f456fefb728da9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 12:44:07 -1000 Subject: [PATCH 0106/1626] preen --- esphome/components/api/api_pb2.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 78b4e7a3cc..d530fba494 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -8,8 +8,6 @@ #include "proto.h" #include "api_pb2_includes.h" -#include - namespace esphome::api { namespace enums { From dbe895f0a3181541a64c81df05f197e0631878b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 12:46:58 -1000 Subject: [PATCH 0107/1626] preen --- esphome/components/api/api_pb2_includes.h | 4 ---- 1 file changed, 4 deletions(-) diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index ad1a906f0c..55d95304b1 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -22,10 +22,6 @@ #include "esphome/components/select/select_traits.h" #endif -#ifdef USE_MEDIA_PLAYER -#include "esphome/components/media_player/media_player_traits.h" -#endif - // Standard library includes that might be needed #include #include From 4f425c700ae0f596647f466212d7611f1f0d1497 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 13:33:54 -1000 Subject: [PATCH 0108/1626] [esp32] Enable LWIP core locking on ESP-IDF to reduce socket operation overhead (#9857) --- esphome/components/esp32/__init__.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 2b4c4ff043..78bafcb790 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -571,6 +571,8 @@ CONF_SDKCONFIG_OPTIONS = "sdkconfig_options" CONF_ENABLE_LWIP_DHCP_SERVER = "enable_lwip_dhcp_server" CONF_ENABLE_LWIP_MDNS_QUERIES = "enable_lwip_mdns_queries" CONF_ENABLE_LWIP_BRIDGE_INTERFACE = "enable_lwip_bridge_interface" +CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING = "enable_lwip_tcpip_core_locking" +CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY = "enable_lwip_check_thread_safety" def _validate_idf_component(config: ConfigType) -> ConfigType: @@ -619,6 +621,12 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All( cv.Optional( CONF_ENABLE_LWIP_BRIDGE_INTERFACE, default=False ): cv.boolean, + cv.Optional( + CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, default=True + ): cv.boolean, + cv.Optional( + CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True + ): cv.boolean, } ), cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( @@ -785,6 +793,18 @@ async def to_code(config): if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False): add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) + # Apply LWIP core locking for better socket performance + # This is already enabled by default in Arduino framework, where it provides + # significant performance benefits. Our benchmarks show socket operations are + # 24-200% faster with core locking enabled: + # - select() on 4 sockets: ~190μs (Arduino/core locking) vs ~235μs (ESP-IDF default) + # - Up to 200% slower under load when all operations queue through tcpip_thread + # Enabling this makes ESP-IDF socket performance match Arduino framework. + if advanced.get(CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, True): + add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_CORE_LOCKING", True) + if advanced.get(CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, True): + add_idf_sdkconfig_option("CONFIG_LWIP_CHECK_THREAD_SAFETY", True) + cg.add_platformio_option("board_build.partitions", "partitions.csv") if CONF_PARTITIONS in config: add_extra_build_file( From 7657316a9249ee90d83cfbad92b17cdf4660346f Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 28 Jul 2025 18:34:52 -0500 Subject: [PATCH 0109/1626] [sensor] Add support for default filters (#9934) --- esphome/components/sensor/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index bcde623df2..20c6911d28 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -332,6 +332,7 @@ def sensor_schema( device_class: str = cv.UNDEFINED, state_class: str = cv.UNDEFINED, entity_category: str = cv.UNDEFINED, + filters: list = cv.UNDEFINED, ) -> cv.Schema: schema = {} @@ -346,6 +347,7 @@ def sensor_schema( (CONF_DEVICE_CLASS, device_class, validate_device_class), (CONF_STATE_CLASS, state_class, validate_state_class), (CONF_ENTITY_CATEGORY, entity_category, sensor_entity_category), + (CONF_FILTERS, filters, validate_filters), ]: if default is not cv.UNDEFINED: schema[cv.Optional(key, default=default)] = validator From 908891a096a52330216bf60ac6b9ce3f7d3af0f3 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 28 Jul 2025 18:35:11 -0500 Subject: [PATCH 0110/1626] [binary_sensor] Add support for default filters (#9935) --- esphome/components/binary_sensor/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index e3931e3946..376a399637 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -516,6 +516,7 @@ def binary_sensor_schema( icon: str = cv.UNDEFINED, entity_category: str = cv.UNDEFINED, device_class: str = cv.UNDEFINED, + filters: list = cv.UNDEFINED, ) -> cv.Schema: schema = {} @@ -527,6 +528,7 @@ def binary_sensor_schema( (CONF_ICON, icon, cv.icon), (CONF_ENTITY_CATEGORY, entity_category, cv.entity_category), (CONF_DEVICE_CLASS, device_class, validate_device_class), + (CONF_FILTERS, filters, validate_filters), ]: if default is not cv.UNDEFINED: schema[cv.Optional(key, default=default)] = validator From f5f0a01a850e0dcf59c13a7396cde7faf1ecaf18 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 28 Jul 2025 18:35:40 -0500 Subject: [PATCH 0111/1626] [text_sensor] Add support for default filters (#9936) --- esphome/components/text_sensor/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index abb2dcae6c..0341ab2f71 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -162,6 +162,7 @@ def text_sensor_schema( device_class: str = cv.UNDEFINED, entity_category: str = cv.UNDEFINED, icon: str = cv.UNDEFINED, + filters: list = cv.UNDEFINED, ) -> cv.Schema: schema = {} @@ -172,6 +173,7 @@ def text_sensor_schema( (CONF_ICON, icon, cv.icon), (CONF_DEVICE_CLASS, device_class, validate_device_class), (CONF_ENTITY_CATEGORY, entity_category, cv.entity_category), + (CONF_FILTERS, filters, validate_filters), ]: if default is not cv.UNDEFINED: schema[cv.Optional(key, default=default)] = validator From f733c43dec39596b12936ff6be5c6c55169cdb4d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:59:58 -0400 Subject: [PATCH 0112/1626] [heatpumpir] Fix issue with IRremoteESP8266 being included on ESP32 (#9950) --- esphome/components/heatpumpir/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index d2b907d437..4f83bf2435 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -127,5 +127,5 @@ async def to_code(config): cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) cg.add_library("tonia/HeatpumpIR", "1.0.37") - if CORE.is_libretiny: + if CORE.is_libretiny or CORE.is_esp32: CORE.add_platformio_option("lib_ignore", "IRremoteESP8266") From 9d43ddd6f110b447f4f0cd5c56a46d36613001b7 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Tue, 29 Jul 2025 00:25:17 -0400 Subject: [PATCH 0113/1626] Openthread add Teardown (#9275) Co-authored-by: mc Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/openthread/openthread.cpp | 42 +++++++++++++------ esphome/components/openthread/openthread.h | 3 ++ .../components/openthread/openthread_esp.cpp | 3 ++ 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 800128745c..322ff43238 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -1,6 +1,9 @@ #include "esphome/core/defines.h" #ifdef USE_OPENTHREAD #include "openthread.h" +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) +#include "esp_openthread.h" +#endif #include @@ -28,18 +31,6 @@ OpenThreadComponent *global_openthread_component = // NOLINT(cppcoreguidelines- OpenThreadComponent::OpenThreadComponent() { global_openthread_component = this; } -OpenThreadComponent::~OpenThreadComponent() { - auto lock = InstanceLock::try_acquire(100); - if (!lock) { - ESP_LOGW(TAG, "Failed to acquire OpenThread lock in destructor, leaking memory"); - return; - } - otInstance *instance = lock->get_instance(); - otSrpClientClearHostAndServices(instance); - otSrpClientBuffersFreeAllServices(instance); - global_openthread_component = nullptr; -} - bool OpenThreadComponent::is_connected() { auto lock = InstanceLock::try_acquire(100); if (!lock) { @@ -199,6 +190,33 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { void OpenThreadSrpComponent::set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } +bool OpenThreadComponent::teardown() { + if (!this->teardown_started_) { + this->teardown_started_ = true; + ESP_LOGD(TAG, "Clear Srp"); + auto lock = InstanceLock::try_acquire(100); + if (!lock) { + ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory"); + return true; + } + otInstance *instance = lock->get_instance(); + otSrpClientClearHostAndServices(instance); + otSrpClientBuffersFreeAllServices(instance); + global_openthread_component = nullptr; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + ESP_LOGD(TAG, "Exit main loop "); + int error = esp_openthread_mainloop_exit(); + if (error != ESP_OK) { + ESP_LOGW(TAG, "Failed attempt to stop main loop %d", error); + this->teardown_complete_ = true; + } +#else + this->teardown_complete_ = true; +#endif + } + return this->teardown_complete_; +} + } // namespace openthread } // namespace esphome diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 77fd58851a..a0ea1b3f3a 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -21,6 +21,7 @@ class OpenThreadComponent : public Component { OpenThreadComponent(); ~OpenThreadComponent(); void setup() override; + bool teardown() override; float get_setup_priority() const override { return setup_priority::WIFI; } bool is_connected(); @@ -30,6 +31,8 @@ class OpenThreadComponent : public Component { protected: std::optional get_omr_address_(InstanceLock &lock); + bool teardown_started_{false}; + bool teardown_complete_{false}; }; extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index f495027172..b11b7ad34a 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -143,10 +143,13 @@ void OpenThreadComponent::ot_main() { esp_openthread_launch_mainloop(); // Clean up + esp_openthread_deinit(); esp_openthread_netif_glue_deinit(); esp_netif_destroy(openthread_netif); esp_vfs_eventfd_unregister(); + this->teardown_complete_ = true; + vTaskDelete(NULL); } network::IPAddresses OpenThreadComponent::get_ip_addresses() { From 4ff3137c0d2279f5885b2905f543cf69993853f3 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 29 Jul 2025 00:21:52 -0500 Subject: [PATCH 0114/1626] [gps] Fix slow parsing (#9953) --- esphome/components/gps/gps.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/gps/gps.cpp b/esphome/components/gps/gps.cpp index cbbd36887b..65cddcd984 100644 --- a/esphome/components/gps/gps.cpp +++ b/esphome/components/gps/gps.cpp @@ -52,7 +52,7 @@ void GPS::update() { void GPS::loop() { while (this->available() > 0 && !this->has_time_) { if (!this->tiny_gps_.encode(this->read())) { - return; + continue; } if (this->tiny_gps_.location.isUpdated()) { this->latitude_ = this->tiny_gps_.location.lat(); From 6d30269565f086ef1c8f2edf1c98ef81ab36911c Mon Sep 17 00:00:00 2001 From: Djordje Mandic <6750655+DjordjeMandic@users.noreply.github.com> Date: Tue, 29 Jul 2025 07:22:44 +0200 Subject: [PATCH 0115/1626] [output] Add `set_min_power` & `set_max_power` actions for `FloatOutput` (#8934) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/output/__init__.py | 38 ++++++++++++++++++++++++++ esphome/components/output/automation.h | 24 ++++++++++++++++ tests/components/output/common.yaml | 6 ++++ 3 files changed, 68 insertions(+) diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 78bfa045e1..bde106b085 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -43,6 +43,8 @@ FloatOutputPtr = FloatOutput.operator("ptr") TurnOffAction = output_ns.class_("TurnOffAction", automation.Action) TurnOnAction = output_ns.class_("TurnOnAction", automation.Action) SetLevelAction = output_ns.class_("SetLevelAction", automation.Action) +SetMinPowerAction = output_ns.class_("SetMinPowerAction", automation.Action) +SetMaxPowerAction = output_ns.class_("SetMaxPowerAction", automation.Action) async def setup_output_platform_(obj, config): @@ -104,6 +106,42 @@ async def output_set_level_to_code(config, action_id, template_arg, args): return var +@automation.register_action( + "output.set_min_power", + SetMinPowerAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(FloatOutput), + cv.Required(CONF_MIN_POWER): cv.templatable(cv.percentage), + } + ), +) +async def output_set_min_power_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + template_ = await cg.templatable(config[CONF_MIN_POWER], args, float) + cg.add(var.set_min_power(template_)) + return var + + +@automation.register_action( + "output.set_max_power", + SetMaxPowerAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(FloatOutput), + cv.Required(CONF_MAX_POWER): cv.templatable(cv.percentage), + } + ), +) +async def output_set_max_power_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + template_ = await cg.templatable(config[CONF_MAX_POWER], args, float) + cg.add(var.set_max_power(template_)) + return var + + async def to_code(config): cg.add_define("USE_OUTPUT") cg.add_global(output_ns.using) diff --git a/esphome/components/output/automation.h b/esphome/components/output/automation.h index 51c2849702..de84bb91ca 100644 --- a/esphome/components/output/automation.h +++ b/esphome/components/output/automation.h @@ -40,5 +40,29 @@ template class SetLevelAction : public Action { FloatOutput *output_; }; +template class SetMinPowerAction : public Action { + public: + SetMinPowerAction(FloatOutput *output) : output_(output) {} + + TEMPLATABLE_VALUE(float, min_power) + + void play(Ts... x) override { this->output_->set_min_power(this->min_power_.value(x...)); } + + protected: + FloatOutput *output_; +}; + +template class SetMaxPowerAction : public Action { + public: + SetMaxPowerAction(FloatOutput *output) : output_(output) {} + + TEMPLATABLE_VALUE(float, max_power) + + void play(Ts... x) override { this->output_->set_max_power(this->max_power_.value(x...)); } + + protected: + FloatOutput *output_; +}; + } // namespace output } // namespace esphome diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index 5f31ae50a8..81d802e9bf 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -6,6 +6,12 @@ esphome: - output.set_level: id: light_output_1 level: 50% + - output.set_min_power: + id: light_output_1 + min_power: 20% + - output.set_max_power: + id: light_output_1 + max_power: 80% output: - platform: ${output_platform} From 14d1fd02ccbf822e8e1a4f778e8e33d0fa3679ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 19:30:32 -1000 Subject: [PATCH 0116/1626] fix --- esphome/components/api/api_connection.cpp | 16 ++++++++-------- esphome/components/climate/climate_traits.h | 6 ++++++ esphome/components/fan/fan_traits.h | 3 ++- esphome/components/light/light_traits.h | 1 + 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 5576f915e2..337b282d70 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -413,7 +413,7 @@ uint16_t APIConnection::try_send_fan_info(EntityBase *entity, APIConnection *con msg.supports_speed = traits.supports_speed(); msg.supports_direction = traits.supports_direction(); msg.supported_speed_count = traits.supported_speed_count(); - msg.supported_preset_modes = &traits.supported_preset_modes(); + msg.supported_preset_modes = &traits.supported_preset_modes_ref(); return fill_and_encode_entity_info(fan, msg, ListEntitiesFanResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } void APIConnection::fan_command(const FanCommandRequest &msg) { @@ -469,7 +469,7 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c auto *light = static_cast(entity); ListEntitiesLightResponse msg; auto traits = light->get_traits(); - msg.supported_color_modes = &traits.get_supported_color_modes(); + msg.supported_color_modes = &traits.get_supported_color_modes_ref(); if (traits.supports_color_capability(light::ColorCapability::COLOR_TEMPERATURE) || traits.supports_color_capability(light::ColorCapability::COLD_WARM_WHITE)) { msg.min_mireds = traits.get_min_mireds(); @@ -655,7 +655,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.supports_current_humidity = traits.get_supports_current_humidity(); msg.supports_two_point_target_temperature = traits.get_supports_two_point_target_temperature(); msg.supports_target_humidity = traits.get_supports_target_humidity(); - msg.supported_modes = &traits.get_supported_modes(); + msg.supported_modes = &traits.get_supported_modes_ref(); msg.visual_min_temperature = traits.get_visual_min_temperature(); msg.visual_max_temperature = traits.get_visual_max_temperature(); msg.visual_target_temperature_step = traits.get_visual_target_temperature_step(); @@ -663,11 +663,11 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.visual_min_humidity = traits.get_visual_min_humidity(); msg.visual_max_humidity = traits.get_visual_max_humidity(); msg.supports_action = traits.get_supports_action(); - msg.supported_fan_modes = &traits.get_supported_fan_modes(); - msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes(); - msg.supported_presets = &traits.get_supported_presets(); - msg.supported_custom_presets = &traits.get_supported_custom_presets(); - msg.supported_swing_modes = &traits.get_supported_swing_modes(); + msg.supported_fan_modes = &traits.get_supported_fan_modes_ref(); + msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes_ref(); + msg.supported_presets = &traits.get_supported_presets_ref(); + msg.supported_custom_presets = &traits.get_supported_custom_presets_ref(); + msg.supported_swing_modes = &traits.get_supported_swing_modes_ref(); return fill_and_encode_entity_info(climate, msg, ListEntitiesClimateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index c3a0dfca8f..171fd0ab2f 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -74,6 +74,7 @@ class ClimateTraits { void set_supports_dry_mode(bool supports_dry_mode) { set_mode_support_(CLIMATE_MODE_DRY, supports_dry_mode); } bool supports_mode(ClimateMode mode) const { return this->supported_modes_.count(mode); } const std::set &get_supported_modes() const { return this->supported_modes_; } + const std::set &get_supported_modes_ref() const { return this->supported_modes_; } void set_supports_action(bool supports_action) { this->supports_action_ = supports_action; } bool get_supports_action() const { return this->supports_action_; } @@ -104,11 +105,13 @@ class ClimateTraits { return !this->supported_fan_modes_.empty() || !this->supported_custom_fan_modes_.empty(); } const std::set &get_supported_fan_modes() const { return this->supported_fan_modes_; } + const std::set &get_supported_fan_modes_ref() const { return this->supported_fan_modes_; } void set_supported_custom_fan_modes(std::set supported_custom_fan_modes) { this->supported_custom_fan_modes_ = std::move(supported_custom_fan_modes); } const std::set &get_supported_custom_fan_modes() const { return this->supported_custom_fan_modes_; } + const std::set &get_supported_custom_fan_modes_ref() const { return this->supported_custom_fan_modes_; } bool supports_custom_fan_mode(const std::string &custom_fan_mode) const { return this->supported_custom_fan_modes_.count(custom_fan_mode); } @@ -119,11 +122,13 @@ class ClimateTraits { bool supports_preset(ClimatePreset preset) const { return this->supported_presets_.count(preset); } bool get_supports_presets() const { return !this->supported_presets_.empty(); } const std::set &get_supported_presets() const { return this->supported_presets_; } + const std::set &get_supported_presets_ref() const { return this->supported_presets_; } void set_supported_custom_presets(std::set supported_custom_presets) { this->supported_custom_presets_ = std::move(supported_custom_presets); } const std::set &get_supported_custom_presets() const { return this->supported_custom_presets_; } + const std::set &get_supported_custom_presets_ref() const { return this->supported_custom_presets_; } bool supports_custom_preset(const std::string &custom_preset) const { return this->supported_custom_presets_.count(custom_preset); } @@ -143,6 +148,7 @@ class ClimateTraits { bool supports_swing_mode(ClimateSwingMode swing_mode) const { return this->supported_swing_modes_.count(swing_mode); } bool get_supports_swing_modes() const { return !this->supported_swing_modes_.empty(); } const std::set &get_supported_swing_modes() const { return this->supported_swing_modes_; } + const std::set &get_supported_swing_modes_ref() const { return this->supported_swing_modes_; } float get_visual_min_temperature() const { return this->visual_min_temperature_; } void set_visual_min_temperature(float visual_min_temperature) { diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index d3010cb39b..67ba651eb1 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -29,7 +29,8 @@ class FanTraits { /// Set whether this fan supports changing direction void set_direction(bool direction) { this->direction_ = direction; } /// Return the preset modes supported by the fan. - const std::set &supported_preset_modes() const { return this->preset_modes_; } + std::set supported_preset_modes() const { return this->preset_modes_; } + const std::set &supported_preset_modes_ref() const { return this->preset_modes_; } /// Set the preset modes supported by the fan. void set_supported_preset_modes(const std::set &preset_modes) { this->preset_modes_ = preset_modes; } /// Return if preset modes are supported diff --git a/esphome/components/light/light_traits.h b/esphome/components/light/light_traits.h index 7c99d721f0..6c083a1017 100644 --- a/esphome/components/light/light_traits.h +++ b/esphome/components/light/light_traits.h @@ -13,6 +13,7 @@ class LightTraits { LightTraits() = default; const std::set &get_supported_color_modes() const { return this->supported_color_modes_; } + const std::set &get_supported_color_modes_ref() const { return this->supported_color_modes_; } void set_supported_color_modes(std::set supported_color_modes) { this->supported_color_modes_ = std::move(supported_color_modes); } From 7822865aee7fd9119f3b9f96fb35ec4e9e9c1ab7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 19:37:44 -1000 Subject: [PATCH 0117/1626] limit change --- esphome/components/api/api_connection.cpp | 16 +++++------ esphome/components/climate/climate_traits.h | 30 ++++++++++++++++----- esphome/components/fan/fan_traits.h | 17 +++++++++++- esphome/components/light/light_traits.h | 18 ++++++++++++- 4 files changed, 65 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 337b282d70..7f2ce159be 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -413,7 +413,7 @@ uint16_t APIConnection::try_send_fan_info(EntityBase *entity, APIConnection *con msg.supports_speed = traits.supports_speed(); msg.supports_direction = traits.supports_direction(); msg.supported_speed_count = traits.supported_speed_count(); - msg.supported_preset_modes = &traits.supported_preset_modes_ref(); + msg.supported_preset_modes = &traits.supported_preset_modes_for_api_(); return fill_and_encode_entity_info(fan, msg, ListEntitiesFanResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } void APIConnection::fan_command(const FanCommandRequest &msg) { @@ -469,7 +469,7 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c auto *light = static_cast(entity); ListEntitiesLightResponse msg; auto traits = light->get_traits(); - msg.supported_color_modes = &traits.get_supported_color_modes_ref(); + msg.supported_color_modes = &traits.get_supported_color_modes_for_api_(); if (traits.supports_color_capability(light::ColorCapability::COLOR_TEMPERATURE) || traits.supports_color_capability(light::ColorCapability::COLD_WARM_WHITE)) { msg.min_mireds = traits.get_min_mireds(); @@ -655,7 +655,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.supports_current_humidity = traits.get_supports_current_humidity(); msg.supports_two_point_target_temperature = traits.get_supports_two_point_target_temperature(); msg.supports_target_humidity = traits.get_supports_target_humidity(); - msg.supported_modes = &traits.get_supported_modes_ref(); + msg.supported_modes = &traits.get_supported_modes_for_api_(); msg.visual_min_temperature = traits.get_visual_min_temperature(); msg.visual_max_temperature = traits.get_visual_max_temperature(); msg.visual_target_temperature_step = traits.get_visual_target_temperature_step(); @@ -663,11 +663,11 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.visual_min_humidity = traits.get_visual_min_humidity(); msg.visual_max_humidity = traits.get_visual_max_humidity(); msg.supports_action = traits.get_supports_action(); - msg.supported_fan_modes = &traits.get_supported_fan_modes_ref(); - msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes_ref(); - msg.supported_presets = &traits.get_supported_presets_ref(); - msg.supported_custom_presets = &traits.get_supported_custom_presets_ref(); - msg.supported_swing_modes = &traits.get_supported_swing_modes_ref(); + msg.supported_fan_modes = &traits.get_supported_fan_modes_for_api_(); + msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes_for_api_(); + msg.supported_presets = &traits.get_supported_presets_for_api_(); + msg.supported_custom_presets = &traits.get_supported_custom_presets_for_api_(); + msg.supported_swing_modes = &traits.get_supported_swing_modes_for_api_(); return fill_and_encode_entity_info(climate, msg, ListEntitiesClimateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 171fd0ab2f..8bd4714753 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -5,6 +5,13 @@ #include namespace esphome { + +#ifdef USE_API +namespace api { +class APIConnection; +} // namespace api +#endif + namespace climate { /** This class contains all static data for climate devices. @@ -74,7 +81,6 @@ class ClimateTraits { void set_supports_dry_mode(bool supports_dry_mode) { set_mode_support_(CLIMATE_MODE_DRY, supports_dry_mode); } bool supports_mode(ClimateMode mode) const { return this->supported_modes_.count(mode); } const std::set &get_supported_modes() const { return this->supported_modes_; } - const std::set &get_supported_modes_ref() const { return this->supported_modes_; } void set_supports_action(bool supports_action) { this->supports_action_ = supports_action; } bool get_supports_action() const { return this->supports_action_; } @@ -105,13 +111,11 @@ class ClimateTraits { return !this->supported_fan_modes_.empty() || !this->supported_custom_fan_modes_.empty(); } const std::set &get_supported_fan_modes() const { return this->supported_fan_modes_; } - const std::set &get_supported_fan_modes_ref() const { return this->supported_fan_modes_; } void set_supported_custom_fan_modes(std::set supported_custom_fan_modes) { this->supported_custom_fan_modes_ = std::move(supported_custom_fan_modes); } const std::set &get_supported_custom_fan_modes() const { return this->supported_custom_fan_modes_; } - const std::set &get_supported_custom_fan_modes_ref() const { return this->supported_custom_fan_modes_; } bool supports_custom_fan_mode(const std::string &custom_fan_mode) const { return this->supported_custom_fan_modes_.count(custom_fan_mode); } @@ -122,13 +126,11 @@ class ClimateTraits { bool supports_preset(ClimatePreset preset) const { return this->supported_presets_.count(preset); } bool get_supports_presets() const { return !this->supported_presets_.empty(); } const std::set &get_supported_presets() const { return this->supported_presets_; } - const std::set &get_supported_presets_ref() const { return this->supported_presets_; } void set_supported_custom_presets(std::set supported_custom_presets) { this->supported_custom_presets_ = std::move(supported_custom_presets); } const std::set &get_supported_custom_presets() const { return this->supported_custom_presets_; } - const std::set &get_supported_custom_presets_ref() const { return this->supported_custom_presets_; } bool supports_custom_preset(const std::string &custom_preset) const { return this->supported_custom_presets_.count(custom_preset); } @@ -148,7 +150,6 @@ class ClimateTraits { bool supports_swing_mode(ClimateSwingMode swing_mode) const { return this->supported_swing_modes_.count(swing_mode); } bool get_supports_swing_modes() const { return !this->supported_swing_modes_.empty(); } const std::set &get_supported_swing_modes() const { return this->supported_swing_modes_; } - const std::set &get_supported_swing_modes_ref() const { return this->supported_swing_modes_; } float get_visual_min_temperature() const { return this->visual_min_temperature_; } void set_visual_min_temperature(float visual_min_temperature) { @@ -179,6 +180,23 @@ class ClimateTraits { void set_visual_max_humidity(float visual_max_humidity) { this->visual_max_humidity_ = visual_max_humidity; } protected: +#ifdef USE_API + // The API connection is a friend class to access internal methods + friend class api::APIConnection; + // These methods return references to internal data structures. + // They are used by the API to avoid copying data when encoding messages. + // Warning: Do not use these methods outside of the API connection code. + // They return references to internal data that can be invalidated. + const std::set &get_supported_modes_for_api_() const { return this->supported_modes_; } + const std::set &get_supported_fan_modes_for_api_() const { return this->supported_fan_modes_; } + const std::set &get_supported_custom_fan_modes_for_api_() const { + return this->supported_custom_fan_modes_; + } + const std::set &get_supported_presets_for_api_() const { return this->supported_presets_; } + const std::set &get_supported_custom_presets_for_api_() const { return this->supported_custom_presets_; } + const std::set &get_supported_swing_modes_for_api_() const { return this->supported_swing_modes_; } +#endif + void set_mode_support_(climate::ClimateMode mode, bool supported) { if (supported) { this->supported_modes_.insert(mode); diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index 67ba651eb1..48509e5705 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -4,6 +4,13 @@ #pragma once namespace esphome { + +#ifdef USE_API +namespace api { +class APIConnection; +} // namespace api +#endif + namespace fan { class FanTraits { @@ -30,13 +37,21 @@ class FanTraits { void set_direction(bool direction) { this->direction_ = direction; } /// Return the preset modes supported by the fan. std::set supported_preset_modes() const { return this->preset_modes_; } - const std::set &supported_preset_modes_ref() const { return this->preset_modes_; } /// Set the preset modes supported by the fan. void set_supported_preset_modes(const std::set &preset_modes) { this->preset_modes_ = preset_modes; } /// Return if preset modes are supported bool supports_preset_modes() const { return !this->preset_modes_.empty(); } protected: +#ifdef USE_API + // The API connection is a friend class to access internal methods + friend class api::APIConnection; + // This method returns a reference to the internal preset modes set. + // It is used by the API to avoid copying data when encoding messages. + // Warning: Do not use this method outside of the API connection code. + // It returns a reference to internal data that can be invalidated. + const std::set &supported_preset_modes_for_api_() const { return this->preset_modes_; } +#endif bool oscillation_{false}; bool speed_{false}; bool direction_{false}; diff --git a/esphome/components/light/light_traits.h b/esphome/components/light/light_traits.h index 6c083a1017..a45301d148 100644 --- a/esphome/components/light/light_traits.h +++ b/esphome/components/light/light_traits.h @@ -5,6 +5,13 @@ #include namespace esphome { + +#ifdef USE_API +namespace api { +class APIConnection; +} // namespace api +#endif + namespace light { /// This class is used to represent the capabilities of a light. @@ -13,7 +20,6 @@ class LightTraits { LightTraits() = default; const std::set &get_supported_color_modes() const { return this->supported_color_modes_; } - const std::set &get_supported_color_modes_ref() const { return this->supported_color_modes_; } void set_supported_color_modes(std::set supported_color_modes) { this->supported_color_modes_ = std::move(supported_color_modes); } @@ -53,6 +59,16 @@ class LightTraits { void set_max_mireds(float max_mireds) { this->max_mireds_ = max_mireds; } protected: +#ifdef USE_API + // The API connection is a friend class to access internal methods + friend class api::APIConnection; + // This method returns a reference to the internal color modes set. + // It is used by the API to avoid copying data when encoding messages. + // Warning: Do not use this method outside of the API connection code. + // It returns a reference to internal data that can be invalidated. + const std::set &get_supported_color_modes_for_api_() const { return this->supported_color_modes_; } +#endif + std::set supported_color_modes_{}; float min_mireds_{0}; float max_mireds_{0}; From 3d5b602288bd922b898b53429079e8aae8217c0a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 29 Jul 2025 01:52:34 -0400 Subject: [PATCH 0118/1626] [esp32] Bump platform to 54.03.21-1 and add support for tagged releases (#9926) Co-authored-by: J. Nick Koston --- .clang-tidy.hash | 2 +- esphome/components/esp32/__init__.py | 12 ++++++------ esphome/config_validation.py | 2 ++ platformio.ini | 4 ++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 02b528b0d6..5dd779effb 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -b7056e39f1484500ca2d237068670b789fe9241786b48da0681d646b25af05d5 +f84518ea4140c194b21cc516aae05aaa0cf876ab866f89e22e91842df46333ed diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 78bafcb790..4ab85a55cd 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -313,7 +313,7 @@ def _format_framework_espidf_version( RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 2, 1) # The platform-espressif32 version to use for arduino frameworks # - https://github.com/pioarduino/platform-espressif32/releases -ARDUINO_PLATFORM_VERSION = cv.Version(54, 3, 21) +ARDUINO_PLATFORM_VERSION = cv.Version(54, 3, 21, "1") # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases @@ -322,7 +322,7 @@ RECOMMENDED_ESP_IDF_FRAMEWORK_VERSION = cv.Version(5, 4, 2) # The platformio/espressif32 version to use for esp-idf frameworks # - https://github.com/platformio/platform-espressif32/releases # - https://api.registry.platformio.org/v3/packages/platformio/platform/espressif32 -ESP_IDF_PLATFORM_VERSION = cv.Version(54, 3, 21) +ESP_IDF_PLATFORM_VERSION = cv.Version(54, 3, 21, "1") # List based on https://registry.platformio.org/tools/platformio/framework-espidf/versions SUPPORTED_PLATFORMIO_ESP_IDF_5X = [ @@ -468,10 +468,10 @@ def _parse_platform_version(value): try: ver = cv.Version.parse(cv.version_number(value)) if ver.major >= 50: # a pioarduino version - if "-" in value: - # maybe a release candidate?...definitely not our default, just use it as-is... - return f"https://github.com/pioarduino/platform-espressif32/releases/download/{value}/platform-espressif32.zip" - return f"https://github.com/pioarduino/platform-espressif32/releases/download/{ver.major}.{ver.minor:02d}.{ver.patch:02d}/platform-espressif32.zip" + release = f"{ver.major}.{ver.minor:02d}.{ver.patch:02d}" + if ver.extra: + release += f"-{ver.extra}" + return f"https://github.com/pioarduino/platform-espressif32/releases/download/{release}/platform-espressif32.zip" # if platform version is a valid version constraint, prefix the default package cv.platformio_version_constraint(value) return f"platformio/espressif32@{value}" diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 1a4976e235..a79f8cd17c 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -291,6 +291,8 @@ class Version: extra: str = "" def __str__(self): + if self.extra: + return f"{self.major}.{self.minor}.{self.patch}-{self.extra}" return f"{self.major}.{self.minor}.{self.patch}" @classmethod diff --git a/platformio.ini b/platformio.ini index 5956ae8117..ab0774b29f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -125,7 +125,7 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-1/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.2.1/esp32-3.2.1.zip @@ -161,7 +161,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-1/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.4.2/esp-idf-v5.4.2.zip From e113078f82e16ddab8a938ee28c46d9a3cc6f15c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 19:54:08 -1000 Subject: [PATCH 0119/1626] document --- esphome/components/api/api_options.proto | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index e7f65e08b0..85c805260f 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -28,5 +28,30 @@ extend google.protobuf.FieldOptions { optional string field_ifdef = 1042; optional uint32 fixed_array_size = 50007; optional bool no_zero_copy = 50008 [default=false]; + + // container_pointer: Zero-copy optimization for repeated fields. + // + // When container_pointer is set on a repeated field, the generated message will + // store a pointer to an existing container instead of copying the data into the + // message's own repeated field. This eliminates heap allocations and improves performance. + // + // Requirements for safe usage: + // 1. The source container must remain valid until the message is encoded + // 2. Messages must be encoded immediately (which ESPHome does by default) + // 3. The container type must match the field type exactly + // + // Supported container types: + // - "std::vector" for most repeated fields + // - "std::set" for unique/sorted data + // - Full type specification required for enums (e.g., "std::set") + // + // Example usage in .proto file: + // repeated string supported_modes = 12 [(container_pointer) = "std::set"]; + // repeated ColorMode color_modes = 13 [(container_pointer) = "std::set"]; + // + // The corresponding C++ code must provide const reference access to a container + // that matches the specified type and remains valid during message encoding. + // This is typically done through methods returning const T& or special accessor + // methods like get_options() or supported_modes_for_api_(). optional string container_pointer = 50001; } From 5f7c2f771f156c29fe24da1f4033bf33e5904d0b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 29 Jul 2025 16:20:37 +1000 Subject: [PATCH 0120/1626] [adc] Enable ADC on ESP32-P4 (#9954) --- esphome/components/adc/__init__.py | 20 +++++++++++++++++++- esphome/components/adc/adc_sensor.h | 4 ++-- esphome/components/adc/adc_sensor_esp32.cpp | 13 ++++++------- tests/components/adc/test.esp32-p4-idf.yaml | 6 ++++++ 4 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 tests/components/adc/test.esp32-p4-idf.yaml diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index efe3b190af..1232d9677f 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components.esp32 import get_esp32_variant +from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant from esphome.components.esp32.const import ( VARIANT_ESP32, VARIANT_ESP32C2, @@ -140,6 +140,16 @@ ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = { 9: adc_channel_t.ADC_CHANNEL_8, 10: adc_channel_t.ADC_CHANNEL_9, }, + VARIANT_ESP32P4: { + 16: adc_channel_t.ADC_CHANNEL_0, + 17: adc_channel_t.ADC_CHANNEL_1, + 18: adc_channel_t.ADC_CHANNEL_2, + 19: adc_channel_t.ADC_CHANNEL_3, + 20: adc_channel_t.ADC_CHANNEL_4, + 21: adc_channel_t.ADC_CHANNEL_5, + 22: adc_channel_t.ADC_CHANNEL_6, + 23: adc_channel_t.ADC_CHANNEL_7, + }, } # pin to adc2 channel mapping @@ -198,6 +208,14 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { 19: adc_channel_t.ADC_CHANNEL_8, 20: adc_channel_t.ADC_CHANNEL_9, }, + VARIANT_ESP32P4: { + 49: adc_channel_t.ADC_CHANNEL_0, + 50: adc_channel_t.ADC_CHANNEL_1, + 51: adc_channel_t.ADC_CHANNEL_2, + 52: adc_channel_t.ADC_CHANNEL_3, + 53: adc_channel_t.ADC_CHANNEL_4, + 54: adc_channel_t.ADC_CHANNEL_5, + }, } diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index a60272a1f7..00a703191e 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -136,8 +136,8 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage adc_oneshot_unit_handle_t adc_handle_{nullptr}; adc_cali_handle_t calibration_handle_{nullptr}; adc_atten_t attenuation_{ADC_ATTEN_DB_0}; - adc_channel_t channel_; - adc_unit_t adc_unit_; + adc_channel_t channel_{}; + adc_unit_t adc_unit_{}; struct SetupFlags { uint8_t init_complete : 1; uint8_t config_complete : 1; diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index 4f0ffbdc38..9905475b1e 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -72,10 +72,9 @@ void ADCSensor::setup() { // Initialize ADC calibration if (this->calibration_handle_ == nullptr) { adc_cali_handle_t handle = nullptr; - esp_err_t err; #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 + USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 // RISC-V variants and S3 use curve fitting calibration adc_cali_curve_fitting_config_t cali_config = {}; // Zero initialize first #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) @@ -187,7 +186,7 @@ float ADCSensor::sample_fixed_attenuation_() { ESP_LOGW(TAG, "ADC calibration conversion failed with error %d, disabling calibration", err); if (this->calibration_handle_ != nullptr) { #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 + USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); #else // Other ESP32 variants use line fitting calibration adc_cali_delete_scheme_line_fitting(this->calibration_handle_); @@ -220,7 +219,7 @@ float ADCSensor::sample_autorange_() { if (this->calibration_handle_ != nullptr) { // Delete old calibration handle #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 + USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); #else adc_cali_delete_scheme_line_fitting(this->calibration_handle_); @@ -232,7 +231,7 @@ float ADCSensor::sample_autorange_() { adc_cali_handle_t handle = nullptr; #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 + USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 adc_cali_curve_fitting_config_t cali_config = {}; #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) cali_config.chan = this->channel_; @@ -261,7 +260,7 @@ float ADCSensor::sample_autorange_() { ESP_LOGW(TAG, "ADC read failed in autorange with error %d", err); if (handle != nullptr) { #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 + USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 adc_cali_delete_scheme_curve_fitting(handle); #else adc_cali_delete_scheme_line_fitting(handle); @@ -281,7 +280,7 @@ float ADCSensor::sample_autorange_() { } // Clean up calibration handle #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 + USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 adc_cali_delete_scheme_curve_fitting(handle); #else adc_cali_delete_scheme_line_fitting(handle); diff --git a/tests/components/adc/test.esp32-p4-idf.yaml b/tests/components/adc/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..97844cf398 --- /dev/null +++ b/tests/components/adc/test.esp32-p4-idf.yaml @@ -0,0 +1,6 @@ +packages: + base: !include common.yaml + +sensor: + - id: !extend my_sensor + pin: GPIO50 From ace375944c17a3c8bd33d1b051e100b986cfdbf4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 29 Jul 2025 18:44:45 +1200 Subject: [PATCH 0121/1626] [esp32] Fix post build (#9951) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32/post_build.py.script | 64 ++++++++++++------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index 6e0e439011..586f12e00b 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -1,10 +1,11 @@ -Import("env") +Import("env") # noqa: F821 + +import itertools # noqa: E402 +import json # noqa: E402 +import os # noqa: E402 +import pathlib # noqa: E402 +import shutil # noqa: E402 -import os -import json -import shutil -import pathlib -import itertools def merge_factory_bin(source, target, env): """ @@ -25,7 +26,9 @@ def merge_factory_bin(source, target, env): try: with flasher_args_path.open() as f: flash_data = json.load(f) - for addr, fname in sorted(flash_data["flash_files"].items(), key=lambda kv: int(kv[0], 16)): + for addr, fname in sorted( + flash_data["flash_files"].items(), key=lambda kv: int(kv[0], 16) + ): file_path = pathlib.Path(fname) if file_path.exists(): sections.append((addr, str(file_path))) @@ -40,20 +43,27 @@ def merge_factory_bin(source, target, env): if flash_images: print("Using FLASH_EXTRA_IMAGES from PlatformIO environment") # flatten any nested lists - flat = list(itertools.chain.from_iterable( - x if isinstance(x, (list, tuple)) else [x] for x in flash_images - )) + flat = list( + itertools.chain.from_iterable( + x if isinstance(x, (list, tuple)) else [x] for x in flash_images + ) + ) entries = [env.subst(x) for x in flat] for i in range(0, len(entries) - 1, 2): addr, fname = entries[i], entries[i + 1] if isinstance(fname, (list, tuple)): - print(f"Warning: Skipping malformed FLASH_EXTRA_IMAGES entry: {fname}") + print( + f"Warning: Skipping malformed FLASH_EXTRA_IMAGES entry: {fname}" + ) continue file_path = pathlib.Path(str(fname)) if file_path.exists(): - sections.append((addr, str(file_path))) + sections.append((addr, file_path)) else: print(f"Info: {file_path.name} not found — skipping") + if sections: + # Append main firmware to sections + sections.append(("0x10000", firmware_path)) # 3. Final fallback: guess standard image locations if not sections: @@ -62,11 +72,11 @@ def merge_factory_bin(source, target, env): ("0x0", build_dir / "bootloader" / "bootloader.bin"), ("0x8000", build_dir / "partition_table" / "partition-table.bin"), ("0xe000", build_dir / "ota_data_initial.bin"), - ("0x10000", firmware_path) + ("0x10000", firmware_path), ] for addr, file_path in guesses: if file_path.exists(): - sections.append((addr, str(file_path))) + sections.append((addr, file_path)) else: print(f"Info: {file_path.name} not found — skipping") @@ -76,21 +86,25 @@ def merge_factory_bin(source, target, env): return output_path = firmware_path.with_suffix(".factory.bin") + python_exe = f'"{env.subst("$PYTHONEXE")}"' cmd = [ - "--chip", chip, + python_exe, + "-m", + "esptool", + "--chip", + chip, "merge_bin", - "--flash_size", flash_size, - "--output", str(output_path) + "--flash_size", + flash_size, + "--output", + str(output_path), ] for addr, file_path in sections: - cmd += [addr, file_path] + cmd += [addr, str(file_path)] print(f"Merging binaries into {output_path}") result = env.Execute( - env.VerboseAction( - f"{env.subst('$PYTHONEXE')} -m esptool " + " ".join(cmd), - "Merging binaries with esptool" - ) + env.VerboseAction(" ".join(cmd), "Merging binaries with esptool") ) if result == 0: @@ -98,6 +112,7 @@ def merge_factory_bin(source, target, env): else: print(f"Error: esptool merge_bin failed with code {result}") + def esp32_copy_ota_bin(source, target, env): """ Copy the main firmware to a .ota.bin file for compatibility with ESPHome OTA tools. @@ -107,6 +122,7 @@ def esp32_copy_ota_bin(source, target, env): shutil.copyfile(firmware_name, new_file_name) print(f"Copied firmware to {new_file_name}") + # Run merge first, then ota copy second -env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", merge_factory_bin) -env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_copy_ota_bin) +env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", merge_factory_bin) # noqa: F821 +env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_copy_ota_bin) # noqa: F821 From 1f0c606be49940d252cdbe90544a8bcd7c95529e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 29 Jul 2025 17:32:45 +1000 Subject: [PATCH 0122/1626] [component] Revert setup messages to LOG_CONFIG level (#9956) --- esphome/core/component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 513b0a7ba2..40cda17ca3 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -161,7 +161,7 @@ void Component::call() { this->call_setup(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t setup_time = millis() - start_time; - ESP_LOGD(TAG, "Setup %s took %ums", this->get_component_source(), (unsigned) setup_time); + ESP_LOGCONFIG(TAG, "Setup %s took %ums", this->get_component_source(), (unsigned) setup_time); #endif break; } From e0e0a1a420a413a4bc9b789a7c3769cebf0c688d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 22:08:29 -1000 Subject: [PATCH 0123/1626] [esp32_touch] Work around ESP-IDF v5.4 regression in touch_pad_read_filtered() --- .../components/esp32_touch/esp32_touch_v1.cpp | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/esphome/components/esp32_touch/esp32_touch_v1.cpp b/esphome/components/esp32_touch/esp32_touch_v1.cpp index 629dc8e793..d41f1615c8 100644 --- a/esphome/components/esp32_touch/esp32_touch_v1.cpp +++ b/esphome/components/esp32_touch/esp32_touch_v1.cpp @@ -201,15 +201,13 @@ void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) { touch_pad_t pad = child->get_touch_pad(); // Read current value using ISR-safe API - uint32_t value; - if (component->iir_filter_enabled_()) { - uint16_t temp_value = 0; - touch_pad_read_filtered(pad, &temp_value); - value = temp_value; - } else { - // Use low-level HAL function when filter is not enabled - value = touch_ll_read_raw_data(pad); - } + // IMPORTANT: ESP-IDF v5.4 regression - touch_pad_read_filtered() is no longer ISR-safe + // In v5.3 and earlier it was ISR-safe, but v5.4 added mutex protection that causes: + // "assert failed: xQueueSemaphoreTake queue.c:1718" + // We must use raw values even when filter is enabled as a workaround. + // Users should adjust thresholds to compensate for the lack of IIR filtering. + // See: https://github.com/espressif/esp-idf/issues/17045 + uint32_t value = touch_ll_read_raw_data(pad); // Skip pads that aren’t in the trigger mask if (((mask >> pad) & 1) == 0) { From a7dd849a8e4714efb1f304d2e872d1a7013200ab Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Tue, 29 Jul 2025 13:00:47 -0400 Subject: [PATCH 0124/1626] Media player API enumeration alignment and feature flags (#9949) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 10 +++++ esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_pb2.cpp | 2 + esphome/components/api/api_pb2.h | 11 +++++- esphome/components/api/api_pb2_dump.cpp | 17 ++++++++ .../components/media_player/media_player.h | 39 ++++++++++++++++++- 6 files changed, 78 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index e0e1602fcb..4cf275c7f7 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1297,6 +1297,7 @@ enum MediaPlayerState { MEDIA_PLAYER_STATE_IDLE = 1; MEDIA_PLAYER_STATE_PLAYING = 2; MEDIA_PLAYER_STATE_PAUSED = 3; + MEDIA_PLAYER_STATE_ANNOUNCING = 4; } enum MediaPlayerCommand { MEDIA_PLAYER_COMMAND_PLAY = 0; @@ -1304,6 +1305,13 @@ enum MediaPlayerCommand { MEDIA_PLAYER_COMMAND_STOP = 2; MEDIA_PLAYER_COMMAND_MUTE = 3; MEDIA_PLAYER_COMMAND_UNMUTE = 4; + MEDIA_PLAYER_COMMAND_TOGGLE = 5; + MEDIA_PLAYER_COMMAND_VOLUME_UP = 6; + MEDIA_PLAYER_COMMAND_VOLUME_DOWN = 7; + MEDIA_PLAYER_COMMAND_ENQUEUE = 8; + MEDIA_PLAYER_COMMAND_REPEAT_ONE = 9; + MEDIA_PLAYER_COMMAND_REPEAT_OFF = 10; + MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST = 11; } enum MediaPlayerFormatPurpose { MEDIA_PLAYER_FORMAT_PURPOSE_DEFAULT = 0; @@ -1338,6 +1346,8 @@ message ListEntitiesMediaPlayerResponse { repeated MediaPlayerSupportedFormat supported_formats = 9; uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"]; + + uint32 feature_flags = 11; } message MediaPlayerStateResponse { option (id) = 64; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cd27087fe8..6f71ab0929 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1008,6 +1008,7 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec ListEntitiesMediaPlayerResponse msg; auto traits = media_player->get_traits(); msg.supports_pause = traits.get_supports_pause(); + msg.feature_flags = traits.get_feature_flags(); for (auto &supported_format : traits.get_supported_formats()) { msg.supported_formats.emplace_back(); auto &media_format = msg.supported_formats.back(); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index f6f39f901f..35ba1e6c30 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1725,6 +1725,7 @@ void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { #ifdef USE_DEVICES buffer.encode_uint32(10, this->device_id); #endif + buffer.encode_uint32(11, this->feature_flags); } void ListEntitiesMediaPlayerResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->object_id_ref_.size()); @@ -1740,6 +1741,7 @@ void ListEntitiesMediaPlayerResponse::calculate_size(ProtoSize &size) const { #ifdef USE_DEVICES size.add_uint32(1, this->device_id); #endif + size.add_uint32(1, this->feature_flags); } void MediaPlayerStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->key); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index f637e44df3..c994031bcb 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -149,6 +149,7 @@ enum MediaPlayerState : uint32_t { MEDIA_PLAYER_STATE_IDLE = 1, MEDIA_PLAYER_STATE_PLAYING = 2, MEDIA_PLAYER_STATE_PAUSED = 3, + MEDIA_PLAYER_STATE_ANNOUNCING = 4, }; enum MediaPlayerCommand : uint32_t { MEDIA_PLAYER_COMMAND_PLAY = 0, @@ -156,6 +157,13 @@ enum MediaPlayerCommand : uint32_t { MEDIA_PLAYER_COMMAND_STOP = 2, MEDIA_PLAYER_COMMAND_MUTE = 3, MEDIA_PLAYER_COMMAND_UNMUTE = 4, + MEDIA_PLAYER_COMMAND_TOGGLE = 5, + MEDIA_PLAYER_COMMAND_VOLUME_UP = 6, + MEDIA_PLAYER_COMMAND_VOLUME_DOWN = 7, + MEDIA_PLAYER_COMMAND_ENQUEUE = 8, + MEDIA_PLAYER_COMMAND_REPEAT_ONE = 9, + MEDIA_PLAYER_COMMAND_REPEAT_OFF = 10, + MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST = 11, }; enum MediaPlayerFormatPurpose : uint32_t { MEDIA_PLAYER_FORMAT_PURPOSE_DEFAULT = 0, @@ -1683,12 +1691,13 @@ class MediaPlayerSupportedFormat : public ProtoMessage { class ListEntitiesMediaPlayerResponse : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 63; - static constexpr uint8_t ESTIMATED_SIZE = 76; + static constexpr uint8_t ESTIMATED_SIZE = 80; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_media_player_response"; } #endif bool supports_pause{false}; std::vector supported_formats{}; + uint32_t feature_flags{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index aca60464a3..5e17f6148a 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -383,6 +383,8 @@ template<> const char *proto_enum_to_string(enums::Medi return "MEDIA_PLAYER_STATE_PLAYING"; case enums::MEDIA_PLAYER_STATE_PAUSED: return "MEDIA_PLAYER_STATE_PAUSED"; + case enums::MEDIA_PLAYER_STATE_ANNOUNCING: + return "MEDIA_PLAYER_STATE_ANNOUNCING"; default: return "UNKNOWN"; } @@ -399,6 +401,20 @@ template<> const char *proto_enum_to_string(enums::Me return "MEDIA_PLAYER_COMMAND_MUTE"; case enums::MEDIA_PLAYER_COMMAND_UNMUTE: return "MEDIA_PLAYER_COMMAND_UNMUTE"; + case enums::MEDIA_PLAYER_COMMAND_TOGGLE: + return "MEDIA_PLAYER_COMMAND_TOGGLE"; + case enums::MEDIA_PLAYER_COMMAND_VOLUME_UP: + return "MEDIA_PLAYER_COMMAND_VOLUME_UP"; + case enums::MEDIA_PLAYER_COMMAND_VOLUME_DOWN: + return "MEDIA_PLAYER_COMMAND_VOLUME_DOWN"; + case enums::MEDIA_PLAYER_COMMAND_ENQUEUE: + return "MEDIA_PLAYER_COMMAND_ENQUEUE"; + case enums::MEDIA_PLAYER_COMMAND_REPEAT_ONE: + return "MEDIA_PLAYER_COMMAND_REPEAT_ONE"; + case enums::MEDIA_PLAYER_COMMAND_REPEAT_OFF: + return "MEDIA_PLAYER_COMMAND_REPEAT_OFF"; + case enums::MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST: + return "MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST"; default: return "UNKNOWN"; } @@ -1466,6 +1482,7 @@ void ListEntitiesMediaPlayerResponse::dump_to(std::string &out) const { #ifdef USE_DEVICES dump_field(out, "device_id", this->device_id); #endif + dump_field(out, "feature_flags", this->feature_flags); } void MediaPlayerStateResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "MediaPlayerStateResponse"); diff --git a/esphome/components/media_player/media_player.h b/esphome/components/media_player/media_player.h index ee5889901c..98aa8f609d 100644 --- a/esphome/components/media_player/media_player.h +++ b/esphome/components/media_player/media_player.h @@ -6,12 +6,38 @@ namespace esphome { namespace media_player { +enum MediaPlayerEntityFeature : uint32_t { + PAUSE = 1 << 0, + SEEK = 1 << 1, + VOLUME_SET = 1 << 2, + VOLUME_MUTE = 1 << 3, + PREVIOUS_TRACK = 1 << 4, + NEXT_TRACK = 1 << 5, + + TURN_ON = 1 << 7, + TURN_OFF = 1 << 8, + PLAY_MEDIA = 1 << 9, + VOLUME_STEP = 1 << 10, + SELECT_SOURCE = 1 << 11, + STOP = 1 << 12, + CLEAR_PLAYLIST = 1 << 13, + PLAY = 1 << 14, + SHUFFLE_SET = 1 << 15, + SELECT_SOUND_MODE = 1 << 16, + BROWSE_MEDIA = 1 << 17, + REPEAT_SET = 1 << 18, + GROUPING = 1 << 19, + MEDIA_ANNOUNCE = 1 << 20, + MEDIA_ENQUEUE = 1 << 21, + SEARCH_MEDIA = 1 << 22, +}; + enum MediaPlayerState : uint8_t { MEDIA_PLAYER_STATE_NONE = 0, MEDIA_PLAYER_STATE_IDLE = 1, MEDIA_PLAYER_STATE_PLAYING = 2, MEDIA_PLAYER_STATE_PAUSED = 3, - MEDIA_PLAYER_STATE_ANNOUNCING = 4 + MEDIA_PLAYER_STATE_ANNOUNCING = 4, }; const char *media_player_state_to_string(MediaPlayerState state); @@ -56,6 +82,17 @@ class MediaPlayerTraits { std::vector &get_supported_formats() { return this->supported_formats_; } + uint32_t get_feature_flags() const { + uint32_t flags = 0; + flags |= MediaPlayerEntityFeature::PLAY_MEDIA | MediaPlayerEntityFeature::BROWSE_MEDIA | + MediaPlayerEntityFeature::STOP | MediaPlayerEntityFeature::VOLUME_SET | + MediaPlayerEntityFeature::VOLUME_MUTE | MediaPlayerEntityFeature::MEDIA_ANNOUNCE; + if (this->get_supports_pause()) { + flags |= MediaPlayerEntityFeature::PAUSE | MediaPlayerEntityFeature::PLAY; + } + return flags; + } + protected: bool supports_pause_{false}; std::vector supported_formats_{}; From 9c6dbbd8ea624894e78b80aeba2901ea92f2cfd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Jul 2025 17:43:35 +0000 Subject: [PATCH 0125/1626] Bump aioesphomeapi from 37.1.3 to 37.1.4 (#9964) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9889e83910..cfae43e509 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.9.0 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.1.3 +aioesphomeapi==37.1.4 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 5c44cd8962e1b2329fbdecf2ce38faa401a4ec3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 09:50:11 -1000 Subject: [PATCH 0126/1626] [esp32_ble] Add PHY configuration and default to 1M for compatibility --- esphome/components/esp32_ble/__init__.py | 37 +++++++++++++++++ esphome/components/esp32_ble/ble.cpp | 52 +++++++++++++++++++++++- esphome/components/esp32_ble/ble.h | 8 ++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 93bb643596..224b30aa3f 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -117,9 +117,22 @@ CONF_BLE_ID = "ble_id" CONF_IO_CAPABILITY = "io_capability" CONF_ADVERTISING_CYCLE_TIME = "advertising_cycle_time" CONF_DISABLE_BT_LOGS = "disable_bt_logs" +CONF_PREFERRED_PHY = "preferred_phy" NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] +# ESP32 variants that support BLE +BLE_VARIANTS = { + const.VARIANT_ESP32, + const.VARIANT_ESP32C3, + const.VARIANT_ESP32S3, + const.VARIANT_ESP32C6, + const.VARIANT_ESP32H2, +} + +# ESP32 variants that support 2M PHY +BLE_2M_PHY_VARIANTS = BLE_VARIANTS - {const.VARIANT_ESP32} + esp32_ble_ns = cg.esphome_ns.namespace("esp32_ble") ESP32BLE = esp32_ble_ns.class_("ESP32BLE", cg.Component) @@ -140,6 +153,13 @@ IO_CAPABILITY = { "display_yes_no": IoCapability.IO_CAP_IO, } +BLEPhy = esp32_ble_ns.enum("BLEPhy") +BLE_PHY_OPTIONS = { + "1m": BLEPhy.BLE_PHY_1M, + "2m": BLEPhy.BLE_PHY_2M, + "auto": BLEPhy.BLE_PHY_AUTO, +} + esp_power_level_t = cg.global_ns.enum("esp_power_level_t") TX_POWER_LEVELS = { @@ -153,6 +173,18 @@ TX_POWER_LEVELS = { 9: esp_power_level_t.ESP_PWR_LVL_P9, } + +def validate_phy(value: str) -> str: + """Validate PHY selection based on ESP32 variant.""" + variant = get_esp32_variant() + if value == "2m" and variant not in BLE_2M_PHY_VARIANTS: + raise cv.Invalid( + f"2M PHY is not supported on {variant}. " + f"Only supported on: {', '.join(sorted(BLE_2M_PHY_VARIANTS))}" + ) + return value + + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(ESP32BLE), @@ -167,6 +199,10 @@ CONFIG_SCHEMA = cv.Schema( cv.SplitDefault(CONF_DISABLE_BT_LOGS, esp32_idf=True): cv.All( cv.only_with_esp_idf, cv.boolean ), + cv.Optional(CONF_PREFERRED_PHY, default="1m"): cv.All( + cv.enum(BLE_PHY_OPTIONS, lower=True), + validate_phy, + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -237,6 +273,7 @@ async def to_code(config): cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) cg.add(var.set_io_capability(config[CONF_IO_CAPABILITY])) cg.add(var.set_advertising_cycle_time(config[CONF_ADVERTISING_CYCLE_TIME])) + cg.add(var.set_preferred_phy(config[CONF_PREFERRED_PHY])) if (name := config.get(CONF_NAME)) is not None: cg.add(var.set_name(name)) await cg.register_component(var, config) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 6b4ce07f15..97452c508a 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -23,6 +23,35 @@ namespace esphome::esp32_ble { static const char *const TAG = "esp32_ble"; +static const char *phy_mode_to_string(BLEPhy phy) { + switch (phy) { + case BLE_PHY_1M: + return "1M"; + case BLE_PHY_2M: + return "2M"; + case BLE_PHY_AUTO: + return "AUTO"; + default: + return "UNKNOWN"; + } +} + +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ + defined(USE_ESP32_VARIANT_ESP32H2) +static uint8_t phy_mode_to_mask(BLEPhy phy) { + switch (phy) { + case BLE_PHY_1M: + return ESP_BLE_GAP_PHY_1M_PREF_MASK; + case BLE_PHY_2M: + return ESP_BLE_GAP_PHY_2M_PREF_MASK; + case BLE_PHY_AUTO: + return ESP_BLE_GAP_PHY_1M_PREF_MASK | ESP_BLE_GAP_PHY_2M_PREF_MASK; + default: + return ESP_BLE_GAP_PHY_1M_PREF_MASK; // Default to 1M + } +} +#endif + void ESP32BLE::setup() { global_ble = this; if (!ble_pre_setup_()) { @@ -208,6 +237,23 @@ bool ESP32BLE::ble_setup_() { return false; } + // Configure PHY settings +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ + defined(USE_ESP32_VARIANT_ESP32H2) + // Only newer ESP32 variants support PHY configuration + if (this->preferred_phy_ != BLE_PHY_AUTO) { + uint8_t phy_mask = phy_mode_to_mask(this->preferred_phy_); + + err = esp_ble_gap_set_preferred_default_phy(phy_mask, phy_mask); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_ble_gap_set_preferred_default_phy failed: %d", err); + // Not a fatal error, continue + } else { + ESP_LOGD(TAG, "Set preferred PHY to %s", phy_mode_to_string(this->preferred_phy_)); + } + } +#endif + // BLE takes some time to be fully set up, 200ms should be more than enough delay(200); // NOLINT @@ -515,8 +561,10 @@ void ESP32BLE::dump_config() { ESP_LOGCONFIG(TAG, "BLE:\n" " MAC address: %s\n" - " IO Capability: %s", - format_mac_address_pretty(mac_address).c_str(), io_capability_s); + " IO Capability: %s\n" + " Preferred PHY: %s", + format_mac_address_pretty(mac_address).c_str(), io_capability_s, + phy_mode_to_string(this->preferred_phy_)); } else { ESP_LOGCONFIG(TAG, "Bluetooth stack is not enabled"); } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 543b2f26a3..abe0d57aa0 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -55,6 +55,12 @@ enum IoCapability { IO_CAP_KBDISP = ESP_IO_CAP_KBDISP, }; +enum BLEPhy : uint8_t { + BLE_PHY_1M = 0x01, + BLE_PHY_2M = 0x02, + BLE_PHY_AUTO = 0x03, +}; + enum BLEComponentState : uint8_t { /** Nothing has been initialized yet. */ BLE_COMPONENT_STATE_OFF = 0, @@ -98,6 +104,7 @@ class BLEStatusEventHandler { class ESP32BLE : public Component { public: void set_io_capability(IoCapability io_capability) { this->io_cap_ = (esp_ble_io_cap_t) io_capability; } + void set_preferred_phy(BLEPhy phy) { this->preferred_phy_ = phy; } void set_advertising_cycle_time(uint32_t advertising_cycle_time) { this->advertising_cycle_time_ = advertising_cycle_time; @@ -170,6 +177,7 @@ class ESP32BLE : public Component { // 1-byte aligned members (grouped together to minimize padding) BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum) bool enable_on_boot_{}; // 1 byte + BLEPhy preferred_phy_{BLE_PHY_1M}; // 1 byte (uint8_t enum) }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) From 51d2e7085438ca26ef3f9100ea18742046204dae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 09:54:44 -1000 Subject: [PATCH 0127/1626] test --- tests/components/esp32_ble/common.yaml | 1 + tests/components/esp32_ble/test.esp32-s3-ard.yaml | 2 ++ tests/components/esp32_ble/test.esp32-s3-idf.yaml | 2 ++ 3 files changed, 5 insertions(+) create mode 100644 tests/components/esp32_ble/test.esp32-s3-ard.yaml create mode 100644 tests/components/esp32_ble/test.esp32-s3-idf.yaml diff --git a/tests/components/esp32_ble/common.yaml b/tests/components/esp32_ble/common.yaml index 76b35fc8f8..86d5dcee60 100644 --- a/tests/components/esp32_ble/common.yaml +++ b/tests/components/esp32_ble/common.yaml @@ -1,2 +1,3 @@ esp32_ble: io_capability: keyboard_only + # Default configuration - should use 1m PHY diff --git a/tests/components/esp32_ble/test.esp32-s3-ard.yaml b/tests/components/esp32_ble/test.esp32-s3-ard.yaml new file mode 100644 index 0000000000..3c05b93858 --- /dev/null +++ b/tests/components/esp32_ble/test.esp32-s3-ard.yaml @@ -0,0 +1,2 @@ +esp32_ble: + preferred_phy: 2m diff --git a/tests/components/esp32_ble/test.esp32-s3-idf.yaml b/tests/components/esp32_ble/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..8f1d32779d --- /dev/null +++ b/tests/components/esp32_ble/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +esp32_ble: + preferred_phy: auto From 56c88807ee0ed06acff06c453533c890c1b2cfe1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 30 Jul 2025 06:16:32 +1000 Subject: [PATCH 0128/1626] [mipi_dsi] Add dependencies (#9952) --- esphome/components/mipi_dsi/display.py | 3 ++- tests/components/mipi_dsi/test.esp32-p4-idf.yaml | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 4ed70a04c2..4fc837be67 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -57,7 +57,8 @@ from esphome.final_validate import full_config from . import mipi_dsi_ns, models -DEPENDENCIES = ["esp32"] +# Currently only ESP32-P4 is supported, so esp_ldo and psram are required +DEPENDENCIES = ["esp32", "esp_ldo", "psram"] DOMAIN = "mipi_dsi" LOGGER = logging.getLogger(DOMAIN) diff --git a/tests/components/mipi_dsi/test.esp32-p4-idf.yaml b/tests/components/mipi_dsi/test.esp32-p4-idf.yaml index 8a6f3c87ba..9c4eb07d9b 100644 --- a/tests/components/mipi_dsi/test.esp32-p4-idf.yaml +++ b/tests/components/mipi_dsi/test.esp32-p4-idf.yaml @@ -12,6 +12,8 @@ display: #- platform: mipi_dsi #id: backlight_id +psram: + i2c: sda: GPIO7 scl: GPIO8 From 1adf45eebfb13f5695ed0251c3103dd5946378a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 10:17:00 -1000 Subject: [PATCH 0129/1626] [esp32_ble] Fix spurious BLE 5.0 event warnings on ESP32-S3 --- esphome/components/esp32_ble/ble.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 6b4ce07f15..33258552c7 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -468,6 +468,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa // Ignore these GAP events as they are not relevant for our use case case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: + case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete + case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm return; default: From daccaf36a78e4d5e7065ba2bb87e7bcbb478a289 Mon Sep 17 00:00:00 2001 From: Dayowe <143176453+dayowe@users.noreply.github.com> Date: Tue, 29 Jul 2025 23:10:53 +0200 Subject: [PATCH 0130/1626] Fix WiFi to prefer strongest AP when multiple APs have same SSID (#9963) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/wifi/wifi_component.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index e85acbf5a7..2731dc1f3f 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -533,9 +533,17 @@ void WiFiComponent::check_scanning_finished() { return false; if (a.get_matches() && b.get_matches()) { - // if both match, check priority + // For APs with the same SSID, always prefer stronger signal + // This helps with mesh networks and multiple APs + if (a.get_ssid() == b.get_ssid()) { + return a.get_rssi() > b.get_rssi(); + } + + // For different SSIDs, check priority first if (a.get_priority() != b.get_priority()) return a.get_priority() > b.get_priority(); + // If priorities are equal, prefer stronger signal + return a.get_rssi() > b.get_rssi(); } return a.get_rssi() > b.get_rssi(); From 76d33308d92b7f90d2e3c05c96f5ee817aebef56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 12:41:37 -1000 Subject: [PATCH 0131/1626] [api] Eliminate heap allocations when populating repeated fields from containers (#9948) --- esphome/components/api/api.proto | 20 ++--- esphome/components/api/api_connection.cpp | 31 +++----- esphome/components/api/api_options.proto | 26 +++++++ esphome/components/api/api_pb2.cpp | 60 +++++++-------- esphome/components/api/api_pb2.h | 21 +++--- esphome/components/api/api_pb2_dump.cpp | 20 ++--- esphome/components/api/api_pb2_includes.h | 34 +++++++++ esphome/components/climate/climate_traits.h | 24 ++++++ esphome/components/fan/fan_traits.h | 16 ++++ esphome/components/light/light_traits.h | 17 +++++ esphome/components/select/select_traits.cpp | 2 +- esphome/components/select/select_traits.h | 2 +- script/api_protobuf/api_protobuf.py | 83 ++++++++++++++++----- 13 files changed, 255 insertions(+), 101 deletions(-) create mode 100644 esphome/components/api/api_pb2_includes.h diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4cf275c7f7..8eb2d26e54 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -419,7 +419,7 @@ message ListEntitiesFanResponse { bool disabled_by_default = 9; string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON"]; EntityCategory entity_category = 11; - repeated string supported_preset_modes = 12; + repeated string supported_preset_modes = 12 [(container_pointer) = "std::set"]; uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"]; } // Deprecated in API version 1.6 - only used in deprecated fields @@ -500,7 +500,7 @@ message ListEntitiesLightResponse { string name = 3; reserved 4; // Deprecated: was string unique_id - repeated ColorMode supported_color_modes = 12; + repeated ColorMode supported_color_modes = 12 [(container_pointer) = "std::set"]; // next four supports_* are for legacy clients, newer clients should use color modes // Deprecated in API version 1.6 bool legacy_supports_brightness = 5 [deprecated=true]; @@ -966,7 +966,7 @@ message ListEntitiesClimateResponse { bool supports_current_temperature = 5; bool supports_two_point_target_temperature = 6; - repeated ClimateMode supported_modes = 7; + repeated ClimateMode supported_modes = 7 [(container_pointer) = "std::set"]; float visual_min_temperature = 8; float visual_max_temperature = 9; float visual_target_temperature_step = 10; @@ -975,11 +975,11 @@ message ListEntitiesClimateResponse { // Deprecated in API version 1.5 bool legacy_supports_away = 11 [deprecated=true]; bool supports_action = 12; - repeated ClimateFanMode supported_fan_modes = 13; - repeated ClimateSwingMode supported_swing_modes = 14; - repeated string supported_custom_fan_modes = 15; - repeated ClimatePreset supported_presets = 16; - repeated string supported_custom_presets = 17; + repeated ClimateFanMode supported_fan_modes = 13 [(container_pointer) = "std::set"]; + repeated ClimateSwingMode supported_swing_modes = 14 [(container_pointer) = "std::set"]; + repeated string supported_custom_fan_modes = 15 [(container_pointer) = "std::set"]; + repeated ClimatePreset supported_presets = 16 [(container_pointer) = "std::set"]; + repeated string supported_custom_presets = 17 [(container_pointer) = "std::set"]; bool disabled_by_default = 18; string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON"]; EntityCategory entity_category = 20; @@ -1119,7 +1119,7 @@ message ListEntitiesSelectResponse { reserved 4; // Deprecated: was string unique_id string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"]; - repeated string options = 6; + repeated string options = 6 [(container_pointer) = "std::vector"]; bool disabled_by_default = 7; EntityCategory entity_category = 8; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; @@ -1844,7 +1844,7 @@ message VoiceAssistantConfigurationResponse { option (ifdef) = "USE_VOICE_ASSISTANT"; repeated VoiceAssistantWakeWord available_wake_words = 1; - repeated string active_wake_words = 2; + repeated string active_wake_words = 2 [(container_pointer) = "std::vector"]; uint32 max_active_wake_words = 3; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 6f71ab0929..51c7509428 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -413,8 +413,7 @@ uint16_t APIConnection::try_send_fan_info(EntityBase *entity, APIConnection *con msg.supports_speed = traits.supports_speed(); msg.supports_direction = traits.supports_direction(); msg.supported_speed_count = traits.supported_speed_count(); - for (auto const &preset : traits.supported_preset_modes()) - msg.supported_preset_modes.push_back(preset); + msg.supported_preset_modes = &traits.supported_preset_modes_for_api_(); return fill_and_encode_entity_info(fan, msg, ListEntitiesFanResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } void APIConnection::fan_command(const FanCommandRequest &msg) { @@ -470,8 +469,7 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c auto *light = static_cast(entity); ListEntitiesLightResponse msg; auto traits = light->get_traits(); - for (auto mode : traits.get_supported_color_modes()) - msg.supported_color_modes.push_back(static_cast(mode)); + msg.supported_color_modes = &traits.get_supported_color_modes_for_api_(); if (traits.supports_color_capability(light::ColorCapability::COLOR_TEMPERATURE) || traits.supports_color_capability(light::ColorCapability::COLD_WARM_WHITE)) { msg.min_mireds = traits.get_min_mireds(); @@ -657,8 +655,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.supports_current_humidity = traits.get_supports_current_humidity(); msg.supports_two_point_target_temperature = traits.get_supports_two_point_target_temperature(); msg.supports_target_humidity = traits.get_supports_target_humidity(); - for (auto mode : traits.get_supported_modes()) - msg.supported_modes.push_back(static_cast(mode)); + msg.supported_modes = &traits.get_supported_modes_for_api_(); msg.visual_min_temperature = traits.get_visual_min_temperature(); msg.visual_max_temperature = traits.get_visual_max_temperature(); msg.visual_target_temperature_step = traits.get_visual_target_temperature_step(); @@ -666,16 +663,11 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.visual_min_humidity = traits.get_visual_min_humidity(); msg.visual_max_humidity = traits.get_visual_max_humidity(); msg.supports_action = traits.get_supports_action(); - for (auto fan_mode : traits.get_supported_fan_modes()) - msg.supported_fan_modes.push_back(static_cast(fan_mode)); - for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) - msg.supported_custom_fan_modes.push_back(custom_fan_mode); - for (auto preset : traits.get_supported_presets()) - msg.supported_presets.push_back(static_cast(preset)); - for (auto const &custom_preset : traits.get_supported_custom_presets()) - msg.supported_custom_presets.push_back(custom_preset); - for (auto swing_mode : traits.get_supported_swing_modes()) - msg.supported_swing_modes.push_back(static_cast(swing_mode)); + msg.supported_fan_modes = &traits.get_supported_fan_modes_for_api_(); + msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes_for_api_(); + msg.supported_presets = &traits.get_supported_presets_for_api_(); + msg.supported_custom_presets = &traits.get_supported_custom_presets_for_api_(); + msg.supported_swing_modes = &traits.get_supported_swing_modes_for_api_(); return fill_and_encode_entity_info(climate, msg, ListEntitiesClimateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -881,8 +873,7 @@ uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection * bool is_single) { auto *select = static_cast(entity); ListEntitiesSelectResponse msg; - for (const auto &option : select->traits.get_options()) - msg.options.push_back(option); + msg.options = &select->traits.get_options(); return fill_and_encode_entity_info(select, msg, ListEntitiesSelectResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -1197,9 +1188,7 @@ bool APIConnection::send_voice_assistant_get_configuration_response(const VoiceA resp_wake_word.trained_languages.push_back(lang); } } - for (auto &wake_word_id : config.active_wake_words) { - resp.active_wake_words.push_back(wake_word_id); - } + resp.active_wake_words = &config.active_wake_words; resp.max_active_wake_words = config.max_active_wake_words; return this->send_message(resp, VoiceAssistantConfigurationResponse::MESSAGE_TYPE); } diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 4f0f52fc6f..85c805260f 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -28,4 +28,30 @@ extend google.protobuf.FieldOptions { optional string field_ifdef = 1042; optional uint32 fixed_array_size = 50007; optional bool no_zero_copy = 50008 [default=false]; + + // container_pointer: Zero-copy optimization for repeated fields. + // + // When container_pointer is set on a repeated field, the generated message will + // store a pointer to an existing container instead of copying the data into the + // message's own repeated field. This eliminates heap allocations and improves performance. + // + // Requirements for safe usage: + // 1. The source container must remain valid until the message is encoded + // 2. Messages must be encoded immediately (which ESPHome does by default) + // 3. The container type must match the field type exactly + // + // Supported container types: + // - "std::vector" for most repeated fields + // - "std::set" for unique/sorted data + // - Full type specification required for enums (e.g., "std::set") + // + // Example usage in .proto file: + // repeated string supported_modes = 12 [(container_pointer) = "std::set"]; + // repeated ColorMode color_modes = 13 [(container_pointer) = "std::set"]; + // + // The corresponding C++ code must provide const reference access to a container + // that matches the specified type and remains valid during message encoding. + // This is typically done through methods returning const T& or special accessor + // methods like get_options() or supported_modes_for_api_(). + optional string container_pointer = 50001; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 35ba1e6c30..2e8adeaf5c 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -331,7 +331,7 @@ void ListEntitiesFanResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(10, this->icon_ref_); #endif buffer.encode_uint32(11, static_cast(this->entity_category)); - for (auto &it : this->supported_preset_modes) { + for (const auto &it : *this->supported_preset_modes) { buffer.encode_string(12, it, true); } #ifdef USE_DEVICES @@ -351,8 +351,8 @@ void ListEntitiesFanResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->icon_ref_.size()); #endif size.add_uint32(1, static_cast(this->entity_category)); - if (!this->supported_preset_modes.empty()) { - for (const auto &it : this->supported_preset_modes) { + if (!this->supported_preset_modes->empty()) { + for (const auto &it : *this->supported_preset_modes) { size.add_length_force(1, it.size()); } } @@ -447,7 +447,7 @@ void ListEntitiesLightResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->object_id_ref_); buffer.encode_fixed32(2, this->key); buffer.encode_string(3, this->name_ref_); - for (auto &it : this->supported_color_modes) { + for (const auto &it : *this->supported_color_modes) { buffer.encode_uint32(12, static_cast(it), true); } buffer.encode_float(9, this->min_mireds); @@ -468,8 +468,8 @@ void ListEntitiesLightResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->object_id_ref_.size()); size.add_fixed32(1, this->key); size.add_length(1, this->name_ref_.size()); - if (!this->supported_color_modes.empty()) { - for (const auto &it : this->supported_color_modes) { + if (!this->supported_color_modes->empty()) { + for (const auto &it : *this->supported_color_modes) { size.add_uint32_force(1, static_cast(it)); } } @@ -1064,26 +1064,26 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(3, this->name_ref_); buffer.encode_bool(5, this->supports_current_temperature); buffer.encode_bool(6, this->supports_two_point_target_temperature); - for (auto &it : this->supported_modes) { + for (const auto &it : *this->supported_modes) { buffer.encode_uint32(7, static_cast(it), true); } buffer.encode_float(8, this->visual_min_temperature); buffer.encode_float(9, this->visual_max_temperature); buffer.encode_float(10, this->visual_target_temperature_step); buffer.encode_bool(12, this->supports_action); - for (auto &it : this->supported_fan_modes) { + for (const auto &it : *this->supported_fan_modes) { buffer.encode_uint32(13, static_cast(it), true); } - for (auto &it : this->supported_swing_modes) { + for (const auto &it : *this->supported_swing_modes) { buffer.encode_uint32(14, static_cast(it), true); } - for (auto &it : this->supported_custom_fan_modes) { + for (const auto &it : *this->supported_custom_fan_modes) { buffer.encode_string(15, it, true); } - for (auto &it : this->supported_presets) { + for (const auto &it : *this->supported_presets) { buffer.encode_uint32(16, static_cast(it), true); } - for (auto &it : this->supported_custom_presets) { + for (const auto &it : *this->supported_custom_presets) { buffer.encode_string(17, it, true); } buffer.encode_bool(18, this->disabled_by_default); @@ -1106,8 +1106,8 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->name_ref_.size()); size.add_bool(1, this->supports_current_temperature); size.add_bool(1, this->supports_two_point_target_temperature); - if (!this->supported_modes.empty()) { - for (const auto &it : this->supported_modes) { + if (!this->supported_modes->empty()) { + for (const auto &it : *this->supported_modes) { size.add_uint32_force(1, static_cast(it)); } } @@ -1115,28 +1115,28 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { size.add_float(1, this->visual_max_temperature); size.add_float(1, this->visual_target_temperature_step); size.add_bool(1, this->supports_action); - if (!this->supported_fan_modes.empty()) { - for (const auto &it : this->supported_fan_modes) { + if (!this->supported_fan_modes->empty()) { + for (const auto &it : *this->supported_fan_modes) { size.add_uint32_force(1, static_cast(it)); } } - if (!this->supported_swing_modes.empty()) { - for (const auto &it : this->supported_swing_modes) { + if (!this->supported_swing_modes->empty()) { + for (const auto &it : *this->supported_swing_modes) { size.add_uint32_force(1, static_cast(it)); } } - if (!this->supported_custom_fan_modes.empty()) { - for (const auto &it : this->supported_custom_fan_modes) { + if (!this->supported_custom_fan_modes->empty()) { + for (const auto &it : *this->supported_custom_fan_modes) { size.add_length_force(1, it.size()); } } - if (!this->supported_presets.empty()) { - for (const auto &it : this->supported_presets) { + if (!this->supported_presets->empty()) { + for (const auto &it : *this->supported_presets) { size.add_uint32_force(2, static_cast(it)); } } - if (!this->supported_custom_presets.empty()) { - for (const auto &it : this->supported_custom_presets) { + if (!this->supported_custom_presets->empty()) { + for (const auto &it : *this->supported_custom_presets) { size.add_length_force(2, it.size()); } } @@ -1371,7 +1371,7 @@ void ListEntitiesSelectResponse::encode(ProtoWriteBuffer buffer) const { #ifdef USE_ENTITY_ICON buffer.encode_string(5, this->icon_ref_); #endif - for (auto &it : this->options) { + for (const auto &it : *this->options) { buffer.encode_string(6, it, true); } buffer.encode_bool(7, this->disabled_by_default); @@ -1387,8 +1387,8 @@ void ListEntitiesSelectResponse::calculate_size(ProtoSize &size) const { #ifdef USE_ENTITY_ICON size.add_length(1, this->icon_ref_.size()); #endif - if (!this->options.empty()) { - for (const auto &it : this->options) { + if (!this->options->empty()) { + for (const auto &it : *this->options) { size.add_length_force(1, it.size()); } } @@ -2334,15 +2334,15 @@ void VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer buffer) const for (auto &it : this->available_wake_words) { buffer.encode_message(1, it, true); } - for (auto &it : this->active_wake_words) { + for (const auto &it : *this->active_wake_words) { buffer.encode_string(2, it, true); } buffer.encode_uint32(3, this->max_active_wake_words); } void VoiceAssistantConfigurationResponse::calculate_size(ProtoSize &size) const { size.add_repeated_message(1, this->available_wake_words); - if (!this->active_wake_words.empty()) { - for (const auto &it : this->active_wake_words) { + if (!this->active_wake_words->empty()) { + for (const auto &it : *this->active_wake_words) { size.add_length_force(1, it.size()); } } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index c994031bcb..c190c8e191 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -6,6 +6,7 @@ #include "esphome/core/string_ref.h" #include "proto.h" +#include "api_pb2_includes.h" namespace esphome::api { @@ -703,7 +704,7 @@ class ListEntitiesFanResponse : public InfoResponseProtoMessage { bool supports_speed{false}; bool supports_direction{false}; int32_t supported_speed_count{0}; - std::vector supported_preset_modes{}; + const std::set *supported_preset_modes{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -768,7 +769,7 @@ class ListEntitiesLightResponse : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_light_response"; } #endif - std::vector supported_color_modes{}; + const std::set *supported_color_modes{}; float min_mireds{0.0f}; float max_mireds{0.0f}; std::vector effects{}; @@ -1319,16 +1320,16 @@ class ListEntitiesClimateResponse : public InfoResponseProtoMessage { #endif bool supports_current_temperature{false}; bool supports_two_point_target_temperature{false}; - std::vector supported_modes{}; + const std::set *supported_modes{}; float visual_min_temperature{0.0f}; float visual_max_temperature{0.0f}; float visual_target_temperature_step{0.0f}; bool supports_action{false}; - std::vector supported_fan_modes{}; - std::vector supported_swing_modes{}; - std::vector supported_custom_fan_modes{}; - std::vector supported_presets{}; - std::vector supported_custom_presets{}; + const std::set *supported_fan_modes{}; + const std::set *supported_swing_modes{}; + const std::set *supported_custom_fan_modes{}; + const std::set *supported_presets{}; + const std::set *supported_custom_presets{}; float visual_current_temperature_step{0.0f}; bool supports_current_humidity{false}; bool supports_target_humidity{false}; @@ -1475,7 +1476,7 @@ class ListEntitiesSelectResponse : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_select_response"; } #endif - std::vector options{}; + const std::vector *options{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -2448,7 +2449,7 @@ class VoiceAssistantConfigurationResponse : public ProtoMessage { const char *message_name() const override { return "voice_assistant_configuration_response"; } #endif std::vector available_wake_words{}; - std::vector active_wake_words{}; + const std::vector *active_wake_words{}; uint32_t max_active_wake_words{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 5e17f6148a..032cfbc044 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -830,7 +830,7 @@ void ListEntitiesFanResponse::dump_to(std::string &out) const { dump_field(out, "icon", this->icon_ref_); #endif dump_field(out, "entity_category", static_cast(this->entity_category)); - for (const auto &it : this->supported_preset_modes) { + for (const auto &it : *this->supported_preset_modes) { dump_field(out, "supported_preset_modes", it, 4); } #ifdef USE_DEVICES @@ -873,7 +873,7 @@ void ListEntitiesLightResponse::dump_to(std::string &out) const { dump_field(out, "object_id", this->object_id_ref_); dump_field(out, "key", this->key); dump_field(out, "name", this->name_ref_); - for (const auto &it : this->supported_color_modes) { + for (const auto &it : *this->supported_color_modes) { dump_field(out, "supported_color_modes", static_cast(it), 4); } dump_field(out, "min_mireds", this->min_mireds); @@ -1189,26 +1189,26 @@ void ListEntitiesClimateResponse::dump_to(std::string &out) const { dump_field(out, "name", this->name_ref_); dump_field(out, "supports_current_temperature", this->supports_current_temperature); dump_field(out, "supports_two_point_target_temperature", this->supports_two_point_target_temperature); - for (const auto &it : this->supported_modes) { + for (const auto &it : *this->supported_modes) { dump_field(out, "supported_modes", static_cast(it), 4); } dump_field(out, "visual_min_temperature", this->visual_min_temperature); dump_field(out, "visual_max_temperature", this->visual_max_temperature); dump_field(out, "visual_target_temperature_step", this->visual_target_temperature_step); dump_field(out, "supports_action", this->supports_action); - for (const auto &it : this->supported_fan_modes) { + for (const auto &it : *this->supported_fan_modes) { dump_field(out, "supported_fan_modes", static_cast(it), 4); } - for (const auto &it : this->supported_swing_modes) { + for (const auto &it : *this->supported_swing_modes) { dump_field(out, "supported_swing_modes", static_cast(it), 4); } - for (const auto &it : this->supported_custom_fan_modes) { + for (const auto &it : *this->supported_custom_fan_modes) { dump_field(out, "supported_custom_fan_modes", it, 4); } - for (const auto &it : this->supported_presets) { + for (const auto &it : *this->supported_presets) { dump_field(out, "supported_presets", static_cast(it), 4); } - for (const auto &it : this->supported_custom_presets) { + for (const auto &it : *this->supported_custom_presets) { dump_field(out, "supported_custom_presets", it, 4); } dump_field(out, "disabled_by_default", this->disabled_by_default); @@ -1321,7 +1321,7 @@ void ListEntitiesSelectResponse::dump_to(std::string &out) const { #ifdef USE_ENTITY_ICON dump_field(out, "icon", this->icon_ref_); #endif - for (const auto &it : this->options) { + for (const auto &it : *this->options) { dump_field(out, "options", it, 4); } dump_field(out, "disabled_by_default", this->disabled_by_default); @@ -1786,7 +1786,7 @@ void VoiceAssistantConfigurationResponse::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } - for (const auto &it : this->active_wake_words) { + for (const auto &it : *this->active_wake_words) { dump_field(out, "active_wake_words", it, 4); } dump_field(out, "max_active_wake_words", this->max_active_wake_words); diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h new file mode 100644 index 0000000000..55d95304b1 --- /dev/null +++ b/esphome/components/api/api_pb2_includes.h @@ -0,0 +1,34 @@ +#pragma once + +#include "esphome/core/defines.h" + +// This file provides includes needed by the generated protobuf code +// when using pointer optimizations for component-specific types + +#ifdef USE_CLIMATE +#include "esphome/components/climate/climate_mode.h" +#include "esphome/components/climate/climate_traits.h" +#endif + +#ifdef USE_LIGHT +#include "esphome/components/light/light_traits.h" +#endif + +#ifdef USE_FAN +#include "esphome/components/fan/fan_traits.h" +#endif + +#ifdef USE_SELECT +#include "esphome/components/select/select_traits.h" +#endif + +// Standard library includes that might be needed +#include +#include +#include + +namespace esphome::api { + +// This file only provides includes, no actual code + +} // namespace esphome::api diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index c3a0dfca8f..8bd4714753 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -5,6 +5,13 @@ #include namespace esphome { + +#ifdef USE_API +namespace api { +class APIConnection; +} // namespace api +#endif + namespace climate { /** This class contains all static data for climate devices. @@ -173,6 +180,23 @@ class ClimateTraits { void set_visual_max_humidity(float visual_max_humidity) { this->visual_max_humidity_ = visual_max_humidity; } protected: +#ifdef USE_API + // The API connection is a friend class to access internal methods + friend class api::APIConnection; + // These methods return references to internal data structures. + // They are used by the API to avoid copying data when encoding messages. + // Warning: Do not use these methods outside of the API connection code. + // They return references to internal data that can be invalidated. + const std::set &get_supported_modes_for_api_() const { return this->supported_modes_; } + const std::set &get_supported_fan_modes_for_api_() const { return this->supported_fan_modes_; } + const std::set &get_supported_custom_fan_modes_for_api_() const { + return this->supported_custom_fan_modes_; + } + const std::set &get_supported_presets_for_api_() const { return this->supported_presets_; } + const std::set &get_supported_custom_presets_for_api_() const { return this->supported_custom_presets_; } + const std::set &get_supported_swing_modes_for_api_() const { return this->supported_swing_modes_; } +#endif + void set_mode_support_(climate::ClimateMode mode, bool supported) { if (supported) { this->supported_modes_.insert(mode); diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index 2ef6f8b7cc..48509e5705 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -4,6 +4,13 @@ #pragma once namespace esphome { + +#ifdef USE_API +namespace api { +class APIConnection; +} // namespace api +#endif + namespace fan { class FanTraits { @@ -36,6 +43,15 @@ class FanTraits { bool supports_preset_modes() const { return !this->preset_modes_.empty(); } protected: +#ifdef USE_API + // The API connection is a friend class to access internal methods + friend class api::APIConnection; + // This method returns a reference to the internal preset modes set. + // It is used by the API to avoid copying data when encoding messages. + // Warning: Do not use this method outside of the API connection code. + // It returns a reference to internal data that can be invalidated. + const std::set &supported_preset_modes_for_api_() const { return this->preset_modes_; } +#endif bool oscillation_{false}; bool speed_{false}; bool direction_{false}; diff --git a/esphome/components/light/light_traits.h b/esphome/components/light/light_traits.h index 7c99d721f0..a45301d148 100644 --- a/esphome/components/light/light_traits.h +++ b/esphome/components/light/light_traits.h @@ -5,6 +5,13 @@ #include namespace esphome { + +#ifdef USE_API +namespace api { +class APIConnection; +} // namespace api +#endif + namespace light { /// This class is used to represent the capabilities of a light. @@ -52,6 +59,16 @@ class LightTraits { void set_max_mireds(float max_mireds) { this->max_mireds_ = max_mireds; } protected: +#ifdef USE_API + // The API connection is a friend class to access internal methods + friend class api::APIConnection; + // This method returns a reference to the internal color modes set. + // It is used by the API to avoid copying data when encoding messages. + // Warning: Do not use this method outside of the API connection code. + // It returns a reference to internal data that can be invalidated. + const std::set &get_supported_color_modes_for_api_() const { return this->supported_color_modes_; } +#endif + std::set supported_color_modes_{}; float min_mireds_{0}; float max_mireds_{0}; diff --git a/esphome/components/select/select_traits.cpp b/esphome/components/select/select_traits.cpp index 89da30c405..a8cd4290c8 100644 --- a/esphome/components/select/select_traits.cpp +++ b/esphome/components/select/select_traits.cpp @@ -5,7 +5,7 @@ namespace select { void SelectTraits::set_options(std::vector options) { this->options_ = std::move(options); } -std::vector SelectTraits::get_options() const { return this->options_; } +const std::vector &SelectTraits::get_options() const { return this->options_; } } // namespace select } // namespace esphome diff --git a/esphome/components/select/select_traits.h b/esphome/components/select/select_traits.h index ccf23dc6d0..128066dd6b 100644 --- a/esphome/components/select/select_traits.h +++ b/esphome/components/select/select_traits.h @@ -9,7 +9,7 @@ namespace select { class SelectTraits { public: void set_options(std::vector options); - std::vector get_options() const; + const std::vector &get_options() const; protected: std::vector options_; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 275c7ffc9e..fece22499a 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1170,6 +1170,10 @@ class FixedArrayRepeatedType(TypeInfo): class RepeatedTypeInfo(TypeInfo): def __init__(self, field: descriptor.FieldDescriptorProto) -> None: super().__init__(field) + # Check if this is a pointer field by looking for container_pointer option + self._container_type = get_field_opt(field, pb.container_pointer, "") + self._use_pointer = bool(self._container_type) + # For repeated fields, we need to get the base type info # but we can't call create_field_type_info as it would cause recursion # So we extract just the type creation logic @@ -1185,6 +1189,14 @@ class RepeatedTypeInfo(TypeInfo): @property def cpp_type(self) -> str: + if self._use_pointer and self._container_type: + # For pointer fields, use the specified container type + # If the container type already includes the element type (e.g., std::set) + # use it as-is, otherwise append the element type + if "<" in self._container_type and ">" in self._container_type: + return f"const {self._container_type}*" + else: + return f"const {self._container_type}<{self._ti.cpp_type}>*" return f"std::vector<{self._ti.cpp_type}>" @property @@ -1205,6 +1217,9 @@ class RepeatedTypeInfo(TypeInfo): @property def decode_varint_content(self) -> str: + # Pointer fields don't support decoding + if self._use_pointer: + return None content = self._ti.decode_varint if content is None: return None @@ -1214,6 +1229,9 @@ class RepeatedTypeInfo(TypeInfo): @property def decode_length_content(self) -> str: + # Pointer fields don't support decoding + if self._use_pointer: + return None content = self._ti.decode_length if content is None and isinstance(self._ti, MessageType): # Special handling for non-template message decoding @@ -1226,6 +1244,9 @@ class RepeatedTypeInfo(TypeInfo): @property def decode_32bit_content(self) -> str: + # Pointer fields don't support decoding + if self._use_pointer: + return None content = self._ti.decode_32bit if content is None: return None @@ -1235,6 +1256,9 @@ class RepeatedTypeInfo(TypeInfo): @property def decode_64bit_content(self) -> str: + # Pointer fields don't support decoding + if self._use_pointer: + return None content = self._ti.decode_64bit if content is None: return None @@ -1249,16 +1273,31 @@ class RepeatedTypeInfo(TypeInfo): @property def encode_content(self) -> str: - o = f"for (auto {'' if self._ti_is_bool else '&'}it : this->{self.field_name}) {{\n" - if isinstance(self._ti, EnumType): - o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" + if self._use_pointer: + # For pointer fields, just dereference (pointer should never be null in our use case) + o = f"for (const auto &it : *this->{self.field_name}) {{\n" + if isinstance(self._ti, EnumType): + o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" + else: + o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" + o += "}" + return o else: - o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" - o += "}" - return o + o = f"for (auto {'' if self._ti_is_bool else '&'}it : this->{self.field_name}) {{\n" + if isinstance(self._ti, EnumType): + o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" + else: + o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" + o += "}" + return o @property def dump_content(self) -> str: + if self._use_pointer: + # For pointer fields, dereference and use the existing helper + return _generate_array_dump_content( + self._ti, f"*this->{self.field_name}", self.name, is_bool=False + ) return _generate_array_dump_content( self._ti, f"this->{self.field_name}", self.name, is_bool=self._ti_is_bool ) @@ -1269,30 +1308,34 @@ class RepeatedTypeInfo(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: # For repeated fields, we always need to pass force=True to the underlying type's calculation # This is because the encode method always sets force=true for repeated fields + + # Handle message types separately as they use a dedicated helper if isinstance(self._ti, MessageType): - # For repeated messages, use the dedicated helper that handles iteration internally field_id_size = self._ti.calculate_field_id_size() - o = f"size.add_repeated_message({field_id_size}, {name});" - return o + container = f"*{name}" if self._use_pointer else name + return f"size.add_repeated_message({field_id_size}, {container});" - # For other repeated types, use the underlying type's size calculation with force=True - o = f"if (!{name}.empty()) {{\n" + # For non-message types, generate size calculation with iteration + container_ref = f"*{name}" if self._use_pointer else name + empty_check = f"{name}->empty()" if self._use_pointer else f"{name}.empty()" - # Check if this is a fixed-size type by seeing if it has a fixed byte count + o = f"if (!{empty_check}) {{\n" + + # Check if this is a fixed-size type num_bytes = self._ti.get_fixed_size_bytes() if num_bytes is not None: - # Fixed types have constant size per element, so we can multiply + # Fixed types have constant size per element field_id_size = self._ti.calculate_field_id_size() - # Pre-calculate the total bytes per element bytes_per_element = field_id_size + num_bytes - o += ( - f" size.add_precalculated_size({name}.size() * {bytes_per_element});\n" - ) + size_expr = f"{name}->size()" if self._use_pointer else f"{name}.size()" + o += f" size.add_precalculated_size({size_expr} * {bytes_per_element});\n" else: # Other types need the actual value - o += f" for (const auto {'' if self._ti_is_bool else '&'}it : {name}) {{\n" + auto_ref = "" if self._ti_is_bool else "&" + o += f" for (const auto {auto_ref}it : {container_ref}) {{\n" o += f" {self._ti.get_size_calculation('it', True)}\n" o += " }\n" + o += "}" return o @@ -2080,6 +2123,7 @@ def main() -> None: d = descriptor.FileDescriptorSet.FromString(proto_content) file = d.file[0] + content = FILE_HEADER content += """\ #pragma once @@ -2088,7 +2132,10 @@ def main() -> None: #include "esphome/core/string_ref.h" #include "proto.h" +#include "api_pb2_includes.h" +""" + content += """ namespace esphome::api { """ From f810ebbf79c6d74dbf171d4b638c610f77ee9150 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 14:24:46 -1000 Subject: [PATCH 0132/1626] [esp32_ble_client] Fix connection failures with short discovery timeout devices and speed up BLE connections --- .../esp32_ble_client/ble_client_base.cpp | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index bf425b3730..0d48c1f163 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -5,6 +5,8 @@ #ifdef USE_ESP32 +#include + namespace esphome { namespace esp32_ble_client { @@ -129,6 +131,25 @@ void BLEClientBase::connect() { ESP_LOGI(TAG, "[%d] [%s] 0x%02x Attempting BLE connection", this->connection_index_, this->address_str_.c_str(), this->remote_addr_type_); this->paired_ = false; + + // For connections without cache, set fast connection parameters before connecting + // This ensures service discovery completes within the 10-second timeout that + // some devices like HomeKit BLE sensors enforce + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + auto ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, + 0x06, // min_int: 7.5ms + 0x06, // max_int: 7.5ms + 0, // latency: 0 + 1000); // timeout: 10s + if (ret != ESP_OK) { + ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, + this->address_str_.c_str(), ret); + } else { + ESP_LOGD(TAG, "[%d] [%s] Set preferred connection params for fast discovery (no cache)", this->connection_index_, + this->address_str_.c_str()); + } + } + auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); if (ret) { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_open error, status=%d", this->connection_index_, this->address_str_.c_str(), @@ -278,12 +299,14 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->address_str_.c_str(), ret); } this->set_state(espbt::ClientState::CONNECTED); + ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - ESP_LOGI(TAG, "[%d] [%s] Connected", this->connection_index_, this->address_str_.c_str()); + ESP_LOGI(TAG, "[%d] [%s] Using cached services", this->connection_index_, this->address_str_.c_str()); // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; } + ESP_LOGD(TAG, "[%d] [%s] Searching for services", this->connection_index_, this->address_str_.c_str()); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); break; } @@ -296,8 +319,15 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_DISCONNECT_EVT: { if (!this->check_addr(param->disconnect.remote_bda)) return false; - ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason %d", this->connection_index_, - this->address_str_.c_str(), param->disconnect.reason); + // Check if we were disconnected while waiting for service discovery + if (param->disconnect.reason == 0x13 && // 0x13 = ESP_GATT_CONN_TERMINATE_PEER + this->state_ == espbt::ClientState::CONNECTED) { + ESP_LOGW(TAG, "[%d] [%s] Disconnected by remote during service discovery", this->connection_index_, + this->address_str_.c_str()); + } else { + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, + this->address_str_.c_str(), param->disconnect.reason); + } this->release_services(); this->set_state(espbt::ClientState::IDLE); break; @@ -353,7 +383,23 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, this->address_str_.c_str(), svc->start_handle, svc->end_handle); } - ESP_LOGI(TAG, "[%d] [%s] Connected", this->connection_index_, this->address_str_.c_str()); + ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); + + // For non-cached connections, restore default connection parameters after service discovery + // Now that we've discovered all services, we can use more balanced parameters + // that save power and reduce interference + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + esp_ble_conn_update_params_t conn_params = {0}; + memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + conn_params.min_int = 0x0A; // 12.5ms - ESP-IDF default minimum (BTM_BLE_CONN_INT_MIN_DEF) + conn_params.max_int = 0x0C; // 15ms - ESP-IDF default maximum (BTM_BLE_CONN_INT_MAX_DEF) + conn_params.latency = 0; + conn_params.timeout = 600; // 6s - ESP-IDF default timeout (BTM_BLE_CONN_TIMEOUT_DEF) + ESP_LOGD(TAG, "[%d] [%s] Restoring default connection parameters after service discovery", + this->connection_index_, this->address_str_.c_str()); + esp_ble_gap_update_conn_params(&conn_params); + } + this->state_ = espbt::ClientState::ESTABLISHED; break; } From 92055b221a16de46b2dea3ff6d904451ea26491c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 14:26:37 -1000 Subject: [PATCH 0133/1626] Revert "test" This reverts commit 51d2e7085438ca26ef3f9100ea18742046204dae. --- tests/components/esp32_ble/common.yaml | 1 - tests/components/esp32_ble/test.esp32-s3-ard.yaml | 2 -- tests/components/esp32_ble/test.esp32-s3-idf.yaml | 2 -- 3 files changed, 5 deletions(-) delete mode 100644 tests/components/esp32_ble/test.esp32-s3-ard.yaml delete mode 100644 tests/components/esp32_ble/test.esp32-s3-idf.yaml diff --git a/tests/components/esp32_ble/common.yaml b/tests/components/esp32_ble/common.yaml index 86d5dcee60..76b35fc8f8 100644 --- a/tests/components/esp32_ble/common.yaml +++ b/tests/components/esp32_ble/common.yaml @@ -1,3 +1,2 @@ esp32_ble: io_capability: keyboard_only - # Default configuration - should use 1m PHY diff --git a/tests/components/esp32_ble/test.esp32-s3-ard.yaml b/tests/components/esp32_ble/test.esp32-s3-ard.yaml deleted file mode 100644 index 3c05b93858..0000000000 --- a/tests/components/esp32_ble/test.esp32-s3-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -esp32_ble: - preferred_phy: 2m diff --git a/tests/components/esp32_ble/test.esp32-s3-idf.yaml b/tests/components/esp32_ble/test.esp32-s3-idf.yaml deleted file mode 100644 index 8f1d32779d..0000000000 --- a/tests/components/esp32_ble/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,2 +0,0 @@ -esp32_ble: - preferred_phy: auto From f7945060028817e654d96b4cfd1f20374a362816 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 14:26:47 -1000 Subject: [PATCH 0134/1626] Revert "[esp32_ble] Add PHY configuration and default to 1M for compatibility" This reverts commit 5c44cd8962e1b2329fbdecf2ce38faa401a4ec3a. --- esphome/components/esp32_ble/__init__.py | 37 ----------------- esphome/components/esp32_ble/ble.cpp | 52 +----------------------- esphome/components/esp32_ble/ble.h | 8 ---- 3 files changed, 2 insertions(+), 95 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 224b30aa3f..93bb643596 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -117,22 +117,9 @@ CONF_BLE_ID = "ble_id" CONF_IO_CAPABILITY = "io_capability" CONF_ADVERTISING_CYCLE_TIME = "advertising_cycle_time" CONF_DISABLE_BT_LOGS = "disable_bt_logs" -CONF_PREFERRED_PHY = "preferred_phy" NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] -# ESP32 variants that support BLE -BLE_VARIANTS = { - const.VARIANT_ESP32, - const.VARIANT_ESP32C3, - const.VARIANT_ESP32S3, - const.VARIANT_ESP32C6, - const.VARIANT_ESP32H2, -} - -# ESP32 variants that support 2M PHY -BLE_2M_PHY_VARIANTS = BLE_VARIANTS - {const.VARIANT_ESP32} - esp32_ble_ns = cg.esphome_ns.namespace("esp32_ble") ESP32BLE = esp32_ble_ns.class_("ESP32BLE", cg.Component) @@ -153,13 +140,6 @@ IO_CAPABILITY = { "display_yes_no": IoCapability.IO_CAP_IO, } -BLEPhy = esp32_ble_ns.enum("BLEPhy") -BLE_PHY_OPTIONS = { - "1m": BLEPhy.BLE_PHY_1M, - "2m": BLEPhy.BLE_PHY_2M, - "auto": BLEPhy.BLE_PHY_AUTO, -} - esp_power_level_t = cg.global_ns.enum("esp_power_level_t") TX_POWER_LEVELS = { @@ -173,18 +153,6 @@ TX_POWER_LEVELS = { 9: esp_power_level_t.ESP_PWR_LVL_P9, } - -def validate_phy(value: str) -> str: - """Validate PHY selection based on ESP32 variant.""" - variant = get_esp32_variant() - if value == "2m" and variant not in BLE_2M_PHY_VARIANTS: - raise cv.Invalid( - f"2M PHY is not supported on {variant}. " - f"Only supported on: {', '.join(sorted(BLE_2M_PHY_VARIANTS))}" - ) - return value - - CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(ESP32BLE), @@ -199,10 +167,6 @@ CONFIG_SCHEMA = cv.Schema( cv.SplitDefault(CONF_DISABLE_BT_LOGS, esp32_idf=True): cv.All( cv.only_with_esp_idf, cv.boolean ), - cv.Optional(CONF_PREFERRED_PHY, default="1m"): cv.All( - cv.enum(BLE_PHY_OPTIONS, lower=True), - validate_phy, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -273,7 +237,6 @@ async def to_code(config): cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) cg.add(var.set_io_capability(config[CONF_IO_CAPABILITY])) cg.add(var.set_advertising_cycle_time(config[CONF_ADVERTISING_CYCLE_TIME])) - cg.add(var.set_preferred_phy(config[CONF_PREFERRED_PHY])) if (name := config.get(CONF_NAME)) is not None: cg.add(var.set_name(name)) await cg.register_component(var, config) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index f953ccd1f4..33258552c7 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -23,35 +23,6 @@ namespace esphome::esp32_ble { static const char *const TAG = "esp32_ble"; -static const char *phy_mode_to_string(BLEPhy phy) { - switch (phy) { - case BLE_PHY_1M: - return "1M"; - case BLE_PHY_2M: - return "2M"; - case BLE_PHY_AUTO: - return "AUTO"; - default: - return "UNKNOWN"; - } -} - -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32H2) -static uint8_t phy_mode_to_mask(BLEPhy phy) { - switch (phy) { - case BLE_PHY_1M: - return ESP_BLE_GAP_PHY_1M_PREF_MASK; - case BLE_PHY_2M: - return ESP_BLE_GAP_PHY_2M_PREF_MASK; - case BLE_PHY_AUTO: - return ESP_BLE_GAP_PHY_1M_PREF_MASK | ESP_BLE_GAP_PHY_2M_PREF_MASK; - default: - return ESP_BLE_GAP_PHY_1M_PREF_MASK; // Default to 1M - } -} -#endif - void ESP32BLE::setup() { global_ble = this; if (!ble_pre_setup_()) { @@ -237,23 +208,6 @@ bool ESP32BLE::ble_setup_() { return false; } - // Configure PHY settings -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32H2) - // Only newer ESP32 variants support PHY configuration - if (this->preferred_phy_ != BLE_PHY_AUTO) { - uint8_t phy_mask = phy_mode_to_mask(this->preferred_phy_); - - err = esp_ble_gap_set_preferred_default_phy(phy_mask, phy_mask); - if (err != ESP_OK) { - ESP_LOGW(TAG, "esp_ble_gap_set_preferred_default_phy failed: %d", err); - // Not a fatal error, continue - } else { - ESP_LOGD(TAG, "Set preferred PHY to %s", phy_mode_to_string(this->preferred_phy_)); - } - } -#endif - // BLE takes some time to be fully set up, 200ms should be more than enough delay(200); // NOLINT @@ -563,10 +517,8 @@ void ESP32BLE::dump_config() { ESP_LOGCONFIG(TAG, "BLE:\n" " MAC address: %s\n" - " IO Capability: %s\n" - " Preferred PHY: %s", - format_mac_address_pretty(mac_address).c_str(), io_capability_s, - phy_mode_to_string(this->preferred_phy_)); + " IO Capability: %s", + format_mac_address_pretty(mac_address).c_str(), io_capability_s); } else { ESP_LOGCONFIG(TAG, "Bluetooth stack is not enabled"); } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index abe0d57aa0..543b2f26a3 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -55,12 +55,6 @@ enum IoCapability { IO_CAP_KBDISP = ESP_IO_CAP_KBDISP, }; -enum BLEPhy : uint8_t { - BLE_PHY_1M = 0x01, - BLE_PHY_2M = 0x02, - BLE_PHY_AUTO = 0x03, -}; - enum BLEComponentState : uint8_t { /** Nothing has been initialized yet. */ BLE_COMPONENT_STATE_OFF = 0, @@ -104,7 +98,6 @@ class BLEStatusEventHandler { class ESP32BLE : public Component { public: void set_io_capability(IoCapability io_capability) { this->io_cap_ = (esp_ble_io_cap_t) io_capability; } - void set_preferred_phy(BLEPhy phy) { this->preferred_phy_ = phy; } void set_advertising_cycle_time(uint32_t advertising_cycle_time) { this->advertising_cycle_time_ = advertising_cycle_time; @@ -177,7 +170,6 @@ class ESP32BLE : public Component { // 1-byte aligned members (grouped together to minimize padding) BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum) bool enable_on_boot_{}; // 1 byte - BLEPhy preferred_phy_{BLE_PHY_1M}; // 1 byte (uint8_t enum) }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) From 63484d9f08cadb111cc419d6cbe474431aade0a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 14:32:34 -1000 Subject: [PATCH 0135/1626] tidy --- esphome/components/esp32_ble_client/ble_client_base.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 0d48c1f163..9984bd9c24 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -389,7 +389,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // Now that we've discovered all services, we can use more balanced parameters // that save power and reduce interference if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { - esp_ble_conn_update_params_t conn_params = {0}; + esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = 0x0A; // 12.5ms - ESP-IDF default minimum (BTM_BLE_CONN_INT_MIN_DEF) conn_params.max_int = 0x0C; // 15ms - ESP-IDF default maximum (BTM_BLE_CONN_INT_MAX_DEF) From 14dd48f9c36c22c19fb28038e58ef11cb418b0c4 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Tue, 29 Jul 2025 17:41:31 -0700 Subject: [PATCH 0136/1626] [wifi] add more disconnect reason descriptions (#9955) Co-authored-by: Samuel Sieb --- .../components/wifi/wifi_component_esp32_arduino.cpp | 12 ++++++++++++ esphome/components/wifi/wifi_component_esp_idf.cpp | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp32_arduino.cpp b/esphome/components/wifi/wifi_component_esp32_arduino.cpp index b3167c5696..3c3e87d332 100644 --- a/esphome/components/wifi/wifi_component_esp32_arduino.cpp +++ b/esphome/components/wifi/wifi_component_esp32_arduino.cpp @@ -474,8 +474,20 @@ const char *get_disconnect_reason_str(uint8_t reason) { return "Handshake Failed"; case WIFI_REASON_CONNECTION_FAIL: return "Connection Failed"; + case WIFI_REASON_AP_TSF_RESET: + return "AP TSF reset"; case WIFI_REASON_ROAMING: return "Station Roaming"; + case WIFI_REASON_ASSOC_COMEBACK_TIME_TOO_LONG: + return "Association comeback time too long"; + case WIFI_REASON_SA_QUERY_TIMEOUT: + return "SA query timeout"; + case WIFI_REASON_NO_AP_FOUND_W_COMPATIBLE_SECURITY: + return "No AP found with compatible security"; + case WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD: + return "No AP found in auth mode threshold"; + case WIFI_REASON_NO_AP_FOUND_IN_RSSI_THRESHOLD: + return "No AP found in RSSI threshold"; case WIFI_REASON_UNSPECIFIED: default: return "Unspecified"; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index f0655a6d1d..0b281e9b80 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -640,8 +640,20 @@ const char *get_disconnect_reason_str(uint8_t reason) { return "Handshake Failed"; case WIFI_REASON_CONNECTION_FAIL: return "Connection Failed"; + case WIFI_REASON_AP_TSF_RESET: + return "AP TSF reset"; case WIFI_REASON_ROAMING: return "Station Roaming"; + case WIFI_REASON_ASSOC_COMEBACK_TIME_TOO_LONG: + return "Association comeback time too long"; + case WIFI_REASON_SA_QUERY_TIMEOUT: + return "SA query timeout"; + case WIFI_REASON_NO_AP_FOUND_W_COMPATIBLE_SECURITY: + return "No AP found with compatible security"; + case WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD: + return "No AP found in auth mode threshold"; + case WIFI_REASON_NO_AP_FOUND_IN_RSSI_THRESHOLD: + return "No AP found in RSSI threshold"; case WIFI_REASON_UNSPECIFIED: default: return "Unspecified"; From 374858efeb50885de01f0e2075264354cab9f3ce Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 29 Jul 2025 19:53:14 -0500 Subject: [PATCH 0137/1626] [sensor] Add new filter: ``throttle_with_priority`` (#9937) --- esphome/components/sensor/__init__.py | 20 +++++++++++++++ esphome/components/sensor/filter.cpp | 35 +++++++++++++++++++++++++++ esphome/components/sensor/filter.h | 14 +++++++++++ 3 files changed, 69 insertions(+) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 20c6911d28..5d70785389 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -256,6 +256,7 @@ OffsetFilter = sensor_ns.class_("OffsetFilter", Filter) MultiplyFilter = sensor_ns.class_("MultiplyFilter", Filter) FilterOutValueFilter = sensor_ns.class_("FilterOutValueFilter", Filter) ThrottleFilter = sensor_ns.class_("ThrottleFilter", Filter) +ThrottleWithPriorityFilter = sensor_ns.class_("ThrottleWithPriorityFilter", Filter) TimeoutFilter = sensor_ns.class_("TimeoutFilter", Filter, cg.Component) DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component) HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component) @@ -595,6 +596,25 @@ async def throttle_filter_to_code(config, filter_id): return cg.new_Pvariable(filter_id, config) +TIMEOUT_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( + { + cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds, + cv.Optional(CONF_VALUE, default="nan"): cv.ensure_list(cv.float_), + }, + key=CONF_TIMEOUT, +) + + +@FILTER_REGISTRY.register( + "throttle_with_priority", + ThrottleWithPriorityFilter, + TIMEOUT_WITH_PRIORITY_SCHEMA, +) +async def throttle_with_priority_filter_to_code(config, filter_id): + template_ = [await cg.templatable(x, [], float) for x in config[CONF_VALUE]] + return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) + + @FILTER_REGISTRY.register( "heartbeat", HeartbeatFilter, cv.positive_time_period_milliseconds ) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 2fd56b7c8f..39b507f960 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -1,5 +1,6 @@ #include "filter.h" #include +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "sensor.h" @@ -332,6 +333,40 @@ optional ThrottleFilter::new_value(float value) { return {}; } +// ThrottleWithPriorityFilter +ThrottleWithPriorityFilter::ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, + std::vector> prioritized_values) + : min_time_between_inputs_(min_time_between_inputs), prioritized_values_(std::move(prioritized_values)) {} + +optional ThrottleWithPriorityFilter::new_value(float value) { + bool is_prioritized_value = false; + int8_t accuracy = this->parent_->get_accuracy_decimals(); + float accuracy_mult = powf(10.0f, accuracy); + const uint32_t now = App.get_loop_component_start_time(); + // First, determine if the new value is one of the prioritized values + for (auto prioritized_value : this->prioritized_values_) { + if (std::isnan(prioritized_value.value())) { + if (std::isnan(value)) { + is_prioritized_value = true; + break; + } + continue; + } + float rounded_prioritized_value = roundf(accuracy_mult * prioritized_value.value()); + float rounded_value = roundf(accuracy_mult * value); + if (rounded_prioritized_value == rounded_value) { + is_prioritized_value = true; + break; + } + } + // Finally, determine if the new value should be throttled and pass it through if not + if (this->last_input_ == 0 || now - this->last_input_ >= min_time_between_inputs_ || is_prioritized_value) { + this->last_input_ = now; + return value; + } + return {}; +} + // DeltaFilter DeltaFilter::DeltaFilter(float delta, bool percentage_mode) : delta_(delta), current_delta_(delta), percentage_mode_(percentage_mode), last_value_(NAN) {} diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 94fec8208b..8e2c6fef08 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -314,6 +314,20 @@ class ThrottleFilter : public Filter { uint32_t min_time_between_inputs_; }; +/// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`. +class ThrottleWithPriorityFilter : public Filter { + public: + explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, + std::vector> prioritized_values); + + optional new_value(float value) override; + + protected: + uint32_t last_input_{0}; + uint32_t min_time_between_inputs_; + std::vector> prioritized_values_; +}; + class TimeoutFilter : public Filter, public Component { public: explicit TimeoutFilter(uint32_t time_period, TemplatableValue new_value); From 561d7ec97853867a71e94965c836cba96b556115 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 14:57:52 -1000 Subject: [PATCH 0138/1626] cleanup --- .../esp32_ble_client/ble_client_base.cpp | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 9984bd9c24..9f3ca74078 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -132,24 +132,6 @@ void BLEClientBase::connect() { this->remote_addr_type_); this->paired_ = false; - // For connections without cache, set fast connection parameters before connecting - // This ensures service discovery completes within the 10-second timeout that - // some devices like HomeKit BLE sensors enforce - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { - auto ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, - 0x06, // min_int: 7.5ms - 0x06, // max_int: 7.5ms - 0, // latency: 0 - 1000); // timeout: 10s - if (ret != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, - this->address_str_.c_str(), ret); - } else { - ESP_LOGD(TAG, "[%d] [%s] Set preferred connection params for fast discovery (no cache)", this->connection_index_, - this->address_str_.c_str()); - } - } - auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); if (ret) { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_open error, status=%d", this->connection_index_, this->address_str_.c_str(), @@ -157,6 +139,23 @@ void BLEClientBase::connect() { this->set_state(espbt::ClientState::IDLE); } else { this->set_state(espbt::ClientState::CONNECTING); + + // For connections without cache, set fast connection parameters after initiating connection + // This ensures service discovery completes within the 10-second timeout that + // some devices like HomeKit BLE sensors enforce + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, + 0x06, // min_int: 7.5ms + 0x06, // max_int: 7.5ms + 0, // latency: 0 + 1000); // timeout: 10s + if (param_ret != ESP_OK) { + ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, + this->address_str_.c_str(), param_ret); + } else { + ESP_LOGD(TAG, "[%d] [%s] Set fast conn params", this->connection_index_, this->address_str_.c_str()); + } + } } } @@ -395,8 +394,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ conn_params.max_int = 0x0C; // 15ms - ESP-IDF default maximum (BTM_BLE_CONN_INT_MAX_DEF) conn_params.latency = 0; conn_params.timeout = 600; // 6s - ESP-IDF default timeout (BTM_BLE_CONN_TIMEOUT_DEF) - ESP_LOGD(TAG, "[%d] [%s] Restoring default connection parameters after service discovery", - this->connection_index_, this->address_str_.c_str()); + ESP_LOGD(TAG, "[%d] [%s] Restored default conn params", this->connection_index_, this->address_str_.c_str()); esp_ble_gap_update_conn_params(&conn_params); } From 537c774a6cc649b36da7f25139087c55e720216a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 15:47:36 -1000 Subject: [PATCH 0139/1626] use const --- esphome/components/esp32_ble_client/ble_client_base.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 9f3ca74078..a28e2e6b9c 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -6,6 +6,7 @@ #ifdef USE_ESP32 #include +#include namespace esphome { namespace esp32_ble_client { @@ -319,7 +320,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (!this->check_addr(param->disconnect.remote_bda)) return false; // Check if we were disconnected while waiting for service discovery - if (param->disconnect.reason == 0x13 && // 0x13 = ESP_GATT_CONN_TERMINATE_PEER + if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state_ == espbt::ClientState::CONNECTED) { ESP_LOGW(TAG, "[%d] [%s] Disconnected by remote during service discovery", this->connection_index_, this->address_str_.c_str()); From 68b8fab33a52d3d24b8a5b32587d5614742d0191 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 15:53:15 -1000 Subject: [PATCH 0140/1626] const --- .../esp32_ble_client/ble_client_base.cpp | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index a28e2e6b9c..d3416641d9 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -12,6 +12,16 @@ namespace esphome { namespace esp32_ble_client { static const char *const TAG = "esp32_ble_client"; + +// Connection interval defaults matching ESP-IDF's BTM_BLE_CONN_INT_*_DEF +static const uint16_t DEFAULT_MIN_CONN_INTERVAL = 0x0A; // 10 * 1.25ms = 12.5ms +static const uint16_t DEFAULT_MAX_CONN_INTERVAL = 0x0C; // 12 * 1.25ms = 15ms +static const uint16_t DEFAULT_CONN_TIMEOUT = 600; // 600 * 10ms = 6s + +// Fastest connection parameters for devices with short discovery timeouts +static const uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) +static const uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms +static const uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s static const esp_bt_uuid_t NOTIFY_DESC_UUID = { .len = ESP_UUID_LEN_16, .uuid = @@ -145,11 +155,10 @@ void BLEClientBase::connect() { // This ensures service discovery completes within the 10-second timeout that // some devices like HomeKit BLE sensors enforce if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { - auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, - 0x06, // min_int: 7.5ms - 0x06, // max_int: 7.5ms - 0, // latency: 0 - 1000); // timeout: 10s + auto param_ret = + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, + 0, // latency: 0 + FAST_CONN_TIMEOUT); if (param_ret != ESP_OK) { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, this->address_str_.c_str(), param_ret); @@ -391,10 +400,10 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); - conn_params.min_int = 0x0A; // 12.5ms - ESP-IDF default minimum (BTM_BLE_CONN_INT_MIN_DEF) - conn_params.max_int = 0x0C; // 15ms - ESP-IDF default maximum (BTM_BLE_CONN_INT_MAX_DEF) + conn_params.min_int = DEFAULT_MIN_CONN_INTERVAL; + conn_params.max_int = DEFAULT_MAX_CONN_INTERVAL; conn_params.latency = 0; - conn_params.timeout = 600; // 6s - ESP-IDF default timeout (BTM_BLE_CONN_TIMEOUT_DEF) + conn_params.timeout = DEFAULT_CONN_TIMEOUT; ESP_LOGD(TAG, "[%d] [%s] Restored default conn params", this->connection_index_, this->address_str_.c_str()); esp_ble_gap_update_conn_params(&conn_params); } From 913c58cd2c514c90b59c34e74cf80d3168b82395 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 29 Jul 2025 21:20:25 -0500 Subject: [PATCH 0141/1626] [template] Add tests for more sensor filters (#9973) --- tests/components/template/common.yaml | 159 +++++++++++++++++++------- 1 file changed, 118 insertions(+), 41 deletions(-) diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index fd9342b3e5..e185e01c5e 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -1,44 +1,3 @@ -sensor: - - platform: template - name: "Template Sensor" - id: template_sens - lambda: |- - if (id(some_binary_sensor).state) { - return 42.0; - } else { - return 0.0; - } - update_interval: 60s - filters: - - offset: 10 - - multiply: 1 - - offset: !lambda return 10; - - multiply: !lambda return 2; - - filter_out: - - 10 - - 20 - - !lambda return 10; - - filter_out: 10 - - filter_out: !lambda return NAN; - - timeout: - timeout: 10s - value: !lambda return 10; - - timeout: - timeout: 1h - value: 20.0 - - timeout: - timeout: 1d - - to_ntc_resistance: - calibration: - - 10.0kOhm -> 25°C - - 27.219kOhm -> 0°C - - 14.674kOhm -> 15°C - - to_ntc_temperature: - calibration: - - 10.0kOhm -> 25°C - - 27.219kOhm -> 0°C - - 14.674kOhm -> 15°C - esphome: on_boot: - sensor.template.publish: @@ -82,6 +41,123 @@ binary_sensor: sensor.in_range: id: template_sens below: 30.0 + filters: + - invert: + - delayed_on: 100ms + - delayed_off: 100ms + - delayed_on_off: !lambda "if (id(test_switch).state) return 1000; else return 0;" + - delayed_on_off: + time_on: 10s + time_off: !lambda "if (id(test_switch).state) return 1000; else return 0;" + - autorepeat: + - delay: 1s + time_off: 100ms + time_on: 900ms + - delay: 5s + time_off: 100ms + time_on: 400ms + - lambda: |- + if (id(other_binary_sensor).state) { + return x; + } else { + return {}; + } + - settle: 500ms + - timeout: 5s + +sensor: + - platform: template + name: "Template Sensor" + id: template_sens + lambda: |- + if (id(some_binary_sensor).state) { + return 42.0; + } else { + return 0.0; + } + update_interval: 60s + filters: + - calibrate_linear: + - 0.0 -> 0.0 + - 40.0 -> 45.0 + - 100.0 -> 102.5 + - calibrate_polynomial: + degree: 2 + datapoints: + # Map 0.0 (from sensor) to 0.0 (true value) + - 0.0 -> 0.0 + - 10.0 -> 12.1 + - 13.0 -> 14.0 + - clamp: + max_value: 10.0 + min_value: -10.0 + - debounce: 0.1s + - delta: 5.0 + - exponential_moving_average: + alpha: 0.1 + send_every: 15 + - filter_out: + - 10 + - 20 + - !lambda return 10; + - filter_out: 10 + - filter_out: !lambda return NAN; + - heartbeat: 5s + - lambda: return x * (9.0/5.0) + 32.0; + - max: + window_size: 10 + send_every: 2 + send_first_at: 1 + - median: + window_size: 7 + send_every: 4 + send_first_at: 3 + - min: + window_size: 10 + send_every: 2 + send_first_at: 1 + - multiply: 1 + - multiply: !lambda return 2; + - offset: 10 + - offset: !lambda return 10; + - or: + - quantile: + window_size: 7 + send_every: 4 + send_first_at: 3 + quantile: .9 + - round: 1 + - round_to_multiple_of: 0.25 + - skip_initial: 3 + - sliding_window_moving_average: + window_size: 15 + send_every: 15 + - throttle: 1s + - throttle_average: 2s + - throttle_with_priority: 5s + - throttle_with_priority: + timeout: 3s + value: + - 42.0 + - nan + - timeout: + timeout: 10s + value: !lambda return 10; + - timeout: + timeout: 1h + value: 20.0 + - timeout: + timeout: 1d + - to_ntc_resistance: + calibration: + - 10.0kOhm -> 25°C + - 27.219kOhm -> 0°C + - 14.674kOhm -> 15°C + - to_ntc_temperature: + calibration: + - 10.0kOhm -> 25°C + - 27.219kOhm -> 0°C + - 14.674kOhm -> 15°C output: - platform: template @@ -92,6 +168,7 @@ output: switch: - platform: template + id: test_switch name: "Template Switch" lambda: |- if (id(some_binary_sensor).state) { From 9b4fe54f45a6b8ceacb35f4a6ed9ca35d077554c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 19:19:12 -1000 Subject: [PATCH 0142/1626] [esp32_ble_client] Fix connection failures with short discovery timeout devices and speed up BLE connections (#9971) --- .../esp32_ble_client/ble_client_base.cpp | 62 +++++++++++++++++-- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index bf425b3730..d3416641d9 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -5,10 +5,23 @@ #ifdef USE_ESP32 +#include +#include + namespace esphome { namespace esp32_ble_client { static const char *const TAG = "esp32_ble_client"; + +// Connection interval defaults matching ESP-IDF's BTM_BLE_CONN_INT_*_DEF +static const uint16_t DEFAULT_MIN_CONN_INTERVAL = 0x0A; // 10 * 1.25ms = 12.5ms +static const uint16_t DEFAULT_MAX_CONN_INTERVAL = 0x0C; // 12 * 1.25ms = 15ms +static const uint16_t DEFAULT_CONN_TIMEOUT = 600; // 600 * 10ms = 6s + +// Fastest connection parameters for devices with short discovery timeouts +static const uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) +static const uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms +static const uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s static const esp_bt_uuid_t NOTIFY_DESC_UUID = { .len = ESP_UUID_LEN_16, .uuid = @@ -129,6 +142,7 @@ void BLEClientBase::connect() { ESP_LOGI(TAG, "[%d] [%s] 0x%02x Attempting BLE connection", this->connection_index_, this->address_str_.c_str(), this->remote_addr_type_); this->paired_ = false; + auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); if (ret) { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_open error, status=%d", this->connection_index_, this->address_str_.c_str(), @@ -136,6 +150,22 @@ void BLEClientBase::connect() { this->set_state(espbt::ClientState::IDLE); } else { this->set_state(espbt::ClientState::CONNECTING); + + // For connections without cache, set fast connection parameters after initiating connection + // This ensures service discovery completes within the 10-second timeout that + // some devices like HomeKit BLE sensors enforce + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + auto param_ret = + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, + 0, // latency: 0 + FAST_CONN_TIMEOUT); + if (param_ret != ESP_OK) { + ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, + this->address_str_.c_str(), param_ret); + } else { + ESP_LOGD(TAG, "[%d] [%s] Set fast conn params", this->connection_index_, this->address_str_.c_str()); + } + } } } @@ -278,12 +308,14 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->address_str_.c_str(), ret); } this->set_state(espbt::ClientState::CONNECTED); + ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - ESP_LOGI(TAG, "[%d] [%s] Connected", this->connection_index_, this->address_str_.c_str()); + ESP_LOGI(TAG, "[%d] [%s] Using cached services", this->connection_index_, this->address_str_.c_str()); // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; } + ESP_LOGD(TAG, "[%d] [%s] Searching for services", this->connection_index_, this->address_str_.c_str()); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); break; } @@ -296,8 +328,15 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_DISCONNECT_EVT: { if (!this->check_addr(param->disconnect.remote_bda)) return false; - ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason %d", this->connection_index_, - this->address_str_.c_str(), param->disconnect.reason); + // Check if we were disconnected while waiting for service discovery + if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && + this->state_ == espbt::ClientState::CONNECTED) { + ESP_LOGW(TAG, "[%d] [%s] Disconnected by remote during service discovery", this->connection_index_, + this->address_str_.c_str()); + } else { + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, + this->address_str_.c_str(), param->disconnect.reason); + } this->release_services(); this->set_state(espbt::ClientState::IDLE); break; @@ -353,7 +392,22 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, this->address_str_.c_str(), svc->start_handle, svc->end_handle); } - ESP_LOGI(TAG, "[%d] [%s] Connected", this->connection_index_, this->address_str_.c_str()); + ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); + + // For non-cached connections, restore default connection parameters after service discovery + // Now that we've discovered all services, we can use more balanced parameters + // that save power and reduce interference + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + esp_ble_conn_update_params_t conn_params = {{0}}; + memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + conn_params.min_int = DEFAULT_MIN_CONN_INTERVAL; + conn_params.max_int = DEFAULT_MAX_CONN_INTERVAL; + conn_params.latency = 0; + conn_params.timeout = DEFAULT_CONN_TIMEOUT; + ESP_LOGD(TAG, "[%d] [%s] Restored default conn params", this->connection_index_, this->address_str_.c_str()); + esp_ble_gap_update_conn_params(&conn_params); + } + this->state_ = espbt::ClientState::ESTABLISHED; break; } From 48a4dde8248c3f88c62ae51c88fac133b52eb605 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 09:16:00 +0000 Subject: [PATCH 0143/1626] Bump aioesphomeapi from 37.1.4 to 37.1.5 (#9977) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cfae43e509..3b3ee8142e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.9.0 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.1.4 +aioesphomeapi==37.1.5 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From f4f69e827b47b127a63c2a1ea9432f0dd21acda6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 09:17:47 +0000 Subject: [PATCH 0144/1626] Bump ruff from 0.12.5 to 0.12.7 (#9976) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ae3858c0ef..3e2da47446 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.5 + rev: v0.12.7 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index a87a4bafac..188d2ff22c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.12.5 # also change in .pre-commit-config.yaml when updating +ruff==0.12.7 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From a8493df659546603589a6168d8d0d6e4c09842a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 23:33:43 -1000 Subject: [PATCH 0145/1626] api polish --- esphome/components/api/api_connection.cpp | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 51c7509428..3705e0c947 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1647,6 +1647,8 @@ void APIConnection::process_batch_() { return; } + // Get shared buffer reference once to avoid multiple calls + auto &shared_buf = this->parent_->get_shared_buffer_ref(); size_t num_items = this->deferred_batch_.size(); // Fast path for single message - allocate exact size needed @@ -1657,8 +1659,7 @@ void APIConnection::process_batch_() { uint16_t payload_size = item.creator(item.entity, this, std::numeric_limits::max(), true, item.message_type); - if (payload_size > 0 && - this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, item.message_type)) { + if (payload_size > 0 && this->send_buffer(ProtoWriteBuffer{&shared_buf}, item.message_type)) { #ifdef HAS_PROTO_MESSAGE_DUMP // Log messages after send attempt for VV debugging // It's safe to use the buffer for logging at this point regardless of send result @@ -1685,20 +1686,17 @@ void APIConnection::process_batch_() { const uint8_t footer_size = this->helper_->frame_footer_size(); // Initialize buffer and tracking variables - this->parent_->get_shared_buffer_ref().clear(); + shared_buf.clear(); // Pre-calculate exact buffer size needed based on message types - uint32_t total_estimated_size = 0; - for (size_t i = 0; i < this->deferred_batch_.size(); i++) { - const auto &item = this->deferred_batch_[i]; + uint32_t total_estimated_size = num_items * (header_padding + footer_size); + for (const auto &item : this->deferred_batch_.items) { total_estimated_size += item.estimated_size; } // Calculate total overhead for all messages - uint32_t total_overhead = (header_padding + footer_size) * num_items; - // Reserve based on estimated size (much more accurate than 24-byte worst-case) - this->parent_->get_shared_buffer_ref().reserve(total_estimated_size + total_overhead); + shared_buf.reserve(total_estimated_size); this->flags_.batch_first_message = true; size_t items_processed = 0; @@ -1740,7 +1738,7 @@ void APIConnection::process_batch_() { remaining_size -= payload_size; // Calculate where the next message's header padding will start // Current buffer size + footer space (that prepare_message_buffer will add for this message) - current_offset = this->parent_->get_shared_buffer_ref().size() + footer_size; + current_offset = shared_buf.size() + footer_size; } if (items_processed == 0) { @@ -1750,12 +1748,11 @@ void APIConnection::process_batch_() { // Add footer space for the last message (for Noise protocol MAC) if (footer_size > 0) { - auto &shared_buf = this->parent_->get_shared_buffer_ref(); shared_buf.resize(shared_buf.size() + footer_size); } // Send all collected packets - APIError err = this->helper_->write_protobuf_packets(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, + APIError err = this->helper_->write_protobuf_packets(ProtoWriteBuffer{&shared_buf}, std::span(packet_info, packet_count)); if (err != APIError::OK && err != APIError::WOULD_BLOCK) { on_fatal_error(); From 1568fc36cc22d67aebc42a815389ba24e5e78eda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 23:39:32 -1000 Subject: [PATCH 0146/1626] preen --- esphome/components/api/api_connection.cpp | 24 +++++++++++------------ esphome/components/api/api_connection.h | 5 +++++ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 3705e0c947..c30fa7e03a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -112,8 +112,7 @@ void APIConnection::start() { APIError err = this->helper_->init(); if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Helper init failed %s errno=%d", this->get_client_combined_info().c_str(), api_error_to_str(err), - errno); + this->log_warning_("Helper init failed", err); return; } this->client_info_.peername = helper_->getpeername(); @@ -144,8 +143,7 @@ void APIConnection::loop() { APIError err = this->helper_->loop(); if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Socket operation failed %s errno=%d", this->get_client_combined_info().c_str(), - api_error_to_str(err), errno); + this->log_socket_operation_failed_(err); return; } @@ -161,8 +159,7 @@ void APIConnection::loop() { break; } else if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Reading failed %s errno=%d", this->get_client_combined_info().c_str(), api_error_to_str(err), - errno); + this->log_warning_("Reading failed", err); return; } else { this->last_traffic_ = now; @@ -1540,8 +1537,7 @@ bool APIConnection::try_to_clear_buffer(bool log_out_of_space) { APIError err = this->helper_->loop(); if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Socket operation failed %s errno=%d", this->get_client_combined_info().c_str(), - api_error_to_str(err), errno); + this->log_socket_operation_failed_(err); return false; } if (this->helper_->can_write_without_blocking()) @@ -1561,8 +1557,7 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { return false; if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Packet write failed %s errno=%d", this->get_client_combined_info().c_str(), - api_error_to_str(err), errno); + this->log_warning_("Packet write failed", err); return false; } // Do not set last_traffic_ on send @@ -1756,8 +1751,7 @@ void APIConnection::process_batch_() { std::span(packet_info, packet_count)); if (err != APIError::OK && err != APIError::WOULD_BLOCK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Batch write failed %s errno=%d", this->get_client_combined_info().c_str(), api_error_to_str(err), - errno); + this->log_warning_("Batch write failed", err); } #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1835,5 +1829,11 @@ void APIConnection::process_state_subscriptions_() { } #endif // USE_API_HOMEASSISTANT_STATES +void APIConnection::log_warning_(const char *message, APIError err) { + ESP_LOGW(TAG, "%s: %s %s errno=%d", this->get_client_combined_info().c_str(), message, api_error_to_str(err), errno); +} + +void APIConnection::log_socket_operation_failed_(APIError err) { this->log_warning_("Socket operation failed", err); } + } // namespace esphome::api #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index f57d37f5a5..5b64adecb3 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -736,6 +736,11 @@ class APIConnection : public APIServerConnection { this->deferred_batch_.add_item_front(entity, MessageCreator(function_ptr), message_type, estimated_size); return this->schedule_batch_(); } + + // Helper function to log API errors with errno + void log_warning_(const char *message, APIError err); + // Specific helper for duplicated error message + void log_socket_operation_failed_(APIError err); }; } // namespace esphome::api From 8d9daca3869ae0f8136a1ac792cb0bc7689d74f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 09:42:28 -1000 Subject: [PATCH 0147/1626] address copilot review comments --- esphome/components/api/api_connection.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c30fa7e03a..da5666a6c2 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1685,7 +1685,8 @@ void APIConnection::process_batch_() { // Pre-calculate exact buffer size needed based on message types uint32_t total_estimated_size = num_items * (header_padding + footer_size); - for (const auto &item : this->deferred_batch_.items) { + for (size_t i = 0; i < this->deferred_batch_.size(); i++) { + const auto &item = this->deferred_batch_[i]; total_estimated_size += item.estimated_size; } From 79533cb0d7436c2eb2d5bcc560389e458bf7f0b9 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Wed, 30 Jul 2025 18:02:53 -0400 Subject: [PATCH 0148/1626] media_player add off on capability (#9294) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 4 ++ esphome/components/api/api_pb2.h | 4 ++ esphome/components/api/api_pb2_dump.cpp | 8 ++++ esphome/components/media_player/__init__.py | 40 +++++++++++++++++++ esphome/components/media_player/automation.h | 16 ++++++++ .../components/media_player/media_player.cpp | 13 ++++++ .../components/media_player/media_player.h | 14 ++++++- 7 files changed, 97 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 8eb2d26e54..32bbc5ec0d 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1298,6 +1298,8 @@ enum MediaPlayerState { MEDIA_PLAYER_STATE_PLAYING = 2; MEDIA_PLAYER_STATE_PAUSED = 3; MEDIA_PLAYER_STATE_ANNOUNCING = 4; + MEDIA_PLAYER_STATE_OFF = 5; + MEDIA_PLAYER_STATE_ON = 6; } enum MediaPlayerCommand { MEDIA_PLAYER_COMMAND_PLAY = 0; @@ -1312,6 +1314,8 @@ enum MediaPlayerCommand { MEDIA_PLAYER_COMMAND_REPEAT_ONE = 9; MEDIA_PLAYER_COMMAND_REPEAT_OFF = 10; MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST = 11; + MEDIA_PLAYER_COMMAND_TURN_ON = 12; + MEDIA_PLAYER_COMMAND_TURN_OFF = 13; } enum MediaPlayerFormatPurpose { MEDIA_PLAYER_FORMAT_PURPOSE_DEFAULT = 0; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index c190c8e191..7f2299f77c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -151,6 +151,8 @@ enum MediaPlayerState : uint32_t { MEDIA_PLAYER_STATE_PLAYING = 2, MEDIA_PLAYER_STATE_PAUSED = 3, MEDIA_PLAYER_STATE_ANNOUNCING = 4, + MEDIA_PLAYER_STATE_OFF = 5, + MEDIA_PLAYER_STATE_ON = 6, }; enum MediaPlayerCommand : uint32_t { MEDIA_PLAYER_COMMAND_PLAY = 0, @@ -165,6 +167,8 @@ enum MediaPlayerCommand : uint32_t { MEDIA_PLAYER_COMMAND_REPEAT_ONE = 9, MEDIA_PLAYER_COMMAND_REPEAT_OFF = 10, MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST = 11, + MEDIA_PLAYER_COMMAND_TURN_ON = 12, + MEDIA_PLAYER_COMMAND_TURN_OFF = 13, }; enum MediaPlayerFormatPurpose : uint32_t { MEDIA_PLAYER_FORMAT_PURPOSE_DEFAULT = 0, diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 032cfbc044..b934aead32 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -385,6 +385,10 @@ template<> const char *proto_enum_to_string(enums::Medi return "MEDIA_PLAYER_STATE_PAUSED"; case enums::MEDIA_PLAYER_STATE_ANNOUNCING: return "MEDIA_PLAYER_STATE_ANNOUNCING"; + case enums::MEDIA_PLAYER_STATE_OFF: + return "MEDIA_PLAYER_STATE_OFF"; + case enums::MEDIA_PLAYER_STATE_ON: + return "MEDIA_PLAYER_STATE_ON"; default: return "UNKNOWN"; } @@ -415,6 +419,10 @@ template<> const char *proto_enum_to_string(enums::Me return "MEDIA_PLAYER_COMMAND_REPEAT_OFF"; case enums::MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST: return "MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST"; + case enums::MEDIA_PLAYER_COMMAND_TURN_ON: + return "MEDIA_PLAYER_COMMAND_TURN_ON"; + case enums::MEDIA_PLAYER_COMMAND_TURN_OFF: + return "MEDIA_PLAYER_COMMAND_TURN_OFF"; default: return "UNKNOWN"; } diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index ccded1deb2..d288e70cba 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -7,6 +7,8 @@ from esphome.const import ( CONF_ID, CONF_ON_IDLE, CONF_ON_STATE, + CONF_ON_TURN_OFF, + CONF_ON_TURN_ON, CONF_TRIGGER_ID, CONF_VOLUME, ) @@ -58,6 +60,12 @@ VolumeDownAction = media_player_ns.class_( VolumeSetAction = media_player_ns.class_( "VolumeSetAction", automation.Action, cg.Parented.template(MediaPlayer) ) +TurnOnAction = media_player_ns.class_( + "TurnOnAction", automation.Action, cg.Parented.template(MediaPlayer) +) +TurnOffAction = media_player_ns.class_( + "TurnOffAction", automation.Action, cg.Parented.template(MediaPlayer) +) CONF_ANNOUNCEMENT = "announcement" CONF_ON_PLAY = "on_play" @@ -72,12 +80,16 @@ PauseTrigger = media_player_ns.class_("PauseTrigger", automation.Trigger.templat AnnoucementTrigger = media_player_ns.class_( "AnnouncementTrigger", automation.Trigger.template() ) +OnTrigger = media_player_ns.class_("OnTrigger", automation.Trigger.template()) +OffTrigger = media_player_ns.class_("OffTrigger", automation.Trigger.template()) IsIdleCondition = media_player_ns.class_("IsIdleCondition", automation.Condition) IsPausedCondition = media_player_ns.class_("IsPausedCondition", automation.Condition) IsPlayingCondition = media_player_ns.class_("IsPlayingCondition", automation.Condition) IsAnnouncingCondition = media_player_ns.class_( "IsAnnouncingCondition", automation.Condition ) +IsOnCondition = media_player_ns.class_("IsOnCondition", automation.Condition) +IsOffCondition = media_player_ns.class_("IsOffCondition", automation.Condition) async def setup_media_player_core_(var, config): @@ -97,6 +109,12 @@ async def setup_media_player_core_(var, config): for conf in config.get(CONF_ON_ANNOUNCEMENT, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) + for conf in config.get(CONF_ON_TURN_ON, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + for conf in config.get(CONF_ON_TURN_OFF, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) async def register_media_player(var, config): @@ -140,6 +158,16 @@ _MEDIA_PLAYER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(AnnoucementTrigger), } ), + cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnTrigger), + } + ), + cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OffTrigger), + } + ), } ) @@ -218,6 +246,12 @@ async def media_player_play_media_action(config, action_id, template_arg, args): @automation.register_action( "media_player.volume_down", VolumeDownAction, MEDIA_PLAYER_ACTION_SCHEMA ) +@automation.register_action( + "media_player.turn_on", TurnOnAction, MEDIA_PLAYER_ACTION_SCHEMA +) +@automation.register_action( + "media_player.turn_off", TurnOffAction, MEDIA_PLAYER_ACTION_SCHEMA +) async def media_player_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -238,6 +272,12 @@ async def media_player_action(config, action_id, template_arg, args): @automation.register_condition( "media_player.is_announcing", IsAnnouncingCondition, MEDIA_PLAYER_CONDITION_SCHEMA ) +@automation.register_condition( + "media_player.is_on", IsOnCondition, MEDIA_PLAYER_CONDITION_SCHEMA +) +@automation.register_condition( + "media_player.is_off", IsOffCondition, MEDIA_PLAYER_CONDITION_SCHEMA +) async def media_player_condition(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 422c224a85..3af5959f32 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -28,6 +28,10 @@ template using VolumeUpAction = MediaPlayerCommandAction; template using VolumeDownAction = MediaPlayerCommandAction; +template +using TurnOnAction = MediaPlayerCommandAction; +template +using TurnOffAction = MediaPlayerCommandAction; template class PlayMediaAction : public Action, public Parented { TEMPLATABLE_VALUE(std::string, media_url) @@ -66,6 +70,8 @@ using IdleTrigger = MediaPlayerStateTrigger; using PauseTrigger = MediaPlayerStateTrigger; using AnnouncementTrigger = MediaPlayerStateTrigger; +using OnTrigger = MediaPlayerStateTrigger; +using OffTrigger = MediaPlayerStateTrigger; template class IsIdleCondition : public Condition, public Parented { public: @@ -87,5 +93,15 @@ template class IsAnnouncingCondition : public Condition, bool check(Ts... x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING; } }; +template class IsOnCondition : public Condition, public Parented { + public: + bool check(Ts... x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ON; } +}; + +template class IsOffCondition : public Condition, public Parented { + public: + bool check(Ts... x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_OFF; } +}; + } // namespace media_player } // namespace esphome diff --git a/esphome/components/media_player/media_player.cpp b/esphome/components/media_player/media_player.cpp index 32da7ee265..3f274bf73b 100644 --- a/esphome/components/media_player/media_player.cpp +++ b/esphome/components/media_player/media_player.cpp @@ -9,6 +9,10 @@ static const char *const TAG = "media_player"; const char *media_player_state_to_string(MediaPlayerState state) { switch (state) { + case MEDIA_PLAYER_STATE_ON: + return "ON"; + case MEDIA_PLAYER_STATE_OFF: + return "OFF"; case MEDIA_PLAYER_STATE_IDLE: return "IDLE"; case MEDIA_PLAYER_STATE_PLAYING: @@ -18,6 +22,7 @@ const char *media_player_state_to_string(MediaPlayerState state) { case MEDIA_PLAYER_STATE_ANNOUNCING: return "ANNOUNCING"; case MEDIA_PLAYER_STATE_NONE: + return "NONE"; default: return "UNKNOWN"; } @@ -49,6 +54,10 @@ const char *media_player_command_to_string(MediaPlayerCommand command) { return "REPEAT_OFF"; case MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST: return "CLEAR_PLAYLIST"; + case MEDIA_PLAYER_COMMAND_TURN_ON: + return "TURN_ON"; + case MEDIA_PLAYER_COMMAND_TURN_OFF: + return "TURN_OFF"; default: return "UNKNOWN"; } @@ -110,6 +119,10 @@ MediaPlayerCall &MediaPlayerCall::set_command(const std::string &command) { this->set_command(MEDIA_PLAYER_COMMAND_UNMUTE); } else if (str_equals_case_insensitive(command, "TOGGLE")) { this->set_command(MEDIA_PLAYER_COMMAND_TOGGLE); + } else if (str_equals_case_insensitive(command, "TURN_ON")) { + this->set_command(MEDIA_PLAYER_COMMAND_TURN_ON); + } else if (str_equals_case_insensitive(command, "TURN_OFF")) { + this->set_command(MEDIA_PLAYER_COMMAND_TURN_OFF); } else { ESP_LOGW(TAG, "'%s' - Unrecognized command %s", this->parent_->get_name().c_str(), command.c_str()); } diff --git a/esphome/components/media_player/media_player.h b/esphome/components/media_player/media_player.h index 98aa8f609d..2f1c99115f 100644 --- a/esphome/components/media_player/media_player.h +++ b/esphome/components/media_player/media_player.h @@ -38,6 +38,8 @@ enum MediaPlayerState : uint8_t { MEDIA_PLAYER_STATE_PLAYING = 2, MEDIA_PLAYER_STATE_PAUSED = 3, MEDIA_PLAYER_STATE_ANNOUNCING = 4, + MEDIA_PLAYER_STATE_OFF = 5, + MEDIA_PLAYER_STATE_ON = 6, }; const char *media_player_state_to_string(MediaPlayerState state); @@ -54,6 +56,8 @@ enum MediaPlayerCommand : uint8_t { MEDIA_PLAYER_COMMAND_REPEAT_ONE = 9, MEDIA_PLAYER_COMMAND_REPEAT_OFF = 10, MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST = 11, + MEDIA_PLAYER_COMMAND_TURN_ON = 12, + MEDIA_PLAYER_COMMAND_TURN_OFF = 13, }; const char *media_player_command_to_string(MediaPlayerCommand command); @@ -77,9 +81,11 @@ class MediaPlayerTraits { MediaPlayerTraits() = default; void set_supports_pause(bool supports_pause) { this->supports_pause_ = supports_pause; } - bool get_supports_pause() const { return this->supports_pause_; } + void set_supports_turn_off_on(bool supports_turn_off_on) { this->supports_turn_off_on_ = supports_turn_off_on; } + bool get_supports_turn_off_on() const { return this->supports_turn_off_on_; } + std::vector &get_supported_formats() { return this->supported_formats_; } uint32_t get_feature_flags() const { @@ -90,12 +96,16 @@ class MediaPlayerTraits { if (this->get_supports_pause()) { flags |= MediaPlayerEntityFeature::PAUSE | MediaPlayerEntityFeature::PLAY; } + if (this->get_supports_turn_off_on()) { + flags |= MediaPlayerEntityFeature::TURN_OFF | MediaPlayerEntityFeature::TURN_ON; + } return flags; } protected: - bool supports_pause_{false}; std::vector supported_formats_{}; + bool supports_pause_{false}; + bool supports_turn_off_on_{false}; }; class MediaPlayerCall { From 110eac4f094d19e7628da870e61b9c9e0a0f626c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:46:01 -1000 Subject: [PATCH 0149/1626] Bump aioesphomeapi from 37.1.5 to 37.1.6 (#9988) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3b3ee8142e..d261f6d896 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.9.0 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.1.5 +aioesphomeapi==37.1.6 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 4b7f3355ea50be226bffa0f00a3a3ce41c6d399b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 31 Jul 2025 08:56:43 +1000 Subject: [PATCH 0150/1626] [core] Fix regex for lambda id() replacement (#9975) --- esphome/core/__init__.py | 2 +- tests/components/host/common.yaml | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 472067797e..39c6c3def1 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -263,7 +263,7 @@ class TimePeriodMinutes(TimePeriod): pass -LAMBDA_PROG = re.compile(r"id\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\)(\.?)") +LAMBDA_PROG = re.compile(r"\bid\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\)(\.?)") class Lambda: diff --git a/tests/components/host/common.yaml b/tests/components/host/common.yaml index fca0c5d597..5c329c8245 100644 --- a/tests/components/host/common.yaml +++ b/tests/components/host/common.yaml @@ -8,3 +8,10 @@ logger: host: mac_address: "62:23:45:AF:B3:DD" + +esphome: + on_boot: + - lambda: |- + static const uint8_t my_addr[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + if (!mac_address_is_valid(my_addr)) + ESP_LOGD("test", "Invalid mac address %X", my_addr[0]); // etc. From 79bee386ff551dc1fc8b25240636012214903b0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 13:16:40 -1000 Subject: [PATCH 0151/1626] [wifi] Fix crash during WiFi reconnection on ESP32 with poor signal quality --- .../wifi/wifi_component_esp32_arduino.cpp | 18 ++++++++++++++++++ .../components/wifi/wifi_component_esp_idf.cpp | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp32_arduino.cpp b/esphome/components/wifi/wifi_component_esp32_arduino.cpp index 3c3e87d332..67b1f565ff 100644 --- a/esphome/components/wifi/wifi_component_esp32_arduino.cpp +++ b/esphome/components/wifi/wifi_component_esp32_arduino.cpp @@ -283,6 +283,12 @@ bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { if (!this->wifi_mode_(true, {})) return false; + // Check if the STA interface is initialized before using it + if (s_sta_netif == nullptr) { + ESP_LOGW(TAG, "STA interface not initialized"); + return false; + } + esp_netif_dhcp_status_t dhcp_status; esp_err_t err = esp_netif_dhcpc_get_status(s_sta_netif, &dhcp_status); if (err != ESP_OK) { @@ -541,6 +547,8 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); + // Clear the STA interface handle to prevent use-after-free + s_sta_netif = nullptr; break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { @@ -630,6 +638,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_AP_STOP: { ESP_LOGV(TAG, "AP stop"); +#ifdef USE_WIFI_AP + // Clear the AP interface handle to prevent use-after-free + s_ap_netif = nullptr; +#endif break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { @@ -719,6 +731,12 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { if (!this->wifi_mode_({}, true)) return false; + // Check if the AP interface is initialized before using it + if (s_ap_netif == nullptr) { + ESP_LOGW(TAG, "AP interface not initialized"); + return false; + } + esp_netif_ip_info_t info; if (manual_ip.has_value()) { info.ip = manual_ip->static_ip; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 0b281e9b80..94f1f5125f 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -473,6 +473,12 @@ bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { if (!this->wifi_mode_(true, {})) return false; + // Check if the STA interface is initialized before using it + if (s_sta_netif == nullptr) { + ESP_LOGW(TAG, "STA interface not initialized"); + return false; + } + esp_netif_dhcp_status_t dhcp_status; esp_err_t err = esp_netif_dhcpc_get_status(s_sta_netif, &dhcp_status); if (err != ESP_OK) { @@ -691,6 +697,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) { ESP_LOGV(TAG, "STA stop"); s_sta_started = false; + // Clear the STA interface handle to prevent use-after-free + s_sta_netif = nullptr; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) { const auto &it = data->data.sta_authmode_change; @@ -789,6 +797,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); s_ap_started = false; +#ifdef USE_WIFI_AP + // Clear the AP interface handle to prevent use-after-free + s_ap_netif = nullptr; +#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_PROBEREQRECVED) { const auto &it = data->data.ap_probe_req_rx; @@ -865,6 +877,12 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { if (!this->wifi_mode_({}, true)) return false; + // Check if the AP interface is initialized before using it + if (s_ap_netif == nullptr) { + ESP_LOGW(TAG, "AP interface not initialized"); + return false; + } + esp_netif_ip_info_t info; if (manual_ip.has_value()) { info.ip = manual_ip->static_ip; From 97560fd9ef39ee583f096a633a9324f412e95d68 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 31 Jul 2025 12:17:20 +1200 Subject: [PATCH 0152/1626] [CI] Add labels for checkboxes (#9991) --- .github/workflows/auto-label-pr.yml | 42 +++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 729fae27fe..36086579fc 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -63,7 +63,11 @@ jobs: 'needs-docs', 'needs-codeowners', 'too-big', - 'labeller-recheck' + 'labeller-recheck', + 'bugfix', + 'new-feature', + 'breaking-change', + 'code-quality' ]; const DOCS_PR_PATTERNS = [ @@ -341,6 +345,31 @@ jobs: return labels; } + // Strategy: PR Template Checkbox detection + async function detectPRTemplateCheckboxes() { + const labels = new Set(); + const prBody = context.payload.pull_request.body || ''; + + console.log('Checking PR template checkboxes...'); + + // Check for checked checkboxes in the "Types of changes" section + const checkboxPatterns = [ + { pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' }, + { pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' }, + { pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' }, + { pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' } + ]; + + for (const { pattern, label } of checkboxPatterns) { + if (pattern.test(prBody)) { + console.log(`Found checked checkbox for: ${label}`); + labels.add(label); + } + } + + return labels; + } + // Strategy: Requirements detection async function detectRequirements(allLabels) { const labels = new Set(); @@ -351,7 +380,7 @@ jobs: } // Check for missing docs - if (allLabels.has('new-component') || allLabels.has('new-platform')) { + if (allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) { const prBody = context.payload.pull_request.body || ''; const hasDocsLink = DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody)); @@ -535,7 +564,8 @@ jobs: dashboardLabels, actionsLabels, codeOwnerLabels, - testLabels + testLabels, + checkboxLabels ] = await Promise.all([ detectMergeBranch(), detectComponentPlatforms(apiData), @@ -546,7 +576,8 @@ jobs: detectDashboardChanges(), detectGitHubActionsChanges(), detectCodeOwner(), - detectTests() + detectTests(), + detectPRTemplateCheckboxes() ]); // Combine all labels @@ -560,7 +591,8 @@ jobs: ...dashboardLabels, ...actionsLabels, ...codeOwnerLabels, - ...testLabels + ...testLabels, + ...checkboxLabels ]); // Detect requirements based on all other labels From a4ebcc691a918ea44e0d0ff57320a221aa62f393 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 14:33:11 -1000 Subject: [PATCH 0153/1626] Batch 3 services --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_pb2.cpp | 6 +- esphome/components/api/api_pb2.h | 4 +- .../bluetooth_proxy/bluetooth_connection.cpp | 193 ++++++++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 1 + 5 files changed, 113 insertions(+), 93 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 32bbc5ec0d..27edf4680f 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1505,7 +1505,7 @@ message BluetoothGATTGetServicesResponse { option (ifdef) = "USE_BLUETOOTH_PROXY"; uint64 address = 1; - repeated BluetoothGATTService services = 2 [(fixed_array_size) = 1]; + repeated BluetoothGATTService services = 2; } message BluetoothGATTGetServicesDoneResponse { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 2e8adeaf5c..ef02a5a774 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1929,11 +1929,13 @@ void BluetoothGATTService::calculate_size(ProtoSize &size) const { } void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); - buffer.encode_message(2, this->services[0], true); + for (auto &it : this->services) { + buffer.encode_message(2, it, true); + } } void BluetoothGATTGetServicesResponse::calculate_size(ProtoSize &size) const { size.add_uint64(1, this->address); - size.add_message_object_force(1, this->services[0]); + size.add_repeated_message(1, this->services); } void BluetoothGATTGetServicesDoneResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7f2299f77c..6c2ca60e00 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1895,12 +1895,12 @@ class BluetoothGATTService : public ProtoMessage { class BluetoothGATTGetServicesResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 71; - static constexpr uint8_t ESTIMATED_SIZE = 21; + static constexpr uint8_t ESTIMATED_SIZE = 38; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_get_services_response"; } #endif uint64_t address{0}; - std::array services{}; + std::vector services{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index b3484032b2..e761fd3496 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -57,7 +57,7 @@ void BluetoothConnection::reset_connection_(esp_err_t reason) { } void BluetoothConnection::send_service_for_discovery_() { - if (this->send_service_ == this->service_count_) { + if (this->send_service_ >= this->service_count_) { this->send_service_ = DONE_SENDING_SERVICES; this->proxy_->send_gatt_services_done(this->address_); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || @@ -73,117 +73,134 @@ void BluetoothConnection::send_service_for_discovery_() { return; } - // Send next service - esp_gattc_service_elem_t service_result; - uint16_t service_count = 1; - esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, - &service_result, &service_count, this->send_service_); - this->send_service_++; - - if (service_status != ESP_GATT_OK || service_count == 0) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", - this->connection_index_, this->address_str().c_str(), service_status != ESP_GATT_OK ? "error" : "missing", - service_status, service_count, this->send_service_ - 1); - return; - } - + // Prepare response for up to 3 services api::BluetoothGATTGetServicesResponse resp; resp.address = this->address_; - auto &service_resp = resp.services[0]; - fill_128bit_uuid_array(service_resp.uuid, service_result.uuid); - service_resp.handle = service_result.start_handle; - // Get the number of characteristics directly with one call - uint16_t total_char_count = 0; - esp_gatt_status_t char_count_status = - esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, - service_result.start_handle, service_result.end_handle, 0, &total_char_count); + // Process up to 3 services in this iteration + static constexpr int MAX_SERVICES_PER_BATCH = 3; + int services_to_process = std::min(MAX_SERVICES_PER_BATCH, this->service_count_ - this->send_service_); + resp.services.reserve(services_to_process); - if (char_count_status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, - this->address_str().c_str(), char_count_status); - return; - } + for (int service_idx = 0; service_idx < services_to_process; service_idx++) { + esp_gattc_service_elem_t service_result; + uint16_t service_count = 1; + esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, + &service_result, &service_count, this->send_service_); - if (total_char_count == 0) { - // No characteristics, just send the service response - api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); - return; - } - - // Reserve space and process characteristics - service_resp.characteristics.reserve(total_char_count); - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - while (true) { // characteristics - uint16_t char_count = 1; - esp_gatt_status_t char_status = - esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, - service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { - break; - } - if (char_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, - this->address_str().c_str(), char_status); - return; - } - if (char_count == 0) { + if (service_status != ESP_GATT_OK || service_count == 0) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", + this->connection_index_, this->address_str().c_str(), + service_status != ESP_GATT_OK ? "error" : "missing", service_status, service_count, this->send_service_); + // If first service fails, return. If second fails, send what we have. + if (services_processed == 0) { + return; + } break; } - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); - fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); - characteristic_resp.handle = char_result.char_handle; - characteristic_resp.properties = char_result.properties; - char_offset++; + this->send_service_++; + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + fill_128bit_uuid_array(service_resp.uuid, service_result.uuid); + service_resp.handle = service_result.start_handle; - // Get the number of descriptors directly with one call - uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( - this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); + // Get the number of characteristics directly with one call + uint16_t total_char_count = 0; + esp_gatt_status_t char_count_status = + esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, + service_result.start_handle, service_result.end_handle, 0, &total_char_count); - if (desc_count_status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, - this->address_str().c_str(), char_result.char_handle, desc_count_status); + if (char_count_status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, + this->address_str().c_str(), char_count_status); return; } - if (total_desc_count == 0) { - // No descriptors, continue to next characteristic + + if (total_char_count == 0) { + // No characteristics, continue to next service + services_processed++; continue; } - // Reserve space and process descriptors - characteristic_resp.descriptors.reserve(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + // Reserve space and process characteristics + service_resp.characteristics.reserve(total_char_count); + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + while (true) { // characteristics + uint16_t char_count = 1; + esp_gatt_status_t char_status = + esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &char_count, char_offset); + if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { break; } - if (desc_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, - this->address_str().c_str(), desc_status); + if (char_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, + this->address_str().c_str(), char_status); return; } - if (desc_count == 0) { - break; // No more descriptors + if (char_count == 0) { + break; } - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); - descriptor_resp.handle = desc_result.handle; - desc_offset++; + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + char_offset++; + + // Get the number of descriptors directly with one call + uint16_t total_desc_count = 0; + esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( + this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); + + if (desc_count_status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, + this->address_str().c_str(), char_result.char_handle, desc_count_status); + return; + } + if (total_desc_count == 0) { + // No descriptors, continue to next characteristic + continue; + } + + // Reserve space and process descriptors + characteristic_resp.descriptors.reserve(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (true) { // descriptors + uint16_t desc_count = 1; + esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( + this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + break; + } + if (desc_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, + this->address_str().c_str(), desc_status); + return; + } + if (desc_count == 0) { + break; // No more descriptors + } + + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } } + + services_processed++; } - // Send the message (we already checked api_conn is not null at the beginning) - api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); + // Send the message with 1-3 services + if (services_processed > 0) { + api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); + } } bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d249515fdf..b33460339b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -22,6 +22,7 @@ namespace esphome::bluetooth_proxy { static const esp_err_t ESP_GATT_NOT_CONNECTED = -1; static const int DONE_SENDING_SERVICES = -2; +static const uint8_t MAX_SERVICES_PER_BATCH = 3; using namespace esp32_ble_client; From 12cd1ec52590ae19c613dc9578f6c25ef245cda3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 14:46:13 -1000 Subject: [PATCH 0154/1626] [bluetooth_proxy] Batch BLE service discovery messages for 67% reduction in API traffic --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index e761fd3496..3c1b198ee4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -78,8 +78,9 @@ void BluetoothConnection::send_service_for_discovery_() { resp.address = this->address_; // Process up to 3 services in this iteration - static constexpr int MAX_SERVICES_PER_BATCH = 3; - int services_to_process = std::min(MAX_SERVICES_PER_BATCH, this->service_count_ - this->send_service_); + uint8_t services_to_process = + std::min(MAX_SERVICES_PER_BATCH, static_cast(this->service_count_ - this->send_service_)); + uint8_t services_processed = 0; resp.services.reserve(services_to_process); for (int service_idx = 0; service_idx < services_to_process; service_idx++) { From ecb029e0a7ee85693e2253270a6dcb18ff94ce38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 14:50:20 -1000 Subject: [PATCH 0155/1626] [bluetooth_proxy] Batch BLE service discovery messages for 67% reduction in API traffic --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 3c1b198ee4..ac31e74124 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -199,9 +199,7 @@ void BluetoothConnection::send_service_for_discovery_() { } // Send the message with 1-3 services - if (services_processed > 0) { - api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); - } + api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); } bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, From 7692aacc2df4e53d711a198ed274287f31061cbb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 14:51:35 -1000 Subject: [PATCH 0156/1626] [bluetooth_proxy] Batch BLE service discovery messages for 67% reduction in API traffic --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index ac31e74124..e4b1c80619 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -93,11 +93,7 @@ void BluetoothConnection::send_service_for_discovery_() { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", this->connection_index_, this->address_str().c_str(), service_status != ESP_GATT_OK ? "error" : "missing", service_status, service_count, this->send_service_); - // If first service fails, return. If second fails, send what we have. - if (services_processed == 0) { - return; - } - break; + return; } this->send_service_++; From 08aad73af920487f3835dde1772b906fae6966da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 15:00:11 -1000 Subject: [PATCH 0157/1626] did not need --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index e4b1c80619..895819909a 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -80,7 +80,6 @@ void BluetoothConnection::send_service_for_discovery_() { // Process up to 3 services in this iteration uint8_t services_to_process = std::min(MAX_SERVICES_PER_BATCH, static_cast(this->service_count_ - this->send_service_)); - uint8_t services_processed = 0; resp.services.reserve(services_to_process); for (int service_idx = 0; service_idx < services_to_process; service_idx++) { @@ -116,7 +115,6 @@ void BluetoothConnection::send_service_for_discovery_() { if (total_char_count == 0) { // No characteristics, continue to next service - services_processed++; continue; } @@ -190,8 +188,6 @@ void BluetoothConnection::send_service_for_discovery_() { desc_offset++; } } - - services_processed++; } // Send the message with 1-3 services From 853dca6c5c356d969907c33917ea171313be9af7 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Wed, 30 Jul 2025 21:02:09 -0400 Subject: [PATCH 0158/1626] [api] Bump APIVersion to 1.11 (#9990) --- esphome/components/api/api_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 51c7509428..5b5cb49740 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1366,7 +1366,7 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 10; + resp.api_version_minor = 11; // Temporary string for concatenation - will be valid during send_message call std::string server_info = App.get_name() + " (esphome v" ESPHOME_VERSION ")"; resp.set_server_info(StringRef(server_info)); From 1d0a38446f5f82493cab2975df5fd6e4a700dedc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 15:10:23 -1000 Subject: [PATCH 0159/1626] [api] Reduce flash usage through targeted optimizations (#9979) --- esphome/components/api/api_connection.cpp | 42 +++++++++++------------ esphome/components/api/api_connection.h | 5 +++ 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 5b5cb49740..c0dbe4e198 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -112,8 +112,7 @@ void APIConnection::start() { APIError err = this->helper_->init(); if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Helper init failed %s errno=%d", this->get_client_combined_info().c_str(), api_error_to_str(err), - errno); + this->log_warning_("Helper init failed", err); return; } this->client_info_.peername = helper_->getpeername(); @@ -144,8 +143,7 @@ void APIConnection::loop() { APIError err = this->helper_->loop(); if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Socket operation failed %s errno=%d", this->get_client_combined_info().c_str(), - api_error_to_str(err), errno); + this->log_socket_operation_failed_(err); return; } @@ -161,8 +159,7 @@ void APIConnection::loop() { break; } else if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Reading failed %s errno=%d", this->get_client_combined_info().c_str(), api_error_to_str(err), - errno); + this->log_warning_("Reading failed", err); return; } else { this->last_traffic_ = now; @@ -1540,8 +1537,7 @@ bool APIConnection::try_to_clear_buffer(bool log_out_of_space) { APIError err = this->helper_->loop(); if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Socket operation failed %s errno=%d", this->get_client_combined_info().c_str(), - api_error_to_str(err), errno); + this->log_socket_operation_failed_(err); return false; } if (this->helper_->can_write_without_blocking()) @@ -1561,8 +1557,7 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { return false; if (err != APIError::OK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Packet write failed %s errno=%d", this->get_client_combined_info().c_str(), - api_error_to_str(err), errno); + this->log_warning_("Packet write failed", err); return false; } // Do not set last_traffic_ on send @@ -1647,6 +1642,8 @@ void APIConnection::process_batch_() { return; } + // Get shared buffer reference once to avoid multiple calls + auto &shared_buf = this->parent_->get_shared_buffer_ref(); size_t num_items = this->deferred_batch_.size(); // Fast path for single message - allocate exact size needed @@ -1657,8 +1654,7 @@ void APIConnection::process_batch_() { uint16_t payload_size = item.creator(item.entity, this, std::numeric_limits::max(), true, item.message_type); - if (payload_size > 0 && - this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, item.message_type)) { + if (payload_size > 0 && this->send_buffer(ProtoWriteBuffer{&shared_buf}, item.message_type)) { #ifdef HAS_PROTO_MESSAGE_DUMP // Log messages after send attempt for VV debugging // It's safe to use the buffer for logging at this point regardless of send result @@ -1685,20 +1681,18 @@ void APIConnection::process_batch_() { const uint8_t footer_size = this->helper_->frame_footer_size(); // Initialize buffer and tracking variables - this->parent_->get_shared_buffer_ref().clear(); + shared_buf.clear(); // Pre-calculate exact buffer size needed based on message types - uint32_t total_estimated_size = 0; + uint32_t total_estimated_size = num_items * (header_padding + footer_size); for (size_t i = 0; i < this->deferred_batch_.size(); i++) { const auto &item = this->deferred_batch_[i]; total_estimated_size += item.estimated_size; } // Calculate total overhead for all messages - uint32_t total_overhead = (header_padding + footer_size) * num_items; - // Reserve based on estimated size (much more accurate than 24-byte worst-case) - this->parent_->get_shared_buffer_ref().reserve(total_estimated_size + total_overhead); + shared_buf.reserve(total_estimated_size); this->flags_.batch_first_message = true; size_t items_processed = 0; @@ -1740,7 +1734,7 @@ void APIConnection::process_batch_() { remaining_size -= payload_size; // Calculate where the next message's header padding will start // Current buffer size + footer space (that prepare_message_buffer will add for this message) - current_offset = this->parent_->get_shared_buffer_ref().size() + footer_size; + current_offset = shared_buf.size() + footer_size; } if (items_processed == 0) { @@ -1750,17 +1744,15 @@ void APIConnection::process_batch_() { // Add footer space for the last message (for Noise protocol MAC) if (footer_size > 0) { - auto &shared_buf = this->parent_->get_shared_buffer_ref(); shared_buf.resize(shared_buf.size() + footer_size); } // Send all collected packets - APIError err = this->helper_->write_protobuf_packets(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, + APIError err = this->helper_->write_protobuf_packets(ProtoWriteBuffer{&shared_buf}, std::span(packet_info, packet_count)); if (err != APIError::OK && err != APIError::WOULD_BLOCK) { on_fatal_error(); - ESP_LOGW(TAG, "%s: Batch write failed %s errno=%d", this->get_client_combined_info().c_str(), api_error_to_str(err), - errno); + this->log_warning_("Batch write failed", err); } #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1838,5 +1830,11 @@ void APIConnection::process_state_subscriptions_() { } #endif // USE_API_HOMEASSISTANT_STATES +void APIConnection::log_warning_(const char *message, APIError err) { + ESP_LOGW(TAG, "%s: %s %s errno=%d", this->get_client_combined_info().c_str(), message, api_error_to_str(err), errno); +} + +void APIConnection::log_socket_operation_failed_(APIError err) { this->log_warning_("Socket operation failed", err); } + } // namespace esphome::api #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index f57d37f5a5..5b64adecb3 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -736,6 +736,11 @@ class APIConnection : public APIServerConnection { this->deferred_batch_.add_item_front(entity, MessageCreator(function_ptr), message_type, estimated_size); return this->schedule_batch_(); } + + // Helper function to log API errors with errno + void log_warning_(const char *message, APIError err); + // Specific helper for duplicated error message + void log_socket_operation_failed_(APIError err); }; } // namespace esphome::api From 5b6e152d6c00c925cb2647191e39f82fb21a58c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 15:17:35 -1000 Subject: [PATCH 0160/1626] [esp32_touch] Work around ESP-IDF v5.4 regression in `touch_pad_read_filtered` (#9957) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/esp32_touch/esp32_touch_v1.cpp | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/esphome/components/esp32_touch/esp32_touch_v1.cpp b/esphome/components/esp32_touch/esp32_touch_v1.cpp index 629dc8e793..ffb805e008 100644 --- a/esphome/components/esp32_touch/esp32_touch_v1.cpp +++ b/esphome/components/esp32_touch/esp32_touch_v1.cpp @@ -201,15 +201,13 @@ void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) { touch_pad_t pad = child->get_touch_pad(); // Read current value using ISR-safe API - uint32_t value; - if (component->iir_filter_enabled_()) { - uint16_t temp_value = 0; - touch_pad_read_filtered(pad, &temp_value); - value = temp_value; - } else { - // Use low-level HAL function when filter is not enabled - value = touch_ll_read_raw_data(pad); - } + // IMPORTANT: ESP-IDF v5.4 regression - touch_pad_read_filtered() is no longer ISR-safe + // In ESP-IDF v5.3 and earlier it was ISR-safe, but ESP-IDF v5.4 added mutex protection that causes: + // "assert failed: xQueueSemaphoreTake queue.c:1718" + // We must use raw values even when filter is enabled as a workaround. + // Users should adjust thresholds to compensate for the lack of IIR filtering. + // See: https://github.com/espressif/esp-idf/issues/17045 + uint32_t value = touch_ll_read_raw_data(pad); // Skip pads that aren’t in the trigger mask if (((mask >> pad) & 1) == 0) { From f25abc32489c34d2728ab2fce69f458624d70254 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 15:18:50 -1000 Subject: [PATCH 0161/1626] [esp32_ble] Fix spurious BLE 5.0 event warnings on ESP32-S3 (#9969) --- esphome/components/esp32_ble/ble.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 6b4ce07f15..33258552c7 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -468,6 +468,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa // Ignore these GAP events as they are not relevant for our use case case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: + case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete + case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm return; default: From 88d8cfe6a2f78c6d18060b9b726da52bc4c07ef9 Mon Sep 17 00:00:00 2001 From: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:20:55 +1000 Subject: [PATCH 0162/1626] [tm1651] Remove dependency on Arduino Library (#9645) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Keith Burzinski --- CODEOWNERS | 2 +- esphome/components/tm1651/__init__.py | 93 +++--- esphome/components/tm1651/tm1651.cpp | 268 ++++++++++++++---- esphome/components/tm1651/tm1651.h | 69 ++--- .../components/tm1651/test.esp32-c3-idf.yaml | 1 + tests/components/tm1651/test.esp32-idf.yaml | 1 + 6 files changed, 303 insertions(+), 131 deletions(-) create mode 100644 tests/components/tm1651/test.esp32-c3-idf.yaml create mode 100644 tests/components/tm1651/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index dbd3d2c592..244e204ab6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -472,7 +472,7 @@ esphome/components/tlc5971/* @IJIJI esphome/components/tm1621/* @Philippe12 esphome/components/tm1637/* @glmnet esphome/components/tm1638/* @skykingjwc -esphome/components/tm1651/* @freekode +esphome/components/tm1651/* @mrtoy-me esphome/components/tmp102/* @timsavage esphome/components/tmp1075/* @sybrenstuvel esphome/components/tmp117/* @Azimath diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index 153cc690e7..49796d9b42 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -10,26 +10,28 @@ from esphome.const import ( CONF_LEVEL, ) -CODEOWNERS = ["@freekode"] +CODEOWNERS = ["@mrtoy-me"] + +CONF_LEVEL_PERCENT = "level_percent" tm1651_ns = cg.esphome_ns.namespace("tm1651") TM1651Brightness = tm1651_ns.enum("TM1651Brightness") TM1651Display = tm1651_ns.class_("TM1651Display", cg.Component) -SetLevelPercentAction = tm1651_ns.class_("SetLevelPercentAction", automation.Action) -SetLevelAction = tm1651_ns.class_("SetLevelAction", automation.Action) SetBrightnessAction = tm1651_ns.class_("SetBrightnessAction", automation.Action) -TurnOnAction = tm1651_ns.class_("SetLevelPercentAction", automation.Action) -TurnOffAction = tm1651_ns.class_("SetLevelPercentAction", automation.Action) - -CONF_LEVEL_PERCENT = "level_percent" +SetLevelAction = tm1651_ns.class_("SetLevelAction", automation.Action) +SetLevelPercentAction = tm1651_ns.class_("SetLevelPercentAction", automation.Action) +TurnOnAction = tm1651_ns.class_("TurnOnAction", automation.Action) +TurnOffAction = tm1651_ns.class_("TurnOffAction", automation.Action) TM1651_BRIGHTNESS_OPTIONS = { - 1: TM1651Brightness.TM1651_BRIGHTNESS_LOW, - 2: TM1651Brightness.TM1651_BRIGHTNESS_MEDIUM, - 3: TM1651Brightness.TM1651_BRIGHTNESS_HIGH, + 1: TM1651Brightness.TM1651_DARKEST, + 2: TM1651Brightness.TM1651_TYPICAL, + 3: TM1651Brightness.TM1651_BRIGHTEST, } +MULTI_CONF = True + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -38,26 +40,21 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_DIO_PIN): pins.internal_gpio_output_pin_schema, } ), - cv.only_with_arduino, ) -validate_level_percent = cv.All(cv.int_range(min=0, max=100)) -validate_level = cv.All(cv.int_range(min=0, max=7)) -validate_brightness = cv.enum(TM1651_BRIGHTNESS_OPTIONS, int=True) - async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - clk_pin = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) cg.add(var.set_clk_pin(clk_pin)) dio_pin = await cg.gpio_pin_expression(config[CONF_DIO_PIN]) cg.add(var.set_dio_pin(dio_pin)) - # https://platformio.org/lib/show/6865/TM1651 - cg.add_library("freekode/TM1651", "1.0.1") +validate_brightness = cv.enum(TM1651_BRIGHTNESS_OPTIONS, int=True) +validate_level = cv.All(cv.int_range(min=0, max=7)) +validate_level_percent = cv.All(cv.int_range(min=0, max=100)) BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( { @@ -66,38 +63,22 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action("tm1651.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA) -async def output_turn_on_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - @automation.register_action( - "tm1651.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA -) -async def output_turn_off_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "tm1651.set_level_percent", - SetLevelPercentAction, + "tm1651.set_brightness", + SetBrightnessAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(TM1651Display), - cv.Required(CONF_LEVEL_PERCENT): cv.templatable(validate_level_percent), + cv.Required(CONF_BRIGHTNESS): cv.templatable(validate_brightness), }, - key=CONF_LEVEL_PERCENT, + key=CONF_BRIGHTNESS, ), ) -async def tm1651_set_level_percent_to_code(config, action_id, template_arg, args): +async def tm1651_set_brightness_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_LEVEL_PERCENT], args, cg.uint8) - cg.add(var.set_level_percent(template_)) + template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.uint8) + cg.add(var.set_brightness(template_)) return var @@ -121,19 +102,35 @@ async def tm1651_set_level_to_code(config, action_id, template_arg, args): @automation.register_action( - "tm1651.set_brightness", - SetBrightnessAction, + "tm1651.set_level_percent", + SetLevelPercentAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(TM1651Display), - cv.Required(CONF_BRIGHTNESS): cv.templatable(validate_brightness), + cv.Required(CONF_LEVEL_PERCENT): cv.templatable(validate_level_percent), }, - key=CONF_BRIGHTNESS, + key=CONF_LEVEL_PERCENT, ), ) -async def tm1651_set_brightness_to_code(config, action_id, template_arg, args): +async def tm1651_set_level_percent_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_LEVEL_PERCENT], args, cg.uint8) + cg.add(var.set_level_percent(template_)) + return var + + +@automation.register_action( + "tm1651.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA +) +async def output_turn_off_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_action("tm1651.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA) +async def output_turn_on_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.uint8) - cg.add(var.set_brightness(template_)) return var diff --git a/esphome/components/tm1651/tm1651.cpp b/esphome/components/tm1651/tm1651.cpp index 1173bf0e35..15ada0f8ff 100644 --- a/esphome/components/tm1651/tm1651.cpp +++ b/esphome/components/tm1651/tm1651.cpp @@ -1,7 +1,54 @@ -#ifdef USE_ARDUINO +// This Esphome TM1651 component for use with Mini Battery Displays (7 LED levels) +// and removes the Esphome dependency on the TM1651 Arduino library. +// It was largely based on the work of others as set out below. +// @mrtoy-me July 2025 +// ============================================================================================== +// Original Arduino TM1651 library: +// Author:Fred.Chu +// Date:14 August, 2014 +// Applicable Module: Battery Display v1.0 +// This library is free software; you can redistribute it and/or +// modify it under the terms of the GNU Lesser General Public +// License as published by the Free Software Foundation; either +// version 2.1 of the License, or (at your option) any later version. +// This library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the GNU +// Lesser General Public License for more details. +// Modified record: +// Author: Detlef Giessmann Germany +// Mail: mydiyp@web.de +// Demo for the new 7 LED Battery-Display 2017 +// IDE: Arduino-1.6.5 +// Type: OPEN-SMART CX10*4RY68 4Color +// Date: 01.05.2017 +// ============================================================================================== +// Esphome component using arduino TM1651 library: +// MIT License +// Copyright (c) 2019 freekode +// ============================================================================================== +// Library and command-line (python) program to control mini battery displays on Raspberry Pi: +// MIT License +// Copyright (c) 2020 Koen Vervloese +// ============================================================================================== +// MIT License +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. #include "tm1651.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -9,84 +56,205 @@ namespace tm1651 { static const char *const TAG = "tm1651.display"; -static const uint8_t MAX_INPUT_LEVEL_PERCENT = 100; -static const uint8_t TM1651_MAX_LEVEL = 7; +static const bool LINE_HIGH = true; +static const bool LINE_LOW = false; -static const uint8_t TM1651_BRIGHTNESS_LOW_HW = 0; -static const uint8_t TM1651_BRIGHTNESS_MEDIUM_HW = 2; -static const uint8_t TM1651_BRIGHTNESS_HIGH_HW = 7; +// TM1651 maximum frequency is 500 kHz (duty ratio 50%) = 2 microseconds / cycle +static const uint8_t CLOCK_CYCLE = 8; + +static const uint8_t HALF_CLOCK_CYCLE = CLOCK_CYCLE / 2; +static const uint8_t QUARTER_CLOCK_CYCLE = CLOCK_CYCLE / 4; + +static const uint8_t ADDR_FIXED = 0x44; // fixed address mode +static const uint8_t ADDR_START = 0xC0; // address of the display register + +static const uint8_t DISPLAY_OFF = 0x80; +static const uint8_t DISPLAY_ON = 0x88; + +static const uint8_t MAX_DISPLAY_LEVELS = 7; + +static const uint8_t PERCENT100 = 100; +static const uint8_t PERCENT50 = 50; + +static const uint8_t TM1651_BRIGHTNESS_DARKEST = 0; +static const uint8_t TM1651_BRIGHTNESS_TYPICAL = 2; +static const uint8_t TM1651_BRIGHTNESS_BRIGHTEST = 7; + +static const uint8_t TM1651_LEVEL_TAB[] = {0b00000000, 0b00000001, 0b00000011, 0b00000111, + 0b00001111, 0b00011111, 0b00111111, 0b01111111}; + +// public void TM1651Display::setup() { - uint8_t clk = clk_pin_->get_pin(); - uint8_t dio = dio_pin_->get_pin(); + this->clk_pin_->setup(); + this->clk_pin_->pin_mode(gpio::FLAG_OUTPUT); - battery_display_ = make_unique(clk, dio); - battery_display_->init(); - battery_display_->clearDisplay(); + this->dio_pin_->setup(); + this->dio_pin_->pin_mode(gpio::FLAG_OUTPUT); + + this->brightness_ = TM1651_BRIGHTNESS_TYPICAL; + + // clear display + this->display_level_(); + this->update_brightness_(DISPLAY_ON); } void TM1651Display::dump_config() { - ESP_LOGCONFIG(TAG, "TM1651 Battery Display"); + ESP_LOGCONFIG(TAG, "Battery Display"); LOG_PIN(" CLK: ", clk_pin_); LOG_PIN(" DIO: ", dio_pin_); } -void TM1651Display::set_level_percent(uint8_t new_level) { - this->level_ = calculate_level_(new_level); - this->repaint_(); +void TM1651Display::set_brightness(uint8_t new_brightness) { + this->brightness_ = this->remap_brightness_(new_brightness); + if (this->display_on_) { + this->update_brightness_(DISPLAY_ON); + } } void TM1651Display::set_level(uint8_t new_level) { + if (new_level > MAX_DISPLAY_LEVELS) + new_level = MAX_DISPLAY_LEVELS; this->level_ = new_level; - this->repaint_(); + if (this->display_on_) { + this->display_level_(); + } } -void TM1651Display::set_brightness(uint8_t new_brightness) { - this->brightness_ = calculate_brightness_(new_brightness); - this->repaint_(); -} - -void TM1651Display::turn_on() { - this->is_on_ = true; - this->repaint_(); +void TM1651Display::set_level_percent(uint8_t percentage) { + this->level_ = this->calculate_level_(percentage); + if (this->display_on_) { + this->display_level_(); + } } void TM1651Display::turn_off() { - this->is_on_ = false; - battery_display_->displayLevel(0); + this->display_on_ = false; + this->update_brightness_(DISPLAY_OFF); } -void TM1651Display::repaint_() { - if (!this->is_on_) { - return; - } - - battery_display_->set(this->brightness_); - battery_display_->displayLevel(this->level_); +void TM1651Display::turn_on() { + this->display_on_ = true; + // display level as it could have been changed when display turned off + this->display_level_(); + this->update_brightness_(DISPLAY_ON); } -uint8_t TM1651Display::calculate_level_(uint8_t new_level) { - if (new_level == 0) { - return 0; - } +// protected - float calculated_level = TM1651_MAX_LEVEL / (float) (MAX_INPUT_LEVEL_PERCENT / (float) new_level); - return (uint8_t) roundf(calculated_level); +uint8_t TM1651Display::calculate_level_(uint8_t percentage) { + if (percentage > PERCENT100) + percentage = PERCENT100; + // scale 0-100% to 0-7 display levels + // use integer arithmetic with rounding + uint16_t initial_scaling = (percentage * MAX_DISPLAY_LEVELS) + PERCENT50; + return (uint8_t) (initial_scaling / PERCENT100); } -uint8_t TM1651Display::calculate_brightness_(uint8_t new_brightness) { - if (new_brightness <= 1) { - return TM1651_BRIGHTNESS_LOW_HW; - } else if (new_brightness == 2) { - return TM1651_BRIGHTNESS_MEDIUM_HW; - } else if (new_brightness >= 3) { - return TM1651_BRIGHTNESS_HIGH_HW; +void TM1651Display::display_level_() { + this->start_(); + this->write_byte_(ADDR_FIXED); + this->stop_(); + + this->start_(); + this->write_byte_(ADDR_START); + this->write_byte_(TM1651_LEVEL_TAB[this->level_]); + this->stop_(); +} + +uint8_t TM1651Display::remap_brightness_(uint8_t new_brightness) { + if (new_brightness <= 1) + return TM1651_BRIGHTNESS_DARKEST; + if (new_brightness == 2) + return TM1651_BRIGHTNESS_TYPICAL; + + // new_brightness >= 3 + return TM1651_BRIGHTNESS_BRIGHTEST; +} + +void TM1651Display::update_brightness_(uint8_t on_off_control) { + this->start_(); + this->write_byte_(on_off_control | this->brightness_); + this->stop_(); +} + +// low level functions + +bool TM1651Display::write_byte_(uint8_t data) { + // data bit written to DIO when CLK is low + for (uint8_t i = 0; i < 8; i++) { + this->half_cycle_clock_low_((bool) (data & 0x01)); + this->half_cycle_clock_high_(); + data >>= 1; } - return TM1651_BRIGHTNESS_LOW_HW; + // start 9th cycle, setting DIO high and look for ack + this->half_cycle_clock_low_(LINE_HIGH); + return this->half_cycle_clock_high_ack_(); +} + +void TM1651Display::half_cycle_clock_low_(bool data_bit) { + // first half cycle, clock low and write data bit + this->clk_pin_->digital_write(LINE_LOW); + delayMicroseconds(QUARTER_CLOCK_CYCLE); + + this->dio_pin_->digital_write(data_bit); + delayMicroseconds(QUARTER_CLOCK_CYCLE); +} + +void TM1651Display::half_cycle_clock_high_() { + // second half cycle, clock high + this->clk_pin_->digital_write(LINE_HIGH); + delayMicroseconds(HALF_CLOCK_CYCLE); +} + +bool TM1651Display::half_cycle_clock_high_ack_() { + // second half cycle, clock high and check for ack + this->clk_pin_->digital_write(LINE_HIGH); + delayMicroseconds(QUARTER_CLOCK_CYCLE); + + this->dio_pin_->pin_mode(gpio::FLAG_INPUT); + // valid ack on DIO is low + bool ack = (!this->dio_pin_->digital_read()); + + this->dio_pin_->pin_mode(gpio::FLAG_OUTPUT); + + // ack should be set DIO low by now + // if its not, set DIO low before the next cycle + if (!ack) { + this->dio_pin_->digital_write(LINE_LOW); + } + delayMicroseconds(QUARTER_CLOCK_CYCLE); + + // begin next cycle + this->clk_pin_->digital_write(LINE_LOW); + + return ack; +} + +void TM1651Display::start_() { + // start data transmission + this->delineate_transmission_(LINE_HIGH); +} + +void TM1651Display::stop_() { + // stop data transmission + this->delineate_transmission_(LINE_LOW); +} + +void TM1651Display::delineate_transmission_(bool dio_state) { + // delineate data transmission + // DIO changes its value while CLK is high + + this->dio_pin_->digital_write(dio_state); + delayMicroseconds(HALF_CLOCK_CYCLE); + + this->clk_pin_->digital_write(LINE_HIGH); + delayMicroseconds(QUARTER_CLOCK_CYCLE); + + this->dio_pin_->digital_write(!dio_state); + delayMicroseconds(QUARTER_CLOCK_CYCLE); } } // namespace tm1651 } // namespace esphome - -#endif // USE_ARDUINO diff --git a/esphome/components/tm1651/tm1651.h b/esphome/components/tm1651/tm1651.h index fe7b7d9c6f..7079910adf 100644 --- a/esphome/components/tm1651/tm1651.h +++ b/esphome/components/tm1651/tm1651.h @@ -1,22 +1,16 @@ #pragma once -#ifdef USE_ARDUINO - -#include - +#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" - -#include namespace esphome { namespace tm1651 { enum TM1651Brightness : uint8_t { - TM1651_BRIGHTNESS_LOW = 1, - TM1651_BRIGHTNESS_MEDIUM = 2, - TM1651_BRIGHTNESS_HIGH = 3, + TM1651_DARKEST = 1, + TM1651_TYPICAL = 2, + TM1651_BRIGHTEST = 3, }; class TM1651Display : public Component { @@ -27,36 +21,49 @@ class TM1651Display : public Component { void setup() override; void dump_config() override; - void set_level_percent(uint8_t new_level); - void set_level(uint8_t new_level); void set_brightness(uint8_t new_brightness); void set_brightness(TM1651Brightness new_brightness) { this->set_brightness(static_cast(new_brightness)); } - void turn_on(); + void set_level(uint8_t new_level); + void set_level_percent(uint8_t percentage); + void turn_off(); + void turn_on(); protected: - std::unique_ptr battery_display_; + uint8_t calculate_level_(uint8_t percentage); + void display_level_(); + + uint8_t remap_brightness_(uint8_t new_brightness); + void update_brightness_(uint8_t on_off_control); + + // low level functions + bool write_byte_(uint8_t data); + + void half_cycle_clock_low_(bool data_bit); + void half_cycle_clock_high_(); + bool half_cycle_clock_high_ack_(); + + void start_(); + void stop_(); + + void delineate_transmission_(bool dio_state); + InternalGPIOPin *clk_pin_; InternalGPIOPin *dio_pin_; - bool is_on_ = true; - uint8_t brightness_; - uint8_t level_; - - void repaint_(); - - uint8_t calculate_level_(uint8_t new_level); - uint8_t calculate_brightness_(uint8_t new_brightness); + bool display_on_{true}; + uint8_t brightness_{}; + uint8_t level_{0}; }; -template class SetLevelPercentAction : public Action, public Parented { +template class SetBrightnessAction : public Action, public Parented { public: - TEMPLATABLE_VALUE(uint8_t, level_percent) + TEMPLATABLE_VALUE(uint8_t, brightness) void play(Ts... x) override { - auto level_percent = this->level_percent_.value(x...); - this->parent_->set_level_percent(level_percent); + auto brightness = this->brightness_.value(x...); + this->parent_->set_brightness(brightness); } }; @@ -70,13 +77,13 @@ template class SetLevelAction : public Action, public Par } }; -template class SetBrightnessAction : public Action, public Parented { +template class SetLevelPercentAction : public Action, public Parented { public: - TEMPLATABLE_VALUE(uint8_t, brightness) + TEMPLATABLE_VALUE(uint8_t, level_percent) void play(Ts... x) override { - auto brightness = this->brightness_.value(x...); - this->parent_->set_brightness(brightness); + auto level_percent = this->level_percent_.value(x...); + this->parent_->set_level_percent(level_percent); } }; @@ -92,5 +99,3 @@ template class TurnOffAction : public Action, public Pare } // namespace tm1651 } // namespace esphome - -#endif // USE_ARDUINO diff --git a/tests/components/tm1651/test.esp32-c3-idf.yaml b/tests/components/tm1651/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/tm1651/test.esp32-c3-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/tm1651/test.esp32-idf.yaml b/tests/components/tm1651/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/tm1651/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 2b58f780823911911dec09ed0d33bb19c9c6ddd9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 15:38:19 -1000 Subject: [PATCH 0163/1626] fix busy loop on fail --- .../bluetooth_proxy/bluetooth_connection.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 895819909a..1295c18985 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -70,6 +70,7 @@ void BluetoothConnection::send_service_for_discovery_() { // Early return if no API connection auto *api_conn = this->proxy_->get_api_connection(); if (api_conn == nullptr) { + this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -92,6 +93,7 @@ void BluetoothConnection::send_service_for_discovery_() { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", this->connection_index_, this->address_str().c_str(), service_status != ESP_GATT_OK ? "error" : "missing", service_status, service_count, this->send_service_); + this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -108,8 +110,9 @@ void BluetoothConnection::send_service_for_discovery_() { service_result.start_handle, service_result.end_handle, 0, &total_char_count); if (char_count_status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, + ESP_LOGE(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, this->address_str().c_str(), char_count_status); + this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -133,6 +136,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (char_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, this->address_str().c_str(), char_status); + this->send_service_ = DONE_SENDING_SERVICES; return; } if (char_count == 0) { @@ -152,8 +156,9 @@ void BluetoothConnection::send_service_for_discovery_() { this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); if (desc_count_status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, + ESP_LOGE(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, this->address_str().c_str(), char_result.char_handle, desc_count_status); + this->send_service_ = DONE_SENDING_SERVICES; return; } if (total_desc_count == 0) { @@ -175,6 +180,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (desc_status != ESP_GATT_OK) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, this->address_str().c_str(), desc_status); + this->send_service_ = DONE_SENDING_SERVICES; return; } if (desc_count == 0) { From d1cf6c2b14b531a9d2fb2099130b36cd6d96c04f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 16:12:07 -1000 Subject: [PATCH 0164/1626] [esp32_ble_client] Fix BLE connection stability for WiFi-based proxies --- .../esp32_ble_client/ble_client_base.cpp | 62 ++++++++++++------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index d3416641d9..98b6111732 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -13,10 +13,12 @@ namespace esp32_ble_client { static const char *const TAG = "esp32_ble_client"; -// Connection interval defaults matching ESP-IDF's BTM_BLE_CONN_INT_*_DEF -static const uint16_t DEFAULT_MIN_CONN_INTERVAL = 0x0A; // 10 * 1.25ms = 12.5ms -static const uint16_t DEFAULT_MAX_CONN_INTERVAL = 0x0C; // 12 * 1.25ms = 15ms -static const uint16_t DEFAULT_CONN_TIMEOUT = 600; // 600 * 10ms = 6s +// Intermediate connection parameters for standard operation +// ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies, +// causing disconnections. These medium parameters balance responsiveness with bandwidth usage. +static const uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x08; // 8 * 1.25ms = 10ms +static const uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x0A; // 10 * 1.25ms = 12.5ms +static const uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s // Fastest connection parameters for devices with short discovery timeouts static const uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) @@ -151,20 +153,32 @@ void BLEClientBase::connect() { } else { this->set_state(espbt::ClientState::CONNECTING); - // For connections without cache, set fast connection parameters after initiating connection - // This ensures service discovery completes within the 10-second timeout that - // some devices like HomeKit BLE sensors enforce + // Always set connection parameters to ensure stable operation + // Use FAST for V3_WITHOUT_CACHE (devices that need lowest latency) + // Use MEDIUM for all other connections (balanced performance) + uint16_t min_interval, max_interval, timeout; + const char *param_type; + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { - auto param_ret = - esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, - 0, // latency: 0 - FAST_CONN_TIMEOUT); - if (param_ret != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, - this->address_str_.c_str(), param_ret); - } else { - ESP_LOGD(TAG, "[%d] [%s] Set fast conn params", this->connection_index_, this->address_str_.c_str()); - } + min_interval = FAST_MIN_CONN_INTERVAL; + max_interval = FAST_MAX_CONN_INTERVAL; + timeout = FAST_CONN_TIMEOUT; + param_type = "fast"; + } else { + min_interval = MEDIUM_MIN_CONN_INTERVAL; + max_interval = MEDIUM_MAX_CONN_INTERVAL; + timeout = MEDIUM_CONN_TIMEOUT; + param_type = "medium"; + } + + auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, + 0, // latency: 0 + timeout); + if (param_ret != ESP_OK) { + ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, + this->address_str_.c_str(), param_ret); + } else { + ESP_LOGD(TAG, "[%d] [%s] Set %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); } } } @@ -394,17 +408,17 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); - // For non-cached connections, restore default connection parameters after service discovery - // Now that we've discovered all services, we can use more balanced parameters - // that save power and reduce interference + // For non-cached connections, restore to medium connection parameters after service discovery + // This balances performance with bandwidth usage after the critical discovery phase if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); - conn_params.min_int = DEFAULT_MIN_CONN_INTERVAL; - conn_params.max_int = DEFAULT_MAX_CONN_INTERVAL; + conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; + conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; conn_params.latency = 0; - conn_params.timeout = DEFAULT_CONN_TIMEOUT; - ESP_LOGD(TAG, "[%d] [%s] Restored default conn params", this->connection_index_, this->address_str_.c_str()); + conn_params.timeout = MEDIUM_CONN_TIMEOUT; + ESP_LOGD(TAG, "[%d] [%s] Restored medium conn params after service discovery", this->connection_index_, + this->address_str_.c_str()); esp_ble_gap_update_conn_params(&conn_params); } From fb379bbb88d99758ff89161865a881c68bb6bfbb Mon Sep 17 00:00:00 2001 From: GilDev Date: Thu, 31 Jul 2025 05:34:49 +0200 Subject: [PATCH 0165/1626] [wifi] Allow fast_connect with multiple networks (#9947) --- esphome/components/wifi/__init__.py | 2 - esphome/components/wifi/wifi_component.cpp | 57 ++++++++++++++++------ esphome/components/wifi/wifi_component.h | 6 ++- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 26195694e7..b74237ad2e 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -265,8 +265,6 @@ def _validate(config): networks = config.get(CONF_NETWORKS, []) if not networks: raise cv.Invalid("At least one network required for fast_connect!") - if len(networks) != 1: - raise cv.Invalid("Fast connect can only be used with one network!") if CONF_USE_ADDRESS not in config: use_address = CORE.name + config[CONF_DOMAIN] diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 2731dc1f3f..98f75894f4 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -91,8 +91,11 @@ void WiFiComponent::start() { } if (this->fast_connect_) { - this->selected_ap_ = this->sta_[0]; - this->load_fast_connect_settings_(); + this->trying_loaded_ap_ = this->load_fast_connect_settings_(); + if (!this->trying_loaded_ap_) { + this->ap_index_ = 0; + this->selected_ap_ = this->sta_[this->ap_index_]; + } this->start_connecting(this->selected_ap_, false); } else { this->start_scanning(); @@ -121,6 +124,14 @@ void WiFiComponent::start() { this->wifi_apply_hostname_(); } +void WiFiComponent::restart_adapter() { + ESP_LOGW(TAG, "Restarting adapter"); + this->wifi_mode_(false, {}); + delay(100); // NOLINT + this->num_retried_ = 0; + this->retry_hidden_ = false; +} + void WiFiComponent::loop() { this->wifi_loop_(); const uint32_t now = App.get_loop_component_start_time(); @@ -140,7 +151,7 @@ void WiFiComponent::loop() { this->status_set_warning("waiting to reconnect"); if (millis() - this->action_started_ > 5000) { if (this->fast_connect_ || this->retry_hidden_) { - this->start_connecting(this->sta_[0], false); + this->start_connecting(this->selected_ap_, false); } else { this->start_scanning(); } @@ -703,18 +714,30 @@ void WiFiComponent::retry_connect() { delay(10); if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_() && (this->num_retried_ > 3 || this->error_from_callback_)) { - if (this->num_retried_ > 5) { - // If retry failed for more than 5 times, let's restart STA - ESP_LOGW(TAG, "Restarting adapter"); - this->wifi_mode_(false, {}); - delay(100); // NOLINT + if (this->fast_connect_) { + if (this->trying_loaded_ap_) { + this->trying_loaded_ap_ = false; + this->ap_index_ = 0; // Retry from the first configured AP + } else if (this->ap_index_ >= this->sta_.size() - 1) { + ESP_LOGW(TAG, "No more APs to try"); + this->ap_index_ = 0; + this->restart_adapter(); + } else { + // Try next AP + this->ap_index_++; + } this->num_retried_ = 0; - this->retry_hidden_ = false; + this->selected_ap_ = this->sta_[this->ap_index_]; } else { - // Try hidden networks after 3 failed retries - ESP_LOGD(TAG, "Retrying with hidden networks"); - this->retry_hidden_ = true; - this->num_retried_++; + if (this->num_retried_ > 5) { + // If retry failed for more than 5 times, let's restart STA + this->restart_adapter(); + } else { + // Try hidden networks after 3 failed retries + ESP_LOGD(TAG, "Retrying with hidden networks"); + this->retry_hidden_ = true; + this->num_retried_++; + } } } else { this->num_retried_++; @@ -761,17 +784,22 @@ bool WiFiComponent::is_esp32_improv_active_() { #endif } -void WiFiComponent::load_fast_connect_settings_() { +bool WiFiComponent::load_fast_connect_settings_() { SavedWifiFastConnectSettings fast_connect_save{}; if (this->fast_connect_pref_.load(&fast_connect_save)) { bssid_t bssid{}; std::copy(fast_connect_save.bssid, fast_connect_save.bssid + 6, bssid.begin()); + this->ap_index_ = fast_connect_save.ap_index; + this->selected_ap_ = this->sta_[this->ap_index_]; this->selected_ap_.set_bssid(bssid); this->selected_ap_.set_channel(fast_connect_save.channel); ESP_LOGD(TAG, "Loaded fast_connect settings"); + return true; } + + return false; } void WiFiComponent::save_fast_connect_settings_() { @@ -783,6 +811,7 @@ void WiFiComponent::save_fast_connect_settings_() { memcpy(fast_connect_save.bssid, bssid.data(), 6); fast_connect_save.channel = channel; + fast_connect_save.ap_index = this->ap_index_; this->fast_connect_pref_.save(&fast_connect_save); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 64797a5801..bbe1bbb874 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -60,6 +60,7 @@ struct SavedWifiSettings { struct SavedWifiFastConnectSettings { uint8_t bssid[6]; uint8_t channel; + int8_t ap_index; } PACKED; // NOLINT enum WiFiComponentState : uint8_t { @@ -256,6 +257,7 @@ class WiFiComponent : public Component { void setup() override; void start(); void dump_config() override; + void restart_adapter(); /// WIFI setup_priority. float get_setup_priority() const override; float get_loop_priority() const override; @@ -353,7 +355,7 @@ class WiFiComponent : public Component { bool is_captive_portal_active_(); bool is_esp32_improv_active_(); - void load_fast_connect_settings_(); + bool load_fast_connect_settings_(); void save_fast_connect_settings_(); #ifdef USE_ESP8266 @@ -400,12 +402,14 @@ class WiFiComponent : public Component { WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF}; WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE}; uint8_t num_retried_{0}; + uint8_t ap_index_{0}; #if USE_NETWORK_IPV6 uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ // Group all boolean values together bool fast_connect_{false}; + bool trying_loaded_ap_{false}; bool retry_hidden_{false}; bool has_ap_{false}; bool handled_connected_state_{false}; From 88cfcc1967ac58e68165e33b1c5e5d9bd0a94aff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 17:36:33 -1000 Subject: [PATCH 0166/1626] [esp32_ble_client] Fix BLE connection stability for WiFi-based proxies (#9993) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../esp32_ble_client/ble_client_base.cpp | 65 ++++++++++++------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index d3416641d9..94f2a6073c 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -13,10 +13,15 @@ namespace esp32_ble_client { static const char *const TAG = "esp32_ble_client"; -// Connection interval defaults matching ESP-IDF's BTM_BLE_CONN_INT_*_DEF -static const uint16_t DEFAULT_MIN_CONN_INTERVAL = 0x0A; // 10 * 1.25ms = 12.5ms -static const uint16_t DEFAULT_MAX_CONN_INTERVAL = 0x0C; // 12 * 1.25ms = 15ms -static const uint16_t DEFAULT_CONN_TIMEOUT = 600; // 600 * 10ms = 6s +// Intermediate connection parameters for standard operation +// ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies, +// causing disconnections. These medium parameters balance responsiveness with bandwidth usage. +static const uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x08; // 8 * 1.25ms = 10ms +static const uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x0A; // 10 * 1.25ms = 12.5ms +// The timeout value was increased from 6s to 8s to address stability issues observed +// in certain BLE devices when operating through WiFi-based BLE proxies. The longer +// timeout reduces the likelihood of disconnections during periods of high latency. +static const uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s // Fastest connection parameters for devices with short discovery timeouts static const uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) @@ -151,20 +156,32 @@ void BLEClientBase::connect() { } else { this->set_state(espbt::ClientState::CONNECTING); - // For connections without cache, set fast connection parameters after initiating connection - // This ensures service discovery completes within the 10-second timeout that - // some devices like HomeKit BLE sensors enforce + // Always set connection parameters to ensure stable operation + // Use FAST for V3_WITHOUT_CACHE (devices that need lowest latency) + // Use MEDIUM for all other connections (balanced performance) + uint16_t min_interval, max_interval, timeout; + const char *param_type; + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { - auto param_ret = - esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, - 0, // latency: 0 - FAST_CONN_TIMEOUT); - if (param_ret != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, - this->address_str_.c_str(), param_ret); - } else { - ESP_LOGD(TAG, "[%d] [%s] Set fast conn params", this->connection_index_, this->address_str_.c_str()); - } + min_interval = FAST_MIN_CONN_INTERVAL; + max_interval = FAST_MAX_CONN_INTERVAL; + timeout = FAST_CONN_TIMEOUT; + param_type = "fast"; + } else { + min_interval = MEDIUM_MIN_CONN_INTERVAL; + max_interval = MEDIUM_MAX_CONN_INTERVAL; + timeout = MEDIUM_CONN_TIMEOUT; + param_type = "medium"; + } + + auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, + 0, // latency: 0 + timeout); + if (param_ret != ESP_OK) { + ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, + this->address_str_.c_str(), param_ret); + } else { + ESP_LOGD(TAG, "[%d] [%s] Set %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); } } } @@ -394,17 +411,17 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); - // For non-cached connections, restore default connection parameters after service discovery - // Now that we've discovered all services, we can use more balanced parameters - // that save power and reduce interference + // For non-cached connections, restore to medium connection parameters after service discovery + // This balances performance with bandwidth usage after the critical discovery phase if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); - conn_params.min_int = DEFAULT_MIN_CONN_INTERVAL; - conn_params.max_int = DEFAULT_MAX_CONN_INTERVAL; + conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; + conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; conn_params.latency = 0; - conn_params.timeout = DEFAULT_CONN_TIMEOUT; - ESP_LOGD(TAG, "[%d] [%s] Restored default conn params", this->connection_index_, this->address_str_.c_str()); + conn_params.timeout = MEDIUM_CONN_TIMEOUT; + ESP_LOGD(TAG, "[%d] [%s] Restored medium conn params after service discovery", this->connection_index_, + this->address_str_.c_str()); esp_ble_gap_update_conn_params(&conn_params); } From 71557c9f58acf754fa80ce5836c51066ec46ce12 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 18:11:11 -1000 Subject: [PATCH 0167/1626] [bluetooth_proxy] Batch BLE service discovery messages for 67% reduction in API traffic (#9992) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_pb2.cpp | 6 +- esphome/components/api/api_pb2.h | 4 +- .../bluetooth_proxy/bluetooth_connection.cpp | 188 ++++++++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 1 + 5 files changed, 109 insertions(+), 92 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 32bbc5ec0d..27edf4680f 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1505,7 +1505,7 @@ message BluetoothGATTGetServicesResponse { option (ifdef) = "USE_BLUETOOTH_PROXY"; uint64 address = 1; - repeated BluetoothGATTService services = 2 [(fixed_array_size) = 1]; + repeated BluetoothGATTService services = 2; } message BluetoothGATTGetServicesDoneResponse { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 2e8adeaf5c..ef02a5a774 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1929,11 +1929,13 @@ void BluetoothGATTService::calculate_size(ProtoSize &size) const { } void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); - buffer.encode_message(2, this->services[0], true); + for (auto &it : this->services) { + buffer.encode_message(2, it, true); + } } void BluetoothGATTGetServicesResponse::calculate_size(ProtoSize &size) const { size.add_uint64(1, this->address); - size.add_message_object_force(1, this->services[0]); + size.add_repeated_message(1, this->services); } void BluetoothGATTGetServicesDoneResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7f2299f77c..6c2ca60e00 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1895,12 +1895,12 @@ class BluetoothGATTService : public ProtoMessage { class BluetoothGATTGetServicesResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 71; - static constexpr uint8_t ESTIMATED_SIZE = 21; + static constexpr uint8_t ESTIMATED_SIZE = 38; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_get_services_response"; } #endif uint64_t address{0}; - std::array services{}; + std::vector services{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index b3484032b2..1295c18985 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -57,7 +57,7 @@ void BluetoothConnection::reset_connection_(esp_err_t reason) { } void BluetoothConnection::send_service_for_discovery_() { - if (this->send_service_ == this->service_count_) { + if (this->send_service_ >= this->service_count_) { this->send_service_ = DONE_SENDING_SERVICES; this->proxy_->send_gatt_services_done(this->address_); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || @@ -70,119 +70,133 @@ void BluetoothConnection::send_service_for_discovery_() { // Early return if no API connection auto *api_conn = this->proxy_->get_api_connection(); if (api_conn == nullptr) { + this->send_service_ = DONE_SENDING_SERVICES; return; } - // Send next service - esp_gattc_service_elem_t service_result; - uint16_t service_count = 1; - esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, - &service_result, &service_count, this->send_service_); - this->send_service_++; - - if (service_status != ESP_GATT_OK || service_count == 0) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", - this->connection_index_, this->address_str().c_str(), service_status != ESP_GATT_OK ? "error" : "missing", - service_status, service_count, this->send_service_ - 1); - return; - } - + // Prepare response for up to 3 services api::BluetoothGATTGetServicesResponse resp; resp.address = this->address_; - auto &service_resp = resp.services[0]; - fill_128bit_uuid_array(service_resp.uuid, service_result.uuid); - service_resp.handle = service_result.start_handle; - // Get the number of characteristics directly with one call - uint16_t total_char_count = 0; - esp_gatt_status_t char_count_status = - esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, - service_result.start_handle, service_result.end_handle, 0, &total_char_count); + // Process up to 3 services in this iteration + uint8_t services_to_process = + std::min(MAX_SERVICES_PER_BATCH, static_cast(this->service_count_ - this->send_service_)); + resp.services.reserve(services_to_process); - if (char_count_status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, - this->address_str().c_str(), char_count_status); - return; - } + for (int service_idx = 0; service_idx < services_to_process; service_idx++) { + esp_gattc_service_elem_t service_result; + uint16_t service_count = 1; + esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, + &service_result, &service_count, this->send_service_); - if (total_char_count == 0) { - // No characteristics, just send the service response - api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); - return; - } - - // Reserve space and process characteristics - service_resp.characteristics.reserve(total_char_count); - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - while (true) { // characteristics - uint16_t char_count = 1; - esp_gatt_status_t char_status = - esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, - service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { - break; - } - if (char_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, - this->address_str().c_str(), char_status); + if (service_status != ESP_GATT_OK || service_count == 0) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", + this->connection_index_, this->address_str().c_str(), + service_status != ESP_GATT_OK ? "error" : "missing", service_status, service_count, this->send_service_); + this->send_service_ = DONE_SENDING_SERVICES; return; } - if (char_count == 0) { - break; - } - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); - fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); - characteristic_resp.handle = char_result.char_handle; - characteristic_resp.properties = char_result.properties; - char_offset++; + this->send_service_++; + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + fill_128bit_uuid_array(service_resp.uuid, service_result.uuid); + service_resp.handle = service_result.start_handle; - // Get the number of descriptors directly with one call - uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( - this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); + // Get the number of characteristics directly with one call + uint16_t total_char_count = 0; + esp_gatt_status_t char_count_status = + esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, + service_result.start_handle, service_result.end_handle, 0, &total_char_count); - if (desc_count_status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, - this->address_str().c_str(), char_result.char_handle, desc_count_status); + if (char_count_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, + this->address_str().c_str(), char_count_status); + this->send_service_ = DONE_SENDING_SERVICES; return; } - if (total_desc_count == 0) { - // No descriptors, continue to next characteristic + + if (total_char_count == 0) { + // No characteristics, continue to next service continue; } - // Reserve space and process descriptors - characteristic_resp.descriptors.reserve(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + // Reserve space and process characteristics + service_resp.characteristics.reserve(total_char_count); + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + while (true) { // characteristics + uint16_t char_count = 1; + esp_gatt_status_t char_status = + esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &char_count, char_offset); + if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { break; } - if (desc_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, - this->address_str().c_str(), desc_status); + if (char_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, + this->address_str().c_str(), char_status); + this->send_service_ = DONE_SENDING_SERVICES; return; } - if (desc_count == 0) { - break; // No more descriptors + if (char_count == 0) { + break; } - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); - descriptor_resp.handle = desc_result.handle; - desc_offset++; + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + char_offset++; + + // Get the number of descriptors directly with one call + uint16_t total_desc_count = 0; + esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( + this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); + + if (desc_count_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, + this->address_str().c_str(), char_result.char_handle, desc_count_status); + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (total_desc_count == 0) { + // No descriptors, continue to next characteristic + continue; + } + + // Reserve space and process descriptors + characteristic_resp.descriptors.reserve(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (true) { // descriptors + uint16_t desc_count = 1; + esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( + this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + break; + } + if (desc_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, + this->address_str().c_str(), desc_status); + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (desc_count == 0) { + break; // No more descriptors + } + + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } } } - // Send the message (we already checked api_conn is not null at the beginning) + // Send the message with 1-3 services api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d249515fdf..b33460339b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -22,6 +22,7 @@ namespace esphome::bluetooth_proxy { static const esp_err_t ESP_GATT_NOT_CONNECTED = -1; static const int DONE_SENDING_SERVICES = -2; +static const uint8_t MAX_SERVICES_PER_BATCH = 3; using namespace esp32_ble_client; From 37911e84f2ab72082fb092971a2bb911c7c7cb68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 21:24:40 -1000 Subject: [PATCH 0168/1626] [bluetooth_proxy] Send native 16/32-bit UUIDs instead of always converting to 128-bit --- esphome/components/api/api.proto | 27 +++++++++-- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 48 ++++++++++++++----- esphome/components/api/api_pb2.h | 6 +++ esphome/components/api/api_pb2_dump.cpp | 6 +++ .../bluetooth_proxy/bluetooth_connection.cpp | 43 +++++++++++++++-- script/api_protobuf/api_protobuf.py | 29 +++++++++++ 8 files changed, 143 insertions(+), 19 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 27edf4680f..4103b0976d 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1482,21 +1482,42 @@ message BluetoothGATTGetServicesRequest { } message BluetoothGATTDescriptor { - repeated uint64 uuid = 1 [(fixed_array_size) = 2]; + repeated uint64 uuid = 1 [(fixed_array_size) = 2, (fixed_array_skip_zero) = true]; uint32 handle = 2; + + // New fields for efficient UUID (v1.12+) + // Only one of uuid, uuid16, or uuid32 will be set. + // uuid16/uuid32 are only used for 16/32-bit UUIDs with v1.12+ clients. + // 128-bit UUIDs always use the uuid field for backwards compatibility. + uint32 uuid16 = 3; // 16-bit UUID + uint32 uuid32 = 4; // 32-bit UUID } message BluetoothGATTCharacteristic { - repeated uint64 uuid = 1 [(fixed_array_size) = 2]; + repeated uint64 uuid = 1 [(fixed_array_size) = 2, (fixed_array_skip_zero) = true]; uint32 handle = 2; uint32 properties = 3; repeated BluetoothGATTDescriptor descriptors = 4; + + // New fields for efficient UUID (v1.12+) + // Only one of uuid, uuid16, or uuid32 will be set. + // uuid16/uuid32 are only used for 16/32-bit UUIDs with v1.12+ clients. + // 128-bit UUIDs always use the uuid field for backwards compatibility. + uint32 uuid16 = 5; // 16-bit UUID + uint32 uuid32 = 6; // 32-bit UUID } message BluetoothGATTService { - repeated uint64 uuid = 1 [(fixed_array_size) = 2]; + repeated uint64 uuid = 1 [(fixed_array_size) = 2, (fixed_array_skip_zero) = true]; uint32 handle = 2; repeated BluetoothGATTCharacteristic characteristics = 3; + + // New fields for efficient UUID (v1.12+) + // Only one of uuid, uuid16, or uuid32 will be set. + // uuid16/uuid32 are only used for 16/32-bit UUIDs with v1.12+ clients. + // 128-bit UUIDs always use the uuid field for backwards compatibility. + uint32 uuid16 = 4; // 16-bit UUID + uint32 uuid32 = 5; // 32-bit UUID } message BluetoothGATTGetServicesResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c0dbe4e198..8ac6c3b71e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1363,7 +1363,7 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 11; + resp.api_version_minor = 12; // Temporary string for concatenation - will be valid during send_message call std::string server_info = App.get_name() + " (esphome v" ESPHOME_VERSION ")"; resp.set_server_info(StringRef(server_info)); diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 85c805260f..d4b5700024 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -28,6 +28,7 @@ extend google.protobuf.FieldOptions { optional string field_ifdef = 1042; optional uint32 fixed_array_size = 50007; optional bool no_zero_copy = 50008 [default=false]; + optional bool fixed_array_skip_zero = 50009 [default=false]; // container_pointer: Zero-copy optimization for repeated fields. // diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index ef02a5a774..ac0808f2c3 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1888,44 +1888,68 @@ bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, ProtoVarI return true; } void BluetoothGATTDescriptor::encode(ProtoWriteBuffer buffer) const { - buffer.encode_uint64(1, this->uuid[0], true); - buffer.encode_uint64(1, this->uuid[1], true); + if (!(this->uuid[0] == 0 && this->uuid[1] == 0)) { + buffer.encode_uint64(1, this->uuid[0], true); + buffer.encode_uint64(1, this->uuid[1], true); + } buffer.encode_uint32(2, this->handle); + buffer.encode_uint32(3, this->uuid16); + buffer.encode_uint32(4, this->uuid32); } void BluetoothGATTDescriptor::calculate_size(ProtoSize &size) const { - size.add_uint64_force(1, this->uuid[0]); - size.add_uint64_force(1, this->uuid[1]); + if (!(this->uuid[0] == 0 && this->uuid[1] == 0)) { + size.add_uint64_force(1, this->uuid[0]); + size.add_uint64_force(1, this->uuid[1]); + } size.add_uint32(1, this->handle); + size.add_uint32(1, this->uuid16); + size.add_uint32(1, this->uuid32); } void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { - buffer.encode_uint64(1, this->uuid[0], true); - buffer.encode_uint64(1, this->uuid[1], true); + if (!(this->uuid[0] == 0 && this->uuid[1] == 0)) { + buffer.encode_uint64(1, this->uuid[0], true); + buffer.encode_uint64(1, this->uuid[1], true); + } buffer.encode_uint32(2, this->handle); buffer.encode_uint32(3, this->properties); for (auto &it : this->descriptors) { buffer.encode_message(4, it, true); } + buffer.encode_uint32(5, this->uuid16); + buffer.encode_uint32(6, this->uuid32); } void BluetoothGATTCharacteristic::calculate_size(ProtoSize &size) const { - size.add_uint64_force(1, this->uuid[0]); - size.add_uint64_force(1, this->uuid[1]); + if (!(this->uuid[0] == 0 && this->uuid[1] == 0)) { + size.add_uint64_force(1, this->uuid[0]); + size.add_uint64_force(1, this->uuid[1]); + } size.add_uint32(1, this->handle); size.add_uint32(1, this->properties); size.add_repeated_message(1, this->descriptors); + size.add_uint32(1, this->uuid16); + size.add_uint32(1, this->uuid32); } void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { - buffer.encode_uint64(1, this->uuid[0], true); - buffer.encode_uint64(1, this->uuid[1], true); + if (!(this->uuid[0] == 0 && this->uuid[1] == 0)) { + buffer.encode_uint64(1, this->uuid[0], true); + buffer.encode_uint64(1, this->uuid[1], true); + } buffer.encode_uint32(2, this->handle); for (auto &it : this->characteristics) { buffer.encode_message(3, it, true); } + buffer.encode_uint32(4, this->uuid16); + buffer.encode_uint32(5, this->uuid32); } void BluetoothGATTService::calculate_size(ProtoSize &size) const { - size.add_uint64_force(1, this->uuid[0]); - size.add_uint64_force(1, this->uuid[1]); + if (!(this->uuid[0] == 0 && this->uuid[1] == 0)) { + size.add_uint64_force(1, this->uuid[0]); + size.add_uint64_force(1, this->uuid[1]); + } size.add_uint32(1, this->handle); size.add_repeated_message(1, this->characteristics); + size.add_uint32(1, this->uuid16); + size.add_uint32(1, this->uuid32); } void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 6c2ca60e00..9dd2460166 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1857,6 +1857,8 @@ class BluetoothGATTDescriptor : public ProtoMessage { public: std::array uuid{}; uint32_t handle{0}; + uint32_t uuid16{0}; + uint32_t uuid32{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1871,6 +1873,8 @@ class BluetoothGATTCharacteristic : public ProtoMessage { uint32_t handle{0}; uint32_t properties{0}; std::vector descriptors{}; + uint32_t uuid16{0}; + uint32_t uuid32{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1884,6 +1888,8 @@ class BluetoothGATTService : public ProtoMessage { std::array uuid{}; uint32_t handle{0}; std::vector characteristics{}; + uint32_t uuid16{0}; + uint32_t uuid32{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index b934aead32..64b88caabc 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1561,6 +1561,8 @@ void BluetoothGATTDescriptor::dump_to(std::string &out) const { dump_field(out, "uuid", it, 4); } dump_field(out, "handle", this->handle); + dump_field(out, "uuid16", this->uuid16); + dump_field(out, "uuid32", this->uuid32); } void BluetoothGATTCharacteristic::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothGATTCharacteristic"); @@ -1574,6 +1576,8 @@ void BluetoothGATTCharacteristic::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } + dump_field(out, "uuid16", this->uuid16); + dump_field(out, "uuid32", this->uuid32); } void BluetoothGATTService::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothGATTService"); @@ -1586,6 +1590,8 @@ void BluetoothGATTService::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } + dump_field(out, "uuid16", this->uuid16); + dump_field(out, "uuid32", this->uuid32); } void BluetoothGATTGetServicesResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothGATTGetServicesResponse"); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 1295c18985..ca784ddc39 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -24,6 +24,13 @@ static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t u ((uint64_t) uuid.uuid.uuid128[1] << 8) | ((uint64_t) uuid.uuid.uuid128[0]); } +static bool supports_efficient_uuids(api::APIConnection *api_conn) { + if (!api_conn) + return false; + return api_conn->get_client_api_version_major() > 1 || + (api_conn->get_client_api_version_major() == 1 && api_conn->get_client_api_version_minor() >= 12); +} + void BluetoothConnection::dump_config() { ESP_LOGCONFIG(TAG, "BLE Connection:"); BLEClientBase::dump_config(); @@ -74,6 +81,9 @@ void BluetoothConnection::send_service_for_discovery_() { return; } + // Check if client supports efficient UUIDs + bool use_efficient_uuids = supports_efficient_uuids(api_conn); + // Prepare response for up to 3 services api::BluetoothGATTGetServicesResponse resp; resp.address = this->address_; @@ -100,7 +110,16 @@ void BluetoothConnection::send_service_for_discovery_() { this->send_service_++; resp.services.emplace_back(); auto &service_resp = resp.services.back(); - fill_128bit_uuid_array(service_resp.uuid, service_result.uuid); + + if (!use_efficient_uuids || service_result.uuid.len == ESP_UUID_LEN_128) { + // Use 128-bit format for old clients or when UUID is already 128-bit + fill_128bit_uuid_array(service_resp.uuid, service_result.uuid); + } else if (service_result.uuid.len == ESP_UUID_LEN_16) { + service_resp.uuid16 = service_result.uuid.uuid.uuid16; + } else if (service_result.uuid.len == ESP_UUID_LEN_32) { + service_resp.uuid32 = service_result.uuid.uuid.uuid32; + } + service_resp.handle = service_result.start_handle; // Get the number of characteristics directly with one call @@ -145,7 +164,16 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.characteristics.emplace_back(); auto &characteristic_resp = service_resp.characteristics.back(); - fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); + + if (!use_efficient_uuids || char_result.uuid.len == ESP_UUID_LEN_128) { + // Use 128-bit format for old clients or when UUID is already 128-bit + fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); + } else if (char_result.uuid.len == ESP_UUID_LEN_16) { + characteristic_resp.uuid16 = char_result.uuid.uuid.uuid16; + } else if (char_result.uuid.len == ESP_UUID_LEN_32) { + characteristic_resp.uuid32 = char_result.uuid.uuid.uuid32; + } + characteristic_resp.handle = char_result.char_handle; characteristic_resp.properties = char_result.properties; char_offset++; @@ -189,7 +217,16 @@ void BluetoothConnection::send_service_for_discovery_() { characteristic_resp.descriptors.emplace_back(); auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); + + if (!use_efficient_uuids || desc_result.uuid.len == ESP_UUID_LEN_128) { + // Use 128-bit format for old clients or when UUID is already 128-bit + fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); + } else if (desc_result.uuid.len == ESP_UUID_LEN_16) { + descriptor_resp.uuid16 = desc_result.uuid.uuid.uuid16; + } else if (desc_result.uuid.len == ESP_UUID_LEN_32) { + descriptor_resp.uuid32 = desc_result.uuid.uuid.uuid32; + } + descriptor_resp.handle = desc_result.handle; desc_offset++; } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index fece22499a..464d3e047e 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1075,6 +1075,11 @@ class FixedArrayRepeatedType(TypeInfo): def __init__(self, field: descriptor.FieldDescriptorProto, size: int) -> None: super().__init__(field) self.array_size = size + # Check if we should skip encoding when all elements are zero + # Use getattr to handle older versions of api_options_pb2 + self.skip_zero = get_field_opt( + field, getattr(pb, "fixed_array_skip_zero", None), False + ) # Create the element type info validate_field_type(field.type, field.name) self._ti: TypeInfo = TYPE_INFO[field.type](field) @@ -1113,6 +1118,18 @@ class FixedArrayRepeatedType(TypeInfo): else: return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" + # If skip_zero is enabled, wrap encoding in a zero check + if self.skip_zero: + # Build the condition to check if all elements are zero + zero_checks = " && ".join( + [f"this->{self.field_name}[{i}] == 0" for i in range(self.array_size)] + ) + encode_lines = [ + f" {encode_element(f'this->{self.field_name}[{i}]')}" + for i in range(self.array_size) + ] + return f"if (!({zero_checks})) {{\n" + "\n".join(encode_lines) + "\n}" + # Unroll small arrays for efficiency if self.array_size == 1: return encode_element(f"this->{self.field_name}[0]") @@ -1141,6 +1158,18 @@ class FixedArrayRepeatedType(TypeInfo): return "" def get_size_calculation(self, name: str, force: bool = False) -> str: + # If skip_zero is enabled, wrap size calculation in a zero check + if self.skip_zero: + # Build the condition to check if all elements are zero + zero_checks = " && ".join( + [f"{name}[{i}] == 0" for i in range(self.array_size)] + ) + size_lines = [ + f" {self._ti.get_size_calculation(f'{name}[{i}]', True)}" + for i in range(self.array_size) + ] + return f"if (!({zero_checks})) {{\n" + "\n".join(size_lines) + "\n}" + # For fixed arrays, we always encode all elements # Special case for single-element arrays - no loop needed From 712de7997306185530f641dd1b6f9599c438c4ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 22:06:31 -1000 Subject: [PATCH 0169/1626] tidy --- esphome/components/api/api_pb2.cpp | 12 ++++++------ script/api_protobuf/api_protobuf.py | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index ac0808f2c3..b797aa3ca6 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1888,7 +1888,7 @@ bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, ProtoVarI return true; } void BluetoothGATTDescriptor::encode(ProtoWriteBuffer buffer) const { - if (!(this->uuid[0] == 0 && this->uuid[1] == 0)) { + if (this->uuid[0] != 0 || this->uuid[1] != 0) { buffer.encode_uint64(1, this->uuid[0], true); buffer.encode_uint64(1, this->uuid[1], true); } @@ -1897,7 +1897,7 @@ void BluetoothGATTDescriptor::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(4, this->uuid32); } void BluetoothGATTDescriptor::calculate_size(ProtoSize &size) const { - if (!(this->uuid[0] == 0 && this->uuid[1] == 0)) { + if (this->uuid[0] != 0 || this->uuid[1] != 0) { size.add_uint64_force(1, this->uuid[0]); size.add_uint64_force(1, this->uuid[1]); } @@ -1906,7 +1906,7 @@ void BluetoothGATTDescriptor::calculate_size(ProtoSize &size) const { size.add_uint32(1, this->uuid32); } void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { - if (!(this->uuid[0] == 0 && this->uuid[1] == 0)) { + if (this->uuid[0] != 0 || this->uuid[1] != 0) { buffer.encode_uint64(1, this->uuid[0], true); buffer.encode_uint64(1, this->uuid[1], true); } @@ -1919,7 +1919,7 @@ void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(6, this->uuid32); } void BluetoothGATTCharacteristic::calculate_size(ProtoSize &size) const { - if (!(this->uuid[0] == 0 && this->uuid[1] == 0)) { + if (this->uuid[0] != 0 || this->uuid[1] != 0) { size.add_uint64_force(1, this->uuid[0]); size.add_uint64_force(1, this->uuid[1]); } @@ -1930,7 +1930,7 @@ void BluetoothGATTCharacteristic::calculate_size(ProtoSize &size) const { size.add_uint32(1, this->uuid32); } void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { - if (!(this->uuid[0] == 0 && this->uuid[1] == 0)) { + if (this->uuid[0] != 0 || this->uuid[1] != 0) { buffer.encode_uint64(1, this->uuid[0], true); buffer.encode_uint64(1, this->uuid[1], true); } @@ -1942,7 +1942,7 @@ void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(5, this->uuid32); } void BluetoothGATTService::calculate_size(ProtoSize &size) const { - if (!(this->uuid[0] == 0 && this->uuid[1] == 0)) { + if (this->uuid[0] != 0 || this->uuid[1] != 0) { size.add_uint64_force(1, this->uuid[0]); size.add_uint64_force(1, this->uuid[1]); } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 464d3e047e..03f8d0f8bc 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1120,15 +1120,15 @@ class FixedArrayRepeatedType(TypeInfo): # If skip_zero is enabled, wrap encoding in a zero check if self.skip_zero: - # Build the condition to check if all elements are zero - zero_checks = " && ".join( - [f"this->{self.field_name}[{i}] == 0" for i in range(self.array_size)] + # Build the condition to check if at least one element is non-zero + non_zero_checks = " || ".join( + [f"this->{self.field_name}[{i}] != 0" for i in range(self.array_size)] ) encode_lines = [ f" {encode_element(f'this->{self.field_name}[{i}]')}" for i in range(self.array_size) ] - return f"if (!({zero_checks})) {{\n" + "\n".join(encode_lines) + "\n}" + return f"if ({non_zero_checks}) {{\n" + "\n".join(encode_lines) + "\n}" # Unroll small arrays for efficiency if self.array_size == 1: @@ -1160,15 +1160,15 @@ class FixedArrayRepeatedType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: # If skip_zero is enabled, wrap size calculation in a zero check if self.skip_zero: - # Build the condition to check if all elements are zero - zero_checks = " && ".join( - [f"{name}[{i}] == 0" for i in range(self.array_size)] + # Build the condition to check if at least one element is non-zero + non_zero_checks = " || ".join( + [f"{name}[{i}] != 0" for i in range(self.array_size)] ) size_lines = [ f" {self._ti.get_size_calculation(f'{name}[{i}]', True)}" for i in range(self.array_size) ] - return f"if (!({zero_checks})) {{\n" + "\n".join(size_lines) + "\n}" + return f"if ({non_zero_checks}) {{\n" + "\n".join(size_lines) + "\n}" # For fixed arrays, we always encode all elements From 40e2960264f626c8203256f628cb78e790fe0047 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 22:15:39 -1000 Subject: [PATCH 0170/1626] fixes --- esphome/components/api/api_connection.h | 7 +++++++ .../bluetooth_proxy/bluetooth_connection.cpp | 10 ++++------ .../components/bluetooth_proxy/bluetooth_connection.h | 1 + 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 5b64adecb3..21688e601c 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -235,6 +235,13 @@ class APIConnection : public APIServerConnection { this->is_authenticated(); } uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; } + + // Get client API version for feature detection + bool client_supports_api_version(uint16_t major, uint16_t minor) const { + return this->client_api_version_major_ > major || + (this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor); + } + void on_fatal_error() override; #ifdef USE_API_PASSWORD void on_unauthenticated_access() override; diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index ca784ddc39..b7326e3a4a 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -24,11 +24,9 @@ static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t u ((uint64_t) uuid.uuid.uuid128[1] << 8) | ((uint64_t) uuid.uuid.uuid128[0]); } -static bool supports_efficient_uuids(api::APIConnection *api_conn) { - if (!api_conn) - return false; - return api_conn->get_client_api_version_major() > 1 || - (api_conn->get_client_api_version_major() == 1 && api_conn->get_client_api_version_minor() >= 12); +bool BluetoothConnection::supports_efficient_uuids_() const { + auto *api_conn = this->proxy_->get_api_connection(); + return api_conn && api_conn->client_supports_api_version(1, 12); } void BluetoothConnection::dump_config() { @@ -82,7 +80,7 @@ void BluetoothConnection::send_service_for_discovery_() { } // Check if client supports efficient UUIDs - bool use_efficient_uuids = supports_efficient_uuids(api_conn); + bool use_efficient_uuids = this->supports_efficient_uuids_(); // Prepare response for up to 3 services api::BluetoothGATTGetServicesResponse resp; diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 3fed9d531f..622d257bf8 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -27,6 +27,7 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { protected: friend class BluetoothProxy; + bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); From 1be6d27012a270f543a248b1aedc67489a3a86cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 23:20:43 -1000 Subject: [PATCH 0171/1626] Bump aioesphomeapi from 37.1.6 to 37.2.0 (#9996) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d261f6d896..1289cf9aba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.9.0 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.1.6 +aioesphomeapi==37.2.0 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From f3d42ef6e4b88f1aa09490aab88cf938349beeb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 04:05:48 -1000 Subject: [PATCH 0172/1626] save 4 bytes since we must store as uint32_t anyways --- esphome/components/api/api.proto | 27 +++++++++---------- esphome/components/api/api_pb2.cpp | 18 +++++-------- esphome/components/api/api_pb2.h | 9 +++---- esphome/components/api/api_pb2_dump.cpp | 9 +++---- .../bluetooth_proxy/bluetooth_connection.cpp | 12 ++++----- 5 files changed, 30 insertions(+), 45 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4103b0976d..4aa5cc4be0 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1485,12 +1485,11 @@ message BluetoothGATTDescriptor { repeated uint64 uuid = 1 [(fixed_array_size) = 2, (fixed_array_skip_zero) = true]; uint32 handle = 2; - // New fields for efficient UUID (v1.12+) - // Only one of uuid, uuid16, or uuid32 will be set. - // uuid16/uuid32 are only used for 16/32-bit UUIDs with v1.12+ clients. + // New field for efficient UUID (v1.12+) + // Only one of uuid or short_uuid will be set. + // short_uuid is used for both 16-bit and 32-bit UUIDs with v1.12+ clients. // 128-bit UUIDs always use the uuid field for backwards compatibility. - uint32 uuid16 = 3; // 16-bit UUID - uint32 uuid32 = 4; // 32-bit UUID + uint32 short_uuid = 3; // 16-bit or 32-bit UUID } message BluetoothGATTCharacteristic { @@ -1499,12 +1498,11 @@ message BluetoothGATTCharacteristic { uint32 properties = 3; repeated BluetoothGATTDescriptor descriptors = 4; - // New fields for efficient UUID (v1.12+) - // Only one of uuid, uuid16, or uuid32 will be set. - // uuid16/uuid32 are only used for 16/32-bit UUIDs with v1.12+ clients. + // New field for efficient UUID (v1.12+) + // Only one of uuid or short_uuid will be set. + // short_uuid is used for both 16-bit and 32-bit UUIDs with v1.12+ clients. // 128-bit UUIDs always use the uuid field for backwards compatibility. - uint32 uuid16 = 5; // 16-bit UUID - uint32 uuid32 = 6; // 32-bit UUID + uint32 short_uuid = 5; // 16-bit or 32-bit UUID } message BluetoothGATTService { @@ -1512,12 +1510,11 @@ message BluetoothGATTService { uint32 handle = 2; repeated BluetoothGATTCharacteristic characteristics = 3; - // New fields for efficient UUID (v1.12+) - // Only one of uuid, uuid16, or uuid32 will be set. - // uuid16/uuid32 are only used for 16/32-bit UUIDs with v1.12+ clients. + // New field for efficient UUID (v1.12+) + // Only one of uuid or short_uuid will be set. + // short_uuid is used for both 16-bit and 32-bit UUIDs with v1.12+ clients. // 128-bit UUIDs always use the uuid field for backwards compatibility. - uint32 uuid16 = 4; // 16-bit UUID - uint32 uuid32 = 5; // 32-bit UUID + uint32 short_uuid = 4; // 16-bit or 32-bit UUID } message BluetoothGATTGetServicesResponse { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index b797aa3ca6..29d0f2842c 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1893,8 +1893,7 @@ void BluetoothGATTDescriptor::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->uuid[1], true); } buffer.encode_uint32(2, this->handle); - buffer.encode_uint32(3, this->uuid16); - buffer.encode_uint32(4, this->uuid32); + buffer.encode_uint32(3, this->short_uuid); } void BluetoothGATTDescriptor::calculate_size(ProtoSize &size) const { if (this->uuid[0] != 0 || this->uuid[1] != 0) { @@ -1902,8 +1901,7 @@ void BluetoothGATTDescriptor::calculate_size(ProtoSize &size) const { size.add_uint64_force(1, this->uuid[1]); } size.add_uint32(1, this->handle); - size.add_uint32(1, this->uuid16); - size.add_uint32(1, this->uuid32); + size.add_uint32(1, this->short_uuid); } void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { if (this->uuid[0] != 0 || this->uuid[1] != 0) { @@ -1915,8 +1913,7 @@ void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->descriptors) { buffer.encode_message(4, it, true); } - buffer.encode_uint32(5, this->uuid16); - buffer.encode_uint32(6, this->uuid32); + buffer.encode_uint32(5, this->short_uuid); } void BluetoothGATTCharacteristic::calculate_size(ProtoSize &size) const { if (this->uuid[0] != 0 || this->uuid[1] != 0) { @@ -1926,8 +1923,7 @@ void BluetoothGATTCharacteristic::calculate_size(ProtoSize &size) const { size.add_uint32(1, this->handle); size.add_uint32(1, this->properties); size.add_repeated_message(1, this->descriptors); - size.add_uint32(1, this->uuid16); - size.add_uint32(1, this->uuid32); + size.add_uint32(1, this->short_uuid); } void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { if (this->uuid[0] != 0 || this->uuid[1] != 0) { @@ -1938,8 +1934,7 @@ void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->characteristics) { buffer.encode_message(3, it, true); } - buffer.encode_uint32(4, this->uuid16); - buffer.encode_uint32(5, this->uuid32); + buffer.encode_uint32(4, this->short_uuid); } void BluetoothGATTService::calculate_size(ProtoSize &size) const { if (this->uuid[0] != 0 || this->uuid[1] != 0) { @@ -1948,8 +1943,7 @@ void BluetoothGATTService::calculate_size(ProtoSize &size) const { } size.add_uint32(1, this->handle); size.add_repeated_message(1, this->characteristics); - size.add_uint32(1, this->uuid16); - size.add_uint32(1, this->uuid32); + size.add_uint32(1, this->short_uuid); } void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 9dd2460166..524674e6ef 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1857,8 +1857,7 @@ class BluetoothGATTDescriptor : public ProtoMessage { public: std::array uuid{}; uint32_t handle{0}; - uint32_t uuid16{0}; - uint32_t uuid32{0}; + uint32_t short_uuid{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1873,8 +1872,7 @@ class BluetoothGATTCharacteristic : public ProtoMessage { uint32_t handle{0}; uint32_t properties{0}; std::vector descriptors{}; - uint32_t uuid16{0}; - uint32_t uuid32{0}; + uint32_t short_uuid{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1888,8 +1886,7 @@ class BluetoothGATTService : public ProtoMessage { std::array uuid{}; uint32_t handle{0}; std::vector characteristics{}; - uint32_t uuid16{0}; - uint32_t uuid32{0}; + uint32_t short_uuid{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 64b88caabc..b212353ad8 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1561,8 +1561,7 @@ void BluetoothGATTDescriptor::dump_to(std::string &out) const { dump_field(out, "uuid", it, 4); } dump_field(out, "handle", this->handle); - dump_field(out, "uuid16", this->uuid16); - dump_field(out, "uuid32", this->uuid32); + dump_field(out, "short_uuid", this->short_uuid); } void BluetoothGATTCharacteristic::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothGATTCharacteristic"); @@ -1576,8 +1575,7 @@ void BluetoothGATTCharacteristic::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } - dump_field(out, "uuid16", this->uuid16); - dump_field(out, "uuid32", this->uuid32); + dump_field(out, "short_uuid", this->short_uuid); } void BluetoothGATTService::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothGATTService"); @@ -1590,8 +1588,7 @@ void BluetoothGATTService::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } - dump_field(out, "uuid16", this->uuid16); - dump_field(out, "uuid32", this->uuid32); + dump_field(out, "short_uuid", this->short_uuid); } void BluetoothGATTGetServicesResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothGATTGetServicesResponse"); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index b7326e3a4a..fdc6e9d12a 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -113,9 +113,9 @@ void BluetoothConnection::send_service_for_discovery_() { // Use 128-bit format for old clients or when UUID is already 128-bit fill_128bit_uuid_array(service_resp.uuid, service_result.uuid); } else if (service_result.uuid.len == ESP_UUID_LEN_16) { - service_resp.uuid16 = service_result.uuid.uuid.uuid16; + service_resp.short_uuid = service_result.uuid.uuid.uuid16; } else if (service_result.uuid.len == ESP_UUID_LEN_32) { - service_resp.uuid32 = service_result.uuid.uuid.uuid32; + service_resp.short_uuid = service_result.uuid.uuid.uuid32; } service_resp.handle = service_result.start_handle; @@ -167,9 +167,9 @@ void BluetoothConnection::send_service_for_discovery_() { // Use 128-bit format for old clients or when UUID is already 128-bit fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); } else if (char_result.uuid.len == ESP_UUID_LEN_16) { - characteristic_resp.uuid16 = char_result.uuid.uuid.uuid16; + characteristic_resp.short_uuid = char_result.uuid.uuid.uuid16; } else if (char_result.uuid.len == ESP_UUID_LEN_32) { - characteristic_resp.uuid32 = char_result.uuid.uuid.uuid32; + characteristic_resp.short_uuid = char_result.uuid.uuid.uuid32; } characteristic_resp.handle = char_result.char_handle; @@ -220,9 +220,9 @@ void BluetoothConnection::send_service_for_discovery_() { // Use 128-bit format for old clients or when UUID is already 128-bit fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); } else if (desc_result.uuid.len == ESP_UUID_LEN_16) { - descriptor_resp.uuid16 = desc_result.uuid.uuid.uuid16; + descriptor_resp.short_uuid = desc_result.uuid.uuid.uuid16; } else if (desc_result.uuid.len == ESP_UUID_LEN_32) { - descriptor_resp.uuid32 = desc_result.uuid.uuid.uuid32; + descriptor_resp.short_uuid = desc_result.uuid.uuid.uuid32; } descriptor_resp.handle = desc_result.handle; From 936a090aaa1e4b0f8e5d244ce3045331e358a3a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 04:41:19 -1000 Subject: [PATCH 0173/1626] Bump aioesphomeapi from 37.2.0 to 37.2.1 (#9998) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1289cf9aba..b7d259f0c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.9.0 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.2.0 +aioesphomeapi==37.2.1 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From f1202403500161d77d89a4078b35eae73262e5aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 05:15:12 -1000 Subject: [PATCH 0174/1626] dry --- .../bluetooth_proxy/bluetooth_connection.cpp | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index fdc6e9d12a..4f312fce30 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -24,6 +24,19 @@ static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t u ((uint64_t) uuid.uuid.uuid128[1] << 8) | ((uint64_t) uuid.uuid.uuid128[0]); } +// Helper to fill UUID in the appropriate format based on client support and UUID type +static void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uuid, const esp_bt_uuid_t &uuid, + bool use_efficient_uuids) { + if (!use_efficient_uuids || uuid.len == ESP_UUID_LEN_128) { + // Use 128-bit format for old clients or when UUID is already 128-bit + fill_128bit_uuid_array(uuid_128, uuid); + } else if (uuid.len == ESP_UUID_LEN_16) { + short_uuid = uuid.uuid.uuid16; + } else if (uuid.len == ESP_UUID_LEN_32) { + short_uuid = uuid.uuid.uuid32; + } +} + bool BluetoothConnection::supports_efficient_uuids_() const { auto *api_conn = this->proxy_->get_api_connection(); return api_conn && api_conn->client_supports_api_version(1, 12); @@ -109,14 +122,7 @@ void BluetoothConnection::send_service_for_discovery_() { resp.services.emplace_back(); auto &service_resp = resp.services.back(); - if (!use_efficient_uuids || service_result.uuid.len == ESP_UUID_LEN_128) { - // Use 128-bit format for old clients or when UUID is already 128-bit - fill_128bit_uuid_array(service_resp.uuid, service_result.uuid); - } else if (service_result.uuid.len == ESP_UUID_LEN_16) { - service_resp.short_uuid = service_result.uuid.uuid.uuid16; - } else if (service_result.uuid.len == ESP_UUID_LEN_32) { - service_resp.short_uuid = service_result.uuid.uuid.uuid32; - } + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); service_resp.handle = service_result.start_handle; @@ -163,14 +169,7 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.characteristics.emplace_back(); auto &characteristic_resp = service_resp.characteristics.back(); - if (!use_efficient_uuids || char_result.uuid.len == ESP_UUID_LEN_128) { - // Use 128-bit format for old clients or when UUID is already 128-bit - fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); - } else if (char_result.uuid.len == ESP_UUID_LEN_16) { - characteristic_resp.short_uuid = char_result.uuid.uuid.uuid16; - } else if (char_result.uuid.len == ESP_UUID_LEN_32) { - characteristic_resp.short_uuid = char_result.uuid.uuid.uuid32; - } + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); characteristic_resp.handle = char_result.char_handle; characteristic_resp.properties = char_result.properties; @@ -216,14 +215,7 @@ void BluetoothConnection::send_service_for_discovery_() { characteristic_resp.descriptors.emplace_back(); auto &descriptor_resp = characteristic_resp.descriptors.back(); - if (!use_efficient_uuids || desc_result.uuid.len == ESP_UUID_LEN_128) { - // Use 128-bit format for old clients or when UUID is already 128-bit - fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); - } else if (desc_result.uuid.len == ESP_UUID_LEN_16) { - descriptor_resp.short_uuid = desc_result.uuid.uuid.uuid16; - } else if (desc_result.uuid.len == ESP_UUID_LEN_32) { - descriptor_resp.short_uuid = desc_result.uuid.uuid.uuid32; - } + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); descriptor_resp.handle = desc_result.handle; desc_offset++; From 3a80aac6e8356889b1c394f9d349e3571953c915 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 06:35:19 -1000 Subject: [PATCH 0175/1626] ble dynamic batch --- .../bluetooth_proxy/bluetooth_connection.cpp | 82 +++++++++++++++++-- .../bluetooth_proxy/bluetooth_proxy.h | 1 - 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 4f312fce30..931bae7247 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -37,6 +37,25 @@ static void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uu } } +// Constants for size estimation +static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4) +static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) +static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t DESC_SIZE_16BIT = 10; // UUID(6) + handle(4) +static constexpr uint8_t DESC_PER_CHAR = 2; // Assume 2 descriptors per characteristic + +// Helper to estimate service size before fetching all data +static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { + size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; + // Always assume 128-bit UUIDs for characteristics to be safe + size_t char_size = CHAR_SIZE_128BIT; + // Assume mix of descriptor types: one 128-bit + one 16-bit per characteristic + size_t desc_size = (DESC_SIZE_128BIT + DESC_SIZE_16BIT) * DESC_PER_CHAR; + + return service_overhead + (char_size + desc_size) * char_count; +} + bool BluetoothConnection::supports_efficient_uuids_() const { auto *api_conn = this->proxy_->get_api_connection(); return api_conn && api_conn->client_supports_api_version(1, 12); @@ -95,16 +114,21 @@ void BluetoothConnection::send_service_for_discovery_() { // Check if client supports efficient UUIDs bool use_efficient_uuids = this->supports_efficient_uuids_(); - // Prepare response for up to 3 services + // Prepare response api::BluetoothGATTGetServicesResponse resp; resp.address = this->address_; - // Process up to 3 services in this iteration - uint8_t services_to_process = - std::min(MAX_SERVICES_PER_BATCH, static_cast(this->service_count_ - this->send_service_)); - resp.services.reserve(services_to_process); + // Dynamic batching based on actual size + static constexpr size_t MAX_PACKET_SIZE = 1390; // MTU limit for API messages + static constexpr size_t NEXT_SERVICE_BUFFER = 400; // Reserve space for next service - for (int service_idx = 0; service_idx < services_to_process; service_idx++) { + // Keep running total of actual message size + size_t current_size = 0; + api::ProtoSize size; + resp.calculate_size(size); + current_size = size.get_size(); + + while (this->send_service_ < this->service_count_) { esp_gattc_service_elem_t service_result; uint16_t service_count = 1; esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, @@ -118,7 +142,6 @@ void BluetoothConnection::send_service_for_discovery_() { return; } - this->send_service_++; resp.services.emplace_back(); auto &service_resp = resp.services.back(); @@ -139,8 +162,16 @@ void BluetoothConnection::send_service_for_discovery_() { return; } + // If this service likely won't fit, send current batch (unless it's the first) + if (!resp.services.empty() && + (current_size + estimate_service_size(total_char_count, use_efficient_uuids) > MAX_PACKET_SIZE)) { + // This service likely won't fit, send current batch + break; + } + if (total_char_count == 0) { - // No characteristics, continue to next service + // No characteristics, increment and continue to next service + this->send_service_++; continue; } @@ -221,9 +252,42 @@ void BluetoothConnection::send_service_for_discovery_() { desc_offset++; } } + + // Calculate the actual size of just this service + api::ProtoSize service_size; + service_resp.calculate_size(service_size); + + // Update running total + current_size += service_size.get_size() + 1; // +1 for field tag + + // Check if we've exceeded the limit (worst case scenario) + // Our estimation above should have caught this, but if we're here it means + // this service is extraordinarily large (many characteristics/descriptors) + if (current_size > MAX_PACKET_SIZE) { + // We've gone over - pop the last service if we have more than one + if (resp.services.size() > 1) { + resp.services.pop_back(); + // Don't increment send_service_ - we'll retry this service in next batch + } else { + // This single service is too large, but we have to send it anyway + // Increment so we don't get stuck + this->send_service_++; + } + // Send what we have + break; + } + + // Successfully added this service, increment counter + this->send_service_++; + + // Check if we have room for another service + if (current_size > MAX_PACKET_SIZE - NEXT_SERVICE_BUFFER) { + // Getting close to limit, send this batch + break; + } } - // Send the message with 1-3 services + // Send the message with dynamically batched services api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index b33460339b..d249515fdf 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -22,7 +22,6 @@ namespace esphome::bluetooth_proxy { static const esp_err_t ESP_GATT_NOT_CONNECTED = -1; static const int DONE_SENDING_SERVICES = -2; -static const uint8_t MAX_SERVICES_PER_BATCH = 3; using namespace esp32_ble_client; From 551bff33c2c9727f86f4ac75fb27d1964d491e51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 06:47:31 -1000 Subject: [PATCH 0176/1626] preen --- .../bluetooth_proxy/bluetooth_connection.cpp | 155 +++++++++--------- 1 file changed, 77 insertions(+), 78 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 931bae7247..974ecfb3e2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -56,6 +56,13 @@ static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuid return service_overhead + (char_size + desc_size) * char_count; } +// Helper to calculate actual service size +static size_t get_service_size(api::BluetoothGATTService &service) { + api::ProtoSize service_size; + service.calculate_size(service_size); + return service_size.get_size(); +} + bool BluetoothConnection::supports_efficient_uuids_() const { auto *api_conn = this->proxy_->get_api_connection(); return api_conn && api_conn->client_supports_api_version(1, 12); @@ -169,96 +176,88 @@ void BluetoothConnection::send_service_for_discovery_() { break; } - if (total_char_count == 0) { - // No characteristics, increment and continue to next service - this->send_service_++; - continue; - } - - // Reserve space and process characteristics - service_resp.characteristics.reserve(total_char_count); - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - while (true) { // characteristics - uint16_t char_count = 1; - esp_gatt_status_t char_status = - esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, - service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { - break; - } - if (char_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, - this->address_str().c_str(), char_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (char_count == 0) { - break; - } - - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); - - fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); - - characteristic_resp.handle = char_result.char_handle; - characteristic_resp.properties = char_result.properties; - char_offset++; - - // Get the number of descriptors directly with one call - uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( - this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); - - if (desc_count_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, - this->address_str().c_str(), char_result.char_handle, desc_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (total_desc_count == 0) { - // No descriptors, continue to next characteristic - continue; - } - - // Reserve space and process descriptors - characteristic_resp.descriptors.reserve(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + if (total_char_count > 0) { + // Reserve space and process characteristics + service_resp.characteristics.reserve(total_char_count); + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + while (true) { // characteristics + uint16_t char_count = 1; + esp_gatt_status_t char_status = + esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &char_count, char_offset); + if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { break; } - if (desc_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, - this->address_str().c_str(), desc_status); + if (char_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, + this->address_str().c_str(), char_status); this->send_service_ = DONE_SENDING_SERVICES; return; } - if (desc_count == 0) { - break; // No more descriptors + if (char_count == 0) { + break; } - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); - fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); - descriptor_resp.handle = desc_result.handle; - desc_offset++; + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + char_offset++; + + // Get the number of descriptors directly with one call + uint16_t total_desc_count = 0; + esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( + this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); + + if (desc_count_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", + this->connection_index_, this->address_str().c_str(), char_result.char_handle, desc_count_status); + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (total_desc_count == 0) { + // No descriptors, continue to next characteristic + continue; + } + + // Reserve space and process descriptors + characteristic_resp.descriptors.reserve(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (true) { // descriptors + uint16_t desc_count = 1; + esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( + this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + break; + } + if (desc_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, + this->address_str().c_str(), desc_status); + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (desc_count == 0) { + break; // No more descriptors + } + + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); + + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } } - } + } // end if (total_char_count > 0) // Calculate the actual size of just this service - api::ProtoSize service_size; - service_resp.calculate_size(service_size); - - // Update running total - current_size += service_size.get_size() + 1; // +1 for field tag + current_size += get_service_size(service_resp) + 1; // +1 for field tag // Check if we've exceeded the limit (worst case scenario) // Our estimation above should have caught this, but if we're here it means From 1225df594fa9e7929509b7dd3066baaac967ee52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 06:59:10 -1000 Subject: [PATCH 0177/1626] preen --- .../bluetooth_proxy/bluetooth_connection.cpp | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 974ecfb3e2..84d9939d27 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -126,14 +126,15 @@ void BluetoothConnection::send_service_for_discovery_() { resp.address = this->address_; // Dynamic batching based on actual size - static constexpr size_t MAX_PACKET_SIZE = 1390; // MTU limit for API messages - static constexpr size_t NEXT_SERVICE_BUFFER = 400; // Reserve space for next service + static constexpr size_t MAX_PACKET_SIZE = 1390; // MTU limit for API messages // Keep running total of actual message size size_t current_size = 0; api::ProtoSize size; resp.calculate_size(size); current_size = size.get_size(); + ESP_LOGD(TAG, "[%d] [%s] Starting batch with base size: %d", this->connection_index_, this->address_str().c_str(), + current_size); while (this->send_service_ < this->service_count_) { esp_gattc_service_elem_t service_result; @@ -257,7 +258,10 @@ void BluetoothConnection::send_service_for_discovery_() { } // end if (total_char_count > 0) // Calculate the actual size of just this service - current_size += get_service_size(service_resp) + 1; // +1 for field tag + size_t service_size = get_service_size(service_resp) + 1; // +1 for field tag + current_size += service_size; + ESP_LOGD(TAG, "[%d] [%s] Service %d size: %d, total size now: %d", this->connection_index_, + this->address_str().c_str(), this->send_service_ - 1, service_size, current_size); // Check if we've exceeded the limit (worst case scenario) // Our estimation above should have caught this, but if we're here it means @@ -278,15 +282,11 @@ void BluetoothConnection::send_service_for_discovery_() { // Successfully added this service, increment counter this->send_service_++; - - // Check if we have room for another service - if (current_size > MAX_PACKET_SIZE - NEXT_SERVICE_BUFFER) { - // Getting close to limit, send this batch - break; - } } // Send the message with dynamically batched services + ESP_LOGD(TAG, "[%d] [%s] Sending batch with %d services, total size %d", this->connection_index_, + this->address_str().c_str(), resp.services.size(), current_size); api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); } From b66141e5ba450b256ed56e1fa4091fbe46cf12b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 07:12:53 -1000 Subject: [PATCH 0178/1626] fix --- .../bluetooth_proxy/bluetooth_connection.cpp | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 84d9939d27..a49d06994b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -60,7 +60,10 @@ static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuid static size_t get_service_size(api::BluetoothGATTService &service) { api::ProtoSize service_size; service.calculate_size(service_size); - return service_size.get_size(); + size_t size = service_size.get_size(); + ESP_LOGD(TAG, "Service size calculation: uuid[0]=%llx uuid[1]=%llx short_uuid=%u handle=%u -> size=%d", + service.uuid[0], service.uuid[1], service.short_uuid, service.handle, size); + return size; } bool BluetoothConnection::supports_efficient_uuids_() const { @@ -133,8 +136,8 @@ void BluetoothConnection::send_service_for_discovery_() { api::ProtoSize size; resp.calculate_size(size); current_size = size.get_size(); - ESP_LOGD(TAG, "[%d] [%s] Starting batch with base size: %d", this->connection_index_, this->address_str().c_str(), - current_size); + ESP_LOGD(TAG, "[%d] [%s] Starting batch with base size: %d, send_service_: %d", this->connection_index_, + this->address_str().c_str(), current_size, this->send_service_); while (this->send_service_ < this->service_count_) { esp_gattc_service_elem_t service_result; @@ -157,6 +160,10 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.handle = service_result.start_handle; + ESP_LOGD(TAG, "[%d] [%s] Service UUID: %llx,%llx short:%u handle:%u", this->connection_index_, + this->address_str().c_str(), service_resp.uuid[0], service_resp.uuid[1], service_resp.short_uuid, + service_resp.handle); + // Get the number of characteristics directly with one call uint16_t total_char_count = 0; esp_gatt_status_t char_count_status = @@ -259,20 +266,18 @@ void BluetoothConnection::send_service_for_discovery_() { // Calculate the actual size of just this service size_t service_size = get_service_size(service_resp) + 1; // +1 for field tag - current_size += service_size; - ESP_LOGD(TAG, "[%d] [%s] Service %d size: %d, total size now: %d", this->connection_index_, - this->address_str().c_str(), this->send_service_ - 1, service_size, current_size); - // Check if we've exceeded the limit (worst case scenario) - // Our estimation above should have caught this, but if we're here it means - // this service is extraordinarily large (many characteristics/descriptors) - if (current_size > MAX_PACKET_SIZE) { - // We've gone over - pop the last service if we have more than one + // Check if adding this service would exceed the limit + if (current_size + service_size > MAX_PACKET_SIZE) { + // We would go over - pop the last service if we have more than one if (resp.services.size() > 1) { resp.services.pop_back(); // Don't increment send_service_ - we'll retry this service in next batch } else { // This single service is too large, but we have to send it anyway + current_size += service_size; + ESP_LOGW(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, + this->address_str().c_str(), this->send_service_, service_size); // Increment so we don't get stuck this->send_service_++; } @@ -280,6 +285,11 @@ void BluetoothConnection::send_service_for_discovery_() { break; } + // Now we know we're keeping this service, add its size + current_size += service_size; + ESP_LOGD(TAG, "[%d] [%s] Service %d size: %d, total size now: %d", this->connection_index_, + this->address_str().c_str(), this->send_service_, service_size, current_size); + // Successfully added this service, increment counter this->send_service_++; } From fe2b2d5280055507fb3dc9cc44759b7b3f1307de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 07:19:03 -1000 Subject: [PATCH 0179/1626] fix --- .../bluetooth_proxy/bluetooth_connection.cpp | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index a49d06994b..360bd471b8 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -153,18 +153,7 @@ void BluetoothConnection::send_service_for_discovery_() { return; } - resp.services.emplace_back(); - auto &service_resp = resp.services.back(); - - fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); - - service_resp.handle = service_result.start_handle; - - ESP_LOGD(TAG, "[%d] [%s] Service UUID: %llx,%llx short:%u handle:%u", this->connection_index_, - this->address_str().c_str(), service_resp.uuid[0], service_resp.uuid[1], service_resp.short_uuid, - service_resp.handle); - - // Get the number of characteristics directly with one call + // Get the number of characteristics BEFORE adding to response uint16_t total_char_count = 0; esp_gatt_status_t char_count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, @@ -184,6 +173,18 @@ void BluetoothConnection::send_service_for_discovery_() { break; } + // Now add the service since we know it will likely fit + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); + + service_resp.handle = service_result.start_handle; + + ESP_LOGD(TAG, "[%d] [%s] Service UUID: %llx,%llx short:%u handle:%u", this->connection_index_, + this->address_str().c_str(), service_resp.uuid[0], service_resp.uuid[1], service_resp.short_uuid, + service_resp.handle); + if (total_char_count > 0) { // Reserve space and process characteristics service_resp.characteristics.reserve(total_char_count); From 38e2b6c5f3c5a4b79cb917c566b72525cf13c748 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 07:33:14 -1000 Subject: [PATCH 0180/1626] wip --- .../bluetooth_proxy/bluetooth_connection.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 360bd471b8..bcd8eafb8c 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -61,7 +61,7 @@ static size_t get_service_size(api::BluetoothGATTService &service) { api::ProtoSize service_size; service.calculate_size(service_size); size_t size = service_size.get_size(); - ESP_LOGD(TAG, "Service size calculation: uuid[0]=%llx uuid[1]=%llx short_uuid=%u handle=%u -> size=%d", + ESP_LOGV(TAG, "Service size calculation: uuid[0]=%llx uuid[1]=%llx short_uuid=%u handle=%u -> size=%d", service.uuid[0], service.uuid[1], service.short_uuid, service.handle, size); return size; } @@ -136,7 +136,7 @@ void BluetoothConnection::send_service_for_discovery_() { api::ProtoSize size; resp.calculate_size(size); current_size = size.get_size(); - ESP_LOGD(TAG, "[%d] [%s] Starting batch with base size: %d, send_service_: %d", this->connection_index_, + ESP_LOGV(TAG, "[%d] [%s] Starting batch with base size: %d, send_service_: %d", this->connection_index_, this->address_str().c_str(), current_size, this->send_service_); while (this->send_service_ < this->service_count_) { @@ -181,7 +181,7 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.handle = service_result.start_handle; - ESP_LOGD(TAG, "[%d] [%s] Service UUID: %llx,%llx short:%u handle:%u", this->connection_index_, + ESP_LOGV(TAG, "[%d] [%s] Service UUID: %llx,%llx short:%u handle:%u", this->connection_index_, this->address_str().c_str(), service_resp.uuid[0], service_resp.uuid[1], service_resp.short_uuid, service_resp.handle); @@ -277,7 +277,7 @@ void BluetoothConnection::send_service_for_discovery_() { } else { // This single service is too large, but we have to send it anyway current_size += service_size; - ESP_LOGW(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, + ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, this->address_str().c_str(), this->send_service_, service_size); // Increment so we don't get stuck this->send_service_++; @@ -288,7 +288,7 @@ void BluetoothConnection::send_service_for_discovery_() { // Now we know we're keeping this service, add its size current_size += service_size; - ESP_LOGD(TAG, "[%d] [%s] Service %d size: %d, total size now: %d", this->connection_index_, + ESP_LOGV(TAG, "[%d] [%s] Service %d size: %d, total size now: %d", this->connection_index_, this->address_str().c_str(), this->send_service_, service_size, current_size); // Successfully added this service, increment counter From 255cf4b6618cf44b82c1fc7e92fa577d89a9e739 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 07:34:02 -1000 Subject: [PATCH 0181/1626] wip --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index bcd8eafb8c..9ca1e7c6e8 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -273,6 +273,9 @@ void BluetoothConnection::send_service_for_discovery_() { // We would go over - pop the last service if we have more than one if (resp.services.size() > 1) { resp.services.pop_back(); + ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %d + service: %d > %d), sending current batch", + this->connection_index_, this->address_str().c_str(), this->send_service_, current_size, service_size, + MAX_PACKET_SIZE); // Don't increment send_service_ - we'll retry this service in next batch } else { // This single service is too large, but we have to send it anyway From d6776804aeff69daf98497c3283402440284b4a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 07:45:03 -1000 Subject: [PATCH 0182/1626] tweak --- .../bluetooth_proxy/bluetooth_connection.cpp | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 9ca1e7c6e8..05ef181a7c 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -43,15 +43,15 @@ static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4 static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) static constexpr uint8_t DESC_SIZE_16BIT = 10; // UUID(6) + handle(4) -static constexpr uint8_t DESC_PER_CHAR = 2; // Assume 2 descriptors per characteristic +static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic // Helper to estimate service size before fetching all data static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; // Always assume 128-bit UUIDs for characteristics to be safe size_t char_size = CHAR_SIZE_128BIT; - // Assume mix of descriptor types: one 128-bit + one 16-bit per characteristic - size_t desc_size = (DESC_SIZE_128BIT + DESC_SIZE_16BIT) * DESC_PER_CHAR; + // Assume one 128-bit descriptor per characteristic + size_t desc_size = DESC_SIZE_128BIT * DESC_PER_CHAR; return service_overhead + (char_size + desc_size) * char_count; } @@ -129,7 +129,8 @@ void BluetoothConnection::send_service_for_discovery_() { resp.address = this->address_; // Dynamic batching based on actual size - static constexpr size_t MAX_PACKET_SIZE = 1390; // MTU limit for API messages + static constexpr size_t MAX_PACKET_SIZE = + 1360; // Conservative MTU limit for API messages (accounts for WPA3 overhead) // Keep running total of actual message size size_t current_size = 0; @@ -167,8 +168,8 @@ void BluetoothConnection::send_service_for_discovery_() { } // If this service likely won't fit, send current batch (unless it's the first) - if (!resp.services.empty() && - (current_size + estimate_service_size(total_char_count, use_efficient_uuids) > MAX_PACKET_SIZE)) { + size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids); + if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { // This service likely won't fit, send current batch break; } @@ -291,8 +292,12 @@ void BluetoothConnection::send_service_for_discovery_() { // Now we know we're keeping this service, add its size current_size += service_size; - ESP_LOGV(TAG, "[%d] [%s] Service %d size: %d, total size now: %d", this->connection_index_, - this->address_str().c_str(), this->send_service_, service_size, current_size); + + // Log the difference between estimate and actual size + int size_diff = (int) service_size - (int) estimated_size; + ESP_LOGD(TAG, "[%d] [%s] Service %d actual: %d, estimated: %d, diff: %+d", this->connection_index_, + this->address_str().c_str(), this->send_service_, service_size, estimated_size, size_diff); + ESP_LOGV(TAG, "[%d] [%s] Total size now: %d", this->connection_index_, this->address_str().c_str(), current_size); // Successfully added this service, increment counter this->send_service_++; From 0356e24baed223f8fc5287ab77d54eb3ebaabf5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 07:46:18 -1000 Subject: [PATCH 0183/1626] tweak --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 05ef181a7c..1f4bfd9806 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -295,7 +295,7 @@ void BluetoothConnection::send_service_for_discovery_() { // Log the difference between estimate and actual size int size_diff = (int) service_size - (int) estimated_size; - ESP_LOGD(TAG, "[%d] [%s] Service %d actual: %d, estimated: %d, diff: %+d", this->connection_index_, + ESP_LOGV(TAG, "[%d] [%s] Service %d actual: %d, estimated: %d, diff: %+d", this->connection_index_, this->address_str().c_str(), this->send_service_, service_size, estimated_size, size_diff); ESP_LOGV(TAG, "[%d] [%s] Total size now: %d", this->connection_index_, this->address_str().c_str(), current_size); From 0ae7dcdb62a500be32278c328b64f819c3429a51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 07:48:54 -1000 Subject: [PATCH 0184/1626] tweak --- .../bluetooth_proxy/bluetooth_connection.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 1f4bfd9806..fd67d5690f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -61,7 +61,7 @@ static size_t get_service_size(api::BluetoothGATTService &service) { api::ProtoSize service_size; service.calculate_size(service_size); size_t size = service_size.get_size(); - ESP_LOGV(TAG, "Service size calculation: uuid[0]=%llx uuid[1]=%llx short_uuid=%u handle=%u -> size=%d", + ESP_LOGD(TAG, "Service size calculation: uuid[0]=%llx uuid[1]=%llx short_uuid=%u handle=%u -> size=%d", service.uuid[0], service.uuid[1], service.short_uuid, service.handle, size); return size; } @@ -137,7 +137,7 @@ void BluetoothConnection::send_service_for_discovery_() { api::ProtoSize size; resp.calculate_size(size); current_size = size.get_size(); - ESP_LOGV(TAG, "[%d] [%s] Starting batch with base size: %d, send_service_: %d", this->connection_index_, + ESP_LOGD(TAG, "[%d] [%s] Starting batch with base size: %d, send_service_: %d", this->connection_index_, this->address_str().c_str(), current_size, this->send_service_); while (this->send_service_ < this->service_count_) { @@ -182,7 +182,7 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.handle = service_result.start_handle; - ESP_LOGV(TAG, "[%d] [%s] Service UUID: %llx,%llx short:%u handle:%u", this->connection_index_, + ESP_LOGD(TAG, "[%d] [%s] Service UUID: %llx,%llx short:%u handle:%u", this->connection_index_, this->address_str().c_str(), service_resp.uuid[0], service_resp.uuid[1], service_resp.short_uuid, service_resp.handle); @@ -281,7 +281,7 @@ void BluetoothConnection::send_service_for_discovery_() { } else { // This single service is too large, but we have to send it anyway current_size += service_size; - ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, + ESP_LOGD(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, this->address_str().c_str(), this->send_service_, service_size); // Increment so we don't get stuck this->send_service_++; @@ -295,9 +295,9 @@ void BluetoothConnection::send_service_for_discovery_() { // Log the difference between estimate and actual size int size_diff = (int) service_size - (int) estimated_size; - ESP_LOGV(TAG, "[%d] [%s] Service %d actual: %d, estimated: %d, diff: %+d", this->connection_index_, + ESP_LOGD(TAG, "[%d] [%s] Service %d actual: %d, estimated: %d, diff: %+d", this->connection_index_, this->address_str().c_str(), this->send_service_, service_size, estimated_size, size_diff); - ESP_LOGV(TAG, "[%d] [%s] Total size now: %d", this->connection_index_, this->address_str().c_str(), current_size); + ESP_LOGD(TAG, "[%d] [%s] Total size now: %d", this->connection_index_, this->address_str().c_str(), current_size); // Successfully added this service, increment counter this->send_service_++; From f2b3f413fc26ce9ccab4a30939a1289005fee4a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 07:56:57 -1000 Subject: [PATCH 0185/1626] back --- .../bluetooth_proxy/bluetooth_connection.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index fd67d5690f..1f4bfd9806 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -61,7 +61,7 @@ static size_t get_service_size(api::BluetoothGATTService &service) { api::ProtoSize service_size; service.calculate_size(service_size); size_t size = service_size.get_size(); - ESP_LOGD(TAG, "Service size calculation: uuid[0]=%llx uuid[1]=%llx short_uuid=%u handle=%u -> size=%d", + ESP_LOGV(TAG, "Service size calculation: uuid[0]=%llx uuid[1]=%llx short_uuid=%u handle=%u -> size=%d", service.uuid[0], service.uuid[1], service.short_uuid, service.handle, size); return size; } @@ -137,7 +137,7 @@ void BluetoothConnection::send_service_for_discovery_() { api::ProtoSize size; resp.calculate_size(size); current_size = size.get_size(); - ESP_LOGD(TAG, "[%d] [%s] Starting batch with base size: %d, send_service_: %d", this->connection_index_, + ESP_LOGV(TAG, "[%d] [%s] Starting batch with base size: %d, send_service_: %d", this->connection_index_, this->address_str().c_str(), current_size, this->send_service_); while (this->send_service_ < this->service_count_) { @@ -182,7 +182,7 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.handle = service_result.start_handle; - ESP_LOGD(TAG, "[%d] [%s] Service UUID: %llx,%llx short:%u handle:%u", this->connection_index_, + ESP_LOGV(TAG, "[%d] [%s] Service UUID: %llx,%llx short:%u handle:%u", this->connection_index_, this->address_str().c_str(), service_resp.uuid[0], service_resp.uuid[1], service_resp.short_uuid, service_resp.handle); @@ -281,7 +281,7 @@ void BluetoothConnection::send_service_for_discovery_() { } else { // This single service is too large, but we have to send it anyway current_size += service_size; - ESP_LOGD(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, + ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, this->address_str().c_str(), this->send_service_, service_size); // Increment so we don't get stuck this->send_service_++; @@ -295,9 +295,9 @@ void BluetoothConnection::send_service_for_discovery_() { // Log the difference between estimate and actual size int size_diff = (int) service_size - (int) estimated_size; - ESP_LOGD(TAG, "[%d] [%s] Service %d actual: %d, estimated: %d, diff: %+d", this->connection_index_, + ESP_LOGV(TAG, "[%d] [%s] Service %d actual: %d, estimated: %d, diff: %+d", this->connection_index_, this->address_str().c_str(), this->send_service_, service_size, estimated_size, size_diff); - ESP_LOGD(TAG, "[%d] [%s] Total size now: %d", this->connection_index_, this->address_str().c_str(), current_size); + ESP_LOGV(TAG, "[%d] [%s] Total size now: %d", this->connection_index_, this->address_str().c_str(), current_size); // Successfully added this service, increment counter this->send_service_++; From 28b277c1c487abb146f5e294dfe02001682029f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 11:20:53 -1000 Subject: [PATCH 0186/1626] [bluetooth_proxy] Optimize UUID transmission with efficient short_uuid field (#9995) --- esphome/components/api/api.proto | 24 +++++++++-- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_connection.h | 7 ++++ esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 42 +++++++++++++------ esphome/components/api/api_pb2.h | 3 ++ esphome/components/api/api_pb2_dump.cpp | 3 ++ .../bluetooth_proxy/bluetooth_connection.cpp | 33 +++++++++++++-- .../bluetooth_proxy/bluetooth_connection.h | 1 + script/api_protobuf/api_protobuf.py | 29 +++++++++++++ 10 files changed, 126 insertions(+), 19 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 27edf4680f..4aa5cc4be0 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1482,21 +1482,39 @@ message BluetoothGATTGetServicesRequest { } message BluetoothGATTDescriptor { - repeated uint64 uuid = 1 [(fixed_array_size) = 2]; + repeated uint64 uuid = 1 [(fixed_array_size) = 2, (fixed_array_skip_zero) = true]; uint32 handle = 2; + + // New field for efficient UUID (v1.12+) + // Only one of uuid or short_uuid will be set. + // short_uuid is used for both 16-bit and 32-bit UUIDs with v1.12+ clients. + // 128-bit UUIDs always use the uuid field for backwards compatibility. + uint32 short_uuid = 3; // 16-bit or 32-bit UUID } message BluetoothGATTCharacteristic { - repeated uint64 uuid = 1 [(fixed_array_size) = 2]; + repeated uint64 uuid = 1 [(fixed_array_size) = 2, (fixed_array_skip_zero) = true]; uint32 handle = 2; uint32 properties = 3; repeated BluetoothGATTDescriptor descriptors = 4; + + // New field for efficient UUID (v1.12+) + // Only one of uuid or short_uuid will be set. + // short_uuid is used for both 16-bit and 32-bit UUIDs with v1.12+ clients. + // 128-bit UUIDs always use the uuid field for backwards compatibility. + uint32 short_uuid = 5; // 16-bit or 32-bit UUID } message BluetoothGATTService { - repeated uint64 uuid = 1 [(fixed_array_size) = 2]; + repeated uint64 uuid = 1 [(fixed_array_size) = 2, (fixed_array_skip_zero) = true]; uint32 handle = 2; repeated BluetoothGATTCharacteristic characteristics = 3; + + // New field for efficient UUID (v1.12+) + // Only one of uuid or short_uuid will be set. + // short_uuid is used for both 16-bit and 32-bit UUIDs with v1.12+ clients. + // 128-bit UUIDs always use the uuid field for backwards compatibility. + uint32 short_uuid = 4; // 16-bit or 32-bit UUID } message BluetoothGATTGetServicesResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c0dbe4e198..8ac6c3b71e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1363,7 +1363,7 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 11; + resp.api_version_minor = 12; // Temporary string for concatenation - will be valid during send_message call std::string server_info = App.get_name() + " (esphome v" ESPHOME_VERSION ")"; resp.set_server_info(StringRef(server_info)); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 5b64adecb3..21688e601c 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -235,6 +235,13 @@ class APIConnection : public APIServerConnection { this->is_authenticated(); } uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; } + + // Get client API version for feature detection + bool client_supports_api_version(uint16_t major, uint16_t minor) const { + return this->client_api_version_major_ > major || + (this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor); + } + void on_fatal_error() override; #ifdef USE_API_PASSWORD void on_unauthenticated_access() override; diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 85c805260f..d4b5700024 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -28,6 +28,7 @@ extend google.protobuf.FieldOptions { optional string field_ifdef = 1042; optional uint32 fixed_array_size = 50007; optional bool no_zero_copy = 50008 [default=false]; + optional bool fixed_array_skip_zero = 50009 [default=false]; // container_pointer: Zero-copy optimization for repeated fields. // diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index ef02a5a774..29d0f2842c 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1888,44 +1888,62 @@ bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, ProtoVarI return true; } void BluetoothGATTDescriptor::encode(ProtoWriteBuffer buffer) const { - buffer.encode_uint64(1, this->uuid[0], true); - buffer.encode_uint64(1, this->uuid[1], true); + if (this->uuid[0] != 0 || this->uuid[1] != 0) { + buffer.encode_uint64(1, this->uuid[0], true); + buffer.encode_uint64(1, this->uuid[1], true); + } buffer.encode_uint32(2, this->handle); + buffer.encode_uint32(3, this->short_uuid); } void BluetoothGATTDescriptor::calculate_size(ProtoSize &size) const { - size.add_uint64_force(1, this->uuid[0]); - size.add_uint64_force(1, this->uuid[1]); + if (this->uuid[0] != 0 || this->uuid[1] != 0) { + size.add_uint64_force(1, this->uuid[0]); + size.add_uint64_force(1, this->uuid[1]); + } size.add_uint32(1, this->handle); + size.add_uint32(1, this->short_uuid); } void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { - buffer.encode_uint64(1, this->uuid[0], true); - buffer.encode_uint64(1, this->uuid[1], true); + if (this->uuid[0] != 0 || this->uuid[1] != 0) { + buffer.encode_uint64(1, this->uuid[0], true); + buffer.encode_uint64(1, this->uuid[1], true); + } buffer.encode_uint32(2, this->handle); buffer.encode_uint32(3, this->properties); for (auto &it : this->descriptors) { buffer.encode_message(4, it, true); } + buffer.encode_uint32(5, this->short_uuid); } void BluetoothGATTCharacteristic::calculate_size(ProtoSize &size) const { - size.add_uint64_force(1, this->uuid[0]); - size.add_uint64_force(1, this->uuid[1]); + if (this->uuid[0] != 0 || this->uuid[1] != 0) { + size.add_uint64_force(1, this->uuid[0]); + size.add_uint64_force(1, this->uuid[1]); + } size.add_uint32(1, this->handle); size.add_uint32(1, this->properties); size.add_repeated_message(1, this->descriptors); + size.add_uint32(1, this->short_uuid); } void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { - buffer.encode_uint64(1, this->uuid[0], true); - buffer.encode_uint64(1, this->uuid[1], true); + if (this->uuid[0] != 0 || this->uuid[1] != 0) { + buffer.encode_uint64(1, this->uuid[0], true); + buffer.encode_uint64(1, this->uuid[1], true); + } buffer.encode_uint32(2, this->handle); for (auto &it : this->characteristics) { buffer.encode_message(3, it, true); } + buffer.encode_uint32(4, this->short_uuid); } void BluetoothGATTService::calculate_size(ProtoSize &size) const { - size.add_uint64_force(1, this->uuid[0]); - size.add_uint64_force(1, this->uuid[1]); + if (this->uuid[0] != 0 || this->uuid[1] != 0) { + size.add_uint64_force(1, this->uuid[0]); + size.add_uint64_force(1, this->uuid[1]); + } size.add_uint32(1, this->handle); size.add_repeated_message(1, this->characteristics); + size.add_uint32(1, this->short_uuid); } void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 6c2ca60e00..524674e6ef 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1857,6 +1857,7 @@ class BluetoothGATTDescriptor : public ProtoMessage { public: std::array uuid{}; uint32_t handle{0}; + uint32_t short_uuid{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1871,6 +1872,7 @@ class BluetoothGATTCharacteristic : public ProtoMessage { uint32_t handle{0}; uint32_t properties{0}; std::vector descriptors{}; + uint32_t short_uuid{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1884,6 +1886,7 @@ class BluetoothGATTService : public ProtoMessage { std::array uuid{}; uint32_t handle{0}; std::vector characteristics{}; + uint32_t short_uuid{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index b934aead32..b212353ad8 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1561,6 +1561,7 @@ void BluetoothGATTDescriptor::dump_to(std::string &out) const { dump_field(out, "uuid", it, 4); } dump_field(out, "handle", this->handle); + dump_field(out, "short_uuid", this->short_uuid); } void BluetoothGATTCharacteristic::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothGATTCharacteristic"); @@ -1574,6 +1575,7 @@ void BluetoothGATTCharacteristic::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } + dump_field(out, "short_uuid", this->short_uuid); } void BluetoothGATTService::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothGATTService"); @@ -1586,6 +1588,7 @@ void BluetoothGATTService::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } + dump_field(out, "short_uuid", this->short_uuid); } void BluetoothGATTGetServicesResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothGATTGetServicesResponse"); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 1295c18985..4f312fce30 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -24,6 +24,24 @@ static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t u ((uint64_t) uuid.uuid.uuid128[1] << 8) | ((uint64_t) uuid.uuid.uuid128[0]); } +// Helper to fill UUID in the appropriate format based on client support and UUID type +static void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uuid, const esp_bt_uuid_t &uuid, + bool use_efficient_uuids) { + if (!use_efficient_uuids || uuid.len == ESP_UUID_LEN_128) { + // Use 128-bit format for old clients or when UUID is already 128-bit + fill_128bit_uuid_array(uuid_128, uuid); + } else if (uuid.len == ESP_UUID_LEN_16) { + short_uuid = uuid.uuid.uuid16; + } else if (uuid.len == ESP_UUID_LEN_32) { + short_uuid = uuid.uuid.uuid32; + } +} + +bool BluetoothConnection::supports_efficient_uuids_() const { + auto *api_conn = this->proxy_->get_api_connection(); + return api_conn && api_conn->client_supports_api_version(1, 12); +} + void BluetoothConnection::dump_config() { ESP_LOGCONFIG(TAG, "BLE Connection:"); BLEClientBase::dump_config(); @@ -74,6 +92,9 @@ void BluetoothConnection::send_service_for_discovery_() { return; } + // Check if client supports efficient UUIDs + bool use_efficient_uuids = this->supports_efficient_uuids_(); + // Prepare response for up to 3 services api::BluetoothGATTGetServicesResponse resp; resp.address = this->address_; @@ -100,7 +121,9 @@ void BluetoothConnection::send_service_for_discovery_() { this->send_service_++; resp.services.emplace_back(); auto &service_resp = resp.services.back(); - fill_128bit_uuid_array(service_resp.uuid, service_result.uuid); + + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); + service_resp.handle = service_result.start_handle; // Get the number of characteristics directly with one call @@ -145,7 +168,9 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.characteristics.emplace_back(); auto &characteristic_resp = service_resp.characteristics.back(); - fill_128bit_uuid_array(characteristic_resp.uuid, char_result.uuid); + + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); + characteristic_resp.handle = char_result.char_handle; characteristic_resp.properties = char_result.properties; char_offset++; @@ -189,7 +214,9 @@ void BluetoothConnection::send_service_for_discovery_() { characteristic_resp.descriptors.emplace_back(); auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_128bit_uuid_array(descriptor_resp.uuid, desc_result.uuid); + + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); + descriptor_resp.handle = desc_result.handle; desc_offset++; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 3fed9d531f..622d257bf8 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -27,6 +27,7 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { protected: friend class BluetoothProxy; + bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index fece22499a..03f8d0f8bc 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1075,6 +1075,11 @@ class FixedArrayRepeatedType(TypeInfo): def __init__(self, field: descriptor.FieldDescriptorProto, size: int) -> None: super().__init__(field) self.array_size = size + # Check if we should skip encoding when all elements are zero + # Use getattr to handle older versions of api_options_pb2 + self.skip_zero = get_field_opt( + field, getattr(pb, "fixed_array_skip_zero", None), False + ) # Create the element type info validate_field_type(field.type, field.name) self._ti: TypeInfo = TYPE_INFO[field.type](field) @@ -1113,6 +1118,18 @@ class FixedArrayRepeatedType(TypeInfo): else: return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" + # If skip_zero is enabled, wrap encoding in a zero check + if self.skip_zero: + # Build the condition to check if at least one element is non-zero + non_zero_checks = " || ".join( + [f"this->{self.field_name}[{i}] != 0" for i in range(self.array_size)] + ) + encode_lines = [ + f" {encode_element(f'this->{self.field_name}[{i}]')}" + for i in range(self.array_size) + ] + return f"if ({non_zero_checks}) {{\n" + "\n".join(encode_lines) + "\n}" + # Unroll small arrays for efficiency if self.array_size == 1: return encode_element(f"this->{self.field_name}[0]") @@ -1141,6 +1158,18 @@ class FixedArrayRepeatedType(TypeInfo): return "" def get_size_calculation(self, name: str, force: bool = False) -> str: + # If skip_zero is enabled, wrap size calculation in a zero check + if self.skip_zero: + # Build the condition to check if at least one element is non-zero + non_zero_checks = " || ".join( + [f"{name}[{i}] != 0" for i in range(self.array_size)] + ) + size_lines = [ + f" {self._ti.get_size_calculation(f'{name}[{i}]', True)}" + for i in range(self.array_size) + ] + return f"if ({non_zero_checks}) {{\n" + "\n".join(size_lines) + "\n}" + # For fixed arrays, we always encode all elements # Special case for single-element arrays - no loop needed From 7205b1edf0820a3d25c857f7ff637c6e0c09f29c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 07:59:49 -1000 Subject: [PATCH 0187/1626] [bluetooth_proxy] Implement dynamic service batching based on MTU constraints --- .../bluetooth_proxy/bluetooth_connection.cpp | 244 ++++++++++++------ .../bluetooth_proxy/bluetooth_proxy.h | 1 - 2 files changed, 163 insertions(+), 82 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 4f312fce30..1f4bfd9806 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -37,6 +37,35 @@ static void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uu } } +// Constants for size estimation +static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4) +static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) +static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t DESC_SIZE_16BIT = 10; // UUID(6) + handle(4) +static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic + +// Helper to estimate service size before fetching all data +static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { + size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; + // Always assume 128-bit UUIDs for characteristics to be safe + size_t char_size = CHAR_SIZE_128BIT; + // Assume one 128-bit descriptor per characteristic + size_t desc_size = DESC_SIZE_128BIT * DESC_PER_CHAR; + + return service_overhead + (char_size + desc_size) * char_count; +} + +// Helper to calculate actual service size +static size_t get_service_size(api::BluetoothGATTService &service) { + api::ProtoSize service_size; + service.calculate_size(service_size); + size_t size = service_size.get_size(); + ESP_LOGV(TAG, "Service size calculation: uuid[0]=%llx uuid[1]=%llx short_uuid=%u handle=%u -> size=%d", + service.uuid[0], service.uuid[1], service.short_uuid, service.handle, size); + return size; +} + bool BluetoothConnection::supports_efficient_uuids_() const { auto *api_conn = this->proxy_->get_api_connection(); return api_conn && api_conn->client_supports_api_version(1, 12); @@ -95,16 +124,23 @@ void BluetoothConnection::send_service_for_discovery_() { // Check if client supports efficient UUIDs bool use_efficient_uuids = this->supports_efficient_uuids_(); - // Prepare response for up to 3 services + // Prepare response api::BluetoothGATTGetServicesResponse resp; resp.address = this->address_; - // Process up to 3 services in this iteration - uint8_t services_to_process = - std::min(MAX_SERVICES_PER_BATCH, static_cast(this->service_count_ - this->send_service_)); - resp.services.reserve(services_to_process); + // Dynamic batching based on actual size + static constexpr size_t MAX_PACKET_SIZE = + 1360; // Conservative MTU limit for API messages (accounts for WPA3 overhead) - for (int service_idx = 0; service_idx < services_to_process; service_idx++) { + // Keep running total of actual message size + size_t current_size = 0; + api::ProtoSize size; + resp.calculate_size(size); + current_size = size.get_size(); + ESP_LOGV(TAG, "[%d] [%s] Starting batch with base size: %d, send_service_: %d", this->connection_index_, + this->address_str().c_str(), current_size, this->send_service_); + + while (this->send_service_ < this->service_count_) { esp_gattc_service_elem_t service_result; uint16_t service_count = 1; esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, @@ -118,15 +154,7 @@ void BluetoothConnection::send_service_for_discovery_() { return; } - this->send_service_++; - resp.services.emplace_back(); - auto &service_resp = resp.services.back(); - - fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); - - service_resp.handle = service_result.start_handle; - - // Get the number of characteristics directly with one call + // Get the number of characteristics BEFORE adding to response uint16_t total_char_count = 0; esp_gatt_status_t char_count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, @@ -139,91 +167,145 @@ void BluetoothConnection::send_service_for_discovery_() { return; } - if (total_char_count == 0) { - // No characteristics, continue to next service - continue; + // If this service likely won't fit, send current batch (unless it's the first) + size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids); + if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { + // This service likely won't fit, send current batch + break; } - // Reserve space and process characteristics - service_resp.characteristics.reserve(total_char_count); - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - while (true) { // characteristics - uint16_t char_count = 1; - esp_gatt_status_t char_status = - esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, - service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { - break; - } - if (char_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, - this->address_str().c_str(), char_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (char_count == 0) { - break; - } + // Now add the service since we know it will likely fit + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); - fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); + service_resp.handle = service_result.start_handle; - characteristic_resp.handle = char_result.char_handle; - characteristic_resp.properties = char_result.properties; - char_offset++; + ESP_LOGV(TAG, "[%d] [%s] Service UUID: %llx,%llx short:%u handle:%u", this->connection_index_, + this->address_str().c_str(), service_resp.uuid[0], service_resp.uuid[1], service_resp.short_uuid, + service_resp.handle); - // Get the number of descriptors directly with one call - uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( - this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); - - if (desc_count_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, - this->address_str().c_str(), char_result.char_handle, desc_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (total_desc_count == 0) { - // No descriptors, continue to next characteristic - continue; - } - - // Reserve space and process descriptors - characteristic_resp.descriptors.reserve(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + if (total_char_count > 0) { + // Reserve space and process characteristics + service_resp.characteristics.reserve(total_char_count); + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + while (true) { // characteristics + uint16_t char_count = 1; + esp_gatt_status_t char_status = + esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &char_count, char_offset); + if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { break; } - if (desc_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, - this->address_str().c_str(), desc_status); + if (char_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, + this->address_str().c_str(), char_status); this->send_service_ = DONE_SENDING_SERVICES; return; } - if (desc_count == 0) { - break; // No more descriptors + if (char_count == 0) { + break; } - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); - fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); - descriptor_resp.handle = desc_result.handle; - desc_offset++; + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + char_offset++; + + // Get the number of descriptors directly with one call + uint16_t total_desc_count = 0; + esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( + this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); + + if (desc_count_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", + this->connection_index_, this->address_str().c_str(), char_result.char_handle, desc_count_status); + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (total_desc_count == 0) { + // No descriptors, continue to next characteristic + continue; + } + + // Reserve space and process descriptors + characteristic_resp.descriptors.reserve(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (true) { // descriptors + uint16_t desc_count = 1; + esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( + this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + break; + } + if (desc_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, + this->address_str().c_str(), desc_status); + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (desc_count == 0) { + break; // No more descriptors + } + + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); + + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } } + } // end if (total_char_count > 0) + + // Calculate the actual size of just this service + size_t service_size = get_service_size(service_resp) + 1; // +1 for field tag + + // Check if adding this service would exceed the limit + if (current_size + service_size > MAX_PACKET_SIZE) { + // We would go over - pop the last service if we have more than one + if (resp.services.size() > 1) { + resp.services.pop_back(); + ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %d + service: %d > %d), sending current batch", + this->connection_index_, this->address_str().c_str(), this->send_service_, current_size, service_size, + MAX_PACKET_SIZE); + // Don't increment send_service_ - we'll retry this service in next batch + } else { + // This single service is too large, but we have to send it anyway + current_size += service_size; + ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, + this->address_str().c_str(), this->send_service_, service_size); + // Increment so we don't get stuck + this->send_service_++; + } + // Send what we have + break; } + + // Now we know we're keeping this service, add its size + current_size += service_size; + + // Log the difference between estimate and actual size + int size_diff = (int) service_size - (int) estimated_size; + ESP_LOGV(TAG, "[%d] [%s] Service %d actual: %d, estimated: %d, diff: %+d", this->connection_index_, + this->address_str().c_str(), this->send_service_, service_size, estimated_size, size_diff); + ESP_LOGV(TAG, "[%d] [%s] Total size now: %d", this->connection_index_, this->address_str().c_str(), current_size); + + // Successfully added this service, increment counter + this->send_service_++; } - // Send the message with 1-3 services + // Send the message with dynamically batched services + ESP_LOGD(TAG, "[%d] [%s] Sending batch with %d services, total size %d", this->connection_index_, + this->address_str().c_str(), resp.services.size(), current_size); api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index b33460339b..d249515fdf 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -22,7 +22,6 @@ namespace esphome::bluetooth_proxy { static const esp_err_t ESP_GATT_NOT_CONNECTED = -1; static const int DONE_SENDING_SERVICES = -2; -static const uint8_t MAX_SERVICES_PER_BATCH = 3; using namespace esp32_ble_client; From 0f19e234863093abba14f0dac5d150033e7474c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 11:25:33 -1000 Subject: [PATCH 0188/1626] Update esphome/components/bluetooth_proxy/bluetooth_connection.cpp --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 1f4bfd9806..c1932e79e6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -297,7 +297,6 @@ void BluetoothConnection::send_service_for_discovery_() { int size_diff = (int) service_size - (int) estimated_size; ESP_LOGV(TAG, "[%d] [%s] Service %d actual: %d, estimated: %d, diff: %+d", this->connection_index_, this->address_str().c_str(), this->send_service_, service_size, estimated_size, size_diff); - ESP_LOGV(TAG, "[%d] [%s] Total size now: %d", this->connection_index_, this->address_str().c_str(), current_size); // Successfully added this service, increment counter this->send_service_++; From dd7441e104b8b24ba47bb67e8efa4805d64cf0b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 11:25:48 -1000 Subject: [PATCH 0189/1626] Update esphome/components/bluetooth_proxy/bluetooth_connection.cpp --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index c1932e79e6..2f350d74fc 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -303,7 +303,7 @@ void BluetoothConnection::send_service_for_discovery_() { } // Send the message with dynamically batched services - ESP_LOGD(TAG, "[%d] [%s] Sending batch with %d services, total size %d", this->connection_index_, + ESP_LOGV(TAG, "[%d] [%s] Sending batch with %d services, total size %d", this->connection_index_, this->address_str().c_str(), resp.services.size(), current_size); api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); } From 8729ba17a04982c31985c14864575ef423c84eaf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 11:37:17 -1000 Subject: [PATCH 0190/1626] preen --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 2f350d74fc..dee5977e5f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -294,9 +294,9 @@ void BluetoothConnection::send_service_for_discovery_() { current_size += service_size; // Log the difference between estimate and actual size - int size_diff = (int) service_size - (int) estimated_size; ESP_LOGV(TAG, "[%d] [%s] Service %d actual: %d, estimated: %d, diff: %+d", this->connection_index_, - this->address_str().c_str(), this->send_service_, service_size, estimated_size, size_diff); + this->address_str().c_str(), this->send_service_, service_size, estimated_size, + (int) service_size - (int) estimated_size); // Successfully added this service, increment counter this->send_service_++; From 27861d85fed192329091bf9a981ccfb7cd4cd7b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 11:40:04 -1000 Subject: [PATCH 0191/1626] preen --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index dee5977e5f..d5521b081f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -293,11 +293,6 @@ void BluetoothConnection::send_service_for_discovery_() { // Now we know we're keeping this service, add its size current_size += service_size; - // Log the difference between estimate and actual size - ESP_LOGV(TAG, "[%d] [%s] Service %d actual: %d, estimated: %d, diff: %+d", this->connection_index_, - this->address_str().c_str(), this->send_service_, service_size, estimated_size, - (int) service_size - (int) estimated_size); - // Successfully added this service, increment counter this->send_service_++; } From c10330b89082b131bd61b7e8f0f7f7f1704fd39e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 11:40:28 -1000 Subject: [PATCH 0192/1626] preen --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index d5521b081f..dfff020338 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -298,8 +298,6 @@ void BluetoothConnection::send_service_for_discovery_() { } // Send the message with dynamically batched services - ESP_LOGV(TAG, "[%d] [%s] Sending batch with %d services, total size %d", this->connection_index_, - this->address_str().c_str(), resp.services.size(), current_size); api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); } From 6ac8c47b6ebd712be7a3424bd850a90c7a7b029b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 11:41:35 -1000 Subject: [PATCH 0193/1626] preen --- .../bluetooth_proxy/bluetooth_connection.cpp | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index dfff020338..bee157ebfa 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -129,16 +129,14 @@ void BluetoothConnection::send_service_for_discovery_() { resp.address = this->address_; // Dynamic batching based on actual size - static constexpr size_t MAX_PACKET_SIZE = - 1360; // Conservative MTU limit for API messages (accounts for WPA3 overhead) + // Conservative MTU limit for API messages (accounts for WPA3 overhead) + static constexpr size_t MAX_PACKET_SIZE = 1360; // Keep running total of actual message size size_t current_size = 0; api::ProtoSize size; resp.calculate_size(size); current_size = size.get_size(); - ESP_LOGV(TAG, "[%d] [%s] Starting batch with base size: %d, send_service_: %d", this->connection_index_, - this->address_str().c_str(), current_size, this->send_service_); while (this->send_service_ < this->service_count_) { esp_gattc_service_elem_t service_result; @@ -182,10 +180,6 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.handle = service_result.start_handle; - ESP_LOGV(TAG, "[%d] [%s] Service UUID: %llx,%llx short:%u handle:%u", this->connection_index_, - this->address_str().c_str(), service_resp.uuid[0], service_resp.uuid[1], service_resp.short_uuid, - service_resp.handle); - if (total_char_count > 0) { // Reserve space and process characteristics service_resp.characteristics.reserve(total_char_count); @@ -292,7 +286,6 @@ void BluetoothConnection::send_service_for_discovery_() { // Now we know we're keeping this service, add its size current_size += service_size; - // Successfully added this service, increment counter this->send_service_++; } From 854e29161b94f8f489f22a3a0d07f7184958aa73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 11:43:49 -1000 Subject: [PATCH 0194/1626] only needed once --- .../bluetooth_proxy/bluetooth_connection.cpp | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index bee157ebfa..a4cf2d8481 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -56,16 +56,6 @@ static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuid return service_overhead + (char_size + desc_size) * char_count; } -// Helper to calculate actual service size -static size_t get_service_size(api::BluetoothGATTService &service) { - api::ProtoSize service_size; - service.calculate_size(service_size); - size_t size = service_size.get_size(); - ESP_LOGV(TAG, "Service size calculation: uuid[0]=%llx uuid[1]=%llx short_uuid=%u handle=%u -> size=%d", - service.uuid[0], service.uuid[1], service.short_uuid, service.handle, size); - return size; -} - bool BluetoothConnection::supports_efficient_uuids_() const { auto *api_conn = this->proxy_->get_api_connection(); return api_conn && api_conn->client_supports_api_version(1, 12); @@ -261,7 +251,9 @@ void BluetoothConnection::send_service_for_discovery_() { } // end if (total_char_count > 0) // Calculate the actual size of just this service - size_t service_size = get_service_size(service_resp) + 1; // +1 for field tag + api::ProtoSize service_sizer; + service.calculate_size(service_sizer); + size_t service_size = service_sizer.get_size() + 1; // +1 for field tag // Check if adding this service would exceed the limit if (current_size + service_size > MAX_PACKET_SIZE) { From 30b687ccbb9c2b667af87ca0cc932ac8e297183a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 11:45:12 -1000 Subject: [PATCH 0195/1626] fix name --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index a4cf2d8481..8b5190a6bb 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -252,7 +252,7 @@ void BluetoothConnection::send_service_for_discovery_() { // Calculate the actual size of just this service api::ProtoSize service_sizer; - service.calculate_size(service_sizer); + service_resp.calculate_size(service_sizer); size_t service_size = service_sizer.get_size() + 1; // +1 for field tag // Check if adding this service would exceed the limit From 69d33cdd3d496c535f355eb47e9d50f2b5120e81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 11:51:24 -1000 Subject: [PATCH 0196/1626] preen --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 8b5190a6bb..c19eb6aa6f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -266,7 +266,6 @@ void BluetoothConnection::send_service_for_discovery_() { // Don't increment send_service_ - we'll retry this service in next batch } else { // This single service is too large, but we have to send it anyway - current_size += service_size; ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, this->address_str().c_str(), this->send_service_, service_size); // Increment so we don't get stuck From da0c47629a6644d0044e9b6cd1397e1295d34747 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:58:57 -0400 Subject: [PATCH 0197/1626] [esp32] Bump ESP32 platform to 54.03.21-2 (#10000) --- .clang-tidy.hash | 2 +- esphome/components/esp32/__init__.py | 4 ++-- platformio.ini | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 5dd779effb..30cf982649 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -f84518ea4140c194b21cc516aae05aaa0cf876ab866f89e22e91842df46333ed +6af8b429b94191fe8e239fcb3b73f7982d0266cb5b05ffbc81edaeac1bc8c273 diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 4ab85a55cd..b183f10d72 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -313,7 +313,7 @@ def _format_framework_espidf_version( RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 2, 1) # The platform-espressif32 version to use for arduino frameworks # - https://github.com/pioarduino/platform-espressif32/releases -ARDUINO_PLATFORM_VERSION = cv.Version(54, 3, 21, "1") +ARDUINO_PLATFORM_VERSION = cv.Version(54, 3, 21, "2") # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases @@ -322,7 +322,7 @@ RECOMMENDED_ESP_IDF_FRAMEWORK_VERSION = cv.Version(5, 4, 2) # The platformio/espressif32 version to use for esp-idf frameworks # - https://github.com/platformio/platform-espressif32/releases # - https://api.registry.platformio.org/v3/packages/platformio/platform/espressif32 -ESP_IDF_PLATFORM_VERSION = cv.Version(54, 3, 21, "1") +ESP_IDF_PLATFORM_VERSION = cv.Version(54, 3, 21, "2") # List based on https://registry.platformio.org/tools/platformio/framework-espidf/versions SUPPORTED_PLATFORMIO_ESP_IDF_5X = [ diff --git a/platformio.ini b/platformio.ini index ab0774b29f..d9f2f879ec 100644 --- a/platformio.ini +++ b/platformio.ini @@ -125,7 +125,7 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-1/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-2/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.2.1/esp32-3.2.1.zip @@ -161,7 +161,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-1/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-2/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.4.2/esp-idf-v5.4.2.zip From 161f51e1f44a008d07e0ac98490f55d1f75165f0 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:48:25 +1000 Subject: [PATCH 0198/1626] [esp32] Fix strapping pin validation for P4 and H2 (#9980) --- esphome/components/esp32/gpio_esp32_h2.py | 9 +---- esphome/components/esp32/gpio_esp32_p4.py | 9 +---- tests/component_tests/mipi_spi/conftest.py | 43 +++++++++++++++++++++ tests/component_tests/mipi_spi/test_init.py | 29 +------------- 4 files changed, 48 insertions(+), 42 deletions(-) create mode 100644 tests/component_tests/mipi_spi/conftest.py diff --git a/esphome/components/esp32/gpio_esp32_h2.py b/esphome/components/esp32/gpio_esp32_h2.py index 7c3a658b17..f37297764b 100644 --- a/esphome/components/esp32/gpio_esp32_h2.py +++ b/esphome/components/esp32/gpio_esp32_h2.py @@ -2,6 +2,7 @@ import logging import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin _ESP32H2_SPI_FLASH_PINS = {6, 7, 15, 16, 17, 18, 19, 20, 21} @@ -15,13 +16,6 @@ _LOGGER = logging.getLogger(__name__) def esp32_h2_validate_gpio_pin(value): if value < 0 or value > 27: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-27)") - if value in _ESP32H2_STRAPPING_PINS: - _LOGGER.warning( - "GPIO%d is a Strapping PIN and should be avoided.\n" - "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" - "See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins", - value, - ) if value in _ESP32H2_SPI_FLASH_PINS: _LOGGER.warning( "GPIO%d is reserved for SPI Flash communication on some ESP32-H2 chip variants.\n" @@ -49,4 +43,5 @@ def esp32_h2_validate_supports(value): if is_input: # All ESP32 pins support input mode pass + check_strapping_pin(value, _ESP32H2_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py index 650d06e108..34d1b3139d 100644 --- a/esphome/components/esp32/gpio_esp32_p4.py +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -2,6 +2,7 @@ import logging import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin _ESP32P4_USB_JTAG_PINS = {24, 25} @@ -13,13 +14,6 @@ _LOGGER = logging.getLogger(__name__) def esp32_p4_validate_gpio_pin(value): if value < 0 or value > 54: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)") - if value in _ESP32P4_STRAPPING_PINS: - _LOGGER.warning( - "GPIO%d is a Strapping PIN and should be avoided.\n" - "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" - "See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins", - value, - ) if value in _ESP32P4_USB_JTAG_PINS: _LOGGER.warning( "GPIO%d is reserved for the USB-Serial-JTAG interface.\n" @@ -40,4 +34,5 @@ def esp32_p4_validate_supports(value): if is_input: # All ESP32 pins support input mode pass + check_strapping_pin(value, _ESP32P4_STRAPPING_PINS, _LOGGER) return value diff --git a/tests/component_tests/mipi_spi/conftest.py b/tests/component_tests/mipi_spi/conftest.py new file mode 100644 index 0000000000..c3070c7965 --- /dev/null +++ b/tests/component_tests/mipi_spi/conftest.py @@ -0,0 +1,43 @@ +"""Tests for mpip_spi configuration validation.""" + +from collections.abc import Callable, Generator + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS +from esphome.components.esp32.gpio import validate_gpio_pin +from esphome.const import CONF_INPUT, CONF_OUTPUT +from esphome.core import CORE +from esphome.pins import gpio_pin_schema + + +@pytest.fixture +def choose_variant_with_pins() -> Generator[Callable[[list], None]]: + """ + Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms + do not have variants. + """ + + def chooser(pins: list) -> None: + for v in VARIANTS: + try: + CORE.data[KEY_ESP32][KEY_VARIANT] = v + for pin in pins: + if pin is not None: + pin = gpio_pin_schema( + { + CONF_INPUT: True, + CONF_OUTPUT: True, + }, + internal=True, + )(pin) + validate_gpio_pin(pin) + return + except cv.Invalid: + continue + raise cv.Invalid( + f"No compatible variant found for pins: {', '.join(map(str, pins))}" + ) + + yield chooser diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index c4c93866ca..fbb3222812 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -9,13 +9,10 @@ import pytest from esphome import config_validation as cv from esphome.components.esp32 import ( KEY_BOARD, - KEY_ESP32, KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32S3, - VARIANTS, ) -from esphome.components.esp32.gpio import validate_gpio_pin from esphome.components.mipi import CONF_NATIVE_HEIGHT from esphome.components.mipi_spi.display import ( CONF_BUS_MODE, @@ -32,8 +29,6 @@ from esphome.const import ( CONF_WIDTH, PlatformFramework, ) -from esphome.core import CORE -from esphome.pins import internal_gpio_pin_number from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -43,28 +38,6 @@ def run_schema_validation(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) -@pytest.fixture -def choose_variant_with_pins() -> Callable[..., None]: - """ - Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms - do not have variants. - """ - - def chooser(*pins: int | str | None) -> None: - for v in VARIANTS: - try: - CORE.data[KEY_ESP32][KEY_VARIANT] = v - for pin in pins: - if pin is not None: - pin = internal_gpio_pin_number(pin) - validate_gpio_pin(pin) - return - except cv.Invalid: - continue - - return chooser - - @pytest.mark.parametrize( ("config", "error_match"), [ @@ -315,7 +288,7 @@ def test_custom_model_with_all_options( def test_all_predefined_models( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], - choose_variant_with_pins: Callable[..., None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test all predefined display models validate successfully with appropriate defaults.""" set_core_config( From d4ff1bcf5c48ab109f4e8076421a59acfc4f6399 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 14:15:12 -1000 Subject: [PATCH 0199/1626] [bluetooth_proxy] Implement dynamic service batching based on MTU constraints (#10001) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../bluetooth_proxy/bluetooth_connection.cpp | 234 ++++++++++++------ .../bluetooth_proxy/bluetooth_proxy.h | 1 - 2 files changed, 152 insertions(+), 83 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 4f312fce30..fd1324dcdc 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -37,6 +37,37 @@ static void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uu } } +// Constants for size estimation +static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4) +static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) +static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t DESC_SIZE_16BIT = 10; // UUID(6) + handle(4) +static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic + +// Helper to estimate service size before fetching all data +/** + * Estimate the size of a Bluetooth service based on the number of characteristics and UUID format. + * + * @param char_count The number of characteristics in the service. + * @param use_efficient_uuids Whether to use efficient UUIDs (16-bit or 32-bit) for newer APIVersions. + * @return The estimated size of the service in bytes. + * + * This function calculates the size of a Bluetooth service by considering: + * - A service overhead, which depends on whether efficient UUIDs are used. + * - The size of each characteristic, assuming 128-bit UUIDs for safety. + * - The size of descriptors, assuming one 128-bit descriptor per characteristic. + */ +static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { + size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; + // Always assume 128-bit UUIDs for characteristics to be safe + size_t char_size = CHAR_SIZE_128BIT; + // Assume one 128-bit descriptor per characteristic + size_t desc_size = DESC_SIZE_128BIT * DESC_PER_CHAR; + + return service_overhead + (char_size + desc_size) * char_count; +} + bool BluetoothConnection::supports_efficient_uuids_() const { auto *api_conn = this->proxy_->get_api_connection(); return api_conn && api_conn->client_supports_api_version(1, 12); @@ -95,16 +126,21 @@ void BluetoothConnection::send_service_for_discovery_() { // Check if client supports efficient UUIDs bool use_efficient_uuids = this->supports_efficient_uuids_(); - // Prepare response for up to 3 services + // Prepare response api::BluetoothGATTGetServicesResponse resp; resp.address = this->address_; - // Process up to 3 services in this iteration - uint8_t services_to_process = - std::min(MAX_SERVICES_PER_BATCH, static_cast(this->service_count_ - this->send_service_)); - resp.services.reserve(services_to_process); + // Dynamic batching based on actual size + // Conservative MTU limit for API messages (accounts for WPA3 overhead) + static constexpr size_t MAX_PACKET_SIZE = 1360; - for (int service_idx = 0; service_idx < services_to_process; service_idx++) { + // Keep running total of actual message size + size_t current_size = 0; + api::ProtoSize size; + resp.calculate_size(size); + current_size = size.get_size(); + + while (this->send_service_ < this->service_count_) { esp_gattc_service_elem_t service_result; uint16_t service_count = 1; esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, @@ -118,15 +154,7 @@ void BluetoothConnection::send_service_for_discovery_() { return; } - this->send_service_++; - resp.services.emplace_back(); - auto &service_resp = resp.services.back(); - - fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); - - service_resp.handle = service_result.start_handle; - - // Get the number of characteristics directly with one call + // Get the number of characteristics BEFORE adding to response uint16_t total_char_count = 0; esp_gatt_status_t char_count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, @@ -139,91 +167,133 @@ void BluetoothConnection::send_service_for_discovery_() { return; } - if (total_char_count == 0) { - // No characteristics, continue to next service - continue; + // If this service likely won't fit, send current batch (unless it's the first) + size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids); + if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { + // This service likely won't fit, send current batch + break; } - // Reserve space and process characteristics - service_resp.characteristics.reserve(total_char_count); - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - while (true) { // characteristics - uint16_t char_count = 1; - esp_gatt_status_t char_status = - esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, - service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { - break; - } - if (char_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, - this->address_str().c_str(), char_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (char_count == 0) { - break; - } + // Now add the service since we know it will likely fit + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); - fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); + service_resp.handle = service_result.start_handle; - characteristic_resp.handle = char_result.char_handle; - characteristic_resp.properties = char_result.properties; - char_offset++; - - // Get the number of descriptors directly with one call - uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( - this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); - - if (desc_count_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, - this->address_str().c_str(), char_result.char_handle, desc_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (total_desc_count == 0) { - // No descriptors, continue to next characteristic - continue; - } - - // Reserve space and process descriptors - characteristic_resp.descriptors.reserve(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + if (total_char_count > 0) { + // Reserve space and process characteristics + service_resp.characteristics.reserve(total_char_count); + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + while (true) { // characteristics + uint16_t char_count = 1; + esp_gatt_status_t char_status = + esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &char_count, char_offset); + if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { break; } - if (desc_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, - this->address_str().c_str(), desc_status); + if (char_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, + this->address_str().c_str(), char_status); this->send_service_ = DONE_SENDING_SERVICES; return; } - if (desc_count == 0) { - break; // No more descriptors + if (char_count == 0) { + break; } - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); - fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); - descriptor_resp.handle = desc_result.handle; - desc_offset++; + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + char_offset++; + + // Get the number of descriptors directly with one call + uint16_t total_desc_count = 0; + esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( + this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); + + if (desc_count_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", + this->connection_index_, this->address_str().c_str(), char_result.char_handle, desc_count_status); + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (total_desc_count == 0) { + // No descriptors, continue to next characteristic + continue; + } + + // Reserve space and process descriptors + characteristic_resp.descriptors.reserve(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (true) { // descriptors + uint16_t desc_count = 1; + esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( + this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + break; + } + if (desc_status != ESP_GATT_OK) { + ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, + this->address_str().c_str(), desc_status); + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (desc_count == 0) { + break; // No more descriptors + } + + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); + + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } } + } // end if (total_char_count > 0) + + // Calculate the actual size of just this service + api::ProtoSize service_sizer; + service_resp.calculate_size(service_sizer); + size_t service_size = service_sizer.get_size() + 1; // +1 for field tag + + // Check if adding this service would exceed the limit + if (current_size + service_size > MAX_PACKET_SIZE) { + // We would go over - pop the last service if we have more than one + if (resp.services.size() > 1) { + resp.services.pop_back(); + ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %d + service: %d > %d), sending current batch", + this->connection_index_, this->address_str().c_str(), this->send_service_, current_size, service_size, + MAX_PACKET_SIZE); + // Don't increment send_service_ - we'll retry this service in next batch + } else { + // This single service is too large, but we have to send it anyway + ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, + this->address_str().c_str(), this->send_service_, service_size); + // Increment so we don't get stuck + this->send_service_++; + } + // Send what we have + break; } + + // Now we know we're keeping this service, add its size + current_size += service_size; + // Successfully added this service, increment counter + this->send_service_++; } - // Send the message with 1-3 services + // Send the message with dynamically batched services api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index b33460339b..d249515fdf 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -22,7 +22,6 @@ namespace esphome::bluetooth_proxy { static const esp_err_t ESP_GATT_NOT_CONNECTED = -1; static const int DONE_SENDING_SERVICES = -2; -static const uint8_t MAX_SERVICES_PER_BATCH = 3; using namespace esp32_ble_client; From 412f4ac341bd047d958849beb76e608534bfa105 Mon Sep 17 00:00:00 2001 From: Djordje Mandic <6750655+DjordjeMandic@users.noreply.github.com> Date: Fri, 1 Aug 2025 02:28:22 +0200 Subject: [PATCH 0200/1626] [midea] Use c++17 constexpr and inline static in IrFollowMeData (#10002) --- esphome/components/midea/ir_transmitter.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/midea/ir_transmitter.h b/esphome/components/midea/ir_transmitter.h index eba8fc87f7..a16aed2e72 100644 --- a/esphome/components/midea/ir_transmitter.h +++ b/esphome/components/midea/ir_transmitter.h @@ -54,15 +54,15 @@ class IrFollowMeData : public IrData { void set_fahrenheit(bool val) { this->set_mask_(2, val, 32); } protected: - static const uint8_t MIN_TEMP_C = 0; - static const uint8_t MAX_TEMP_C = 37; + inline static constexpr uint8_t MIN_TEMP_C = 0; + inline static constexpr uint8_t MAX_TEMP_C = 37; // see // https://github.com/crankyoldgit/IRremoteESP8266/blob/9bdf8abcb465268c5409db99dc83a26df64c7445/src/ir_Midea.h#L116 - static const uint8_t MIN_TEMP_F = 32; + inline static constexpr uint8_t MIN_TEMP_F = 32; // see // https://github.com/crankyoldgit/IRremoteESP8266/blob/9bdf8abcb465268c5409db99dc83a26df64c7445/src/ir_Midea.h#L117 - static const uint8_t MAX_TEMP_F = 99; + inline static constexpr uint8_t MAX_TEMP_F = 99; }; class IrSpecialData : public IrData { From 549b0d12b6f2b6701a3f7dc759da663fc07a291a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 1 Aug 2025 11:19:32 +1000 Subject: [PATCH 0201/1626] [image] Improve schemas (#9791) --- esphome/components/animation/__init__.py | 25 ++- esphome/components/image/__init__.py | 205 ++++++++++-------- .../image/config/image_test.yaml | 8 +- tests/component_tests/image/test_init.py | 62 +++++- 4 files changed, 196 insertions(+), 104 deletions(-) diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index f73b8ef08f..c4ac7adb23 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -34,17 +34,20 @@ SetFrameAction = animation_ns.class_( "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) ) -CONFIG_SCHEMA = espImage.IMAGE_SCHEMA.extend( - { - cv.Required(CONF_ID): cv.declare_id(Animation_), - cv.Optional(CONF_LOOP): cv.All( - { - cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, - cv.Optional(CONF_END_FRAME): cv.positive_int, - cv.Optional(CONF_REPEAT): cv.positive_int, - } - ), - }, +CONFIG_SCHEMA = cv.All( + espImage.IMAGE_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(Animation_), + cv.Optional(CONF_LOOP): cv.All( + { + cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, + cv.Optional(CONF_END_FRAME): cv.positive_int, + cv.Optional(CONF_REPEAT): cv.positive_int, + } + ), + }, + ), + espImage.validate_settings, ) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 99646c9f7e..f880b5f736 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -108,6 +108,24 @@ class ImageEncoder: :return: """ + @classmethod + def is_endian(cls) -> bool: + """ + Check if the image encoder supports endianness configuration + """ + return getattr(cls, "set_big_endian", None) is not None + + @classmethod + def get_options(cls) -> list[str]: + """ + Get the available options for this image encoder + """ + options = [*OPTIONS] + if not cls.is_endian(): + options.remove(CONF_BYTE_ORDER) + options.append(CONF_RAW_DATA_ID) + return options + def is_alpha_only(image: Image): """ @@ -446,13 +464,14 @@ def validate_type(image_types): return validate -def validate_settings(value): +def validate_settings(value, path=()): """ Validate the settings for a single image configuration. """ conf_type = value[CONF_TYPE] type_class = IMAGE_TYPE[conf_type] - transparency = value[CONF_TRANSPARENCY].lower() + + transparency = value.get(CONF_TRANSPARENCY, CONF_OPAQUE).lower() if transparency not in type_class.allow_config: raise cv.Invalid( f"Image format '{conf_type}' cannot have transparency: {transparency}" @@ -464,11 +483,10 @@ def validate_settings(value): and CONF_INVERT_ALPHA not in type_class.allow_config ): raise cv.Invalid("No alpha channel to invert") - if value.get(CONF_BYTE_ORDER) is not None and not callable( - getattr(type_class, "set_big_endian", None) - ): + if value.get(CONF_BYTE_ORDER) is not None and not type_class.is_endian(): raise cv.Invalid( - f"Image format '{conf_type}' does not support byte order configuration" + f"Image format '{conf_type}' does not support byte order configuration", + path=path, ) if file := value.get(CONF_FILE): file = Path(file) @@ -479,7 +497,7 @@ def validate_settings(value): Image.open(file) except UnidentifiedImageError as exc: raise cv.Invalid( - f"File can't be opened as image: {file.absolute()}" + f"File can't be opened as image: {file.absolute()}", path=path ) from exc return value @@ -499,6 +517,10 @@ OPTIONS_SCHEMA = { cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), +} + +DEFAULTS_SCHEMA = { + **OPTIONS_SCHEMA, cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE), } @@ -510,47 +532,61 @@ IMAGE_SCHEMA_NO_DEFAULTS = { **{cv.Optional(key): OPTIONS_SCHEMA[key] for key in OPTIONS}, } -BASE_SCHEMA = cv.Schema( +IMAGE_SCHEMA = cv.Schema( { **IMAGE_ID_SCHEMA, **OPTIONS_SCHEMA, - } -).add_extra(validate_settings) - -IMAGE_SCHEMA = BASE_SCHEMA.extend( - { cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), } ) +def apply_defaults(image, defaults, path): + """ + Apply defaults to an image configuration + """ + type = image.get(CONF_TYPE, defaults.get(CONF_TYPE)) + if type is None: + raise cv.Invalid( + "Type is required either in the image config or in the defaults", path=path + ) + type_class = IMAGE_TYPE[type] + config = { + **{key: image.get(key, defaults.get(key)) for key in type_class.get_options()}, + **{key.schema: image[key.schema] for key in IMAGE_ID_SCHEMA}, + CONF_TYPE: image.get(CONF_TYPE, defaults.get(CONF_TYPE)), + } + validate_settings(config, path) + return config + + def validate_defaults(value): """ - Validate the options for images with defaults + Apply defaults to the images in the configuration and flatten to a single list. """ defaults = value[CONF_DEFAULTS] result = [] - for index, image in enumerate(value[CONF_IMAGES]): - type = image.get(CONF_TYPE, defaults.get(CONF_TYPE)) - if type is None: - raise cv.Invalid( - "Type is required either in the image config or in the defaults", - path=[CONF_IMAGES, index], - ) - type_class = IMAGE_TYPE[type] - # A default byte order should be simply ignored if the type does not support it - available_options = [*OPTIONS] - if ( - not callable(getattr(type_class, "set_big_endian", None)) - and CONF_BYTE_ORDER not in image - ): - available_options.remove(CONF_BYTE_ORDER) - config = { - **{key: image.get(key, defaults.get(key)) for key in available_options}, - **{key.schema: image[key.schema] for key in IMAGE_ID_SCHEMA}, - } - validate_settings(config) - result.append(config) + # Apply defaults to the images: list and add the list entries to the result + for index, image in enumerate(value.get(CONF_IMAGES, [])): + result.append(apply_defaults(image, defaults, [CONF_IMAGES, index])) + + # Apply defaults to images under the type keys and add them to the result + for image_type, type_config in value.items(): + type_upper = image_type.upper() + if type_upper not in IMAGE_TYPE: + continue + type_class = IMAGE_TYPE[type_upper] + if isinstance(type_config, list): + # If the type is a list, apply defaults to each entry + for index, image in enumerate(type_config): + result.append(apply_defaults(image, defaults, [image_type, index])) + else: + # Handle transparency options for the type + for trans_type in set(type_class.allow_config).intersection(type_config): + for index, image in enumerate(type_config[trans_type]): + result.append( + apply_defaults(image, defaults, [image_type, trans_type, index]) + ) return result @@ -562,16 +598,20 @@ def typed_image_schema(image_type): cv.Schema( { cv.Optional(t.lower()): cv.ensure_list( - BASE_SCHEMA.extend( - { - cv.Optional( - CONF_TRANSPARENCY, default=t - ): validate_transparency((t,)), - cv.Optional(CONF_TYPE, default=image_type): validate_type( - (image_type,) - ), - } - ) + { + **IMAGE_ID_SCHEMA, + **{ + cv.Optional(key): OPTIONS_SCHEMA[key] + for key in OPTIONS + if key != CONF_TRANSPARENCY + }, + cv.Optional( + CONF_TRANSPARENCY, default=t + ): validate_transparency((t,)), + cv.Optional(CONF_TYPE, default=image_type): validate_type( + (image_type,) + ), + } ) for t in IMAGE_TYPE[image_type].allow_config.intersection( TRANSPARENCY_TYPES @@ -580,46 +620,44 @@ def typed_image_schema(image_type): ), # Allow a default configuration with no transparency preselected cv.ensure_list( - BASE_SCHEMA.extend( - { - cv.Optional( - CONF_TRANSPARENCY, default=CONF_OPAQUE - ): validate_transparency(), - cv.Optional(CONF_TYPE, default=image_type): validate_type( - (image_type,) - ), - } - ) + { + **IMAGE_SCHEMA_NO_DEFAULTS, + cv.Optional(CONF_TYPE, default=image_type): validate_type( + (image_type,) + ), + } ), ) # The config schema can be a (possibly empty) single list of images, -# or a dictionary of image types each with a list of images -# or a dictionary with keys `defaults:` and `images:` +# or a dictionary with optional keys `defaults:`, `images:` and the image types -def _config_schema(config): - if isinstance(config, list): - return cv.Schema([IMAGE_SCHEMA])(config) - if not isinstance(config, dict): +def _config_schema(value): + if isinstance(value, list) or ( + isinstance(value, dict) and (CONF_ID in value or CONF_FILE in value) + ): + return cv.ensure_list(cv.All(IMAGE_SCHEMA, validate_settings))(value) + if not isinstance(value, dict): raise cv.Invalid( - "Badly formed image configuration, expected a list or a dictionary" + "Badly formed image configuration, expected a list or a dictionary", ) - if CONF_DEFAULTS in config or CONF_IMAGES in config: - return validate_defaults( - cv.Schema( - { - cv.Required(CONF_DEFAULTS): OPTIONS_SCHEMA, - cv.Required(CONF_IMAGES): cv.ensure_list(IMAGE_SCHEMA_NO_DEFAULTS), - } - )(config) - ) - if CONF_ID in config or CONF_FILE in config: - return cv.ensure_list(IMAGE_SCHEMA)([config]) - return cv.Schema( - {cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE} - )(config) + return cv.All( + cv.Schema( + { + cv.Optional(CONF_DEFAULTS, default={}): DEFAULTS_SCHEMA, + cv.Optional(CONF_IMAGES, default=[]): cv.ensure_list( + { + **IMAGE_SCHEMA_NO_DEFAULTS, + cv.Optional(CONF_TYPE): validate_type(IMAGE_TYPE), + } + ), + **{cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE}, + } + ), + validate_defaults, + )(value) CONFIG_SCHEMA = _config_schema @@ -668,7 +706,7 @@ async def write_image(config, all_frames=False): else Image.Dither.FLOYDSTEINBERG ) type = config[CONF_TYPE] - transparency = config[CONF_TRANSPARENCY] + transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE) invert_alpha = config[CONF_INVERT_ALPHA] frame_count = 1 if all_frames: @@ -699,14 +737,9 @@ async def write_image(config, all_frames=False): async def to_code(config): - if isinstance(config, list): - for entry in config: - await to_code(entry) - elif CONF_ID not in config: - for entry in config.values(): - await to_code(entry) - else: - prog_arr, width, height, image_type, trans_value, _ = await write_image(config) + # By now the config should be a simple list. + for entry in config: + prog_arr, width, height, image_type, trans_value, _ = await write_image(entry) cg.new_Pvariable( - config[CONF_ID], prog_arr, width, height, image_type, trans_value + entry[CONF_ID], prog_arr, width, height, image_type, trans_value ) diff --git a/tests/component_tests/image/config/image_test.yaml b/tests/component_tests/image/config/image_test.yaml index 3ff1260bd0..c34e0993a5 100644 --- a/tests/component_tests/image/config/image_test.yaml +++ b/tests/component_tests/image/config/image_test.yaml @@ -5,10 +5,12 @@ esp32: board: esp32s3box image: - - file: image.png - byte_order: little_endian - id: cat_img + defaults: type: rgb565 + byte_order: little_endian + images: + - file: image.png + id: cat_img spi: mosi_pin: 6 diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index d8a883d32f..f0b132cef8 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -9,7 +9,8 @@ from typing import Any import pytest from esphome import config_validation as cv -from esphome.components.image import CONFIG_SCHEMA +from esphome.components.image import CONF_TRANSPARENCY, CONFIG_SCHEMA +from esphome.const import CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE @pytest.mark.parametrize( @@ -22,12 +23,12 @@ from esphome.components.image import CONFIG_SCHEMA ), pytest.param( {"id": "image_id", "type": "rgb565"}, - r"required key not provided @ data\[0\]\['file'\]", + r"required key not provided @ data\['file'\]", id="missing_file", ), pytest.param( {"file": "image.png", "type": "rgb565"}, - r"required key not provided @ data\[0\]\['id'\]", + r"required key not provided @ data\['id'\]", id="missing_id", ), pytest.param( @@ -160,13 +161,66 @@ def test_image_configuration_errors( }, id="type_based_organization", ), + pytest.param( + { + "defaults": { + "type": "binary", + "transparency": "chroma_key", + "byte_order": "little_endian", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + }, + "rgb565": { + "alpha_channel": [ + { + "id": "image_id", + "file": "image.png", + "transparency": "alpha_channel", + "dither": "none", + } + ] + }, + "binary": [ + { + "id": "image_id", + "file": "image.png", + "transparency": "opaque", + } + ], + }, + id="type_based_with_defaults", + ), + pytest.param( + { + "defaults": { + "type": "rgb565", + "transparency": "alpha_channel", + }, + "binary": { + "opaque": [ + { + "id": "image_id", + "file": "image.png", + } + ], + }, + }, + id="binary_with_defaults", + ), ], ) def test_image_configuration_success( config: dict[str, Any] | list[dict[str, Any]], ) -> None: """Test successful configuration validation.""" - CONFIG_SCHEMA(config) + result = CONFIG_SCHEMA(config) + # All valid configurations should return a list of images + assert isinstance(result, list) + for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID): + assert all(key in x for x in result), ( + f"Missing key {key} in image configuration" + ) def test_image_generation( From 7a4738ec4ec1d75fa989fb1e417ea41d61033651 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 1 Aug 2025 03:49:39 +0200 Subject: [PATCH 0202/1626] [nrf52] add adc (#9321) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/adc/__init__.py | 6 + esphome/components/adc/adc_sensor.h | 23 +- esphome/components/adc/adc_sensor_common.cpp | 14 +- esphome/components/adc/adc_sensor_esp32.cpp | 2 +- esphome/components/adc/adc_sensor_esp8266.cpp | 2 +- .../components/adc/adc_sensor_libretiny.cpp | 2 +- esphome/components/adc/adc_sensor_rp2040.cpp | 2 +- esphome/components/adc/adc_sensor_zephyr.cpp | 207 ++++++++++++++++++ esphome/components/adc/sensor.py | 54 ++++- esphome/components/nrf52/const.py | 14 ++ esphome/components/nrf52/gpio.py | 30 ++- esphome/components/zephyr/__init__.py | 25 ++- esphome/components/zephyr/const.py | 2 + script/helpers_zephyr.py | 1 + tests/components/adc/test.nrf52-adafruit.yaml | 23 ++ tests/components/adc/test.nrf52-mcumgr.yaml | 23 ++ 16 files changed, 412 insertions(+), 18 deletions(-) create mode 100644 esphome/components/adc/adc_sensor_zephyr.cpp create mode 100644 tests/components/adc/test.nrf52-adafruit.yaml create mode 100644 tests/components/adc/test.nrf52-mcumgr.yaml diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 1232d9677f..f260e13242 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -267,6 +267,11 @@ def validate_adc_pin(value): {CONF_ANALOG: True, CONF_INPUT: True}, internal=True )(value) + if CORE.is_nrf52: + return pins.gpio_pin_schema( + {CONF_ANALOG: True, CONF_INPUT: True}, internal=True + )(value) + raise NotImplementedError @@ -283,5 +288,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "adc_sensor_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 00a703191e..526dd57fd5 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -13,6 +13,10 @@ #include "hal/adc_types.h" // This defines ADC_CHANNEL_MAX #endif // USE_ESP32 +#ifdef USE_ZEPHYR +#include +#endif + namespace esphome { namespace adc { @@ -38,15 +42,15 @@ enum class SamplingMode : uint8_t { const LogString *sampling_mode_to_str(SamplingMode mode); -class Aggregator { +template class Aggregator { public: Aggregator(SamplingMode mode); - void add_sample(uint32_t value); - uint32_t aggregate(); + void add_sample(T value); + T aggregate(); protected: - uint32_t aggr_{0}; - uint32_t samples_{0}; + T aggr_{0}; + uint8_t samples_{0}; SamplingMode mode_{SamplingMode::AVG}; }; @@ -69,6 +73,11 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage /// @return A float representing the setup priority. float get_setup_priority() const override; +#ifdef USE_ZEPHYR + /// Set the ADC channel to be used by the ADC sensor. + /// @param channel Pointer to an adc_dt_spec structure representing the ADC channel. + void set_adc_channel(const adc_dt_spec *channel) { this->channel_ = channel; } +#endif /// Set the GPIO pin to be used by the ADC sensor. /// @param pin Pointer to an InternalGPIOPin representing the ADC input pin. void set_pin(InternalGPIOPin *pin) { this->pin_ = pin; } @@ -151,6 +160,10 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage #ifdef USE_RP2040 bool is_temperature_{false}; #endif // USE_RP2040 + +#ifdef USE_ZEPHYR + const struct adc_dt_spec *channel_ = nullptr; +#endif }; } // namespace adc diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 797ab75045..748c8634b7 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -18,15 +18,15 @@ const LogString *sampling_mode_to_str(SamplingMode mode) { return LOG_STR("unknown"); } -Aggregator::Aggregator(SamplingMode mode) { +template Aggregator::Aggregator(SamplingMode mode) { this->mode_ = mode; // set to max uint if mode is "min" if (mode == SamplingMode::MIN) { - this->aggr_ = UINT32_MAX; + this->aggr_ = std::numeric_limits::max(); } } -void Aggregator::add_sample(uint32_t value) { +template void Aggregator::add_sample(T value) { this->samples_ += 1; switch (this->mode_) { @@ -47,7 +47,7 @@ void Aggregator::add_sample(uint32_t value) { } } -uint32_t Aggregator::aggregate() { +template T Aggregator::aggregate() { if (this->mode_ == SamplingMode::AVG) { if (this->samples_ == 0) { return this->aggr_; @@ -59,6 +59,12 @@ uint32_t Aggregator::aggregate() { return this->aggr_; } +#ifdef USE_ZEPHYR +template class Aggregator; +#else +template class Aggregator; +#endif + void ADCSensor::update() { float value_v = this->sample(); ESP_LOGV(TAG, "'%s': Voltage=%.4fV", this->get_name().c_str(), value_v); diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index 9905475b1e..87d4ddd35f 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -152,7 +152,7 @@ float ADCSensor::sample() { } float ADCSensor::sample_fixed_attenuation_() { - auto aggr = Aggregator(this->sampling_mode_); + auto aggr = Aggregator(this->sampling_mode_); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { int raw; diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp index 1b4b314570..be14b252d4 100644 --- a/esphome/components/adc/adc_sensor_esp8266.cpp +++ b/esphome/components/adc/adc_sensor_esp8266.cpp @@ -37,7 +37,7 @@ void ADCSensor::dump_config() { } float ADCSensor::sample() { - auto aggr = Aggregator(this->sampling_mode_); + auto aggr = Aggregator(this->sampling_mode_); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { uint32_t raw = 0; diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp index e4fd4e5d4d..0b1393c2e7 100644 --- a/esphome/components/adc/adc_sensor_libretiny.cpp +++ b/esphome/components/adc/adc_sensor_libretiny.cpp @@ -30,7 +30,7 @@ void ADCSensor::dump_config() { float ADCSensor::sample() { uint32_t raw = 0; - auto aggr = Aggregator(this->sampling_mode_); + auto aggr = Aggregator(this->sampling_mode_); if (this->output_raw_) { for (uint8_t sample = 0; sample < this->sample_count_; sample++) { diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index 90c640a0b1..8496e0f41e 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -41,7 +41,7 @@ void ADCSensor::dump_config() { float ADCSensor::sample() { uint32_t raw = 0; - auto aggr = Aggregator(this->sampling_mode_); + auto aggr = Aggregator(this->sampling_mode_); if (this->is_temperature_) { adc_set_temp_sensor_enabled(true); diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp new file mode 100644 index 0000000000..2fb9d4b0e5 --- /dev/null +++ b/esphome/components/adc/adc_sensor_zephyr.cpp @@ -0,0 +1,207 @@ + +#include "adc_sensor.h" +#ifdef USE_ZEPHYR +#include "esphome/core/log.h" + +#include "hal/nrf_saadc.h" + +namespace esphome { +namespace adc { + +static const char *const TAG = "adc.zephyr"; + +void ADCSensor::setup() { + if (!adc_is_ready_dt(this->channel_)) { + ESP_LOGE(TAG, "ADC controller device %s not ready", this->channel_->dev->name); + return; + } + + auto err = adc_channel_setup_dt(this->channel_); + if (err < 0) { + ESP_LOGE(TAG, "Could not setup channel %s (%d)", this->channel_->dev->name, err); + return; + } +} + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE +static const LogString *gain_to_str(enum adc_gain gain) { + switch (gain) { + case ADC_GAIN_1_6: + return LOG_STR("1/6"); + case ADC_GAIN_1_5: + return LOG_STR("1/5"); + case ADC_GAIN_1_4: + return LOG_STR("1/4"); + case ADC_GAIN_1_3: + return LOG_STR("1/3"); + case ADC_GAIN_2_5: + return LOG_STR("2/5"); + case ADC_GAIN_1_2: + return LOG_STR("1/2"); + case ADC_GAIN_2_3: + return LOG_STR("2/3"); + case ADC_GAIN_4_5: + return LOG_STR("4/5"); + case ADC_GAIN_1: + return LOG_STR("1"); + case ADC_GAIN_2: + return LOG_STR("2"); + case ADC_GAIN_3: + return LOG_STR("3"); + case ADC_GAIN_4: + return LOG_STR("4"); + case ADC_GAIN_6: + return LOG_STR("6"); + case ADC_GAIN_8: + return LOG_STR("8"); + case ADC_GAIN_12: + return LOG_STR("12"); + case ADC_GAIN_16: + return LOG_STR("16"); + case ADC_GAIN_24: + return LOG_STR("24"); + case ADC_GAIN_32: + return LOG_STR("32"); + case ADC_GAIN_64: + return LOG_STR("64"); + case ADC_GAIN_128: + return LOG_STR("128"); + } + return LOG_STR("undefined gain"); +} + +static const LogString *reference_to_str(enum adc_reference reference) { + switch (reference) { + case ADC_REF_VDD_1: + return LOG_STR("VDD"); + case ADC_REF_VDD_1_2: + return LOG_STR("VDD/2"); + case ADC_REF_VDD_1_3: + return LOG_STR("VDD/3"); + case ADC_REF_VDD_1_4: + return LOG_STR("VDD/4"); + case ADC_REF_INTERNAL: + return LOG_STR("INTERNAL"); + case ADC_REF_EXTERNAL0: + return LOG_STR("External, input 0"); + case ADC_REF_EXTERNAL1: + return LOG_STR("External, input 1"); + } + return LOG_STR("undefined reference"); +} + +static const LogString *input_to_str(uint8_t input) { + switch (input) { + case NRF_SAADC_INPUT_AIN0: + return LOG_STR("AIN0"); + case NRF_SAADC_INPUT_AIN1: + return LOG_STR("AIN1"); + case NRF_SAADC_INPUT_AIN2: + return LOG_STR("AIN2"); + case NRF_SAADC_INPUT_AIN3: + return LOG_STR("AIN3"); + case NRF_SAADC_INPUT_AIN4: + return LOG_STR("AIN4"); + case NRF_SAADC_INPUT_AIN5: + return LOG_STR("AIN5"); + case NRF_SAADC_INPUT_AIN6: + return LOG_STR("AIN6"); + case NRF_SAADC_INPUT_AIN7: + return LOG_STR("AIN7"); + case NRF_SAADC_INPUT_VDD: + return LOG_STR("VDD"); + case NRF_SAADC_INPUT_VDDHDIV5: + return LOG_STR("VDDHDIV5"); + } + return LOG_STR("undefined input"); +} +#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + +void ADCSensor::dump_config() { + LOG_SENSOR("", "ADC Sensor", this); + LOG_PIN(" Pin: ", this->pin_); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, + " Name: %s\n" + " Channel: %d\n" + " vref_mv: %d\n" + " Resolution %d\n" + " Oversampling %d", + this->channel_->dev->name, this->channel_->channel_id, this->channel_->vref_mv, this->channel_->resolution, + this->channel_->oversampling); + + ESP_LOGV(TAG, + " Gain: %s\n" + " reference: %s\n" + " acquisition_time: %d\n" + " differential %s", + LOG_STR_ARG(gain_to_str(this->channel_->channel_cfg.gain)), + LOG_STR_ARG(reference_to_str(this->channel_->channel_cfg.reference)), + this->channel_->channel_cfg.acquisition_time, YESNO(this->channel_->channel_cfg.differential)); + if (this->channel_->channel_cfg.differential) { + ESP_LOGV(TAG, + " Positive: %s\n" + " Negative: %s", + LOG_STR_ARG(input_to_str(this->channel_->channel_cfg.input_positive)), + LOG_STR_ARG(input_to_str(this->channel_->channel_cfg.input_negative))); + } else { + ESP_LOGV(TAG, " Positive: %s", LOG_STR_ARG(input_to_str(this->channel_->channel_cfg.input_positive))); + } +#endif + + LOG_UPDATE_INTERVAL(this); +} + +float ADCSensor::sample() { + auto aggr = Aggregator(this->sampling_mode_); + int err; + for (uint8_t sample = 0; sample < this->sample_count_; sample++) { + int16_t buf = 0; + struct adc_sequence sequence = { + .buffer = &buf, + /* buffer size in bytes, not number of samples */ + .buffer_size = sizeof(buf), + }; + int32_t val_raw; + + err = adc_sequence_init_dt(this->channel_, &sequence); + if (err < 0) { + ESP_LOGE(TAG, "Could sequence init %s (%d)", this->channel_->dev->name, err); + return 0.0; + } + + err = adc_read(this->channel_->dev, &sequence); + if (err < 0) { + ESP_LOGE(TAG, "Could not read %s (%d)", this->channel_->dev->name, err); + return 0.0; + } + + val_raw = (int32_t) buf; + if (!this->channel_->channel_cfg.differential) { + // https://github.com/adafruit/Adafruit_nRF52_Arduino/blob/0ed4d9ffc674ae407be7cacf5696a02f5e789861/cores/nRF5/wiring_analog_nRF52.c#L222 + if (val_raw < 0) { + val_raw = 0; + } + } + aggr.add_sample(val_raw); + } + + int32_t val_mv = aggr.aggregate(); + + if (this->output_raw_) { + return val_mv; + } + + err = adc_raw_to_millivolts_dt(this->channel_, &val_mv); + /* conversion to mV may not be supported, skip if not */ + if (err < 0) { + ESP_LOGE(TAG, "Value in mV not available %s (%d)", this->channel_->dev->name, err); + return 0.0; + } + + return val_mv / 1000.0f; +} + +} // namespace adc +} // namespace esphome +#endif diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 01bbaeda15..49970c5e3d 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -3,6 +3,12 @@ import logging import esphome.codegen as cg from esphome.components import sensor, voltage_sampler from esphome.components.esp32 import get_esp32_variant +from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC +from esphome.components.zephyr import ( + zephyr_add_overlay, + zephyr_add_prj_conf, + zephyr_add_user, +) import esphome.config_validation as cv from esphome.const import ( CONF_ATTENUATION, @@ -11,6 +17,7 @@ from esphome.const import ( CONF_PIN, CONF_RAW, DEVICE_CLASS_VOLTAGE, + PLATFORM_NRF52, STATE_CLASS_MEASUREMENT, UNIT_VOLT, ) @@ -60,6 +67,10 @@ ADCSensor = adc_ns.class_( "ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler ) +CONF_NRF_SAADC = "nrf_saadc" + +adc_dt_spec = cg.global_ns.class_("adc_dt_spec") + CONFIG_SCHEMA = cv.All( sensor.sensor_schema( ADCSensor, @@ -75,6 +86,7 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault(CONF_ATTENUATION, esp32="0db"): cv.All( cv.only_on_esp32, _attenuation ), + cv.OnlyWith(CONF_NRF_SAADC, PLATFORM_NRF52): cv.declare_id(adc_dt_spec), cv.Optional(CONF_SAMPLES, default=1): cv.int_range(min=1, max=255), cv.Optional(CONF_SAMPLING_MODE, default="avg"): _sampling_mode, } @@ -83,6 +95,8 @@ CONFIG_SCHEMA = cv.All( validate_config, ) +CONF_ADC_CHANNEL_ID = "adc_channel_id" + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -93,7 +107,7 @@ async def to_code(config): cg.add_define("USE_ADC_SENSOR_VCC") elif config[CONF_PIN] == "TEMPERATURE": cg.add(var.set_is_temperature()) - else: + elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) @@ -122,3 +136,41 @@ async def to_code(config): ): chan = ESP32_VARIANT_ADC2_PIN_TO_CHANNEL[variant][pin_num] cg.add(var.set_channel(adc_unit_t.ADC_UNIT_2, chan)) + + elif CORE.is_nrf52: + CORE.data.setdefault(CONF_ADC_CHANNEL_ID, 0) + channel_id = CORE.data[CONF_ADC_CHANNEL_ID] + CORE.data[CONF_ADC_CHANNEL_ID] = channel_id + 1 + zephyr_add_prj_conf("ADC", True) + nrf_saadc = config[CONF_NRF_SAADC] + rhs = cg.RawExpression( + f"ADC_DT_SPEC_GET_BY_IDX(DT_PATH(zephyr_user), {channel_id})" + ) + adc = cg.new_Pvariable(nrf_saadc, rhs) + cg.add(var.set_adc_channel(adc)) + gain = "ADC_GAIN_1_6" + pin_number = config[CONF_PIN][CONF_NUMBER] + if pin_number == "VDDHDIV5": + gain = "ADC_GAIN_1_2" + if isinstance(pin_number, int): + GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()} + pin_number = GPIO_TO_AIN[pin_number] + zephyr_add_user("io-channels", f"<&adc {channel_id}>") + zephyr_add_overlay( + f""" +&adc {{ + #address-cells = <1>; + #size-cells = <0>; + + channel@{channel_id} {{ + reg = <{channel_id}>; + zephyr,gain = "{gain}"; + zephyr,reference = "ADC_REF_INTERNAL"; + zephyr,acquisition-time = ; + zephyr,input-positive = ; + zephyr,resolution = <14>; + zephyr,oversampling = <8>; + }}; +}}; +""" + ) diff --git a/esphome/components/nrf52/const.py b/esphome/components/nrf52/const.py index d827e5fb22..715d527a66 100644 --- a/esphome/components/nrf52/const.py +++ b/esphome/components/nrf52/const.py @@ -2,3 +2,17 @@ BOOTLOADER_ADAFRUIT = "adafruit" BOOTLOADER_ADAFRUIT_NRF52_SD132 = "adafruit_nrf52_sd132" BOOTLOADER_ADAFRUIT_NRF52_SD140_V6 = "adafruit_nrf52_sd140_v6" BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 = "adafruit_nrf52_sd140_v7" +EXTRA_ADC = [ + "VDD", + "VDDHDIV5", +] +AIN_TO_GPIO = { + "AIN0": 2, + "AIN1": 3, + "AIN2": 4, + "AIN3": 5, + "AIN4": 28, + "AIN5": 29, + "AIN6": 30, + "AIN7": 31, +} diff --git a/esphome/components/nrf52/gpio.py b/esphome/components/nrf52/gpio.py index 85230c1f57..260114f90e 100644 --- a/esphome/components/nrf52/gpio.py +++ b/esphome/components/nrf52/gpio.py @@ -2,12 +2,23 @@ from esphome import pins import esphome.codegen as cg from esphome.components.zephyr.const import zephyr_ns import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER, PLATFORM_NRF52 +from esphome.const import ( + CONF_ANALOG, + CONF_ID, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + PLATFORM_NRF52, +) + +from .const import AIN_TO_GPIO, EXTRA_ADC ZephyrGPIOPin = zephyr_ns.class_("ZephyrGPIOPin", cg.InternalGPIOPin) def _translate_pin(value): + if value in AIN_TO_GPIO: + return AIN_TO_GPIO[value] if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -28,18 +39,33 @@ def _translate_pin(value): def validate_gpio_pin(value): + if value in EXTRA_ADC: + return value value = _translate_pin(value) if value < 0 or value > (32 + 16): raise cv.Invalid(f"NRF52: Invalid pin number: {value}") return value +def validate_supports(value): + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_analog = mode[CONF_ANALOG] + if is_analog: + if num in EXTRA_ADC: + return value + if num not in AIN_TO_GPIO.values(): + raise cv.Invalid(f"Cannot use {num} as analog pin") + return value + + NRF52_PIN_SCHEMA = cv.All( pins.gpio_base_schema( ZephyrGPIOPin, validate_gpio_pin, - modes=pins.GPIO_STANDARD_MODES, + modes=pins.GPIO_STANDARD_MODES + (CONF_ANALOG,), ), + validate_supports, ) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 2b542404a5..c698122030 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -1,5 +1,5 @@ import os -from typing import Final, TypedDict +from typing import TypedDict import esphome.codegen as cg from esphome.const import CONF_BOARD @@ -8,18 +8,19 @@ from esphome.helpers import copy_file_if_changed, write_file_if_changed from .const import ( BOOTLOADER_MCUBOOT, + KEY_BOARD, KEY_BOOTLOADER, KEY_EXTRA_BUILD_FILES, KEY_OVERLAY, KEY_PM_STATIC, KEY_PRJ_CONF, + KEY_USER, KEY_ZEPHYR, zephyr_ns, ) CODEOWNERS = ["@tomaszduda23"] AUTO_LOAD = ["preferences"] -KEY_BOARD: Final = "board" PrjConfValueType = bool | str | int @@ -49,6 +50,7 @@ class ZephyrData(TypedDict): overlay: str extra_build_files: dict[str, str] pm_static: list[Section] + user: dict[str, list[str]] def zephyr_set_core_data(config): @@ -59,6 +61,7 @@ def zephyr_set_core_data(config): overlay="", extra_build_files={}, pm_static=[], + user={}, ) return config @@ -178,7 +181,25 @@ def zephyr_add_pm_static(section: Section): CORE.data[KEY_ZEPHYR][KEY_PM_STATIC].extend(section) +def zephyr_add_user(key, value): + user = zephyr_data()[KEY_USER] + if key not in user: + user[key] = [] + user[key] += [value] + + def copy_files(): + user = zephyr_data()[KEY_USER] + if user: + zephyr_add_overlay( + f""" +/ {{ + zephyr,user {{ + {[f"{key} = {', '.join(value)};" for key, value in user.items()][0]} +}}; +}};""" + ) + want_opts = zephyr_data()[KEY_PRJ_CONF] prj_conf = ( diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index f14a326344..06a4fc42bc 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -10,5 +10,7 @@ KEY_OVERLAY: Final = "overlay" KEY_PM_STATIC: Final = "pm_static" KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" +KEY_BOARD: Final = "board" +KEY_USER: Final = "user" zephyr_ns = cg.esphome_ns.namespace("zephyr") diff --git a/script/helpers_zephyr.py b/script/helpers_zephyr.py index 305ca00c0c..455f4f4b64 100644 --- a/script/helpers_zephyr.py +++ b/script/helpers_zephyr.py @@ -25,6 +25,7 @@ int main() { return 0;} Path(zephyr_dir / "prj.conf").write_text( """ CONFIG_NEWLIB_LIBC=y +CONFIG_ADC=y """, encoding="utf-8", ) diff --git a/tests/components/adc/test.nrf52-adafruit.yaml b/tests/components/adc/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..4be5b4b5c2 --- /dev/null +++ b/tests/components/adc/test.nrf52-adafruit.yaml @@ -0,0 +1,23 @@ +sensor: + - platform: adc + pin: VDDHDIV5 + name: "VDDH Voltage" + update_interval: 5sec + filters: + - multiply: 5 + - platform: adc + pin: VDD + name: "VDD Voltage" + update_interval: 5sec + - platform: adc + pin: AIN0 + name: "AIN0 Voltage" + update_interval: 5sec + - platform: adc + pin: P0.03 + name: "AIN1 Voltage" + update_interval: 5sec + - platform: adc + name: "AIN2 Voltage" + update_interval: 5sec + pin: 4 diff --git a/tests/components/adc/test.nrf52-mcumgr.yaml b/tests/components/adc/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..4be5b4b5c2 --- /dev/null +++ b/tests/components/adc/test.nrf52-mcumgr.yaml @@ -0,0 +1,23 @@ +sensor: + - platform: adc + pin: VDDHDIV5 + name: "VDDH Voltage" + update_interval: 5sec + filters: + - multiply: 5 + - platform: adc + pin: VDD + name: "VDD Voltage" + update_interval: 5sec + - platform: adc + pin: AIN0 + name: "AIN0 Voltage" + update_interval: 5sec + - platform: adc + pin: P0.03 + name: "AIN1 Voltage" + update_interval: 5sec + - platform: adc + name: "AIN2 Voltage" + update_interval: 5sec + pin: 4 From f13e742bd544691b9c4409f0aef39dd3faba1e47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 16:10:56 -1000 Subject: [PATCH 0203/1626] [ruff] Enable RET and fix all violations (#9929) --- esphome/automation.py | 6 +-- .../alarm_control_panel/__init__.py | 12 ++---- esphome/components/ble_client/__init__.py | 10 ++--- esphome/components/esp32/__init__.py | 6 +-- esphome/components/esp32/gpio.py | 3 +- .../components/esp32_ble_server/__init__.py | 3 +- esphome/components/haier/climate.py | 15 +++---- esphome/components/light/effects.py | 3 +- esphome/components/lvgl/automation.py | 6 +-- esphome/components/lvgl/lv_validation.py | 3 +- esphome/components/lvgl/styles.py | 3 +- esphome/components/lvgl/widgets/__init__.py | 2 +- .../components/lvgl/widgets/buttonmatrix.py | 2 +- esphome/components/mqtt/__init__.py | 3 +- esphome/components/one_wire/__init__.py | 3 +- esphome/components/packages/__init__.py | 3 +- esphome/components/pmwcs3/sensor.py | 3 +- esphome/components/rf_bridge/__init__.py | 12 ++---- .../components/rp2040_pio_led_strip/light.py | 3 +- esphome/components/sim800l/__init__.py | 6 +-- esphome/components/ufire_ec/sensor.py | 3 +- esphome/components/ufire_ise/sensor.py | 3 +- esphome/config_helpers.py | 3 +- esphome/config_validation.py | 2 +- esphome/cpp_helpers.py | 2 +- esphome/mqtt.py | 3 +- esphome/vscode.py | 3 +- esphome/yaml_util.py | 3 +- pyproject.toml | 1 + script/api_protobuf/api_protobuf.py | 43 ++++++++----------- script/build_language_schema.py | 3 +- script/helpers_zephyr.py | 7 ++- tests/dashboard/test_web_server.py | 3 +- tests/integration/conftest.py | 13 +++--- .../loop_test_component/__init__.py | 6 +-- tests/script/test_clang_tidy_hash.py | 2 +- tests/script/test_helpers.py | 5 +-- tests/unit_tests/test_substitutions.py | 5 +-- tests/unit_tests/test_vscode.py | 3 +- 39 files changed, 81 insertions(+), 139 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 34159561c2..99d4362845 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -391,8 +391,7 @@ async def build_action(full_config, template_arg, args): ) action_id = full_config[CONF_TYPE_ID] builder = registry_entry.coroutine_fun - ret = await builder(config, action_id, template_arg, args) - return ret + return await builder(config, action_id, template_arg, args) async def build_action_list(config, templ, arg_type): @@ -409,8 +408,7 @@ async def build_condition(full_config, template_arg, args): ) action_id = full_config[CONF_TYPE_ID] builder = registry_entry.coroutine_fun - ret = await builder(config, action_id, template_arg, args) - return ret + return await builder(config, action_id, template_arg, args) async def build_condition_list(config, templ, args): diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 6d37d53a4c..b076175eb8 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -301,8 +301,7 @@ async def alarm_action_disarm_to_code(config, action_id, template_arg, args): ) async def alarm_action_pending_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -310,8 +309,7 @@ async def alarm_action_pending_to_code(config, action_id, template_arg, args): ) async def alarm_action_trigger_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -319,8 +317,7 @@ async def alarm_action_trigger_to_code(config, action_id, template_arg, args): ) async def alarm_action_chime_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -333,8 +330,7 @@ async def alarm_action_chime_to_code(config, action_id, template_arg, args): ) async def alarm_action_ready_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_condition( diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index a88172ca87..0f3869c23b 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -175,8 +175,7 @@ BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema( ) async def ble_disconnect_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) @automation.register_action( @@ -184,8 +183,7 @@ async def ble_disconnect_to_code(config, action_id, template_arg, args): ) async def ble_connect_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) @automation.register_action( @@ -282,9 +280,7 @@ async def passkey_reply_to_code(config, action_id, template_arg, args): ) async def remove_bond_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - - return var + return cg.new_Pvariable(action_id, template_arg, parent) async def to_code(config): diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index b183f10d72..8d72ff3685 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -892,7 +892,7 @@ def get_arduino_partition_csv(flash_size): eeprom_partition_start = app1_partition_start + app_partition_size spiffs_partition_start = eeprom_partition_start + eeprom_partition_size - partition_csv = f"""\ + return f"""\ nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xE000, 0x2000, app0, app, ota_0, 0x{app0_partition_start:X}, 0x{app_partition_size:X}, @@ -900,20 +900,18 @@ app1, app, ota_1, 0x{app1_partition_start:X}, 0x{app_partition_size:X}, eeprom, data, 0x99, 0x{eeprom_partition_start:X}, 0x{eeprom_partition_size:X}, spiffs, data, spiffs, 0x{spiffs_partition_start:X}, 0x{spiffs_partition_size:X} """ - return partition_csv def get_idf_partition_csv(flash_size): app_partition_size = APP_PARTITION_SIZES[flash_size] - partition_csv = f"""\ + return f"""\ otadata, data, ota, , 0x2000, phy_init, data, phy, , 0x1000, app0, app, ota_0, , 0x{app_partition_size:X}, app1, app, ota_1, , 0x{app_partition_size:X}, nvs, data, nvs, , 0x6D000, """ - return partition_csv def _format_sdkconfig_val(value: SdkconfigValueType) -> str: diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index c35e5c2215..513f463d57 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -187,8 +187,7 @@ def validate_supports(value): "Open-drain only works with output mode", [CONF_MODE, CONF_OPEN_DRAIN] ) - value = _esp32_validations[variant].usage_validation(value) - return value + return _esp32_validations[variant].usage_validation(value) # https://docs.espressif.com/projects/esp-idf/en/v3.3.5/api-reference/peripherals/gpio.html#_CPPv416gpio_drive_cap_t diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 19f466eb7b..6f16d76a32 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -628,5 +628,4 @@ async def ble_server_descriptor_set_value(config, action_id, template_arg, args) ) async def ble_server_characteristic_notify(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 0393c263d4..8c3649058f 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -330,8 +330,7 @@ HAIER_HON_BASE_ACTION_SCHEMA = automation.maybe_simple_id( ) async def display_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -342,8 +341,7 @@ async def display_action_to_code(config, action_id, template_arg, args): ) async def beeper_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) # Start self cleaning or steri-cleaning action action @@ -359,8 +357,7 @@ async def beeper_action_to_code(config, action_id, template_arg, args): ) async def start_cleaning_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) # Set vertical airflow direction action @@ -417,8 +414,7 @@ async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, ) async def health_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -432,8 +428,7 @@ async def health_action_to_code(config, action_id, template_arg, args): ) async def power_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) def _final_validate(config): diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index f5749a17ab..6c8fd86225 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -353,10 +353,9 @@ async def addressable_lambda_effect_to_code(config, effect_id): (bool, "initial_run"), ] lambda_ = await cg.process_lambda(config[CONF_LAMBDA], args, return_type=cg.void) - var = cg.new_Pvariable( + return cg.new_Pvariable( effect_id, config[CONF_NAME], lambda_, config[CONF_UPDATE_INTERVAL] ) - return var @register_addressable_effect( diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index cc0f833ced..fc70b0f682 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -85,8 +85,7 @@ async def action_to_code( async with LambdaContext(parameters=args, where=action_id) as context: for widget in widgets: await action(widget) - var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) - return var + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) async def update_to_code(config, action_id, template_arg, args): @@ -354,8 +353,7 @@ async def widget_focus(config, action_id, template_arg, args): if config[CONF_FREEZE]: lv.group_focus_freeze(group, True) - var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) - return var + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) @automation.register_action( diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 92fe74eb52..5a1b99cf7c 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -271,8 +271,7 @@ padding = LValidator(padding_validator, int32, retmapper=literal) def zoom_validator(value): - value = cv.float_range(0.1, 10.0)(value) - return value + return cv.float_range(0.1, 10.0)(value) def zoom_retmapper(value): diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 11d7bca5fa..3969c9f388 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -66,8 +66,7 @@ async def style_update_to_code(config, action_id, template_arg, args): async with LambdaContext(parameters=args, where=action_id) as context: await style_set(style, config) - var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) - return var + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) async def theme_to_code(config): diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index a8cb8dce33..d12464fe71 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -189,7 +189,7 @@ class Widget: for matrix buttons :return: """ - return None + return def get_max(self): return self.type.get_max(self.config) diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index aa33be722c..c6b6d2440f 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -193,7 +193,7 @@ class ButtonMatrixType(WidgetType): async def to_code(self, w: Widget, config): lvgl_components_required.add("BUTTONMATRIX") if CONF_ROWS not in config: - return [] + return text_list, ctrl_list, width_list, key_list = await get_button_data( config[CONF_ROWS], w ) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 1a6fcabf42..52d3181780 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -312,14 +312,13 @@ CONFIG_SCHEMA = cv.All( def exp_mqtt_message(config): if config is None: return cg.optional(cg.TemplateArguments(MQTTMessage)) - exp = cg.StructInitializer( + return cg.StructInitializer( MQTTMessage, ("topic", config[CONF_TOPIC]), ("payload", config.get(CONF_PAYLOAD, "")), ("qos", config[CONF_QOS]), ("retain", config[CONF_RETAIN]), ) - return exp @coroutine_with_priority(40.0) diff --git a/esphome/components/one_wire/__init__.py b/esphome/components/one_wire/__init__.py index 99a1ccd1eb..6d95b8fd33 100644 --- a/esphome/components/one_wire/__init__.py +++ b/esphome/components/one_wire/__init__.py @@ -18,13 +18,12 @@ def one_wire_device_schema(): :return: The 1-wire device schema, `extend` this in your config schema. """ - schema = cv.Schema( + return cv.Schema( { cv.GenerateID(CONF_ONE_WIRE_ID): cv.use_id(OneWireBus), cv.Optional(CONF_ADDRESS): cv.hex_uint64_t, } ) - return schema async def register_one_wire_device(var, config): diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 0db7841db2..2e7dc0e197 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -186,8 +186,7 @@ def _process_package(package_config, config): package_config = _process_base_package(package_config) if isinstance(package_config, dict): recursive_package = do_packages_pass(package_config) - config = merge_config(recursive_package, config) - return config + return merge_config(recursive_package, config) def do_packages_pass(config: dict): diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index d42338ab6f..075b9b00b5 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -114,8 +114,7 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( ) async def pmwcs3_calibration_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 5ccca823de..b4770726b4 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -136,8 +136,7 @@ RFBRIDGE_ID_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(RFBridgeComponent)}) @automation.register_action("rf_bridge.learn", RFBridgeLearnAction, RFBRIDGE_ID_SCHEMA) async def rf_bridge_learnx_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - return var + return cg.new_Pvariable(action_id, template_args, paren) @automation.register_action( @@ -149,8 +148,7 @@ async def rf_bridge_start_advanced_sniffing_to_code( config, action_id, template_args, args ): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - return var + return cg.new_Pvariable(action_id, template_args, paren) @automation.register_action( @@ -162,8 +160,7 @@ async def rf_bridge_stop_advanced_sniffing_to_code( config, action_id, template_args, args ): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - return var + return cg.new_Pvariable(action_id, template_args, paren) @automation.register_action( @@ -175,8 +172,7 @@ async def rf_bridge_start_bucket_sniffing_to_code( config, action_id, template_args, args ): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - return var + return cg.new_Pvariable(action_id, template_args, paren) RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA = cv.Schema( diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 9107db9b7f..62f7fffdc9 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -125,8 +125,7 @@ writezero: def time_to_cycles(time_us): cycles_per_us = 57.5 - cycles = round(float(time_us) * cycles_per_us) - return cycles + return round(float(time_us) * cycles_per_us) CONF_PIO = "pio" diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index 2ca9127d3f..c48a3c63c4 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -171,8 +171,7 @@ async def sim800l_dial_to_code(config, action_id, template_arg, args): ) async def sim800l_connect_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) SIM800L_SEND_USSD_SCHEMA = cv.Schema( @@ -201,5 +200,4 @@ async def sim800l_send_ussd_to_code(config, action_id, template_arg, args): ) async def sim800l_disconnect_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 944fdfdee9..9edf0f89ff 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -122,5 +122,4 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( ) async def ufire_ec_reset_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index e57a1155a4..8009cdaa6a 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -123,5 +123,4 @@ UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent ) async def ufire_ise_reset_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - return var + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index 50ce4e8e34..00cd8f9818 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -111,8 +111,7 @@ def merge_config(full_old, full_new): else: ids[new_id] = len(res) res.append(v) - res = [v for i, v in enumerate(res) if i not in ids_to_delete] - return res + return [v for i, v in enumerate(res) if i not in ids_to_delete] if new is None: return old diff --git a/esphome/config_validation.py b/esphome/config_validation.py index a79f8cd17c..84ffd9941e 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1868,7 +1868,7 @@ def validate_registry_entry(name, registry): def none(value): if value in ("none", "None"): - return None + return raise Invalid("Must be none") diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 3f64be6154..b61b215bdc 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -115,7 +115,7 @@ async def build_registry_list(registry, config): async def past_safe_mode(): if CONF_SAFE_MODE not in CORE.config: - return + return None def _safe_mode_generator(): while True: diff --git a/esphome/mqtt.py b/esphome/mqtt.py index acfa8a0926..f1c631697a 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -36,7 +36,7 @@ _LOGGER = logging.getLogger(__name__) def config_from_env(): - config = { + return { CONF_MQTT: { CONF_USERNAME: get_str_env("ESPHOME_DASHBOARD_MQTT_USERNAME"), CONF_PASSWORD: get_str_env("ESPHOME_DASHBOARD_MQTT_PASSWORD"), @@ -44,7 +44,6 @@ def config_from_env(): CONF_PORT: get_int_env("ESPHOME_DASHBOARD_MQTT_PORT", 1883), }, } - return config def initialize( diff --git a/esphome/vscode.py b/esphome/vscode.py index d8cfe91938..f5e2a20b97 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -81,8 +81,7 @@ def _print_file_read_event(path: str) -> None: def _request_and_get_stream_on_stdin(fname: str) -> StringIO: _print_file_read_event(fname) - raw_yaml_stream = StringIO(_read_file_content_from_json_on_stdin()) - return raw_yaml_stream + return StringIO(_read_file_content_from_json_on_stdin()) def _vscode_loader(fname: str) -> dict[str, Any]: diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 33a56fc158..f26bc0502d 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -305,8 +305,7 @@ class ESPHomeLoaderMixin: result = self.yaml_loader(self._rel_path(file)) if not vars: vars = {} - result = substitute_vars(result, vars) - return result + return substitute_vars(result, vars) @_add_data_ref def construct_include_dir_list(self, node: yaml.Node) -> list[dict[str, Any]]: diff --git a/pyproject.toml b/pyproject.toml index 200f51a873..4943c48eb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,7 @@ select = [ "PERF", # performance "PL", # pylint "SIM", # flake8-simplify + "RET", # flake8-ret "UP", # pyupgrade ] diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 03f8d0f8bc..24e2b25e90 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -539,8 +539,7 @@ class BoolType(TypeInfo): wire_type = WireType.VARINT # Uses wire type 0 def dump(self, name: str) -> str: - o = f"out.append(YESNO({name}));" - return o + return f"out.append(YESNO({name}));" def get_size_calculation(self, name: str, force: bool = False) -> str: return self._get_simple_size_calculation(name, force, "add_bool") @@ -592,9 +591,8 @@ class StringType(TypeInfo): if no_zero_copy: # Use the std::string directly return f"buffer.encode_string({self.number}, this->{self.field_name});" - else: - # Use the StringRef - return f"buffer.encode_string({self.number}, this->{self.field_name}_ref_);" + # Use the StringRef + return f"buffer.encode_string({self.number}, this->{self.field_name}_ref_);" def dump(self, name): # Check if no_zero_copy option is set @@ -716,8 +714,7 @@ class MessageType(TypeInfo): return f"case {self.number}: value.decode_to_message(this->{self.field_name}); break;" def dump(self, name: str) -> str: - o = f"{name}.dump_to(out);" - return o + return f"{name}.dump_to(out);" @property def dump_content(self) -> str: @@ -865,8 +862,7 @@ class FixedArrayBytesType(TypeInfo): return f"buffer.encode_bytes({self.number}, this->{self.field_name}, this->{self.field_name}_len);" def dump(self, name: str) -> str: - o = f"out.append(format_hex_pretty({name}, {name}_len));" - return o + return f"out.append(format_hex_pretty({name}, {name}_len));" @property def dump_content(self) -> str: @@ -883,9 +879,8 @@ class FixedArrayBytesType(TypeInfo): if force: # For repeated fields, always calculate size (no zero check) return f"size.add_length_force({field_id_size}, {length_field});" - else: - # For non-repeated fields, add_length already checks for zero - return f"size.add_length({field_id_size}, {length_field});" + # For non-repeated fields, add_length already checks for zero + return f"size.add_length({field_id_size}, {length_field});" def get_estimated_size(self) -> int: # Estimate based on typical BLE advertisement size @@ -940,8 +935,7 @@ class EnumType(TypeInfo): return f"buffer.{self.encode_func}({self.number}, static_cast(this->{self.field_name}));" def dump(self, name: str) -> str: - o = f"out.append(proto_enum_to_string<{self.cpp_type}>({name}));" - return o + return f"out.append(proto_enum_to_string<{self.cpp_type}>({name}));" def dump_field_value(self, value: str) -> str: # Enums need explicit cast for the template @@ -1115,8 +1109,7 @@ class FixedArrayRepeatedType(TypeInfo): def encode_element(element: str) -> str: if isinstance(self._ti, EnumType): return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" - else: - return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" + return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" # If skip_zero is enabled, wrap encoding in a zero check if self.skip_zero: @@ -1133,7 +1126,7 @@ class FixedArrayRepeatedType(TypeInfo): # Unroll small arrays for efficiency if self.array_size == 1: return encode_element(f"this->{self.field_name}[0]") - elif self.array_size == 2: + if self.array_size == 2: return ( encode_element(f"this->{self.field_name}[0]") + "\n " @@ -1224,8 +1217,7 @@ class RepeatedTypeInfo(TypeInfo): # use it as-is, otherwise append the element type if "<" in self._container_type and ">" in self._container_type: return f"const {self._container_type}*" - else: - return f"const {self._container_type}<{self._ti.cpp_type}>*" + return f"const {self._container_type}<{self._ti.cpp_type}>*" return f"std::vector<{self._ti.cpp_type}>" @property @@ -1311,14 +1303,13 @@ class RepeatedTypeInfo(TypeInfo): o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" o += "}" return o + o = f"for (auto {'' if self._ti_is_bool else '&'}it : this->{self.field_name}) {{\n" + if isinstance(self._ti, EnumType): + o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" else: - o = f"for (auto {'' if self._ti_is_bool else '&'}it : this->{self.field_name}) {{\n" - if isinstance(self._ti, EnumType): - o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" - else: - o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" - o += "}" - return o + o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" + o += "}" + return o @property def dump_content(self) -> str: diff --git a/script/build_language_schema.py b/script/build_language_schema.py index c114d15315..ff6e898902 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -444,8 +444,7 @@ def get_str_path_schema(strPath): if len(parts) > 2: parts[0] += "." + parts[1] parts[1] = parts[2] - s1 = output.get(parts[0], {}).get(S_SCHEMAS, {}).get(parts[1], {}) - return s1 + return output.get(parts[0], {}).get(S_SCHEMAS, {}).get(parts[1], {}) def pop_str_path_schema(strPath): diff --git a/script/helpers_zephyr.py b/script/helpers_zephyr.py index 455f4f4b64..922f1171b4 100644 --- a/script/helpers_zephyr.py +++ b/script/helpers_zephyr.py @@ -43,12 +43,11 @@ CONFIG_ADC=y def extract_defines(command): define_pattern = re.compile(r"-D\s*([^\s]+)") - defines = [ + return [ match for match in define_pattern.findall(command) if match not in ("_ASMLANGUAGE") ] - return defines def find_cxx_path(commands): for entry in commands: @@ -57,6 +56,7 @@ CONFIG_ADC=y if not cxx_path.endswith("++"): continue return cxx_path + return None def get_builtin_include_paths(compiler): result = subprocess.run( @@ -84,11 +84,10 @@ CONFIG_ADC=y flag_pattern = re.compile( r"(-O[0-3s]|-g|-std=[^\s]+|-Wall|-Wextra|-Werror|--[^\s]+|-f[^\s]+|-m[^\s]+|-imacros\s*[^\s]+)" ) - flags = [ + return [ match.replace("-imacros ", "-imacros") for match in flag_pattern.findall(command) ] - return flags def transform_to_idedata_format(compile_commands): cxx_path = find_cxx_path(compile_commands) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index cd02200d0b..b77ab7a7a3 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -31,8 +31,7 @@ class DashboardTestHelper: else: url = f"http://127.0.0.1:{self.port}{path}" future = self.client.fetch(url, raise_error=True, **kwargs) - result = await future - return result + return await future @pytest_asyncio.fixture() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 46eb6c88e2..55bf0b97a7 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -251,19 +251,18 @@ async def compile_esphome( if proc.returncode == 0: # Success! break - elif proc.returncode == -11 and attempt < max_retries - 1: + if proc.returncode == -11 and attempt < max_retries - 1: # Segfault (-11 = SIGSEGV), retry print( f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..." ) await asyncio.sleep(1) # Brief pause before retry continue - else: - # Other error or final retry - raise RuntimeError( - f"Failed to compile {config_path}, return code: {proc.returncode}. " - f"Run with 'pytest -s' to see compilation output." - ) + # Other error or final retry + raise RuntimeError( + f"Failed to compile {config_path}, return code: {proc.returncode}. " + f"Run with 'pytest -s' to see compilation output." + ) # Load the config to get idedata (blocking call, must use executor) loop = asyncio.get_running_loop() diff --git a/tests/integration/fixtures/external_components/loop_test_component/__init__.py b/tests/integration/fixtures/external_components/loop_test_component/__init__.py index 3f3a40db09..a0b0f8c65a 100644 --- a/tests/integration/fixtures/external_components/loop_test_component/__init__.py +++ b/tests/integration/fixtures/external_components/loop_test_component/__init__.py @@ -72,8 +72,7 @@ DisableAction = loop_test_component_ns.class_("DisableAction", automation.Action ) async def enable_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) @automation.register_action( @@ -87,8 +86,7 @@ async def enable_to_code(config, action_id, template_arg, args): ) async def disable_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - return var + return cg.new_Pvariable(action_id, template_arg, parent) async def to_code(config): diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index 7b66a69adb..2f84d11a0d 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -69,7 +69,7 @@ def test_calculate_clang_tidy_hash() -> None: def read_file_mock(path: Path) -> bytes: if ".clang-tidy" in str(path): return clang_tidy_content - elif "platformio.ini" in str(path): + if "platformio.ini" in str(path): return platformio_content return b"" diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 423e2d3c30..9730efd366 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -315,9 +315,8 @@ def test_local_development_no_remotes_configured(monkeypatch: MonkeyPatch) -> No def side_effect_func(*args): if args == ("git", "remote"): return "origin\nupstream\n" - else: - # All merge-base attempts fail - raise Exception("Command failed") + # All merge-base attempts fail + raise Exception("Command failed") mock_output.side_effect = side_effect_func diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index b65fecb26e..b2b7cb1ea4 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -18,11 +18,10 @@ def sort_dicts(obj): """Recursively sort dictionaries for order-insensitive comparison.""" if isinstance(obj, dict): return {k: sort_dicts(obj[k]) for k in sorted(obj)} - elif isinstance(obj, list): + if isinstance(obj, list): # Lists are not sorted; we preserve order return [sort_dicts(i) for i in obj] - else: - return obj + return obj def dict_diff(a, b, path=""): diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 6e0bde23b2..4b28a2215b 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -22,8 +22,7 @@ def _run_repl_test(input_data): call[0][0] for call in mock_stdout.write.call_args_list ).strip() splitted_output = full_output.split("\n") - remove_version = splitted_output[1:] # remove first entry with version info - return remove_version + return splitted_output[1:] # remove first entry with version info def _validate(file_path: str): From 0954a6185cc68fc5ff36e167ec98c2d65e5c28d3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 31 Jul 2025 22:15:56 -0400 Subject: [PATCH 0204/1626] [sensor] Fix bug in percentage based delta filter (#8157) Co-authored-by: J. Nick Koston --- esphome/components/sensor/filter.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 39b507f960..7107f12462 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -375,13 +375,11 @@ optional DeltaFilter::new_value(float value) { if (std::isnan(this->last_value_)) { return {}; } else { - if (this->percentage_mode_) { - this->current_delta_ = fabsf(value * this->delta_); - } return this->last_value_ = value; } } - if (std::isnan(this->last_value_) || fabsf(value - this->last_value_) >= this->current_delta_) { + float diff = fabsf(value - this->last_value_); + if (std::isnan(this->last_value_) || (diff > 0.0f && diff >= this->current_delta_)) { if (this->percentage_mode_) { this->current_delta_ = fabsf(value * this->delta_); } From 291215909aec990d70f6f38afa5069cd4ca4591f Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 31 Jul 2025 21:55:59 -0500 Subject: [PATCH 0205/1626] [sensor] A little bit of filter clean-up (#9986) --- esphome/components/sensor/__init__.py | 6 +++++- esphome/components/sensor/filter.cpp | 6 +++--- esphome/components/sensor/filter.h | 10 +++++----- tests/components/template/common.yaml | 7 +++++++ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 5d70785389..23e6ad0f2c 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -599,7 +599,9 @@ async def throttle_filter_to_code(config, filter_id): TIMEOUT_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( { cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds, - cv.Optional(CONF_VALUE, default="nan"): cv.ensure_list(cv.float_), + cv.Optional(CONF_VALUE, default="nan"): cv.Any( + cv.templatable(cv.float_), [cv.templatable(cv.float_)] + ), }, key=CONF_TIMEOUT, ) @@ -611,6 +613,8 @@ TIMEOUT_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( TIMEOUT_WITH_PRIORITY_SCHEMA, ) async def throttle_with_priority_filter_to_code(config, filter_id): + if not isinstance(config[CONF_VALUE], list): + config[CONF_VALUE] = [config[CONF_VALUE]] template_ = [await cg.templatable(x, [], float) for x in config[CONF_VALUE]] return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 7107f12462..f077ad2416 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -225,7 +225,7 @@ optional SlidingWindowMovingAverageFilter::new_value(float value) { // ExponentialMovingAverageFilter ExponentialMovingAverageFilter::ExponentialMovingAverageFilter(float alpha, size_t send_every, size_t send_first_at) - : send_every_(send_every), send_at_(send_every - send_first_at), alpha_(alpha) {} + : alpha_(alpha), send_every_(send_every), send_at_(send_every - send_first_at) {} optional ExponentialMovingAverageFilter::new_value(float value) { if (!std::isnan(value)) { if (this->first_value_) { @@ -325,7 +325,7 @@ optional FilterOutValueFilter::new_value(float value) { // ThrottleFilter ThrottleFilter::ThrottleFilter(uint32_t min_time_between_inputs) : min_time_between_inputs_(min_time_between_inputs) {} optional ThrottleFilter::new_value(float value) { - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); if (this->last_input_ == 0 || now - this->last_input_ >= min_time_between_inputs_) { this->last_input_ = now; return value; @@ -369,7 +369,7 @@ optional ThrottleWithPriorityFilter::new_value(float value) { // DeltaFilter DeltaFilter::DeltaFilter(float delta, bool percentage_mode) - : delta_(delta), current_delta_(delta), percentage_mode_(percentage_mode), last_value_(NAN) {} + : delta_(delta), current_delta_(delta), last_value_(NAN), percentage_mode_(percentage_mode) {} optional DeltaFilter::new_value(float value) { if (std::isnan(value)) { if (std::isnan(this->last_value_)) { diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 8e2c6fef08..5765c9a081 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -221,11 +221,11 @@ class ExponentialMovingAverageFilter : public Filter { void set_alpha(float alpha); protected: - bool first_value_{true}; float accumulator_{NAN}; + float alpha_; size_t send_every_; size_t send_at_; - float alpha_; + bool first_value_{true}; }; /** Simple throttle average filter. @@ -243,9 +243,9 @@ class ThrottleAverageFilter : public Filter, public Component { float get_setup_priority() const override; protected: - uint32_t time_period_; float sum_{0.0f}; unsigned int n_{0}; + uint32_t time_period_; bool have_nan_{false}; }; @@ -378,8 +378,8 @@ class DeltaFilter : public Filter { protected: float delta_; float current_delta_; - bool percentage_mode_; float last_value_{NAN}; + bool percentage_mode_; }; class OrFilter : public Filter { @@ -401,8 +401,8 @@ class OrFilter : public Filter { }; std::vector filters_; - bool has_value_{false}; PhiNode phi_; + bool has_value_{false}; }; class CalibrateLinearFilter : public Filter { diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index e185e01c5e..6b7c7ddea1 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -135,10 +135,17 @@ sensor: - throttle: 1s - throttle_average: 2s - throttle_with_priority: 5s + - throttle_with_priority: + timeout: 3s + value: 42.0 + - throttle_with_priority: + timeout: 3s + value: !lambda return 1.0f / 2.0f; - throttle_with_priority: timeout: 3s value: - 42.0 + - !lambda return 2.0f / 2.0f; - nan - timeout: timeout: 10s From 1c67dfc850a9c280a8bd90c2e9cf90e539cf5e54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 17:01:34 -1000 Subject: [PATCH 0206/1626] Support multiple --device arguments for address fallback --- esphome/__main__.py | 136 +++++++++++++++++++++---------- esphome/components/api/client.py | 18 ++-- esphome/dashboard/web_server.py | 23 ++++-- 3 files changed, 122 insertions(+), 55 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 341c1fa893..ae450238e4 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -356,7 +356,7 @@ def upload_program(config, args, host): return upload_using_esptool(config, host, file, args.upload_speed) if CORE.target_platform in (PLATFORM_RP2040): - return upload_using_platformio(config, args.device) + return upload_using_platformio(config, host) if CORE.is_libretiny: return upload_using_platformio(config, host) @@ -379,9 +379,12 @@ def upload_program(config, args, host): remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD, "") + # Check if we should use MQTT for address resolution + # This happens when no device was specified, or the current host is "MQTT"/"OTA" + devices = args.device or [] if ( CONF_MQTT in config # pylint: disable=too-many-boolean-expressions - and (not args.device or args.device in ("MQTT", "OTA")) + and (not devices or host in ("MQTT", "OTA")) and ( ((config[CONF_MDNS][CONF_DISABLED]) and not is_ip_address(CORE.address)) or get_port_type(host) == "MQTT" @@ -399,23 +402,28 @@ def upload_program(config, args, host): return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) -def show_logs(config, args, port): +def show_logs(config, args, devices): if "logger" not in config: raise EsphomeError("Logger is not configured!") + + port = devices[0] + if get_port_type(port) == "SERIAL": check_permissions(port) return run_miniterm(config, port, args) if get_port_type(port) == "NETWORK" and "api" in config: + addresses_to_use = devices if config[CONF_MDNS][CONF_DISABLED] and CONF_MQTT in config: from esphome import mqtt - port = mqtt.get_esphome_device_ip( + mqtt_address = mqtt.get_esphome_device_ip( config, args.username, args.password, args.client_id )[0] + addresses_to_use = [mqtt_address] from esphome.components.api.client import run_logs - return run_logs(config, port) + return run_logs(config, addresses_to_use) if get_port_type(port) == "MQTT" and "mqtt" in config: from esphome import mqtt @@ -478,19 +486,31 @@ def command_compile(args, config): def command_upload(args, config): - port = choose_upload_log_host( - default=args.device, - check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, - purpose="uploading", - ) - exit_code = upload_program(config, args, port) - if exit_code != 0: - return exit_code - _LOGGER.info("Successfully uploaded program.") - return 0 + devices = args.device or [] + if not devices: + # No devices specified, use the interactive chooser + devices = [ + choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + purpose="uploading", + ) + ] + + # Try each device until one succeeds + for device in devices: + _LOGGER.info("Uploading to %s", device) + exit_code = upload_program(config, args, device) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + return 0 + if len(devices) > 1: + _LOGGER.warning("Failed to upload to %s", device) + + return exit_code def command_discover(args, config): @@ -503,15 +523,21 @@ def command_discover(args, config): def command_logs(args, config): - port = choose_upload_log_host( - default=args.device, - check_default=None, - show_ota=False, - show_mqtt=True, - show_api=True, - purpose="logging", - ) - return show_logs(config, args, port) + devices = args.device or [] + if not devices: + # No devices specified, use the interactive chooser + devices = [ + choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=True, + purpose="logging", + ) + ] + + return show_logs(config, args, devices) def command_run(args, config): @@ -531,29 +557,48 @@ def command_run(args, config): program_path = idedata.raw["prog_path"] return run_external_process(program_path) - port = choose_upload_log_host( - default=args.device, - check_default=None, - show_ota=True, - show_mqtt=False, - show_api=True, - purpose="uploading", - ) - exit_code = upload_program(config, args, port) - if exit_code != 0: + devices = args.device or [] + if not devices: + # No devices specified, use the interactive chooser + devices = [ + choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=True, + purpose="uploading", + ) + ] + + # Try each device for upload until one succeeds + successful_device = None + for device in devices: + _LOGGER.info("Uploading to %s", device) + exit_code = upload_program(config, args, device) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + successful_device = device + break + if len(devices) > 1: + _LOGGER.warning("Failed to upload to %s", device) + + if successful_device is None: return exit_code - _LOGGER.info("Successfully uploaded program.") + if args.no_logs: return 0 + + # For logs, prefer the device we successfully uploaded to port = choose_upload_log_host( - default=args.device, - check_default=port, + default=successful_device, + check_default=successful_device, show_ota=False, show_mqtt=True, show_api=True, purpose="logging", ) - return show_logs(config, args, port) + return show_logs(config, args, [port]) def command_clean_mqtt(args, config): @@ -854,7 +899,8 @@ def parse_args(argv): ) parser_upload.add_argument( "--device", - help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", + action="append", + help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.", ) parser_upload.add_argument( "--upload_speed", @@ -876,7 +922,8 @@ def parse_args(argv): ) parser_logs.add_argument( "--device", - help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", + action="append", + help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.", ) parser_logs.add_argument( "--reset", @@ -905,7 +952,8 @@ def parse_args(argv): ) parser_run.add_argument( "--device", - help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", + action="append", + help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.", ) parser_run.add_argument( "--upload_speed", diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 5239e07435..ce018b3b98 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -async def async_run_logs(config: dict[str, Any], address: str) -> None: +async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: """Run the logs command in the event loop.""" conf = config["api"] name = config["esphome"]["name"] @@ -39,13 +39,21 @@ async def async_run_logs(config: dict[str, Any], address: str) -> None: noise_psk: str | None = None if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)): noise_psk = key - _LOGGER.info("Starting log output from %s using esphome API", address) + + if len(addresses) == 1: + _LOGGER.info("Starting log output from %s using esphome API", addresses[0]) + else: + _LOGGER.info( + "Starting log output from %s using esphome API", " or ".join(addresses) + ) + cli = APIClient( - address, + addresses[0], # Primary address for compatibility port, password, client_info=f"ESPHome Logs {__version__}", noise_psk=noise_psk, + addresses=addresses, # Pass all addresses for automatic retry ) dashboard = CORE.dashboard @@ -66,7 +74,7 @@ async def async_run_logs(config: dict[str, Any], address: str) -> None: await stop() -def run_logs(config: dict[str, Any], address: str) -> None: +def run_logs(config: dict[str, Any], addresses: list[str]) -> None: """Run the logs command.""" with contextlib.suppress(KeyboardInterrupt): - asyncio.run(async_run_logs(config, address)) + asyncio.run(async_run_logs(config, addresses)) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 286dc9e1d7..6519a85f89 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -324,6 +324,8 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): configuration = json_message["configuration"] config_file = settings.rel_path(configuration) port = json_message["port"] + addresses: list[str] = [] + if ( port == "OTA" # pylint: disable=too-many-boolean-expressions and (entry := entries.get(config_file)) @@ -333,10 +335,10 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): if (mdns := dashboard.mdns_status) and ( address_list := await mdns.async_resolve_host(entry.name) ): - # Use the IP address if available but only + # Use all IP addresses if available but only # if the API is loaded and the device is online # since MQTT logging will not work otherwise - port = sort_ip_addresses(address_list)[0] + addresses = sort_ip_addresses(address_list) elif ( entry.address and ( @@ -347,16 +349,25 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): and not isinstance(address_list, Exception) ): # If mdns is not available, try to use the DNS cache - port = sort_ip_addresses(address_list)[0] + addresses = sort_ip_addresses(address_list) - return [ + # Build command with multiple --device arguments for each address + command = [ *DASHBOARD_COMMAND, *args, config_file, - "--device", - port, ] + if addresses: + # Add multiple --device arguments for each resolved address + for address in addresses: + command.extend(["--device", address]) + else: + # Fallback to original port if no addresses were resolved + command.extend(["--device", port]) + + return command + class EsphomeLogsHandler(EsphomePortCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: From 13e9350568753567d10536389b1c2deb161c36b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 17:13:42 -1000 Subject: [PATCH 0207/1626] cleanup --- esphome/dashboard/web_server.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 6519a85f89..0fefad0ed1 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -332,14 +332,8 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): and entry.loaded_integrations and "api" in entry.loaded_integrations ): - if (mdns := dashboard.mdns_status) and ( - address_list := await mdns.async_resolve_host(entry.name) - ): - # Use all IP addresses if available but only - # if the API is loaded and the device is online - # since MQTT logging will not work otherwise - addresses = sort_ip_addresses(address_list) - elif ( + # First priority: use_address from configuration + if ( entry.address and ( address_list := await dashboard.dns_cache.async_resolve( @@ -348,7 +342,14 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): ) and not isinstance(address_list, Exception) ): - # If mdns is not available, try to use the DNS cache + addresses = sort_ip_addresses(address_list) + # Second priority: mDNS resolved addresses + elif (mdns := dashboard.mdns_status) and ( + address_list := await mdns.async_resolve_host(entry.name) + ): + # Use all IP addresses if available but only + # if the API is loaded and the device is online + # since MQTT logging will not work otherwise addresses = sort_ip_addresses(address_list) # Build command with multiple --device arguments for each address From 4caf2b70429e8b4405c0a9089f064b3161cfeb0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 17:16:15 -1000 Subject: [PATCH 0208/1626] cleanup --- esphome/__main__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index ae450238e4..54e3529096 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -402,7 +402,7 @@ def upload_program(config, args, host): return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) -def show_logs(config, args, devices): +def show_logs(config, args, devices: list[str]): if "logger" not in config: raise EsphomeError("Logger is not configured!") @@ -485,7 +485,7 @@ def command_compile(args, config): return 0 -def command_upload(args, config): +def command_upload(args, config) -> int: devices = args.device or [] if not devices: # No devices specified, use the interactive chooser @@ -522,7 +522,7 @@ def command_discover(args, config): raise EsphomeError("No discover method configured (mqtt)") -def command_logs(args, config): +def command_logs(args, config) -> int: devices = args.device or [] if not devices: # No devices specified, use the interactive chooser From d3f103c789d84c6c77fe97e3b0c14a763b7aae11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 17:28:04 -1000 Subject: [PATCH 0209/1626] make entry.address take priority over mdns --- esphome/dashboard/web_server.py | 37 +++++++++++++-------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 0fefad0ed1..8489c6f09c 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -324,15 +324,15 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): configuration = json_message["configuration"] config_file = settings.rel_path(configuration) port = json_message["port"] - addresses: list[str] = [] - + addresses: list[str] = [port] if ( port == "OTA" # pylint: disable=too-many-boolean-expressions and (entry := entries.get(config_file)) and entry.loaded_integrations and "api" in entry.loaded_integrations ): - # First priority: use_address from configuration + addresses = [] + # First priority: entry.address AKA use_address if ( entry.address and ( @@ -342,32 +342,23 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): ) and not isinstance(address_list, Exception) ): - addresses = sort_ip_addresses(address_list) - # Second priority: mDNS resolved addresses - elif (mdns := dashboard.mdns_status) and ( + addresses.extend(sort_ip_addresses(address_list)) + + # Second priority: mDNS + if (mdns := dashboard.mdns_status) and ( address_list := await mdns.async_resolve_host(entry.name) ): - # Use all IP addresses if available but only + # Use the IP address if available but only # if the API is loaded and the device is online # since MQTT logging will not work otherwise - addresses = sort_ip_addresses(address_list) + addresses.extend(sort_ip_addresses(address_list)) - # Build command with multiple --device arguments for each address - command = [ - *DASHBOARD_COMMAND, - *args, - config_file, - ] + device_args: list[str] = [] + for address in addresses: + device_args.append("--device") + device_args.append(address) - if addresses: - # Add multiple --device arguments for each resolved address - for address in addresses: - command.extend(["--device", address]) - else: - # Fallback to original port if no addresses were resolved - command.extend(["--device", port]) - - return command + return [*DASHBOARD_COMMAND, *args, config_file, *device_args] class EsphomeLogsHandler(EsphomePortCommandWebSocket): From c42c5dd946d75fc5120577d6ea02dd1fcc70650e Mon Sep 17 00:00:00 2001 From: NP v/d Spek Date: Fri, 1 Aug 2025 06:51:01 +0200 Subject: [PATCH 0210/1626] [espnow] Basic communication between ESP32 devices (#9582) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/espnow/__init__.py | 320 ++++++++++++ esphome/components/espnow/automation.h | 175 +++++++ .../components/espnow/espnow_component.cpp | 468 ++++++++++++++++++ esphome/components/espnow/espnow_component.h | 182 +++++++ esphome/components/espnow/espnow_err.h | 19 + esphome/components/espnow/espnow_packet.h | 166 +++++++ tests/components/espnow/common.yaml | 52 ++ tests/components/espnow/test.esp32-idf.yaml | 1 + 9 files changed, 1384 insertions(+) create mode 100644 esphome/components/espnow/__init__.py create mode 100644 esphome/components/espnow/automation.h create mode 100644 esphome/components/espnow/espnow_component.cpp create mode 100644 esphome/components/espnow/espnow_component.h create mode 100644 esphome/components/espnow/espnow_err.h create mode 100644 esphome/components/espnow/espnow_packet.h create mode 100644 tests/components/espnow/common.yaml create mode 100644 tests/components/espnow/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 244e204ab6..e40be9a737 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -155,6 +155,7 @@ esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core esphome/components/esp_ldo/* @clydebarrow +esphome/components/espnow/* @jesserockz esphome/components/ethernet_info/* @gtjadsonsantos esphome/components/event/* @nohat esphome/components/event_emitter/* @Rapsssito diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py new file mode 100644 index 0000000000..d15817cf92 --- /dev/null +++ b/esphome/components/espnow/__init__.py @@ -0,0 +1,320 @@ +from esphome import automation, core +import esphome.codegen as cg +from esphome.components import wifi +from esphome.components.udp import CONF_ON_RECEIVE +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_CHANNEL, + CONF_DATA, + CONF_ENABLE_ON_BOOT, + CONF_ID, + CONF_ON_ERROR, + CONF_TRIGGER_ID, + CONF_WIFI, +) +from esphome.core import CORE, HexInt +from esphome.types import ConfigType + +CODEOWNERS = ["@jesserockz"] + +byte_vector = cg.std_vector.template(cg.uint8) +peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) + +espnow_ns = cg.esphome_ns.namespace("espnow") +ESPNowComponent = espnow_ns.class_("ESPNowComponent", cg.Component) + +# Handler interfaces that other components can use to register callbacks +ESPNowReceivedPacketHandler = espnow_ns.class_("ESPNowReceivedPacketHandler") +ESPNowUnknownPeerHandler = espnow_ns.class_("ESPNowUnknownPeerHandler") +ESPNowBroadcastedHandler = espnow_ns.class_("ESPNowBroadcastedHandler") + +ESPNowRecvInfo = espnow_ns.class_("ESPNowRecvInfo") +ESPNowRecvInfoConstRef = ESPNowRecvInfo.operator("const").operator("ref") + +SendAction = espnow_ns.class_("SendAction", automation.Action) +SetChannelAction = espnow_ns.class_("SetChannelAction", automation.Action) +AddPeerAction = espnow_ns.class_("AddPeerAction", automation.Action) +DeletePeerAction = espnow_ns.class_("DeletePeerAction", automation.Action) + +ESPNowHandlerTrigger = automation.Trigger.template( + ESPNowRecvInfoConstRef, + cg.uint8.operator("const").operator("ptr"), + cg.uint8, +) + +OnUnknownPeerTrigger = espnow_ns.class_( + "OnUnknownPeerTrigger", ESPNowHandlerTrigger, ESPNowUnknownPeerHandler +) +OnReceiveTrigger = espnow_ns.class_( + "OnReceiveTrigger", ESPNowHandlerTrigger, ESPNowReceivedPacketHandler +) +OnBroadcastedTrigger = espnow_ns.class_( + "OnBroadcastedTrigger", ESPNowHandlerTrigger, ESPNowBroadcastedHandler +) + + +CONF_AUTO_ADD_PEER = "auto_add_peer" +CONF_PEERS = "peers" +CONF_ON_SENT = "on_sent" +CONF_ON_UNKNOWN_PEER = "on_unknown_peer" +CONF_ON_BROADCAST = "on_broadcast" +CONF_CONTINUE_ON_ERROR = "continue_on_error" +CONF_WAIT_FOR_SENT = "wait_for_sent" + +MAX_ESPNOW_PACKET_SIZE = 250 # Maximum size of the payload in bytes + + +def _validate_unknown_peer(config): + if config[CONF_AUTO_ADD_PEER] and config.get(CONF_ON_UNKNOWN_PEER): + raise cv.Invalid( + f"'{CONF_ON_UNKNOWN_PEER}' cannot be used when '{CONF_AUTO_ADD_PEER}' is enabled.", + path=[CONF_ON_UNKNOWN_PEER], + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ESPNowComponent), + cv.OnlyWithout(CONF_CHANNEL, CONF_WIFI): wifi.validate_channel, + cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + cv.Optional(CONF_AUTO_ADD_PEER, default=False): cv.boolean, + cv.Optional(CONF_PEERS): cv.ensure_list(cv.mac_address), + cv.Optional(CONF_ON_UNKNOWN_PEER): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnUnknownPeerTrigger), + }, + single=True, + ), + cv.Optional(CONF_ON_RECEIVE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnReceiveTrigger), + cv.Optional(CONF_ADDRESS): cv.mac_address, + } + ), + cv.Optional(CONF_ON_BROADCAST): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnBroadcastedTrigger), + cv.Optional(CONF_ADDRESS): cv.mac_address, + } + ), + }, + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, + _validate_unknown_peer, +) + + +async def _trigger_to_code(config): + if address := config.get(CONF_ADDRESS): + address = address.parts + trigger = cg.new_Pvariable(config[CONF_TRIGGER_ID], address) + await automation.build_automation( + trigger, + [ + (ESPNowRecvInfoConstRef, "info"), + (cg.uint8.operator("const").operator("ptr"), "data"), + (cg.uint8, "size"), + ], + config, + ) + return trigger + + +async def to_code(config): + print(config) + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if CORE.using_arduino: + cg.add_library("WiFi", None) + + cg.add_define("USE_ESPNOW") + if wifi_channel := config.get(CONF_CHANNEL): + cg.add(var.set_wifi_channel(wifi_channel)) + + cg.add(var.set_auto_add_peer(config[CONF_AUTO_ADD_PEER])) + + for peer in config.get(CONF_PEERS, []): + cg.add(var.add_peer(peer.parts)) + + if on_receive := config.get(CONF_ON_UNKNOWN_PEER): + trigger = await _trigger_to_code(on_receive) + cg.add(var.register_unknown_peer_handler(trigger)) + + for on_receive in config.get(CONF_ON_RECEIVE, []): + trigger = await _trigger_to_code(on_receive) + cg.add(var.register_received_handler(trigger)) + + for on_receive in config.get(CONF_ON_BROADCAST, []): + trigger = await _trigger_to_code(on_receive) + cg.add(var.register_broadcasted_handler(trigger)) + + +# ========================================== A C T I O N S ================================================ + + +def validate_peer(value): + if isinstance(value, cv.Lambda): + return cv.returning_lambda(value) + return cv.mac_address(value) + + +def _validate_raw_data(value): + if isinstance(value, str): + if len(value) >= MAX_ESPNOW_PACKET_SIZE: + raise cv.Invalid( + f"'{CONF_DATA}' must be less than {MAX_ESPNOW_PACKET_SIZE} characters long, got {len(value)}" + ) + return value + if isinstance(value, list): + if len(value) > MAX_ESPNOW_PACKET_SIZE: + raise cv.Invalid( + f"'{CONF_DATA}' must be less than {MAX_ESPNOW_PACKET_SIZE} bytes long, got {len(value)}" + ) + return cv.Schema([cv.hex_uint8_t])(value) + raise cv.Invalid( + f"'{CONF_DATA}' must either be a string wrapped in quotes or a list of bytes" + ) + + +async def register_peer(var, config, args): + peer = config[CONF_ADDRESS] + if isinstance(peer, core.MACAddress): + peer = [HexInt(p) for p in peer.parts] + + template_ = await cg.templatable(peer, args, peer_address_t, peer_address_t) + cg.add(var.set_address(template_)) + + +PEER_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(ESPNowComponent), + cv.Required(CONF_ADDRESS): cv.templatable(cv.mac_address), + } +) + +SEND_SCHEMA = PEER_SCHEMA.extend( + { + cv.Required(CONF_DATA): cv.templatable(_validate_raw_data), + cv.Optional(CONF_ON_SENT): automation.validate_action_list, + cv.Optional(CONF_ON_ERROR): automation.validate_action_list, + cv.Optional(CONF_WAIT_FOR_SENT, default=True): cv.boolean, + cv.Optional(CONF_CONTINUE_ON_ERROR, default=True): cv.boolean, + } +) + + +def _validate_send_action(config): + if not config[CONF_WAIT_FOR_SENT] and not config[CONF_CONTINUE_ON_ERROR]: + raise cv.Invalid( + f"'{CONF_CONTINUE_ON_ERROR}' cannot be false if '{CONF_WAIT_FOR_SENT}' is false as the automation will not wait for the failed result.", + path=[CONF_CONTINUE_ON_ERROR], + ) + return config + + +SEND_SCHEMA.add_extra(_validate_send_action) + + +@automation.register_action( + "espnow.send", + SendAction, + SEND_SCHEMA, +) +@automation.register_action( + "espnow.broadcast", + SendAction, + cv.maybe_simple_value( + SEND_SCHEMA.extend( + { + cv.Optional(CONF_ADDRESS, default="FF:FF:FF:FF:FF:FF"): cv.mac_address, + } + ), + key=CONF_DATA, + ), +) +async def send_action( + config: ConfigType, + action_id: core.ID, + template_arg: cg.TemplateArguments, + args: list[tuple], +): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + await register_peer(var, config, args) + + data = config.get(CONF_DATA, []) + if isinstance(data, str): + data = [cg.RawExpression(f"'{c}'") for c in data] + templ = await cg.templatable(data, args, byte_vector, byte_vector) + cg.add(var.set_data(templ)) + + cg.add(var.set_wait_for_sent(config[CONF_WAIT_FOR_SENT])) + cg.add(var.set_continue_on_error(config[CONF_CONTINUE_ON_ERROR])) + + if on_sent_config := config.get(CONF_ON_SENT): + actions = await automation.build_action_list(on_sent_config, template_arg, args) + cg.add(var.add_on_sent(actions)) + if on_error_config := config.get(CONF_ON_ERROR): + actions = await automation.build_action_list( + on_error_config, template_arg, args + ) + cg.add(var.add_on_error(actions)) + return var + + +@automation.register_action( + "espnow.peer.add", + AddPeerAction, + cv.maybe_simple_value( + PEER_SCHEMA, + key=CONF_ADDRESS, + ), +) +@automation.register_action( + "espnow.peer.delete", + DeletePeerAction, + cv.maybe_simple_value( + PEER_SCHEMA, + key=CONF_ADDRESS, + ), +) +async def peer_action( + config: ConfigType, + action_id: core.ID, + template_arg: cg.TemplateArguments, + args: list[tuple], +): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + await register_peer(var, config, args) + + return var + + +@automation.register_action( + "espnow.set_channel", + SetChannelAction, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(ESPNowComponent), + cv.Required(CONF_CHANNEL): cv.templatable(wifi.validate_channel), + }, + key=CONF_CHANNEL, + ), +) +async def channel_action( + config: ConfigType, + action_id: core.ID, + template_arg: cg.TemplateArguments, + args: list[tuple], +): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) + cg.add(var.set_channel(template_)) + return var diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h new file mode 100644 index 0000000000..ad534b279a --- /dev/null +++ b/esphome/components/espnow/automation.h @@ -0,0 +1,175 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "espnow_component.h" + +#include "esphome/core/automation.h" +#include "esphome/core/base_automation.h" + +namespace esphome::espnow { + +template class SendAction : public Action, public Parented { + TEMPLATABLE_VALUE(peer_address_t, address); + TEMPLATABLE_VALUE(std::vector, data); + + public: + void add_on_sent(const std::vector *> &actions) { + this->sent_.add_actions(actions); + if (this->flags_.wait_for_sent) { + this->sent_.add_action(new LambdaAction([this](Ts... x) { this->play_next_(x...); })); + } + } + void add_on_error(const std::vector *> &actions) { + this->error_.add_actions(actions); + if (this->flags_.wait_for_sent) { + this->error_.add_action(new LambdaAction([this](Ts... x) { + if (this->flags_.continue_on_error) { + this->play_next_(x...); + } else { + this->stop_complex(); + } + })); + } + } + + void set_wait_for_sent(bool wait_for_sent) { this->flags_.wait_for_sent = wait_for_sent; } + void set_continue_on_error(bool continue_on_error) { this->flags_.continue_on_error = continue_on_error; } + + void play_complex(Ts... x) override { + this->num_running_++; + send_callback_t send_callback = [this, x...](esp_err_t status) { + if (status == ESP_OK) { + if (this->sent_.empty() && this->flags_.wait_for_sent) { + this->play_next_(x...); + } else if (!this->sent_.empty()) { + this->sent_.play(x...); + } + } else { + if (this->error_.empty() && this->flags_.wait_for_sent) { + if (this->flags_.continue_on_error) { + this->play_next_(x...); + } else { + this->stop_complex(); + } + } else if (!this->error_.empty()) { + this->error_.play(x...); + } + } + }; + peer_address_t address = this->address_.value(x...); + std::vector data = this->data_.value(x...); + esp_err_t err = this->parent_->send(address.data(), data, send_callback); + if (err != ESP_OK) { + send_callback(err); + } else if (!this->flags_.wait_for_sent) { + this->play_next_(x...); + } + } + + void play(Ts... x) override { /* ignore - see play_complex */ + } + + void stop() override { + this->sent_.stop(); + this->error_.stop(); + } + + protected: + ActionList sent_; + ActionList error_; + + struct { + uint8_t wait_for_sent : 1; // Wait for the send operation to complete before continuing automation + uint8_t continue_on_error : 1; // Continue automation even if the send operation fails + uint8_t reserved : 6; // Reserved for future use + } flags_{0}; +}; + +template class AddPeerAction : public Action, public Parented { + TEMPLATABLE_VALUE(peer_address_t, address); + + public: + void play(Ts... x) override { + peer_address_t address = this->address_.value(x...); + this->parent_->add_peer(address.data()); + } +}; + +template class DeletePeerAction : public Action, public Parented { + TEMPLATABLE_VALUE(peer_address_t, address); + + public: + void play(Ts... x) override { + peer_address_t address = this->address_.value(x...); + this->parent_->del_peer(address.data()); + } +}; + +template class SetChannelAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint8_t, channel) + void play(Ts... x) override { + if (this->parent_->is_wifi_enabled()) { + return; + } + this->parent_->set_wifi_channel(this->channel_.value(x...)); + this->parent_->apply_wifi_channel(); + } +}; + +class OnReceiveTrigger : public Trigger, + public ESPNowReceivedPacketHandler { + public: + explicit OnReceiveTrigger(std::array address) : has_address_(true) { + memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); + } + + explicit OnReceiveTrigger() : has_address_(false) {} + + bool on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool match = !this->has_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); + if (!match) + return false; + + this->trigger(info, data, size); + return false; // Return false to continue processing other internal handlers + } + + protected: + bool has_address_{false}; + const uint8_t *address_[ESP_NOW_ETH_ALEN]; +}; +class OnUnknownPeerTrigger : public Trigger, + public ESPNowUnknownPeerHandler { + public: + bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + this->trigger(info, data, size); + return false; // Return false to continue processing other internal handlers + } +}; +class OnBroadcastedTrigger : public Trigger, + public ESPNowBroadcastedHandler { + public: + explicit OnBroadcastedTrigger(std::array address) : has_address_(true) { + memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); + } + explicit OnBroadcastedTrigger() : has_address_(false) {} + + bool on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool match = !this->has_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); + if (!match) + return false; + + this->trigger(info, data, size); + return false; // Return false to continue processing other internal handlers + } + + protected: + bool has_address_{false}; + const uint8_t *address_[ESP_NOW_ETH_ALEN]; +}; + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp new file mode 100644 index 0000000000..dab8e2b726 --- /dev/null +++ b/esphome/components/espnow/espnow_component.cpp @@ -0,0 +1,468 @@ +#include "espnow_component.h" + +#ifdef USE_ESP32 + +#include "espnow_err.h" + +#include "esphome/core/defines.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif + +namespace esphome::espnow { + +static constexpr const char *TAG = "espnow"; + +static const esp_err_t CONFIG_ESPNOW_WAKE_WINDOW = 50; +static const esp_err_t CONFIG_ESPNOW_WAKE_INTERVAL = 100; + +ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static const LogString *espnow_error_to_str(esp_err_t error) { + switch (error) { + case ESP_ERR_ESPNOW_FAILED: + return LOG_STR("ESPNow is in fail mode"); + case ESP_ERR_ESPNOW_OWN_ADDRESS: + return LOG_STR("Message to your self"); + case ESP_ERR_ESPNOW_DATA_SIZE: + return LOG_STR("Data size to large"); + case ESP_ERR_ESPNOW_PEER_NOT_SET: + return LOG_STR("Peer address not set"); + case ESP_ERR_ESPNOW_PEER_NOT_PAIRED: + return LOG_STR("Peer address not paired"); + case ESP_ERR_ESPNOW_NOT_INIT: + return LOG_STR("Not init"); + case ESP_ERR_ESPNOW_ARG: + return LOG_STR("Invalid argument"); + case ESP_ERR_ESPNOW_INTERNAL: + return LOG_STR("Internal Error"); + case ESP_ERR_ESPNOW_NO_MEM: + return LOG_STR("Our of memory"); + case ESP_ERR_ESPNOW_NOT_FOUND: + return LOG_STR("Peer not found"); + case ESP_ERR_ESPNOW_IF: + return LOG_STR("Interface does not match"); + case ESP_OK: + return LOG_STR("OK"); + case ESP_NOW_SEND_FAIL: + return LOG_STR("Failed"); + default: + return LOG_STR("Unknown Error"); + } +} + +std::string peer_str(uint8_t *peer) { + if (peer == nullptr || peer[0] == 0) { + return "[Not Set]"; + } else if (memcmp(peer, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + return "[Broadcast]"; + } else if (memcmp(peer, ESPNOW_MULTICAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + return "[Multicast]"; + } else { + return format_mac_address_pretty(peer); + } +} + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) +void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status) +#else +void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) +#endif +{ + // Allocate an event from the pool + ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + if (packet == nullptr) { + // No events available - queue is full or we're out of memory + global_esp_now->receive_packet_queue_.increment_dropped_count(); + return; + } + +// Load new packet data (replaces previous packet) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + packet->load_sent_data(info->des_addr, status); +#else + packet->load_sent_data(mac_addr, status); +#endif + + // Push the packet to the queue + global_esp_now->receive_packet_queue_.push(packet); + // Push always because we're the only producer and the pool ensures we never exceed queue size +} + +void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + // Allocate an event from the pool + ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + if (packet == nullptr) { + // No events available - queue is full or we're out of memory + global_esp_now->receive_packet_queue_.increment_dropped_count(); + return; + } + + // Load new packet data (replaces previous packet) + packet->load_received_data(info, data, size); + + // Push the packet to the queue + global_esp_now->receive_packet_queue_.push(packet); + // Push always because we're the only producer and the pool ensures we never exceed queue size +} + +ESPNowComponent::ESPNowComponent() { global_esp_now = this; } + +void ESPNowComponent::dump_config() { + uint32_t version = 0; + esp_now_get_version(&version); + + ESP_LOGCONFIG(TAG, "espnow:"); + if (this->is_disabled()) { + ESP_LOGCONFIG(TAG, " Disabled"); + return; + } + ESP_LOGCONFIG(TAG, + " Own address: %s\n" + " Version: v%" PRIu32 "\n" + " Wi-Fi channel: %d", + format_mac_address_pretty(this->own_address_).c_str(), version, this->wifi_channel_); +#ifdef USE_WIFI + ESP_LOGCONFIG(TAG, " Wi-Fi enabled: %s", YESNO(this->is_wifi_enabled())); +#endif +} + +bool ESPNowComponent::is_wifi_enabled() { +#ifdef USE_WIFI + return wifi::global_wifi_component != nullptr && !wifi::global_wifi_component->is_disabled(); +#else + return false; +#endif +} + +void ESPNowComponent::setup() { + if (this->enable_on_boot_) { + this->enable_(); + } else { + this->state_ = ESPNOW_STATE_DISABLED; + } +} + +void ESPNowComponent::enable() { + if (this->state_ != ESPNOW_STATE_ENABLED) + return; + + ESP_LOGD(TAG, "Enabling"); + this->state_ = ESPNOW_STATE_OFF; + + this->enable_(); +} + +void ESPNowComponent::enable_() { + if (!this->is_wifi_enabled()) { + esp_event_loop_create_default(); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); + ESP_ERROR_CHECK(esp_wifi_start()); + ESP_ERROR_CHECK(esp_wifi_disconnect()); + + this->apply_wifi_channel(); + } +#ifdef USE_WIFI + else { + this->wifi_channel_ = wifi::global_wifi_component->get_wifi_channel(); + } +#endif + + esp_err_t err = esp_now_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_init failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + err = esp_now_register_recv_cb(on_data_received); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + err = esp_now_register_send_cb(on_send_report); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + esp_wifi_get_mac(WIFI_IF_STA, this->own_address_); + +#ifdef USE_DEEP_SLEEP + esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW); + esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL); +#endif + + for (auto peer : this->peers_) { + this->add_peer(peer.address); + } + this->state_ = ESPNOW_STATE_ENABLED; +} + +void ESPNowComponent::disable() { + if (this->state_ == ESPNOW_STATE_DISABLED) + return; + + ESP_LOGD(TAG, "Disabling"); + this->state_ = ESPNOW_STATE_DISABLED; + + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); + + for (auto peer : this->peers_) { + this->del_peer(peer.address); + } + + esp_err_t err = esp_now_deinit(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_now_deinit failed! 0x%x", err); + } +} + +void ESPNowComponent::apply_wifi_channel() { + if (this->state_ == ESPNOW_STATE_DISABLED) { + ESP_LOGE(TAG, "Cannot set channel when ESPNOW disabled"); + this->mark_failed(); + return; + } + + if (this->is_wifi_enabled()) { + ESP_LOGE(TAG, "Cannot set channel when Wi-Fi enabled"); + this->mark_failed(); + return; + } + + ESP_LOGI(TAG, "Channel set to %d.", this->wifi_channel_); + esp_wifi_set_promiscuous(true); + esp_wifi_set_channel(this->wifi_channel_, WIFI_SECOND_CHAN_NONE); + esp_wifi_set_promiscuous(false); +} + +void ESPNowComponent::loop() { +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) { + int32_t new_channel = wifi::global_wifi_component->get_wifi_channel(); + if (new_channel != this->wifi_channel_) { + ESP_LOGI(TAG, "Wifi Channel is changed from %d to %d.", this->wifi_channel_, new_channel); + this->wifi_channel_ = new_channel; + } + } +#endif + + // Process received packets + ESPNowPacket *packet = this->receive_packet_queue_.pop(); + while (packet != nullptr) { + switch (packet->type_) { + case ESPNowPacket::RECEIVED: { + const ESPNowRecvInfo info = packet->get_receive_info(); + if (!esp_now_is_peer_exist(info.src_addr)) { + if (this->auto_add_peer_) { + this->add_peer(info.src_addr); + } else { + for (auto *handler : this->unknown_peer_handlers_) { + if (handler->on_unknown_peer(info, packet->packet_.receive.data, packet->packet_.receive.size)) + break; // If a handler returns true, stop processing further handlers + } + } + } + // Intentionally left as if instead of else in case the peer is added above + if (esp_now_is_peer_exist(info.src_addr)) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, "<<< [%s -> %s] %s", format_mac_address_pretty(info.src_addr).c_str(), + format_mac_address_pretty(info.des_addr).c_str(), + format_hex_pretty(packet->packet_.receive.data, packet->packet_.receive.size).c_str()); +#endif + if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + for (auto *handler : this->broadcasted_handlers_) { + if (handler->on_broadcasted(info, packet->packet_.receive.data, packet->packet_.receive.size)) + break; // If a handler returns true, stop processing further handlers + } + } else { + for (auto *handler : this->received_handlers_) { + if (handler->on_received(info, packet->packet_.receive.data, packet->packet_.receive.size)) + break; // If a handler returns true, stop processing further handlers + } + } + } + break; + } + case ESPNowPacket::SENT: { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, ">>> [%s] %s", format_mac_address_pretty(packet->packet_.sent.address).c_str(), + LOG_STR_ARG(espnow_error_to_str(packet->packet_.sent.status))); +#endif + if (this->current_send_packet_ != nullptr) { + this->current_send_packet_->callback_(packet->packet_.sent.status); + this->send_packet_pool_.release(this->current_send_packet_); + this->current_send_packet_ = nullptr; // Reset current packet after sending + } + break; + } + default: + break; + } + // Return the packet to the pool + this->receive_packet_pool_.release(packet); + packet = this->receive_packet_queue_.pop(); + } + + // Process sending packet queue + if (this->current_send_packet_ == nullptr) { + this->send_(); + } + + // Log dropped received packets periodically + uint16_t received_dropped = this->receive_packet_queue_.get_and_reset_dropped_count(); + if (received_dropped > 0) { + ESP_LOGW(TAG, "Dropped %u received packets due to buffer overflow", received_dropped); + } + + // Log dropped send packets periodically + uint16_t send_dropped = this->send_packet_queue_.get_and_reset_dropped_count(); + if (send_dropped > 0) { + ESP_LOGW(TAG, "Dropped %u send packets due to buffer overflow", send_dropped); + } +} + +esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payload, size_t size, + const send_callback_t &callback) { + if (this->state_ != ESPNOW_STATE_ENABLED) { + return ESP_ERR_ESPNOW_NOT_INIT; + } else if (this->is_failed()) { + return ESP_ERR_ESPNOW_FAILED; + } else if (peer_address == 0ULL) { + return ESP_ERR_ESPNOW_PEER_NOT_SET; + } else if (memcmp(peer_address, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { + return ESP_ERR_ESPNOW_OWN_ADDRESS; + } else if (size > ESP_NOW_MAX_DATA_LEN) { + return ESP_ERR_ESPNOW_DATA_SIZE; + } else if (!esp_now_is_peer_exist(peer_address)) { + if (memcmp(peer_address, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0 || this->auto_add_peer_) { + esp_err_t err = this->add_peer(peer_address); + if (err != ESP_OK) { + return err; + } + } else { + return ESP_ERR_ESPNOW_PEER_NOT_PAIRED; + } + } + // Allocate a packet from the pool + ESPNowSendPacket *packet = this->send_packet_pool_.allocate(); + if (packet == nullptr) { + this->send_packet_queue_.increment_dropped_count(); + ESP_LOGE(TAG, "Failed to allocate send packet from pool"); + this->status_momentary_warning("send-packet-pool-full"); + return ESP_ERR_ESPNOW_NO_MEM; + } + // Load the packet data + packet->load_data(peer_address, payload, size, callback); + // Push the packet to the send queue + this->send_packet_queue_.push(packet); + return ESP_OK; +} + +void ESPNowComponent::send_() { + ESPNowSendPacket *packet = this->send_packet_queue_.pop(); + if (packet == nullptr) { + return; // No packets to send + } + + this->current_send_packet_ = packet; + esp_err_t err = esp_now_send(packet->address_, packet->data_, packet->size_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to send packet to %s - %s", format_mac_address_pretty(packet->address_).c_str(), + LOG_STR_ARG(espnow_error_to_str(err))); + if (packet->callback_ != nullptr) { + packet->callback_(err); + } + this->status_momentary_warning("send-failed"); + this->send_packet_pool_.release(packet); + this->current_send_packet_ = nullptr; // Reset current packet + return; + } +} + +esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { + if (this->state_ != ESPNOW_STATE_ENABLED || this->is_failed()) { + return ESP_ERR_ESPNOW_NOT_INIT; + } + + if (memcmp(peer, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { + this->mark_failed(); + return ESP_ERR_INVALID_MAC; + } + + if (!esp_now_is_peer_exist(peer)) { + esp_now_peer_info_t peer_info = {}; + memset(&peer_info, 0, sizeof(esp_now_peer_info_t)); + peer_info.ifidx = WIFI_IF_STA; + memcpy(peer_info.peer_addr, peer, ESP_NOW_ETH_ALEN); + esp_err_t err = esp_now_add_peer(&peer_info); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to add peer %s - %s", format_mac_address_pretty(peer).c_str(), + LOG_STR_ARG(espnow_error_to_str(err))); + this->status_momentary_warning("peer-add-failed"); + return err; + } + } + bool found = false; + for (auto &it : this->peers_) { + if (it == peer) { + found = true; + break; + } + } + if (!found) { + ESPNowPeer new_peer; + memcpy(new_peer.address, peer, ESP_NOW_ETH_ALEN); + this->peers_.push_back(new_peer); + } + + return ESP_OK; +} + +esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { + if (this->state_ != ESPNOW_STATE_ENABLED || this->is_failed()) { + return ESP_ERR_ESPNOW_NOT_INIT; + } + if (esp_now_is_peer_exist(peer)) { + esp_err_t err = esp_now_del_peer(peer); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to delete peer %s - %s", format_mac_address_pretty(peer).c_str(), + LOG_STR_ARG(espnow_error_to_str(err))); + this->status_momentary_warning("peer-del-failed"); + return err; + } + } + for (auto it = this->peers_.begin(); it != this->peers_.end(); ++it) { + if (*it == peer) { + this->peers_.erase(it); + break; + } + } + return ESP_OK; +} + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h new file mode 100644 index 0000000000..3a523d1f7e --- /dev/null +++ b/esphome/components/espnow/espnow_component.h @@ -0,0 +1,182 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" + +#ifdef USE_ESP32 + +#include "esphome/core/event_pool.h" +#include "esphome/core/lock_free_queue.h" +#include "espnow_packet.h" + +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace esphome::espnow { + +// Maximum size of the ESPNow event queue - must be power of 2 for lock-free queue +static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 16; +static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 16; + +using peer_address_t = std::array; + +enum class ESPNowTriggers : uint8_t { + TRIGGER_NONE = 0, + ON_NEW_PEER = 1, + ON_RECEIVED = 2, + ON_BROADCASTED = 3, + ON_SUCCEED = 10, + ON_FAILED = 11, +}; + +enum ESPNowState : uint8_t { + /** Nothing has been initialized yet. */ + ESPNOW_STATE_OFF = 0, + /** ESPNOW is disabled. */ + ESPNOW_STATE_DISABLED, + /** ESPNOW is enabled. */ + ESPNOW_STATE_ENABLED, +}; + +struct ESPNowPeer { + uint8_t address[ESP_NOW_ETH_ALEN]; // MAC address of the peer + + bool operator==(const ESPNowPeer &other) const { return memcmp(this->address, other.address, ESP_NOW_ETH_ALEN) == 0; } + bool operator==(const uint8_t *other) const { return memcmp(this->address, other, ESP_NOW_ETH_ALEN) == 0; } +}; + +/// Handler interface for receiving ESPNow packets from unknown peers +/// Components should inherit from this class to handle incoming ESPNow data +class ESPNowUnknownPeerHandler { + public: + /// Called when an ESPNow packet is received from an unknown peer + /// @param info Information about the received packet (sender MAC, etc.) + /// @param data Pointer to the received data payload + /// @param size Size of the received data in bytes + /// @return true if the packet was handled, false otherwise + virtual bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; +}; + +/// Handler interface for receiving ESPNow packets +/// Components should inherit from this class to handle incoming ESPNow data +class ESPNowReceivedPacketHandler { + public: + /// Called when an ESPNow packet is received + /// @param info Information about the received packet (sender MAC, etc.) + /// @param data Pointer to the received data payload + /// @param size Size of the received data in bytes + /// @return true if the packet was handled, false otherwise + virtual bool on_received(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; +}; +/// Handler interface for receiving broadcasted ESPNow packets +/// Components should inherit from this class to handle incoming ESPNow data +class ESPNowBroadcastedHandler { + public: + /// Called when a broadcasted ESPNow packet is received + /// @param info Information about the received packet (sender MAC, etc.) + /// @param data Pointer to the received data payload + /// @param size Size of the received data in bytes + /// @return true if the packet was handled, false otherwise + virtual bool on_broadcasted(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; +}; + +class ESPNowComponent : public Component { + public: + ESPNowComponent(); + void setup() override; + void loop() override; + void dump_config() override; + + float get_setup_priority() const override { return setup_priority::LATE; } + + // Add a peer to the internal list of peers + void add_peer(peer_address_t address) { + ESPNowPeer peer; + memcpy(peer.address, address.data(), ESP_NOW_ETH_ALEN); + this->peers_.push_back(peer); + } + // Add a peer with the esp_now api and add to the internal list if doesnt exist already + esp_err_t add_peer(const uint8_t *peer); + // Remove a peer with the esp_now api and remove from the internal list if exists + esp_err_t del_peer(const uint8_t *peer); + + void set_wifi_channel(uint8_t channel) { this->wifi_channel_ = channel; } + void apply_wifi_channel(); + + void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; } + + void enable(); + void disable(); + bool is_disabled() const { return this->state_ == ESPNOW_STATE_DISABLED; }; + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + bool is_wifi_enabled(); + + /// @brief Queue a packet to be sent to a specific peer address. + /// This method will add the packet to the internal queue and + /// call the callback when the packet is sent. + /// Only one packet will be sent at any given time and the next one will not be sent until + /// the previous one has been acknowledged or failed. + /// @param peer_address MAC address of the peer to send the packet to + /// @param payload Data payload to send + /// @param callback Callback to call when the send operation is complete + /// @return ESP_OK on success, or an error code on failure + esp_err_t send(const uint8_t *peer_address, const std::vector &payload, + const send_callback_t &callback = nullptr) { + return this->send(peer_address, payload.data(), payload.size(), callback); + } + esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, + const send_callback_t &callback = nullptr); + + void register_received_handler(ESPNowReceivedPacketHandler *handler) { this->received_handlers_.push_back(handler); } + void register_unknown_peer_handler(ESPNowUnknownPeerHandler *handler) { + this->unknown_peer_handlers_.push_back(handler); + } + void register_broadcasted_handler(ESPNowBroadcastedHandler *handler) { + this->broadcasted_handlers_.push_back(handler); + } + + protected: + friend void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + friend void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status); +#else + friend void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status); +#endif + + void enable_(); + void send_(); + + std::vector unknown_peer_handlers_; + std::vector received_handlers_; + std::vector broadcasted_handlers_; + + std::vector peers_{}; + + uint8_t own_address_[ESP_NOW_ETH_ALEN]{0}; + LockFreeQueue receive_packet_queue_{}; + EventPool receive_packet_pool_{}; + + LockFreeQueue send_packet_queue_{}; + EventPool send_packet_pool_{}; + ESPNowSendPacket *current_send_packet_{nullptr}; // Currently sending packet, nullptr if none + + uint8_t wifi_channel_{0}; + ESPNowState state_{ESPNOW_STATE_OFF}; + + bool auto_add_peer_{false}; + bool enable_on_boot_{true}; +}; + +extern ESPNowComponent *global_esp_now; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_err.h b/esphome/components/espnow/espnow_err.h new file mode 100644 index 0000000000..ceda1b7683 --- /dev/null +++ b/esphome/components/espnow/espnow_err.h @@ -0,0 +1,19 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include + +namespace esphome::espnow { + +static const esp_err_t ESP_ERR_ESPNOW_CMP_BASE = (ESP_ERR_ESPNOW_BASE + 20); +static const esp_err_t ESP_ERR_ESPNOW_FAILED = (ESP_ERR_ESPNOW_CMP_BASE + 1); +static const esp_err_t ESP_ERR_ESPNOW_OWN_ADDRESS = (ESP_ERR_ESPNOW_CMP_BASE + 2); +static const esp_err_t ESP_ERR_ESPNOW_DATA_SIZE = (ESP_ERR_ESPNOW_CMP_BASE + 3); +static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_SET = (ESP_ERR_ESPNOW_CMP_BASE + 4); +static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_PAIRED = (ESP_ERR_ESPNOW_CMP_BASE + 5); + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h new file mode 100644 index 0000000000..d39f7d2c24 --- /dev/null +++ b/esphome/components/espnow/espnow_packet.h @@ -0,0 +1,166 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "espnow_err.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace esphome::espnow { + +static const uint8_t ESPNOW_BROADCAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; +static const uint8_t ESPNOW_MULTICAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE}; + +struct WifiPacketRxControl { + int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm + uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or + // light sleep is not enabled +}; + +struct ESPNowRecvInfo { + uint8_t src_addr[ESP_NOW_ETH_ALEN]; /**< Source address of ESPNOW packet */ + uint8_t des_addr[ESP_NOW_ETH_ALEN]; /**< Destination address of ESPNOW packet */ + wifi_pkt_rx_ctrl_t *rx_ctrl; /**< Rx control info of ESPNOW packet */ +}; + +using send_callback_t = std::function; + +class ESPNowPacket { + public: + // NOLINTNEXTLINE(readability-identifier-naming) + enum esp_now_packet_type_t : uint8_t { + RECEIVED, + SENT, + }; + + // Constructor for received data + ESPNowPacket(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + this->init_received_data_(info, data, size); + }; + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + // Constructor for sent data + ESPNowPacket(const esp_now_send_info_t *info, esp_now_send_status_t status) { + this->init_sent_data(info->src_addr, status); + } +#else + // Constructor for sent data + ESPNowPacket(const uint8_t *mac_addr, esp_now_send_status_t status) { this->init_sent_data_(mac_addr, status); } +#endif + + // Default constructor for pre-allocation in pool + ESPNowPacket() {} + + void release() {} + + void load_received_data(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + this->type_ = RECEIVED; + this->init_received_data_(info, data, size); + } + + void load_sent_data(const uint8_t *mac_addr, esp_now_send_status_t status) { + this->type_ = SENT; + this->init_sent_data_(mac_addr, status); + } + + // Disable copy to prevent double-delete + ESPNowPacket(const ESPNowPacket &) = delete; + ESPNowPacket &operator=(const ESPNowPacket &) = delete; + + union { + // NOLINTNEXTLINE(readability-identifier-naming) + struct received_data { + ESPNowRecvInfo info; // Information about the received packet + uint8_t data[ESP_NOW_MAX_DATA_LEN]; // Data received in the packet + uint8_t size; // Size of the received data + WifiPacketRxControl rx_ctrl; // Status of the received packet + } receive; + + // NOLINTNEXTLINE(readability-identifier-naming) + struct sent_data { + uint8_t address[ESP_NOW_ETH_ALEN]; + esp_now_send_status_t status; + } sent; + } packet_; + + esp_now_packet_type_t type_; + + esp_now_packet_type_t type() const { return this->type_; } + const ESPNowRecvInfo &get_receive_info() const { return this->packet_.receive.info; } + + private: + void init_received_data_(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + memcpy(this->packet_.receive.info.src_addr, info->src_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.info.des_addr, info->des_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.data, data, size); + this->packet_.receive.size = size; + + this->packet_.receive.rx_ctrl.rssi = info->rx_ctrl->rssi; + this->packet_.receive.rx_ctrl.timestamp = info->rx_ctrl->timestamp; + + this->packet_.receive.info.rx_ctrl = reinterpret_cast(&this->packet_.receive.rx_ctrl); + } + + void init_sent_data_(const uint8_t *mac_addr, esp_now_send_status_t status) { + memcpy(this->packet_.sent.address, mac_addr, ESP_NOW_ETH_ALEN); + this->packet_.sent.status = status; + } +}; + +class ESPNowSendPacket { + public: + ESPNowSendPacket(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &&callback) + : callback_(callback) { + this->init_data_(peer_address, payload, size); + } + ESPNowSendPacket(const uint8_t *peer_address, const uint8_t *payload, size_t size) { + this->init_data_(peer_address, payload, size); + } + + // Default constructor for pre-allocation in pool + ESPNowSendPacket() {} + + void release() {} + + // Disable copy to prevent double-delete + ESPNowSendPacket(const ESPNowSendPacket &) = delete; + ESPNowSendPacket &operator=(const ESPNowSendPacket &) = delete; + + void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &callback) { + this->init_data_(peer_address, payload, size); + this->callback_ = callback; + } + + void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size) { + this->init_data_(peer_address, payload, size); + this->callback_ = nullptr; // Reset callback + } + + uint8_t address_[ESP_NOW_ETH_ALEN]{0}; // MAC address of the peer to send the packet to + uint8_t data_[ESP_NOW_MAX_DATA_LEN]{0}; // Data to send + uint8_t size_{0}; // Size of the data to send, must be <= ESP_NOW_MAX_DATA_LEN + send_callback_t callback_{nullptr}; // Callback to call when the send operation is complete + + private: + void init_data_(const uint8_t *peer_address, const uint8_t *payload, size_t size) { + memcpy(this->address_, peer_address, ESP_NOW_ETH_ALEN); + if (size > ESP_NOW_MAX_DATA_LEN) { + this->size_ = 0; + return; + } + this->size_ = size; + memcpy(this->data_, payload, this->size_); + } +}; + +} // namespace esphome::espnow + +#endif // USE_ESP32 diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml new file mode 100644 index 0000000000..abb31c12b8 --- /dev/null +++ b/tests/components/espnow/common.yaml @@ -0,0 +1,52 @@ +espnow: + auto_add_peer: false + channel: 1 + peers: + - 11:22:33:44:55:66 + on_receive: + - logger.log: + format: "Received from: %s = '%s' RSSI: %d" + args: + - format_mac_address_pretty(info.src_addr).c_str() + - format_hex_pretty(data, size).c_str() + - info.rx_ctrl->rssi + - espnow.send: + address: 11:22:33:44:55:66 + data: "Hello from ESPHome" + on_sent: + - logger.log: "ESPNow message sent successfully" + on_error: + - logger.log: "ESPNow message failed to send" + wait_for_sent: true + continue_on_error: true + + - espnow.send: + address: 11:22:33:44:55:66 + data: [0x01, 0x02, 0x03, 0x04, 0x05] + - espnow.send: + address: 11:22:33:44:55:66 + data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};' + - espnow.broadcast: + data: "Hello, World!" + - espnow.broadcast: + data: [0x01, 0x02, 0x03, 0x04, 0x05] + - espnow.broadcast: + data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};' + - espnow.peer.add: + address: 11:22:33:44:55:66 + - espnow.peer.delete: + address: 11:22:33:44:55:66 + on_broadcast: + - logger.log: + format: "Broadcast from: %s = '%s' RSSI: %d" + args: + - format_mac_address_pretty(info.src_addr).c_str() + - format_hex_pretty(data, size).c_str() + - info.rx_ctrl->rssi + on_unknown_peer: + - logger.log: + format: "Unknown peer: %s = '%s' RSSI: %d" + args: + - format_mac_address_pretty(info.src_addr).c_str() + - format_hex_pretty(data, size).c_str() + - info.rx_ctrl->rssi diff --git a/tests/components/espnow/test.esp32-idf.yaml b/tests/components/espnow/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/espnow/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From e4dc62ea74e3b219244c8fe7eedce499bad15a32 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 1 Aug 2025 06:53:40 +0200 Subject: [PATCH 0211/1626] [nrf52, debug] debug component for nrf52 (#8315) --- esphome/components/debug/__init__.py | 5 + esphome/components/debug/debug_zephyr.cpp | 281 ++++++++++++++++++ esphome/components/nrf52/__init__.py | 4 +- .../components/debug/test.nrf52-adafruit.yaml | 1 + tests/components/debug/test.nrf52-mcumgr.yaml | 1 + 5 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 esphome/components/debug/debug_zephyr.cpp create mode 100644 tests/components/debug/test.nrf52-adafruit.yaml create mode 100644 tests/components/debug/test.nrf52-mcumgr.yaml diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index 500dfac1fe..b8dabc3374 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -10,6 +11,7 @@ from esphome.const import ( CONF_LOOP_TIME, PlatformFramework, ) +from esphome.core import CORE CODEOWNERS = ["@OttoWinter"] DEPENDENCIES = ["logger"] @@ -44,6 +46,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + if CORE.using_zephyr: + zephyr_add_prj_conf("HWINFO", True) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -62,5 +66,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "debug_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp new file mode 100644 index 0000000000..9a361b158f --- /dev/null +++ b/esphome/components/debug/debug_zephyr.cpp @@ -0,0 +1,281 @@ +#include "debug_component.h" +#ifdef USE_ZEPHYR +#include +#include "esphome/core/log.h" +#include +#include +#include + +#define BOOTLOADER_VERSION_REGISTER NRF_TIMER2->CC[0] + +namespace esphome { +namespace debug { + +static const char *const TAG = "debug"; +constexpr std::uintptr_t MBR_PARAM_PAGE_ADDR = 0xFFC; +constexpr std::uintptr_t MBR_BOOTLOADER_ADDR = 0xFF8; + +static void show_reset_reason(std::string &reset_reason, bool set, const char *reason) { + if (!set) { + return; + } + if (!reset_reason.empty()) { + reset_reason += ", "; + } + reset_reason += reason; +} + +inline uint32_t read_mem_u32(uintptr_t addr) { + return *reinterpret_cast(addr); // NOLINT(performance-no-int-to-ptr) +} + +std::string DebugComponent::get_reset_reason_() { + uint32_t cause; + auto ret = hwinfo_get_reset_cause(&cause); + if (ret) { + ESP_LOGE(TAG, "Unable to get reset cause: %d", ret); + return ""; + } + std::string reset_reason; + + show_reset_reason(reset_reason, cause & RESET_PIN, "External pin"); + show_reset_reason(reset_reason, cause & RESET_SOFTWARE, "Software reset"); + show_reset_reason(reset_reason, cause & RESET_BROWNOUT, "Brownout (drop in voltage)"); + show_reset_reason(reset_reason, cause & RESET_POR, "Power-on reset (POR)"); + show_reset_reason(reset_reason, cause & RESET_WATCHDOG, "Watchdog timer expiration"); + show_reset_reason(reset_reason, cause & RESET_DEBUG, "Debug event"); + show_reset_reason(reset_reason, cause & RESET_SECURITY, "Security violation"); + show_reset_reason(reset_reason, cause & RESET_LOW_POWER_WAKE, "Waking up from low power mode"); + show_reset_reason(reset_reason, cause & RESET_CPU_LOCKUP, "CPU lock-up detected"); + show_reset_reason(reset_reason, cause & RESET_PARITY, "Parity error"); + show_reset_reason(reset_reason, cause & RESET_PLL, "PLL error"); + show_reset_reason(reset_reason, cause & RESET_CLOCK, "Clock error"); + show_reset_reason(reset_reason, cause & RESET_HARDWARE, "Hardware reset"); + show_reset_reason(reset_reason, cause & RESET_USER, "User reset"); + show_reset_reason(reset_reason, cause & RESET_TEMPERATURE, "Temperature reset"); + + ESP_LOGD(TAG, "Reset Reason: %s", reset_reason.c_str()); + return reset_reason; +} + +uint32_t DebugComponent::get_free_heap_() { return INT_MAX; } + +void DebugComponent::get_device_info_(std::string &device_info) { + std::string supply = "Main supply status: "; + if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_NORMAL) { + supply += "Normal voltage."; + } else { + supply += "High voltage."; + } + ESP_LOGD(TAG, "%s", supply.c_str()); + device_info += "|" + supply; + + std::string reg0 = "Regulator stage 0: "; + if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) { + reg0 += nrf_power_dcdcen_vddh_get(NRF_POWER) ? "DC/DC" : "LDO"; + reg0 += ", "; + switch (NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) { + case (UICR_REGOUT0_VOUT_DEFAULT << UICR_REGOUT0_VOUT_Pos): + reg0 += "1.8V (default)"; + break; + case (UICR_REGOUT0_VOUT_1V8 << UICR_REGOUT0_VOUT_Pos): + reg0 += "1.8V"; + break; + case (UICR_REGOUT0_VOUT_2V1 << UICR_REGOUT0_VOUT_Pos): + reg0 += "2.1V"; + break; + case (UICR_REGOUT0_VOUT_2V4 << UICR_REGOUT0_VOUT_Pos): + reg0 += "2.4V"; + break; + case (UICR_REGOUT0_VOUT_2V7 << UICR_REGOUT0_VOUT_Pos): + reg0 += "2.7V"; + break; + case (UICR_REGOUT0_VOUT_3V0 << UICR_REGOUT0_VOUT_Pos): + reg0 += "3.0V"; + break; + case (UICR_REGOUT0_VOUT_3V3 << UICR_REGOUT0_VOUT_Pos): + reg0 += "3.3V"; + break; + default: + reg0 += "???V"; + } + } else { + reg0 += "disabled"; + } + ESP_LOGD(TAG, "%s", reg0.c_str()); + device_info += "|" + reg0; + + std::string reg1 = "Regulator stage 1: "; + reg1 += nrf_power_dcdcen_get(NRF_POWER) ? "DC/DC" : "LDO"; + ESP_LOGD(TAG, "%s", reg1.c_str()); + device_info += "|" + reg1; + + std::string usb_power = "USB power state: "; + if (nrf_power_usbregstatus_vbusdet_get(NRF_POWER)) { + if (nrf_power_usbregstatus_outrdy_get(NRF_POWER)) { + /**< From the power viewpoint, USB is ready for working. */ + usb_power += "ready"; + } else { + /**< The USB power is detected, but USB power regulator is not ready. */ + usb_power += "connected (regulator is not ready)"; + } + } else { + /**< No power on USB lines detected. */ + usb_power += "disconected"; + } + ESP_LOGD(TAG, "%s", usb_power.c_str()); + device_info += "|" + usb_power; + + bool enabled; + nrf_power_pof_thr_t pof_thr; + + pof_thr = nrf_power_pofcon_get(NRF_POWER, &enabled); + std::string pof = "Power-fail comparator: "; + if (enabled) { + switch (pof_thr) { + case POWER_POFCON_THRESHOLD_V17: + pof += "1.7V"; + break; + case POWER_POFCON_THRESHOLD_V18: + pof += "1.8V"; + break; + case POWER_POFCON_THRESHOLD_V19: + pof += "1.9V"; + break; + case POWER_POFCON_THRESHOLD_V20: + pof += "2.0V"; + break; + case POWER_POFCON_THRESHOLD_V21: + pof += "2.1V"; + break; + case POWER_POFCON_THRESHOLD_V22: + pof += "2.2V"; + break; + case POWER_POFCON_THRESHOLD_V23: + pof += "2.3V"; + break; + case POWER_POFCON_THRESHOLD_V24: + pof += "2.4V"; + break; + case POWER_POFCON_THRESHOLD_V25: + pof += "2.5V"; + break; + case POWER_POFCON_THRESHOLD_V26: + pof += "2.6V"; + break; + case POWER_POFCON_THRESHOLD_V27: + pof += "2.7V"; + break; + case POWER_POFCON_THRESHOLD_V28: + pof += "2.8V"; + break; + } + + if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) { + pof += ", VDDH: "; + switch (nrf_power_pofcon_vddh_get(NRF_POWER)) { + case NRF_POWER_POFTHRVDDH_V27: + pof += "2.7V"; + break; + case NRF_POWER_POFTHRVDDH_V28: + pof += "2.8V"; + break; + case NRF_POWER_POFTHRVDDH_V29: + pof += "2.9V"; + break; + case NRF_POWER_POFTHRVDDH_V30: + pof += "3.0V"; + break; + case NRF_POWER_POFTHRVDDH_V31: + pof += "3.1V"; + break; + case NRF_POWER_POFTHRVDDH_V32: + pof += "3.2V"; + break; + case NRF_POWER_POFTHRVDDH_V33: + pof += "3.3V"; + break; + case NRF_POWER_POFTHRVDDH_V34: + pof += "3.4V"; + break; + case NRF_POWER_POFTHRVDDH_V35: + pof += "3.5V"; + break; + case NRF_POWER_POFTHRVDDH_V36: + pof += "3.6V"; + break; + case NRF_POWER_POFTHRVDDH_V37: + pof += "3.7V"; + break; + case NRF_POWER_POFTHRVDDH_V38: + pof += "3.8V"; + break; + case NRF_POWER_POFTHRVDDH_V39: + pof += "3.9V"; + break; + case NRF_POWER_POFTHRVDDH_V40: + pof += "4.0V"; + break; + case NRF_POWER_POFTHRVDDH_V41: + pof += "4.1V"; + break; + case NRF_POWER_POFTHRVDDH_V42: + pof += "4.2V"; + break; + } + } + } else { + pof += "disabled"; + } + ESP_LOGD(TAG, "%s", pof.c_str()); + device_info += "|" + pof; + + auto package = [](uint32_t value) { + switch (value) { + case 0x2004: + return "QIxx - 7x7 73-pin aQFN"; + case 0x2000: + return "QFxx - 6x6 48-pin QFN"; + case 0x2005: + return "CKxx - 3.544 x 3.607 WLCSP"; + } + return "Unspecified"; + }; + + ESP_LOGD(TAG, "Code page size: %u, code size: %u, device id: 0x%08x%08x", NRF_FICR->CODEPAGESIZE, NRF_FICR->CODESIZE, + NRF_FICR->DEVICEID[1], NRF_FICR->DEVICEID[0]); + ESP_LOGD(TAG, "Encryption root: 0x%08x%08x%08x%08x, Identity Root: 0x%08x%08x%08x%08x", NRF_FICR->ER[0], + NRF_FICR->ER[1], NRF_FICR->ER[2], NRF_FICR->ER[3], NRF_FICR->IR[0], NRF_FICR->IR[1], NRF_FICR->IR[2], + NRF_FICR->IR[3]); + ESP_LOGD(TAG, "Device address type: %s, address: %s", (NRF_FICR->DEVICEADDRTYPE & 0x1 ? "Random" : "Public"), + get_mac_address_pretty().c_str()); + ESP_LOGD(TAG, "Part code: nRF%x, version: %c%c%c%c, package: %s", NRF_FICR->INFO.PART, + NRF_FICR->INFO.VARIANT >> 24 & 0xFF, NRF_FICR->INFO.VARIANT >> 16 & 0xFF, NRF_FICR->INFO.VARIANT >> 8 & 0xFF, + NRF_FICR->INFO.VARIANT & 0xFF, package(NRF_FICR->INFO.PACKAGE)); + ESP_LOGD(TAG, "RAM: %ukB, Flash: %ukB, production test: %sdone", NRF_FICR->INFO.RAM, NRF_FICR->INFO.FLASH, + (NRF_FICR->PRODTEST[0] == 0xBB42319F ? "" : "not ")); + ESP_LOGD( + TAG, "GPIO as NFC pins: %s, GPIO as nRESET pin: %s", + YESNO((NRF_UICR->NFCPINS & UICR_NFCPINS_PROTECT_Msk) == (UICR_NFCPINS_PROTECT_NFC << UICR_NFCPINS_PROTECT_Pos)), + YESNO(((NRF_UICR->PSELRESET[0] & UICR_PSELRESET_CONNECT_Msk) != + (UICR_PSELRESET_CONNECT_Connected << UICR_PSELRESET_CONNECT_Pos)) || + ((NRF_UICR->PSELRESET[1] & UICR_PSELRESET_CONNECT_Msk) != + (UICR_PSELRESET_CONNECT_Connected << UICR_PSELRESET_CONNECT_Pos)))); + +#ifdef USE_BOOTLOADER_MCUBOOT + ESP_LOGD(TAG, "bootloader: mcuboot"); +#else + ESP_LOGD(TAG, "bootloader: Adafruit, version %u.%u.%u", (BOOTLOADER_VERSION_REGISTER >> 16) & 0xFF, + (BOOTLOADER_VERSION_REGISTER >> 8) & 0xFF, BOOTLOADER_VERSION_REGISTER & 0xFF); + ESP_LOGD(TAG, "MBR bootloader addr 0x%08x, UICR bootloader addr 0x%08x", read_mem_u32(MBR_BOOTLOADER_ADDR), + NRF_UICR->NRFFW[0]); + ESP_LOGD(TAG, "MBR param page addr 0x%08x, UICR param page addr 0x%08x", read_mem_u32(MBR_PARAM_PAGE_ADDR), + NRF_UICR->NRFFW[1]); +#endif +} + +void DebugComponent::update_platform_() {} + +} // namespace debug +} // namespace esphome +#endif diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 17807b9e2b..908a855f70 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -124,7 +124,9 @@ async def to_code(config: ConfigType) -> None: ], ) - if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT: + if config[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT: + cg.add_define("USE_BOOTLOADER_MCUBOOT") + else: # make sure that firmware.zip is created # for Adafruit_nRF52_Bootloader cg.add_platformio_option("board_upload.protocol", "nrfutil") diff --git a/tests/components/debug/test.nrf52-adafruit.yaml b/tests/components/debug/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/debug/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/debug/test.nrf52-mcumgr.yaml b/tests/components/debug/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/debug/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From f761404bf6210e3c616f9f0c1fea3faf3f7e2e01 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 1 Aug 2025 06:54:20 +0200 Subject: [PATCH 0212/1626] [nrf52, gpio] check different port notation (#9737) --- tests/components/gpio/test.nrf52-adafruit.yaml | 4 ++-- tests/components/gpio/test.nrf52-mcumgr.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/gpio/test.nrf52-adafruit.yaml b/tests/components/gpio/test.nrf52-adafruit.yaml index 3ca285117d..912b9537c4 100644 --- a/tests/components/gpio/test.nrf52-adafruit.yaml +++ b/tests/components/gpio/test.nrf52-adafruit.yaml @@ -5,10 +5,10 @@ binary_sensor: output: - platform: gpio - pin: 3 + pin: P0.3 id: gpio_output switch: - platform: gpio - pin: 4 + pin: P1.2 id: gpio_switch diff --git a/tests/components/gpio/test.nrf52-mcumgr.yaml b/tests/components/gpio/test.nrf52-mcumgr.yaml index 3ca285117d..912b9537c4 100644 --- a/tests/components/gpio/test.nrf52-mcumgr.yaml +++ b/tests/components/gpio/test.nrf52-mcumgr.yaml @@ -5,10 +5,10 @@ binary_sensor: output: - platform: gpio - pin: 3 + pin: P0.3 id: gpio_output switch: - platform: gpio - pin: 4 + pin: P1.2 id: gpio_switch From 1161bfcc93410a3594fdc4cce97ca5ede56a784c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:35:49 -1000 Subject: [PATCH 0213/1626] preen --- esphome/__main__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 54e3529096..3f17cd57cf 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -341,7 +341,7 @@ def check_permissions(port): ) -def upload_program(config, args, host): +def upload_program(config, args, host: str): try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): @@ -381,7 +381,7 @@ def upload_program(config, args, host): # Check if we should use MQTT for address resolution # This happens when no device was specified, or the current host is "MQTT"/"OTA" - devices = args.device or [] + devices: list[str] = args.device or [] if ( CONF_MQTT in config # pylint: disable=too-many-boolean-expressions and (not devices or host in ("MQTT", "OTA")) @@ -486,7 +486,7 @@ def command_compile(args, config): def command_upload(args, config) -> int: - devices = args.device or [] + devices: list[str] = args.device or [] if not devices: # No devices specified, use the interactive chooser devices = [ From 5fac039a06b5adb356f808e0b92ee5839ddf7a91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:39:08 -1000 Subject: [PATCH 0214/1626] preen --- esphome/__main__.py | 68 +++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 3f17cd57cf..d133b152f5 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -44,6 +44,7 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError, coroutine from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log +from esphome.types import ConfigType from esphome.util import ( get_serial_ports, list_yaml_files, @@ -123,7 +124,7 @@ def mqtt_logging_enabled(mqtt_config): return log_topic.get(CONF_LEVEL, None) != "NONE" -def get_port_type(port): +def get_port_type(port: str) -> str: if port.startswith("/") or port.startswith("COM"): return "SERIAL" if port == "MQTT": @@ -131,7 +132,7 @@ def get_port_type(port): return "NETWORK" -def run_miniterm(config, port, args): +def run_miniterm(config: ConfigType, port: str, args) -> int: from aioesphomeapi import LogParser import serial @@ -249,7 +250,7 @@ def compile_program(args, config): return 0 if idedata is not None else 1 -def upload_using_esptool(config, port, file, speed): +def upload_using_esptool(config: ConfigType, port: str, file: str, speed: int): from esphome import platformio_api first_baudrate = speed or config[CONF_ESPHOME][CONF_PLATFORMIO_OPTIONS].get( @@ -314,7 +315,7 @@ def upload_using_esptool(config, port, file, speed): return run_esptool(115200) -def upload_using_platformio(config, port): +def upload_using_platformio(config: ConfigType, port: str): from esphome import platformio_api upload_args = ["-t", "upload", "-t", "nobuild"] @@ -323,7 +324,7 @@ def upload_using_platformio(config, port): return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args) -def check_permissions(port): +def check_permissions(port: str): if os.name == "posix" and get_port_type(port) == "SERIAL": # Check if we can open selected serial port if not os.access(port, os.F_OK): @@ -341,7 +342,7 @@ def check_permissions(port): ) -def upload_program(config, args, host: str): +def upload_program(config: ConfigType, args, host: str): try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): @@ -402,7 +403,7 @@ def upload_program(config, args, host: str): return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) -def show_logs(config, args, devices: list[str]): +def show_logs(config: ConfigType, args, devices: list[str]) -> int | None: if "logger" not in config: raise EsphomeError("Logger is not configured!") @@ -522,21 +523,18 @@ def command_discover(args, config): raise EsphomeError("No discover method configured (mqtt)") -def command_logs(args, config) -> int: - devices = args.device or [] - if not devices: - # No devices specified, use the interactive chooser - devices = [ - choose_upload_log_host( - default=None, - check_default=None, - show_ota=False, - show_mqtt=True, - show_api=True, - purpose="logging", - ) - ] - +def command_logs(args, config) -> int | None: + # No devices specified, use the interactive chooser + devices = args.device or [ + choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=True, + purpose="logging", + ) + ] return show_logs(config, args, devices) @@ -557,22 +555,20 @@ def command_run(args, config): program_path = idedata.raw["prog_path"] return run_external_process(program_path) - devices = args.device or [] - if not devices: - # No devices specified, use the interactive chooser - devices = [ - choose_upload_log_host( - default=None, - check_default=None, - show_ota=True, - show_mqtt=False, - show_api=True, - purpose="uploading", - ) - ] + # No devices specified, use the interactive chooser + devices = args.device or [ + choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=True, + purpose="uploading", + ) + ] # Try each device for upload until one succeeds - successful_device = None + successful_device: str | None = None for device in devices: _LOGGER.info("Uploading to %s", device) exit_code = upload_program(config, args, device) From b96cd2b932f1e816de19aaad8dc322fe2bb552ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:41:04 -1000 Subject: [PATCH 0215/1626] preen --- esphome/__main__.py | 6 ++++-- esphome/util.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index d133b152f5..4bd611d50c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -250,7 +250,9 @@ def compile_program(args, config): return 0 if idedata is not None else 1 -def upload_using_esptool(config: ConfigType, port: str, file: str, speed: int): +def upload_using_esptool( + config: ConfigType, port: str, file: str, speed: int +) -> str | int: from esphome import platformio_api first_baudrate = speed or config[CONF_ESPHOME][CONF_PLATFORMIO_OPTIONS].get( @@ -342,7 +344,7 @@ def check_permissions(port: str): ) -def upload_program(config: ConfigType, args, host: str): +def upload_program(config: ConfigType, args, host: str) -> int | str: try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): diff --git a/esphome/util.py b/esphome/util.py index 3b346371bc..395d4a7351 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -223,7 +223,7 @@ def run_external_command( return retval -def run_external_process(*cmd, **kwargs): +def run_external_process(*cmd: str, **kwargs: str) -> int | str: full_cmd = " ".join(shlex_quote(x) for x in cmd) _LOGGER.debug("Running: %s", full_cmd) filter_lines = kwargs.get("filter_lines") From 264fbb40292ab8191602dcc949a321c46afda945 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:45:54 -1000 Subject: [PATCH 0216/1626] preen --- esphome/__main__.py | 49 +++++++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 4bd611d50c..3edccc2bdb 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -9,6 +9,7 @@ import os import re import sys import time +from typing import Protocol import argcomplete @@ -56,6 +57,20 @@ from esphome.util import ( _LOGGER = logging.getLogger(__name__) +class ArgsProtocol(Protocol): + device: list[str] | None + reset: bool + username: str | None + password: str | None + client_id: str | None + topic: str | None + file: str | None + no_logs: bool + only_generate: bool + show_secrets: bool + dashboard: bool + + def choose_prompt(options, purpose: str = None): if not options: raise EsphomeError( @@ -344,7 +359,7 @@ def check_permissions(port: str): ) -def upload_program(config: ConfigType, args, host: str) -> int | str: +def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | str: try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): @@ -405,7 +420,7 @@ def upload_program(config: ConfigType, args, host: str) -> int | str: return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) -def show_logs(config: ConfigType, args, devices: list[str]) -> int | None: +def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: if "logger" not in config: raise EsphomeError("Logger is not configured!") @@ -437,7 +452,7 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> int | None: raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)") -def clean_mqtt(config, args): +def clean_mqtt(config: ConfigType, args: ArgsProtocol) -> int | None: from esphome import mqtt return mqtt.clear_topic( @@ -445,13 +460,13 @@ def clean_mqtt(config, args): ) -def command_wizard(args): +def command_wizard(args: ArgsProtocol) -> int | None: from esphome import wizard return wizard.wizard(args.configuration) -def command_config(args, config): +def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: if not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) @@ -466,7 +481,7 @@ def command_config(args, config): return 0 -def command_vscode(args): +def command_vscode(args: ArgsProtocol) -> int | None: from esphome import vscode logging.disable(logging.INFO) @@ -474,7 +489,7 @@ def command_vscode(args): vscode.read_config(args) -def command_compile(args, config): +def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: exit_code = write_cpp(config) if exit_code != 0: return exit_code @@ -488,7 +503,7 @@ def command_compile(args, config): return 0 -def command_upload(args, config) -> int: +def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: devices: list[str] = args.device or [] if not devices: # No devices specified, use the interactive chooser @@ -516,7 +531,7 @@ def command_upload(args, config) -> int: return exit_code -def command_discover(args, config): +def command_discover(args: ArgsProtocol, config: ConfigType) -> int | None: if "mqtt" in config: from esphome import mqtt @@ -525,7 +540,7 @@ def command_discover(args, config): raise EsphomeError("No discover method configured (mqtt)") -def command_logs(args, config) -> int | None: +def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: # No devices specified, use the interactive chooser devices = args.device or [ choose_upload_log_host( @@ -540,7 +555,7 @@ def command_logs(args, config) -> int | None: return show_logs(config, args, devices) -def command_run(args, config): +def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: exit_code = write_cpp(config) if exit_code != 0: return exit_code @@ -599,22 +614,22 @@ def command_run(args, config): return show_logs(config, args, [port]) -def command_clean_mqtt(args, config): +def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None: return clean_mqtt(config, args) -def command_mqtt_fingerprint(args, config): +def command_mqtt_fingerprint(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import mqtt return mqtt.get_fingerprint(config) -def command_version(args): +def command_version(args: ArgsProtocol) -> int | None: safe_print(f"Version: {const.__version__}") return 0 -def command_clean(args, config): +def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: try: writer.clean_build() except OSError as err: @@ -624,13 +639,13 @@ def command_clean(args, config): return 0 -def command_dashboard(args): +def command_dashboard(args: ArgsProtocol) -> int | None: from esphome.dashboard import dashboard return dashboard.start_dashboard(args) -def command_update_all(args): +def command_update_all(args: ArgsProtocol) -> int | None: import click success = {} From 082d741066be2761b516ea4d4d8b4e5a7e89b00d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:46:40 -1000 Subject: [PATCH 0217/1626] preen --- esphome/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 3edccc2bdb..eb34fb74b6 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -692,7 +692,7 @@ def command_update_all(args: ArgsProtocol) -> int | None: return failed -def command_idedata(args, config): +def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json from esphome import platformio_api @@ -708,7 +708,7 @@ def command_idedata(args, config): return 0 -def command_rename(args, config): +def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: for c in args.name: if c not in ALLOWED_NAME_CHARS: print( From 8a15d2ea8cc758491ec5a90da3192bddf1e594df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:51:21 -1000 Subject: [PATCH 0218/1626] preen --- esphome/util.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/util.py b/esphome/util.py index 395d4a7351..047ea8ecea 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -110,7 +110,7 @@ class RedirectText: def __getattr__(self, item): return getattr(self._out, item) - def _write_color_replace(self, s): + def _write_color_replace(self, s: str | bytes) -> None: from esphome.core import CORE if CORE.dashboard: @@ -121,7 +121,7 @@ class RedirectText: s = s.replace("\033", "\\033") self._out.write(s) - def write(self, s): + def write(self, s: str | bytes) -> int: # s is usually a str already (self._out is of type TextIOWrapper) # However, s is sometimes also a bytes object in python3. Let's make sure it's a # str @@ -266,7 +266,7 @@ class OrderedDict(collections.OrderedDict): return dict(self).__repr__() -def list_yaml_files(folders): +def list_yaml_files(folders: list[str]) -> list[str]: files = filter_yaml_files( [os.path.join(folder, p) for folder in folders for p in os.listdir(folder)] ) @@ -274,7 +274,7 @@ def list_yaml_files(folders): return files -def filter_yaml_files(files): +def filter_yaml_files(files: list[str]) -> list[str]: return [ f for f in files From 837863568f9a763c2b310d084bc232f48c16802e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 19:52:34 -1000 Subject: [PATCH 0219/1626] preen --- esphome/__main__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index eb34fb74b6..97ac8388cf 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -69,6 +69,9 @@ class ArgsProtocol(Protocol): only_generate: bool show_secrets: bool dashboard: bool + configuration: str + name: str + upload_speed: str | None def choose_prompt(options, purpose: str = None): @@ -224,7 +227,7 @@ def wrap_to_code(name, comp): return wrapped -def write_cpp(config): +def write_cpp(config: ConfigType) -> int: if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() @@ -232,7 +235,7 @@ def write_cpp(config): return write_cpp_file() -def generate_cpp_contents(config): +def generate_cpp_contents(config: ConfigType) -> None: _LOGGER.info("Generating C++ source...") for name, component, conf in iter_component_configs(CORE.config): @@ -243,7 +246,7 @@ def generate_cpp_contents(config): CORE.flush_tasks() -def write_cpp_file(): +def write_cpp_file() -> int: code_s = indent(CORE.cpp_main_section) writer.write_cpp(code_s) @@ -254,7 +257,7 @@ def write_cpp_file(): return 0 -def compile_program(args, config): +def compile_program(args: ArgsProtocol, config: ConfigType) -> int: from esphome import platformio_api _LOGGER.info("Compiling app...") From 65e2c20bcf2ba0d967d816be771ac77a433e4d2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 20:02:25 -1000 Subject: [PATCH 0220/1626] preen --- esphome/dashboard/web_server.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 8489c6f09c..2e93fdf0d0 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -353,10 +353,9 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): # since MQTT logging will not work otherwise addresses.extend(sort_ip_addresses(address_list)) - device_args: list[str] = [] - for address in addresses: - device_args.append("--device") - device_args.append(address) + device_args: list[str] = [ + arg for address in addresses for arg in ("--device", address) + ] return [*DASHBOARD_COMMAND, *args, config_file, *device_args] From 204da1af8b367a640096ad68f7486ed5aa425293 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 20:03:31 -1000 Subject: [PATCH 0221/1626] preen --- esphome/__main__.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 97ac8388cf..f0d85059d1 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -507,19 +507,17 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: - devices: list[str] = args.device or [] - if not devices: - # No devices specified, use the interactive chooser - devices = [ - choose_upload_log_host( - default=None, - check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, - purpose="uploading", - ) - ] + # No devices specified, use the interactive chooser + devices: list[str] = args.device or [ + choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + purpose="uploading", + ) + ] # Try each device until one succeeds for device in devices: From 42fe7d9fb24ff6b4df41e4ffa28e67791a88d4b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 20:05:34 -1000 Subject: [PATCH 0222/1626] preen --- esphome/dashboard/web_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 2e93fdf0d0..9efd06aaca 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -334,10 +334,10 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): addresses = [] # First priority: entry.address AKA use_address if ( - entry.address + (use_address := entry.address) and ( address_list := await dashboard.dns_cache.async_resolve( - entry.address, time.monotonic() + use_address, time.monotonic() ) ) and not isinstance(address_list, Exception) From bea2f4971eec1cd17874f06e73bd812fbc1e7be2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 20:07:06 -1000 Subject: [PATCH 0223/1626] preen --- esphome/dashboard/web_server.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 9efd06aaca..46f09336bb 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -345,13 +345,19 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): addresses.extend(sort_ip_addresses(address_list)) # Second priority: mDNS - if (mdns := dashboard.mdns_status) and ( - address_list := await mdns.async_resolve_host(entry.name) + if ( + (mdns := dashboard.mdns_status) + and (address_list := await mdns.async_resolve_host(entry.name)) + and ( + new_addresses := [ + addr for addr in address_list if addr not in addresses + ] + ) ): # Use the IP address if available but only # if the API is loaded and the device is online # since MQTT logging will not work otherwise - addresses.extend(sort_ip_addresses(address_list)) + addresses.extend(sort_ip_addresses(new_addresses)) device_args: list[str] = [ arg for address in addresses for arg in ("--device", address) From 940a8b43fa189ae34038b19ca52bd59419b1f434 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:07:11 +1000 Subject: [PATCH 0224/1626] [esp32] Add config option to execute from PSRAM (#9907) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/debug/sensor.py | 3 +- esphome/components/esp32/__init__.py | 70 ++++++++++++++----- esphome/components/lvgl/__init__.py | 3 +- esphome/components/mipi_spi/display.py | 3 +- esphome/components/psram/__init__.py | 3 +- esphome/config.py | 33 +++++++-- tests/component_tests/conftest.py | 3 +- tests/component_tests/esp32/test_esp32.py | 46 ++++++++++-- tests/component_tests/types.py | 1 + tests/components/esp32/test.esp32-s3-idf.yaml | 12 ++++ 10 files changed, 144 insertions(+), 33 deletions(-) create mode 100644 tests/components/esp32/test.esp32-s3-idf.yaml diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 4669095d5d..4484f15935 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.esp32 import CONF_CPU_FREQUENCY +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BLOCK, @@ -54,7 +55,7 @@ CONFIG_SCHEMA = { ), cv.Optional(CONF_PSRAM): cv.All( cv.only_on_esp32, - cv.requires_component("psram"), + cv.requires_component(PSRAM_DOMAIN), sensor.sensor_schema( unit_of_measurement=UNIT_BYTES, icon=ICON_COUNTER, diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8d72ff3685..05a79553a4 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -76,6 +76,7 @@ CONF_ASSERTION_LEVEL = "assertion_level" CONF_COMPILER_OPTIMIZATION = "compiler_optimization" CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES = "enable_idf_experimental_features" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" +CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_RELEASE = "release" ASSERTION_LEVELS = { @@ -519,32 +520,59 @@ def _detect_variant(value): def final_validate(config): - if not ( - pio_options := fv.full_config.get()[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS) - ): - # Not specified or empty - return config - - pio_flash_size_key = "board_upload.flash_size" - pio_partitions_key = "board_build.partitions" - if CONF_PARTITIONS in config and pio_partitions_key in pio_options: - raise cv.Invalid( - f"Do not specify '{pio_partitions_key}' in '{CONF_PLATFORMIO_OPTIONS}' with '{CONF_PARTITIONS}' in esp32" - ) - - if pio_flash_size_key in pio_options: - raise cv.Invalid( - f"Please specify {CONF_FLASH_SIZE} within esp32 configuration only" - ) + # Imported locally to avoid circular import issues + from esphome.components.psram import DOMAIN as PSRAM_DOMAIN + errs = [] + full_config = fv.full_config.get() + if pio_options := full_config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS): + pio_flash_size_key = "board_upload.flash_size" + pio_partitions_key = "board_build.partitions" + if CONF_PARTITIONS in config and pio_partitions_key in pio_options: + errs.append( + cv.Invalid( + f"Do not specify '{pio_partitions_key}' in '{CONF_PLATFORMIO_OPTIONS}' with '{CONF_PARTITIONS}' in esp32" + ) + ) + if pio_flash_size_key in pio_options: + errs.append( + cv.Invalid( + f"Please specify {CONF_FLASH_SIZE} within esp32 configuration only" + ) + ) if ( config[CONF_VARIANT] != VARIANT_ESP32 and CONF_ADVANCED in (conf_fw := config[CONF_FRAMEWORK]) and CONF_IGNORE_EFUSE_MAC_CRC in conf_fw[CONF_ADVANCED] ): - raise cv.Invalid( - f"{CONF_IGNORE_EFUSE_MAC_CRC} is not supported on {config[CONF_VARIANT]}" + errs.append( + cv.Invalid( + f"'{CONF_IGNORE_EFUSE_MAC_CRC}' is not supported on {config[CONF_VARIANT]}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_IGNORE_EFUSE_MAC_CRC], + ) ) + if ( + config.get(CONF_FRAMEWORK, {}) + .get(CONF_ADVANCED, {}) + .get(CONF_EXECUTE_FROM_PSRAM) + ): + if config[CONF_VARIANT] != VARIANT_ESP32S3: + errs.append( + cv.Invalid( + f"'{CONF_EXECUTE_FROM_PSRAM}' is only supported on {VARIANT_ESP32S3} variant", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_EXECUTE_FROM_PSRAM], + ) + ) + if PSRAM_DOMAIN not in full_config: + errs.append( + cv.Invalid( + f"'{CONF_EXECUTE_FROM_PSRAM}' requires PSRAM to be configured", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_EXECUTE_FROM_PSRAM], + ) + ) + + if errs: + raise cv.MultipleInvalid(errs) return config @@ -627,6 +655,7 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All( cv.Optional( CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True ): cv.boolean, + cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, } ), cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( @@ -792,6 +821,9 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False): add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) + if advanced.get(CONF_EXECUTE_FROM_PSRAM, False): + add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True) + add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True) # Apply LWIP core locking for better socket performance # This is already enabled by default in Arduino framework, where it provides diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 0cd65d298f..a37f4570f3 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -4,6 +4,7 @@ from esphome.automation import build_automation, register_action, validate_autom import esphome.codegen as cg from esphome.components.const import CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING from esphome.components.display import Display +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, @@ -219,7 +220,7 @@ def final_validation(configs): draw_rounding, config[CONF_DRAW_ROUNDING] ) buffer_frac = config[CONF_BUFFER_SIZE] - if CORE.is_esp32 and buffer_frac > 0.5 and "psram" not in global_config: + if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config: LOGGER.warning("buffer_size: may need to be reduced without PSRAM") for image_id in lv_images_used: path = global_config.get_path_for_id(image_id)[:-1] diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index cb2de6c3d7..e891e2daad 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -25,6 +25,7 @@ from esphome.components.mipi import ( power_of_two, requires_buffer, ) +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA @@ -292,7 +293,7 @@ def _final_validate(config): # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True - if "psram" not in global_config and CONF_BUFFER_SIZE not in config: + if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: if not requires_buffer(config): return config # No buffer needed, so no need to set a buffer size # If PSRAM is not enabled, choose a small buffer size by default diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 9299cdcd0e..fd7e70a055 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -28,12 +28,13 @@ from esphome.core import CORE import esphome.final_validate as fv CODEOWNERS = ["@esphome/core"] +DOMAIN = "psram" DEPENDENCIES = [PLATFORM_ESP32] _LOGGER = logging.getLogger(__name__) -psram_ns = cg.esphome_ns.namespace("psram") +psram_ns = cg.esphome_ns.namespace(DOMAIN) PsramComponent = psram_ns.class_("PsramComponent", cg.Component) TYPE_QUAD = "quad" diff --git a/esphome/config.py b/esphome/config.py index 670cbe7233..cf7a232d8e 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -329,6 +329,28 @@ class ConfigValidationStep(abc.ABC): def run(self, result: Config) -> None: ... # noqa: E704 +class LoadTargetPlatformValidationStep(ConfigValidationStep): + """Load target platform step.""" + + def __init__(self, domain: str, conf: ConfigType): + self.domain = domain + self.conf = conf + + def run(self, result: Config) -> None: + if self.conf is None: + result[self.domain] = self.conf = {} + result.add_output_path([self.domain], self.domain) + component = get_component(self.domain) + + result[self.domain] = self.conf + path = [self.domain] + CORE.loaded_integrations.add(self.domain) + + result.add_validation_step( + SchemaValidationStep(self.domain, path, self.conf, component) + ) + + class LoadValidationStep(ConfigValidationStep): """Load step, this step is called once for each domain config fragment. @@ -582,16 +604,18 @@ class MetadataValidationStep(ConfigValidationStep): ) return for i, part_conf in enumerate(self.conf): + path = self.path + [i] result.add_validation_step( - SchemaValidationStep( - self.domain, self.path + [i], part_conf, self.comp - ) + SchemaValidationStep(self.domain, path, part_conf, self.comp) ) + result.add_validation_step(FinalValidateValidationStep(path, self.comp)) + return result.add_validation_step( SchemaValidationStep(self.domain, self.path, self.conf, self.comp) ) + result.add_validation_step(FinalValidateValidationStep(self.path, self.comp)) class SchemaValidationStep(ConfigValidationStep): @@ -628,7 +652,6 @@ class SchemaValidationStep(ConfigValidationStep): result.set_by_path(self.path, validated) path_context.reset(token) - result.add_validation_step(FinalValidateValidationStep(self.path, self.comp)) class IDPassValidationStep(ConfigValidationStep): @@ -909,7 +932,7 @@ def validate_config( # First run platform validation steps result.add_validation_step( - LoadValidationStep(target_platform, config[target_platform]) + LoadTargetPlatformValidationStep(target_platform, config[target_platform]) ) result.run_validation_steps() diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index b269e23cd6..2045b03502 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -65,6 +65,7 @@ def set_core_config() -> Generator[SetCoreConfigCallable]: *, core_data: ConfigType | None = None, platform_data: ConfigType | None = None, + full_config: dict[str, ConfigType] | None = None, ) -> None: platform, framework = platform_framework.value @@ -83,7 +84,7 @@ def set_core_config() -> Generator[SetCoreConfigCallable]: CORE.data[platform.value] = platform_data config.path_context.set([]) - final_validate.full_config.set(Config()) + final_validate.full_config.set(full_config or Config()) yield setter diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index fe031c653f..91e96f24d6 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -8,10 +8,13 @@ import pytest from esphome.components.esp32 import VARIANTS import esphome.config_validation as cv -from esphome.const import PlatformFramework +from esphome.const import CONF_ESPHOME, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable -def test_esp32_config(set_core_config) -> None: +def test_esp32_config( + set_core_config: SetCoreConfigCallable, +) -> None: set_core_config(PlatformFramework.ESP32_IDF) from esphome.components.esp32 import CONFIG_SCHEMA @@ -60,14 +63,49 @@ def test_esp32_config(set_core_config) -> None: r"Option 'variant' does not match selected board. @ data\['variant'\]", id="mismatched_board_variant_config", ), + pytest.param( + { + "variant": "esp32s2", + "framework": { + "type": "esp-idf", + "advanced": {"execute_from_psram": True}, + }, + }, + r"'execute_from_psram' is only supported on ESP32S3 variant @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]", + id="execute_from_psram_invalid_for_variant_config", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"execute_from_psram": True}, + }, + }, + r"'execute_from_psram' requires PSRAM to be configured @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]", + id="execute_from_psram_requires_psram_config", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"ignore_efuse_mac_crc": True}, + }, + }, + r"'ignore_efuse_mac_crc' is not supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['ignore_efuse_mac_crc'\]", + id="ignore_efuse_mac_crc_only_on_esp32", + ), ], ) def test_esp32_configuration_errors( config: Any, error_match: str, + set_core_config: SetCoreConfigCallable, ) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config={CONF_ESPHOME: {}}) """Test detection of invalid configuration.""" - from esphome.components.esp32 import CONFIG_SCHEMA + from esphome.components.esp32 import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA with pytest.raises(cv.Invalid, match=error_match): - CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) diff --git a/tests/component_tests/types.py b/tests/component_tests/types.py index 72b8be4503..ee9d317339 100644 --- a/tests/component_tests/types.py +++ b/tests/component_tests/types.py @@ -18,4 +18,5 @@ class SetCoreConfigCallable(Protocol): *, core_data: ConfigType | None = None, platform_data: ConfigType | None = None, + full_config: dict[str, ConfigType] | None = None, ) -> None: ... diff --git a/tests/components/esp32/test.esp32-s3-idf.yaml b/tests/components/esp32/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..1d5a5e52a4 --- /dev/null +++ b/tests/components/esp32/test.esp32-s3-idf.yaml @@ -0,0 +1,12 @@ +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + execute_from_psram: true + +psram: + mode: octal + speed: 80MHz + +logger: From 20ad1ab4eb625909156c0a7a1034bc45e6779262 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 21:46:52 -1000 Subject: [PATCH 0225/1626] [wifi] Fix crash during WiFi reconnection on ESP32 with poor signal quality (#9989) --- .../wifi/wifi_component_esp32_arduino.cpp | 18 ++++++++++++++++++ .../components/wifi/wifi_component_esp_idf.cpp | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp32_arduino.cpp b/esphome/components/wifi/wifi_component_esp32_arduino.cpp index 3c3e87d332..67b1f565ff 100644 --- a/esphome/components/wifi/wifi_component_esp32_arduino.cpp +++ b/esphome/components/wifi/wifi_component_esp32_arduino.cpp @@ -283,6 +283,12 @@ bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { if (!this->wifi_mode_(true, {})) return false; + // Check if the STA interface is initialized before using it + if (s_sta_netif == nullptr) { + ESP_LOGW(TAG, "STA interface not initialized"); + return false; + } + esp_netif_dhcp_status_t dhcp_status; esp_err_t err = esp_netif_dhcpc_get_status(s_sta_netif, &dhcp_status); if (err != ESP_OK) { @@ -541,6 +547,8 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); + // Clear the STA interface handle to prevent use-after-free + s_sta_netif = nullptr; break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { @@ -630,6 +638,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_AP_STOP: { ESP_LOGV(TAG, "AP stop"); +#ifdef USE_WIFI_AP + // Clear the AP interface handle to prevent use-after-free + s_ap_netif = nullptr; +#endif break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { @@ -719,6 +731,12 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { if (!this->wifi_mode_({}, true)) return false; + // Check if the AP interface is initialized before using it + if (s_ap_netif == nullptr) { + ESP_LOGW(TAG, "AP interface not initialized"); + return false; + } + esp_netif_ip_info_t info; if (manual_ip.has_value()) { info.ip = manual_ip->static_ip; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 0b281e9b80..94f1f5125f 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -473,6 +473,12 @@ bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { if (!this->wifi_mode_(true, {})) return false; + // Check if the STA interface is initialized before using it + if (s_sta_netif == nullptr) { + ESP_LOGW(TAG, "STA interface not initialized"); + return false; + } + esp_netif_dhcp_status_t dhcp_status; esp_err_t err = esp_netif_dhcpc_get_status(s_sta_netif, &dhcp_status); if (err != ESP_OK) { @@ -691,6 +697,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) { ESP_LOGV(TAG, "STA stop"); s_sta_started = false; + // Clear the STA interface handle to prevent use-after-free + s_sta_netif = nullptr; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) { const auto &it = data->data.sta_authmode_change; @@ -789,6 +797,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); s_ap_started = false; +#ifdef USE_WIFI_AP + // Clear the AP interface handle to prevent use-after-free + s_ap_netif = nullptr; +#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_PROBEREQRECVED) { const auto &it = data->data.ap_probe_req_rx; @@ -865,6 +877,12 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { if (!this->wifi_mode_({}, true)) return false; + // Check if the AP interface is initialized before using it + if (s_ap_netif == nullptr) { + ESP_LOGW(TAG, "AP interface not initialized"); + return false; + } + esp_netif_ip_info_t info; if (manual_ip.has_value()) { info.ip = manual_ip->static_ip; From e17af87f6e812ca0dfef7a1de5b7ebda27db59e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 22:25:44 -1000 Subject: [PATCH 0226/1626] preen --- esphome/util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/util.py b/esphome/util.py index 047ea8ecea..8fc65967b9 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -6,6 +6,7 @@ from pathlib import Path import re import subprocess import sys +from typing import Any from esphome import const @@ -223,7 +224,7 @@ def run_external_command( return retval -def run_external_process(*cmd: str, **kwargs: str) -> int | str: +def run_external_process(*cmd: str, **kwargs: Any) -> int | str: full_cmd = " ".join(shlex_quote(x) for x in cmd) _LOGGER.debug("Running: %s", full_cmd) filter_lines = kwargs.get("filter_lines") From d8a46c74823ee842bfb9b1c3fa72f29b7ed13815 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 1 Aug 2025 21:40:53 +1200 Subject: [PATCH 0227/1626] [CI] Allow multiple grep options for clang-tidy (#10004) --- .github/workflows/ci.yml | 2 +- script/clang-tidy | 7 ++++++- script/helpers.py | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3f290c43f..992918a035 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,7 +281,7 @@ jobs: pio_cache_key: tidyesp32-idf - id: clang-tidy name: Run script/clang-tidy for ZEPHYR - options: --environment nrf52-tidy --grep USE_ZEPHYR + options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 pio_cache_key: tidy-zephyr ignore_errors: false diff --git a/script/clang-tidy b/script/clang-tidy index 9576b8da8b..2c4a2e36ac 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -205,7 +205,12 @@ def main(): parser.add_argument( "-c", "--changed", action="store_true", help="only run on changed files" ) - parser.add_argument("-g", "--grep", help="only run on files containing value") + parser.add_argument( + "-g", + "--grep", + action="append", + help="only run on files containing value", + ) parser.add_argument( "--split-num", type=int, help="split the files into X jobs.", default=None ) diff --git a/script/helpers.py b/script/helpers.py index 4903521e2d..b346f3a461 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -338,12 +338,12 @@ def filter_changed(files: list[str]) -> list[str]: return files -def filter_grep(files: list[str], value: str) -> list[str]: +def filter_grep(files: list[str], value: list[str]) -> list[str]: matched = [] for file in files: with open(file, encoding="utf-8") as handle: contents = handle.read() - if value in contents: + if any(v in contents for v in value): matched.append(file) return matched From aa0b80b0049d49f685194d39244647026642f8a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 09:50:11 -1000 Subject: [PATCH 0228/1626] Eliminate heap allocations in bluetooth_proxy connection state reporting --- esphome/components/api/api.proto | 5 +- esphome/components/api/api_connection.cpp | 6 +- esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 10 ++-- esphome/components/api/api_pb2.h | 4 +- .../components/bluetooth_proxy/__init__.py | 4 ++ .../bluetooth_proxy/bluetooth_connection.cpp | 15 +++++ .../bluetooth_proxy/bluetooth_connection.h | 2 + .../bluetooth_proxy/bluetooth_proxy.cpp | 57 +++++++++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 11 +++- .../esp32_ble_client/ble_client_base.h | 2 +- script/api_protobuf/api_protobuf.py | 43 +++++++++++++- 12 files changed, 121 insertions(+), 39 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4aa5cc4be0..e0b2c19a21 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1621,7 +1621,10 @@ message BluetoothConnectionsFreeResponse { uint32 free = 1; uint32 limit = 2; - repeated uint64 allocated = 3; + repeated uint64 allocated = 3 [ + (fixed_array_size_define) = "BLUETOOTH_PROXY_MAX_CONNECTIONS", + (fixed_array_skip_zero) = true + ]; } message BluetoothGATTErrorResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 8ac6c3b71e..a6c037d2c2 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1105,10 +1105,8 @@ void APIConnection::bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) bool APIConnection::send_subscribe_bluetooth_connections_free_response( const SubscribeBluetoothConnectionsFreeRequest &msg) { - BluetoothConnectionsFreeResponse resp; - resp.free = bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_connections_free(); - resp.limit = bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_connections_limit(); - return this->send_message(resp, BluetoothConnectionsFreeResponse::MESSAGE_TYPE); + bluetooth_proxy::global_bluetooth_proxy->send_connections_free(); + return true; } void APIConnection::bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) { diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index d4b5700024..ed0e0d7455 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -29,6 +29,7 @@ extend google.protobuf.FieldOptions { optional uint32 fixed_array_size = 50007; optional bool no_zero_copy = 50008 [default=false]; optional bool fixed_array_skip_zero = 50009 [default=false]; + optional string fixed_array_size_define = 50010; // container_pointer: Zero-copy optimization for repeated fields. // diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 29d0f2842c..8c14153155 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2073,15 +2073,17 @@ void BluetoothGATTNotifyDataResponse::calculate_size(ProtoSize &size) const { void BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->free); buffer.encode_uint32(2, this->limit); - for (auto &it : this->allocated) { - buffer.encode_uint64(3, it, true); + for (const auto &it : this->allocated) { + if (it != 0) { + buffer.encode_uint64(3, it, true); + } } } void BluetoothConnectionsFreeResponse::calculate_size(ProtoSize &size) const { size.add_uint32(1, this->free); size.add_uint32(1, this->limit); - if (!this->allocated.empty()) { - for (const auto &it : this->allocated) { + for (const auto &it : this->allocated) { + if (it != 0) { size.add_uint64_force(1, it); } } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 524674e6ef..0bc75ef00b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -2076,13 +2076,13 @@ class SubscribeBluetoothConnectionsFreeRequest : public ProtoMessage { class BluetoothConnectionsFreeResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 81; - static constexpr uint8_t ESTIMATED_SIZE = 16; + static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_connections_free_response"; } #endif uint32_t free{0}; uint32_t limit{0}; - std::vector allocated{}; + std::array allocated{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index a1e9d464df..ec1df6a06c 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -87,6 +87,10 @@ async def to_code(config): cg.add(var.set_active(config[CONF_ACTIVE])) await esp32_ble_tracker.register_raw_ble_device(var, config) + # Define max connections for protobuf fixed array + connection_count = len(config.get(CONF_CONNECTIONS, [])) + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) + for connection_conf in config.get(CONF_CONNECTIONS, []): connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) await cg.register_component(connection_var, connection_conf) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index fd1324dcdc..554c312643 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -78,6 +78,20 @@ void BluetoothConnection::dump_config() { BLEClientBase::dump_config(); } +void BluetoothConnection::set_address(uint64_t address) { + // If we're clearing an address (disconnecting), update the pre-allocated message + if (address == 0 && this->address_ != 0) { + this->proxy_->free_connection_(this->address_); + } + // If we're setting a new address (connecting), update the pre-allocated message + else if (address != 0 && this->address_ == 0) { + this->proxy_->allocate_connection_(this, address); + } + + // Call parent implementation to actually set the address + BLEClientBase::set_address(address); +} + void BluetoothConnection::loop() { BLEClientBase::loop(); @@ -100,6 +114,7 @@ void BluetoothConnection::reset_connection_(esp_err_t reason) { // (aioesphomeapi) implements a 30-second timeout (DEFAULT_BLE_TIMEOUT) // to detect incomplete service discovery rather than relying on us to // tell them about a partial list. + this->set_address(0); this->send_service_ = DONE_SENDING_SERVICES; this->proxy_->send_connections_free(); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 622d257bf8..c6f8d37e4e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -24,6 +24,8 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { esp_err_t notify_characteristic(uint16_t handle, bool enable); + void set_address(uint64_t address) override; + protected: friend class BluetoothProxy; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index de5508c777..eff5c21729 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -35,6 +35,9 @@ void BluetoothProxy::setup() { // Don't pre-allocate pool - let it grow only if needed in busy environments // Many devices in quiet areas will never need the overflow pool + this->connections_free_response_.limit = this->connections_.size(); + this->connections_free_response_.free = this->connections_.size(); + this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { this->send_bluetooth_scanner_state_(state); @@ -134,20 +137,6 @@ void BluetoothProxy::dump_config() { YESNO(this->active_), this->connections_.size()); } -int BluetoothProxy::get_bluetooth_connections_free() { - int free = 0; - for (auto *connection : this->connections_) { - if (connection->address_ == 0) { - free++; - ESP_LOGV(TAG, "[%d] Free connection", connection->get_connection_index()); - } else { - ESP_LOGV(TAG, "[%d] Used connection by [%s]", connection->get_connection_index(), - connection->address_str().c_str()); - } - } - return free; -} - void BluetoothProxy::loop() { if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { for (auto *connection : this->connections_) { @@ -278,6 +267,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest if (connection->state() != espbt::ClientState::IDLE) { connection->disconnect(); } else { + // Manual disconnect for idle connection connection->set_address(0); this->send_device_connection(msg.address, false); this->send_connections_free(); @@ -441,15 +431,9 @@ void BluetoothProxy::send_device_connection(uint64_t address, bool connected, ui void BluetoothProxy::send_connections_free() { if (this->api_connection_ == nullptr) return; - api::BluetoothConnectionsFreeResponse call; - call.free = this->get_bluetooth_connections_free(); - call.limit = this->get_bluetooth_connections_limit(); - for (auto *connection : this->connections_) { - if (connection->address_ != 0) { - call.allocated.push_back(connection->address_); - } - } - this->api_connection_->send_message(call, api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE); + + this->api_connection_->send_message(this->connections_free_response_, + api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE); } void BluetoothProxy::send_gatt_services_done(uint64_t address) { @@ -499,6 +483,33 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { true); // Set this to true to automatically start scanning again when it has cleaned up. } +void BluetoothProxy::allocate_connection_(BluetoothConnection *connection, uint64_t address) { + // Update pre-allocated message directly + this->connections_free_response_.free--; + + // Find first zero slot and set it + auto it = std::find(this->connections_free_response_.allocated.begin(), + this->connections_free_response_.allocated.end(), 0); + if (it != this->connections_free_response_.allocated.end()) { + *it = address; + } +} + +void BluetoothProxy::free_connection_(uint64_t address) { + if (address == 0) + return; // Safety check + + // Update pre-allocated message directly + this->connections_free_response_.free++; + + // Find the address and set to 0 + auto it = std::find(this->connections_free_response_.allocated.begin(), + this->connections_free_response_.allocated.end(), address); + if (it != this->connections_free_response_.allocated.end()) { + *it = 0; + } +} + BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::bluetooth_proxy diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d249515fdf..bf82c5b873 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -49,6 +49,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { }; class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Component { + friend class BluetoothConnection; // Allow connection to call free_connection_ public: BluetoothProxy(); #ifdef USE_ESP32_BLE_DEVICE @@ -74,9 +75,6 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg); void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg); - int get_bluetooth_connections_free(); - int get_bluetooth_connections_limit() { return this->connections_.size(); } - void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags); void unsubscribe_api_connection(api::APIConnection *api_connection); api::APIConnection *get_api_connection() { return this->api_connection_; } @@ -135,6 +133,10 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com BluetoothConnection *get_connection_(uint64_t address, bool reserve); + // Helper functions for connection state management + void allocate_connection_(BluetoothConnection *connection, uint64_t address); + void free_connection_(uint64_t address); + // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; @@ -149,6 +151,9 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 3: 4-byte types uint32_t last_advertisement_flush_time_{0}; + // Pre-allocated response message - always ready to send + api::BluetoothConnectionsFreeResponse connections_free_response_; + // Group 4: 1-byte types grouped together bool active_; uint8_t advertisement_count_{0}; diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 457a88ec1d..0a2fda4476 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -48,7 +48,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; } - void set_address(uint64_t address) { + virtual void set_address(uint64_t address) { this->address_ = address; this->remote_bda_[0] = (address >> 40) & 0xFF; this->remote_bda_[1] = (address >> 32) & 0xFF; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 24e2b25e90..fa2f87d98d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -342,6 +342,11 @@ def create_field_type_info( # Check if this repeated field has fixed_array_size option if (fixed_size := get_field_opt(field, pb.fixed_array_size)) is not None: return FixedArrayRepeatedType(field, fixed_size) + # Check if this repeated field has fixed_array_size_define option + if ( + size_define := get_field_opt(field, pb.fixed_array_size_define) + ) is not None: + return FixedArrayRepeatedType(field, size_define) return RepeatedTypeInfo(field) # Check for fixed_array_size option on bytes fields @@ -1066,9 +1071,10 @@ class FixedArrayRepeatedType(TypeInfo): control how many items we receive when decoding. """ - def __init__(self, field: descriptor.FieldDescriptorProto, size: int) -> None: + def __init__(self, field: descriptor.FieldDescriptorProto, size: int | str) -> None: super().__init__(field) self.array_size = size + self.is_define = isinstance(size, str) # Check if we should skip encoding when all elements are zero # Use getattr to handle older versions of api_options_pb2 self.skip_zero = get_field_opt( @@ -1113,6 +1119,14 @@ class FixedArrayRepeatedType(TypeInfo): # If skip_zero is enabled, wrap encoding in a zero check if self.skip_zero: + if self.is_define: + # When using a define, we need to use a loop-based approach + o = f"for (const auto &it : this->{self.field_name}) {{\n" + o += " if (it != 0) {\n" + o += f" {encode_element('it')}\n" + o += " }\n" + o += "}" + return o # Build the condition to check if at least one element is non-zero non_zero_checks = " || ".join( [f"this->{self.field_name}[{i}] != 0" for i in range(self.array_size)] @@ -1123,6 +1137,13 @@ class FixedArrayRepeatedType(TypeInfo): ] return f"if ({non_zero_checks}) {{\n" + "\n".join(encode_lines) + "\n}" + # When using a define, always use loop-based approach + if self.is_define: + o = f"for (const auto &it : this->{self.field_name}) {{\n" + o += f" {encode_element('it')}\n" + o += "}" + return o + # Unroll small arrays for efficiency if self.array_size == 1: return encode_element(f"this->{self.field_name}[0]") @@ -1153,6 +1174,14 @@ class FixedArrayRepeatedType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: # If skip_zero is enabled, wrap size calculation in a zero check if self.skip_zero: + if self.is_define: + # When using a define, we need to use a loop-based approach + o = f"for (const auto &it : {name}) {{\n" + o += " if (it != 0) {\n" + o += f" {self._ti.get_size_calculation('it', True)}\n" + o += " }\n" + o += "}" + return o # Build the condition to check if at least one element is non-zero non_zero_checks = " || ".join( [f"{name}[{i}] != 0" for i in range(self.array_size)] @@ -1163,6 +1192,13 @@ class FixedArrayRepeatedType(TypeInfo): ] return f"if ({non_zero_checks}) {{\n" + "\n".join(size_lines) + "\n}" + # When using a define, always use loop-based approach + if self.is_define: + o = f"for (const auto &it : {name}) {{\n" + o += f" {self._ti.get_size_calculation('it', True)}\n" + o += "}" + return o + # For fixed arrays, we always encode all elements # Special case for single-element arrays - no loop needed @@ -1186,6 +1222,11 @@ class FixedArrayRepeatedType(TypeInfo): def get_estimated_size(self) -> int: # For fixed arrays, estimate underlying type size * array size underlying_size = self._ti.get_estimated_size() + if self.is_define: + # When using a define, we don't know the actual size so just guess 3 + # This is only used for documentation and never actually used since + # fixed arrays are only for SOURCE_SERVER (encode-only) messages + return underlying_size * 3 return underlying_size * self.array_size From b5f42bc493461464b0ac94668f01dcf7ef04d2ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:53:56 -1000 Subject: [PATCH 0229/1626] Bump aioesphomeapi from 37.2.1 to 37.2.2 (#10009) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b7d259f0c5..0d39e9edcf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.9.0 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.2.1 +aioesphomeapi==37.2.2 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 7d060136089e54d67320cdb13cbfeb83c91d56c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 10:01:33 -1000 Subject: [PATCH 0230/1626] missing define --- esphome/core/defines.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e226f748a8..55652e443e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -147,6 +147,7 @@ #define USE_ESPHOME_TASK_LOG_BUFFER #define USE_BLUETOOTH_PROXY +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #define USE_CAPTIVE_PORTAL #define USE_ESP32_BLE #define USE_ESP32_BLE_CLIENT From 1f7c59f88d94acc26f36fe42ca5faba92fa430e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 10:02:29 -1000 Subject: [PATCH 0231/1626] Bump esptool from 4.9.0 to 5.0.2 (#9983) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0d39e9edcf..b834fd9ac3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile -esptool==4.9.0 +esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 aioesphomeapi==37.2.2 From 7c12f1a5bf341dcf261026cde4c60f7e98acaeb7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 10:06:16 -1000 Subject: [PATCH 0232/1626] [core] Update to use esptool instead of deprecated esptool.py --- esphome/__main__.py | 2 +- esphome/platformio_api.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 341c1fa893..5e5c9ab556 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -277,7 +277,7 @@ def upload_using_esptool(config, port, file, speed): def run_esptool(baud_rate): cmd = [ - "esptool.py", + "esptool", "--before", "default_reset", "--after", diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 7415ec9794..21124fc859 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -61,6 +61,7 @@ FILTER_PLATFORMIO_LINES = [ r"Advanced Memory Usage is available via .*", r"Merged .* ELF section", r"esptool.py v.*", + r"esptool v.*", r"Checking size .*", r"Retrieving maximum program size .*", r"PLATFORM: .*", From f1877ca084fbc2a13a18f9c41407a43dfe175a54 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 20:16:28 +0000 Subject: [PATCH 0233/1626] Bump aioesphomeapi from 37.2.2 to 37.2.3 (#10012) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b834fd9ac3..bfdb08323e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.2.2 +aioesphomeapi==37.2.3 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 1ce52f2b0ff87caf8471024e6e0054683f41c59f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 10:22:03 -1000 Subject: [PATCH 0234/1626] Update esphome/components/bluetooth_proxy/bluetooth_connection.cpp --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 554c312643..1ff94c9e93 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -114,7 +114,6 @@ void BluetoothConnection::reset_connection_(esp_err_t reason) { // (aioesphomeapi) implements a 30-second timeout (DEFAULT_BLE_TIMEOUT) // to detect incomplete service discovery rather than relying on us to // tell them about a partial list. - this->set_address(0); this->send_service_ = DONE_SENDING_SERVICES; this->proxy_->send_connections_free(); From d3cbe21fa392d78ce2d4ed437409642956690ae4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 10:29:24 -1000 Subject: [PATCH 0235/1626] preen --- .../bluetooth_proxy/bluetooth_connection.cpp | 14 ++++++++-- .../bluetooth_proxy/bluetooth_connection.h | 1 + .../bluetooth_proxy/bluetooth_proxy.cpp | 27 ------------------- .../bluetooth_proxy/bluetooth_proxy.h | 4 --- 4 files changed, 13 insertions(+), 33 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 554c312643..55ff47a725 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -78,14 +78,24 @@ void BluetoothConnection::dump_config() { BLEClientBase::dump_config(); } +void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) { + auto &allocated = this->proxy_->connections_free_response_.allocated; + auto it = std::find(allocated.begin(), allocated.end(), find_value); + if (it != allocated.end()) { + *it = set_value; + } +} + void BluetoothConnection::set_address(uint64_t address) { // If we're clearing an address (disconnecting), update the pre-allocated message if (address == 0 && this->address_ != 0) { - this->proxy_->free_connection_(this->address_); + this->proxy_->connections_free_response_.free++; + this->update_allocated_slot_(this->address_, 0); } // If we're setting a new address (connecting), update the pre-allocated message else if (address != 0 && this->address_ == 0) { - this->proxy_->allocate_connection_(this, address); + this->proxy_->connections_free_response_.free--; + this->update_allocated_slot_(0, address); } // Call parent implementation to actually set the address diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index c6f8d37e4e..042868e7a4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -32,6 +32,7 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); + void update_allocated_slot_(uint64_t find_value, uint64_t set_value); // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index eff5c21729..3a9ed69677 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -483,33 +483,6 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { true); // Set this to true to automatically start scanning again when it has cleaned up. } -void BluetoothProxy::allocate_connection_(BluetoothConnection *connection, uint64_t address) { - // Update pre-allocated message directly - this->connections_free_response_.free--; - - // Find first zero slot and set it - auto it = std::find(this->connections_free_response_.allocated.begin(), - this->connections_free_response_.allocated.end(), 0); - if (it != this->connections_free_response_.allocated.end()) { - *it = address; - } -} - -void BluetoothProxy::free_connection_(uint64_t address) { - if (address == 0) - return; // Safety check - - // Update pre-allocated message directly - this->connections_free_response_.free++; - - // Find the address and set to 0 - auto it = std::find(this->connections_free_response_.allocated.begin(), - this->connections_free_response_.allocated.end(), address); - if (it != this->connections_free_response_.allocated.end()) { - *it = 0; - } -} - BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::bluetooth_proxy diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index bf82c5b873..8e7462c660 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -133,10 +133,6 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com BluetoothConnection *get_connection_(uint64_t address, bool reserve); - // Helper functions for connection state management - void allocate_connection_(BluetoothConnection *connection, uint64_t address); - void free_connection_(uint64_t address); - // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; From 3a52b754c0e4c4ca4a314edd27df0ec2704dfb38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 10:30:02 -1000 Subject: [PATCH 0236/1626] preen --- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 3a9ed69677..a9a68e25c5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -267,7 +267,6 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest if (connection->state() != espbt::ClientState::IDLE) { connection->disconnect(); } else { - // Manual disconnect for idle connection connection->set_address(0); this->send_device_connection(msg.address, false); this->send_connections_free(); From eb851174d3090257dab415b1eaac8b51786bf7be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 10:43:40 -1000 Subject: [PATCH 0237/1626] merge --- script/api_protobuf/api_protobuf.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index fc7c22bf6d..fa2f87d98d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -393,7 +393,8 @@ class DoubleType(TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: - return self._get_fixed_size_calculation(name, "add_double") + field_id_size = self.calculate_field_id_size() + return f"size.add_double({field_id_size}, {name});" def get_fixed_size_bytes(self) -> int: return 8 @@ -1116,18 +1117,6 @@ class FixedArrayRepeatedType(TypeInfo): return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" - # If skip_zero is enabled, wrap encoding in a zero check - if self.skip_zero: - # Build the condition to check if at least one element is non-zero - non_zero_checks = " || ".join( - [f"this->{self.field_name}[{i}] != 0" for i in range(self.array_size)] - ) - encode_lines = [ - f" {encode_element(f'this->{self.field_name}[{i}]')}" - for i in range(self.array_size) - ] - return f"if ({non_zero_checks}) {{\n" + "\n".join(encode_lines) + "\n}" - # If skip_zero is enabled, wrap encoding in a zero check if self.skip_zero: if self.is_define: @@ -1477,9 +1466,6 @@ def build_type_usage_map( field_ifdef = get_field_opt(field, pb.field_ifdef) message_field_ifdefs.setdefault(type_name, set()).add(field_ifdef) used_messages.add(type_name) - # Also track the field_ifdef if present - field_ifdef = get_field_opt(field, pb.field_ifdef) - message_field_ifdefs.setdefault(type_name, set()).add(field_ifdef) # Helper to get unique ifdef from a set of messages def get_unique_ifdef(message_names: set[str]) -> str | None: From 493bfaf76a6e1c0f529d1a7d3cba27ad53706fed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 11:02:48 -1000 Subject: [PATCH 0238/1626] cleanup --- esphome/components/api/api_connection.cpp | 2 +- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 10 ++++++---- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 1 + 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index a6c037d2c2..5fff270c99 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1105,7 +1105,7 @@ void APIConnection::bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) bool APIConnection::send_subscribe_bluetooth_connections_free_response( const SubscribeBluetoothConnectionsFreeRequest &msg) { - bluetooth_proxy::global_bluetooth_proxy->send_connections_free(); + bluetooth_proxy::global_bluetooth_proxy->send_connections_free(this); return true; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index a9a68e25c5..a59a33117a 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -428,11 +428,13 @@ void BluetoothProxy::send_device_connection(uint64_t address, bool connected, ui this->api_connection_->send_message(call, api::BluetoothDeviceConnectionResponse::MESSAGE_TYPE); } void BluetoothProxy::send_connections_free() { - if (this->api_connection_ == nullptr) - return; + if (this->api_connection_ != nullptr) { + this->send_connections_free(this->api_connection_); + } +} - this->api_connection_->send_message(this->connections_free_response_, - api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE); +void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { + api_connection->send_message(this->connections_free_response_, api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE); } void BluetoothProxy::send_gatt_services_done(uint64_t address) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 8e7462c660..83cb7f374c 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -81,6 +81,7 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, esp_err_t error = ESP_OK); void send_connections_free(); + void send_connections_free(api::APIConnection *api_connection); void send_gatt_services_done(uint64_t address); void send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error); void send_device_pairing(uint64_t address, bool paired, esp_err_t error = ESP_OK); From 3aaf11f4042695bfb94ccabd3ea9fb6f342ddc77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 11:15:34 -1000 Subject: [PATCH 0239/1626] missed some --- esphome/__main__.py | 2 +- esphome/components/esp32/post_build.py.script | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 5e5c9ab556..9a79c0bde2 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -290,7 +290,7 @@ def upload_using_esptool(config, port, file, speed): mcu, "write_flash", "-z", - "--flash_size", + "--flash-size", "detect", ] for img in flash_images: diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index 586f12e00b..c995214232 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -93,8 +93,8 @@ def merge_factory_bin(source, target, env): "esptool", "--chip", chip, - "merge_bin", - "--flash_size", + "merge-bin", + "--flash-size", flash_size, "--output", str(output_path), @@ -110,7 +110,7 @@ def merge_factory_bin(source, target, env): if result == 0: print(f"Successfully created {output_path}") else: - print(f"Error: esptool merge_bin failed with code {result}") + print(f"Error: esptool merge-bin failed with code {result}") def esp32_copy_ota_bin(source, target, env): From f77d15a381d0822d9a9795079fd61dd050862705 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 11:19:27 -1000 Subject: [PATCH 0240/1626] preen --- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 83cb7f374c..f3a58a8cca 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -49,7 +49,6 @@ enum BluetoothProxySubscriptionFlag : uint32_t { }; class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Component { - friend class BluetoothConnection; // Allow connection to call free_connection_ public: BluetoothProxy(); #ifdef USE_ESP32_BLE_DEVICE From fa267f94ea4e916081253ecf7893ad859c1d4fe6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 11:20:57 -1000 Subject: [PATCH 0241/1626] preen --- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index f3a58a8cca..70deef1ebd 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -49,6 +49,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { }; class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Component { + friend class BluetoothConnection; // Allow connection to update connections_free_response_ public: BluetoothProxy(); #ifdef USE_ESP32_BLE_DEVICE From 559872fa31864a824d9407a7ad4d477a893dfea5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 12:38:44 -1000 Subject: [PATCH 0242/1626] Fix BLE connection slot waste by aligning ESP-IDF timeout with client timeout --- esphome/components/esp32_ble/__init__.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 93bb643596..d418154ed0 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -6,7 +6,7 @@ import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ESPHOME, CONF_ID, CONF_NAME -from esphome.core import CORE +from esphome.core import CORE, TimePeriod from esphome.core.config import CONF_NAME_ADD_MAC_SUFFIX import esphome.final_validate as fv @@ -117,6 +117,7 @@ CONF_BLE_ID = "ble_id" CONF_IO_CAPABILITY = "io_capability" CONF_ADVERTISING_CYCLE_TIME = "advertising_cycle_time" CONF_DISABLE_BT_LOGS = "disable_bt_logs" +CONF_CONNECTION_TIMEOUT = "connection_timeout" NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] @@ -167,6 +168,11 @@ CONFIG_SCHEMA = cv.Schema( cv.SplitDefault(CONF_DISABLE_BT_LOGS, esp32_idf=True): cv.All( cv.only_with_esp_idf, cv.boolean ), + cv.Optional(CONF_CONNECTION_TIMEOUT, default="20s"): cv.All( + cv.only_with_esp_idf, + cv.positive_time_period_seconds, + cv.Range(min=TimePeriod(seconds=1), max=TimePeriod(seconds=180)), + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -255,6 +261,17 @@ async def to_code(config): if logger not in _required_loggers: add_idf_sdkconfig_option(f"{logger.value}_NONE", True) + # Set BLE connection establishment timeout to match aioesphomeapi/bleak-retry-connector + # Default is 20 seconds instead of ESP-IDF's 30 seconds. Because there is no way to + # cancel a BLE connection in progress, when aioesphomeapi times out at 20 seconds, + # the connection slot remains occupied for the remaining time, preventing new connection + # attempts and wasting valuable connection slots. + if CONF_CONNECTION_TIMEOUT in config: + timeout_seconds = int(config[CONF_CONNECTION_TIMEOUT].total_seconds) + add_idf_sdkconfig_option( + "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds + ) + cg.add_define("USE_ESP32_BLE") From 23519c921158f87b2158b1de6fb2db1a5cf2cf00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 12:43:10 -1000 Subject: [PATCH 0243/1626] fix --- esphome/components/esp32_ble/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index d418154ed0..1edf42ece4 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -168,7 +168,7 @@ CONFIG_SCHEMA = cv.Schema( cv.SplitDefault(CONF_DISABLE_BT_LOGS, esp32_idf=True): cv.All( cv.only_with_esp_idf, cv.boolean ), - cv.Optional(CONF_CONNECTION_TIMEOUT, default="20s"): cv.All( + cv.SplitDefault(CONF_CONNECTION_TIMEOUT, esp32_idf="20s"): cv.All( cv.only_with_esp_idf, cv.positive_time_period_seconds, cv.Range(min=TimePeriod(seconds=1), max=TimePeriod(seconds=180)), From 54227ff768318faec119e2348a408ec5aacb6769 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 12:43:51 -1000 Subject: [PATCH 0244/1626] fix --- esphome/components/esp32_ble/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 1edf42ece4..1c7c075cfa 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -171,7 +171,7 @@ CONFIG_SCHEMA = cv.Schema( cv.SplitDefault(CONF_CONNECTION_TIMEOUT, esp32_idf="20s"): cv.All( cv.only_with_esp_idf, cv.positive_time_period_seconds, - cv.Range(min=TimePeriod(seconds=1), max=TimePeriod(seconds=180)), + cv.Range(min=TimePeriod(seconds=10), max=TimePeriod(seconds=180)), ), } ).extend(cv.COMPONENT_SCHEMA) From 11e8cfba3d67591297c7f87965b6c3eccb69363f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 13:00:54 -1000 Subject: [PATCH 0245/1626] tidy --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 9c651ed04b..01c2aa3d22 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -80,7 +80,7 @@ void BluetoothConnection::dump_config() { void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) { auto &allocated = this->proxy_->connections_free_response_.allocated; - auto it = std::find(allocated.begin(), allocated.end(), find_value); + auto *it = std::find(allocated.begin(), allocated.end(), find_value); if (it != allocated.end()) { *it = set_value; } From 68ab351cc879b696c10ab824fbc37c8665f8c7ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 14:12:08 -1000 Subject: [PATCH 0246/1626] [bluetooth_proxy] Optimize memory usage with fixed-size array and const string references --- .../bluetooth_proxy/bluetooth_proxy.cpp | 15 +++++++++------ .../components/bluetooth_proxy/bluetooth_proxy.h | 14 +++++++++----- .../components/esp32_ble_client/ble_client_base.h | 2 +- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index a59a33117a..302945bc12 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -35,8 +35,8 @@ void BluetoothProxy::setup() { // Don't pre-allocate pool - let it grow only if needed in busy environments // Many devices in quiet areas will never need the overflow pool - this->connections_free_response_.limit = this->connections_.size(); - this->connections_free_response_.free = this->connections_.size(); + this->connections_free_response_.limit = this->connection_count_; + this->connections_free_response_.free = this->connection_count_; this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { @@ -134,12 +134,13 @@ void BluetoothProxy::dump_config() { ESP_LOGCONFIG(TAG, " Active: %s\n" " Connections: %d", - YESNO(this->active_), this->connections_.size()); + YESNO(this->active_), this->connection_count_); } void BluetoothProxy::loop() { if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { - for (auto *connection : this->connections_) { + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; if (connection->get_address() != 0 && !connection->disconnect_pending()) { connection->disconnect(); } @@ -162,7 +163,8 @@ esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_par } BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { - for (auto *connection : this->connections_) { + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; if (connection->get_address() == address) return connection; } @@ -170,7 +172,8 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese if (!reserve) return nullptr; - for (auto *connection : this->connections_) { + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; if (connection->get_address() == 0) { connection->send_service_ = DONE_SENDING_SERVICES; connection->set_address(address); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 70deef1ebd..d367dad438 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -2,6 +2,7 @@ #ifdef USE_ESP32 +#include #include #include @@ -63,8 +64,10 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; void register_connection(BluetoothConnection *connection) { - this->connections_.push_back(connection); - connection->proxy_ = this; + if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { + this->connections_[this->connection_count_++] = connection; + connection->proxy_ = this; + } } void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); @@ -138,8 +141,8 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; - // Group 2: Container types (typically 12 bytes on 32-bit) - std::vector connections_{}; + // Group 2: Fixed-size array of connection pointers + std::array connections_{}; // BLE advertisement batching std::vector advertisement_pool_; @@ -154,7 +157,8 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 4: 1-byte types grouped together bool active_; uint8_t advertisement_count_{0}; - // 2 bytes used, 2 bytes padding + uint8_t connection_count_{0}; + // 3 bytes used, 1 byte padding }; extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 0a2fda4476..0bbff8d3c6 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -66,7 +66,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { (uint8_t) (this->address_ >> 0) & 0xff); } } - std::string address_str() const { return this->address_str_; } + const std::string &address_str() const { return this->address_str_; } BLEService *get_service(espbt::ESPBTUUID uuid); BLEService *get_service(uint16_t uuid); From 7351fb374f8d0c5b118c763137f390755f71152e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 14:59:01 -1000 Subject: [PATCH 0247/1626] [core] Convert entity vectors to static allocation for reduced memory usage --- esphome/core/application.h | 150 ++++++++-------------------- esphome/core/component_iterator.cpp | 22 ++-- esphome/core/component_iterator.h | 6 +- esphome/core/config.py | 4 +- esphome/core/helpers.h | 31 ++++++ 5 files changed, 93 insertions(+), 120 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index a83789837f..c91cba8d19 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -216,69 +216,6 @@ class Application { /// Reserve space for components to avoid memory fragmentation void reserve_components(size_t count) { this->components_.reserve(count); } -#ifdef USE_BINARY_SENSOR - void reserve_binary_sensor(size_t count) { this->binary_sensors_.reserve(count); } -#endif -#ifdef USE_SWITCH - void reserve_switch(size_t count) { this->switches_.reserve(count); } -#endif -#ifdef USE_BUTTON - void reserve_button(size_t count) { this->buttons_.reserve(count); } -#endif -#ifdef USE_SENSOR - void reserve_sensor(size_t count) { this->sensors_.reserve(count); } -#endif -#ifdef USE_TEXT_SENSOR - void reserve_text_sensor(size_t count) { this->text_sensors_.reserve(count); } -#endif -#ifdef USE_FAN - void reserve_fan(size_t count) { this->fans_.reserve(count); } -#endif -#ifdef USE_COVER - void reserve_cover(size_t count) { this->covers_.reserve(count); } -#endif -#ifdef USE_CLIMATE - void reserve_climate(size_t count) { this->climates_.reserve(count); } -#endif -#ifdef USE_LIGHT - void reserve_light(size_t count) { this->lights_.reserve(count); } -#endif -#ifdef USE_NUMBER - void reserve_number(size_t count) { this->numbers_.reserve(count); } -#endif -#ifdef USE_DATETIME_DATE - void reserve_date(size_t count) { this->dates_.reserve(count); } -#endif -#ifdef USE_DATETIME_TIME - void reserve_time(size_t count) { this->times_.reserve(count); } -#endif -#ifdef USE_DATETIME_DATETIME - void reserve_datetime(size_t count) { this->datetimes_.reserve(count); } -#endif -#ifdef USE_SELECT - void reserve_select(size_t count) { this->selects_.reserve(count); } -#endif -#ifdef USE_TEXT - void reserve_text(size_t count) { this->texts_.reserve(count); } -#endif -#ifdef USE_LOCK - void reserve_lock(size_t count) { this->locks_.reserve(count); } -#endif -#ifdef USE_VALVE - void reserve_valve(size_t count) { this->valves_.reserve(count); } -#endif -#ifdef USE_MEDIA_PLAYER - void reserve_media_player(size_t count) { this->media_players_.reserve(count); } -#endif -#ifdef USE_ALARM_CONTROL_PANEL - void reserve_alarm_control_panel(size_t count) { this->alarm_control_panels_.reserve(count); } -#endif -#ifdef USE_EVENT - void reserve_event(size_t count) { this->events_.reserve(count); } -#endif -#ifdef USE_UPDATE - void reserve_update(size_t count) { this->updates_.reserve(count); } -#endif #ifdef USE_AREAS void reserve_area(size_t count) { this->areas_.reserve(count); } #endif @@ -394,92 +331,90 @@ class Application { const std::vector &get_areas() { return this->areas_; } #endif #ifdef USE_BINARY_SENSOR - const std::vector &get_binary_sensors() { return this->binary_sensors_; } + auto &get_binary_sensors() const { return this->binary_sensors_; } GET_ENTITY_METHOD(binary_sensor::BinarySensor, binary_sensor, binary_sensors) #endif #ifdef USE_SWITCH - const std::vector &get_switches() { return this->switches_; } + auto &get_switches() const { return this->switches_; } GET_ENTITY_METHOD(switch_::Switch, switch, switches) #endif #ifdef USE_BUTTON - const std::vector &get_buttons() { return this->buttons_; } + auto &get_buttons() const { return this->buttons_; } GET_ENTITY_METHOD(button::Button, button, buttons) #endif #ifdef USE_SENSOR - const std::vector &get_sensors() { return this->sensors_; } + auto &get_sensors() const { return this->sensors_; } GET_ENTITY_METHOD(sensor::Sensor, sensor, sensors) #endif #ifdef USE_TEXT_SENSOR - const std::vector &get_text_sensors() { return this->text_sensors_; } + auto &get_text_sensors() const { return this->text_sensors_; } GET_ENTITY_METHOD(text_sensor::TextSensor, text_sensor, text_sensors) #endif #ifdef USE_FAN - const std::vector &get_fans() { return this->fans_; } + auto &get_fans() const { return this->fans_; } GET_ENTITY_METHOD(fan::Fan, fan, fans) #endif #ifdef USE_COVER - const std::vector &get_covers() { return this->covers_; } + auto &get_covers() const { return this->covers_; } GET_ENTITY_METHOD(cover::Cover, cover, covers) #endif #ifdef USE_LIGHT - const std::vector &get_lights() { return this->lights_; } + auto &get_lights() const { return this->lights_; } GET_ENTITY_METHOD(light::LightState, light, lights) #endif #ifdef USE_CLIMATE - const std::vector &get_climates() { return this->climates_; } + auto &get_climates() const { return this->climates_; } GET_ENTITY_METHOD(climate::Climate, climate, climates) #endif #ifdef USE_NUMBER - const std::vector &get_numbers() { return this->numbers_; } + auto &get_numbers() const { return this->numbers_; } GET_ENTITY_METHOD(number::Number, number, numbers) #endif #ifdef USE_DATETIME_DATE - const std::vector &get_dates() { return this->dates_; } + auto &get_dates() const { return this->dates_; } GET_ENTITY_METHOD(datetime::DateEntity, date, dates) #endif #ifdef USE_DATETIME_TIME - const std::vector &get_times() { return this->times_; } + auto &get_times() const { return this->times_; } GET_ENTITY_METHOD(datetime::TimeEntity, time, times) #endif #ifdef USE_DATETIME_DATETIME - const std::vector &get_datetimes() { return this->datetimes_; } + auto &get_datetimes() const { return this->datetimes_; } GET_ENTITY_METHOD(datetime::DateTimeEntity, datetime, datetimes) #endif #ifdef USE_TEXT - const std::vector &get_texts() { return this->texts_; } + auto &get_texts() const { return this->texts_; } GET_ENTITY_METHOD(text::Text, text, texts) #endif #ifdef USE_SELECT - const std::vector &get_selects() { return this->selects_; } + auto &get_selects() const { return this->selects_; } GET_ENTITY_METHOD(select::Select, select, selects) #endif #ifdef USE_LOCK - const std::vector &get_locks() { return this->locks_; } + auto &get_locks() const { return this->locks_; } GET_ENTITY_METHOD(lock::Lock, lock, locks) #endif #ifdef USE_VALVE - const std::vector &get_valves() { return this->valves_; } + auto &get_valves() const { return this->valves_; } GET_ENTITY_METHOD(valve::Valve, valve, valves) #endif #ifdef USE_MEDIA_PLAYER - const std::vector &get_media_players() { return this->media_players_; } + auto &get_media_players() const { return this->media_players_; } GET_ENTITY_METHOD(media_player::MediaPlayer, media_player, media_players) #endif #ifdef USE_ALARM_CONTROL_PANEL - const std::vector &get_alarm_control_panels() { - return this->alarm_control_panels_; - } + auto &get_alarm_control_panels() const { return this->alarm_control_panels_; } GET_ENTITY_METHOD(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels) #endif #ifdef USE_EVENT - const std::vector &get_events() { return this->events_; } + auto &get_events() const { return this->events_; } GET_ENTITY_METHOD(event::Event, event, events) #endif #ifdef USE_UPDATE - const std::vector &get_updates() { return this->updates_; } + auto &get_updates() const { return this->updates_; } GET_ENTITY_METHOD(update::UpdateEntity, update, updates) #endif @@ -558,67 +493,68 @@ class Application { std::vector areas_{}; #endif #ifdef USE_BINARY_SENSOR - std::vector binary_sensors_{}; + static_vector binary_sensors_{}; #endif #ifdef USE_SWITCH - std::vector switches_{}; + static_vector switches_{}; #endif #ifdef USE_BUTTON - std::vector buttons_{}; + static_vector buttons_{}; #endif #ifdef USE_EVENT - std::vector events_{}; + static_vector events_{}; #endif #ifdef USE_SENSOR - std::vector sensors_{}; + static_vector sensors_{}; #endif #ifdef USE_TEXT_SENSOR - std::vector text_sensors_{}; + static_vector text_sensors_{}; #endif #ifdef USE_FAN - std::vector fans_{}; + static_vector fans_{}; #endif #ifdef USE_COVER - std::vector covers_{}; + static_vector covers_{}; #endif #ifdef USE_CLIMATE - std::vector climates_{}; + static_vector climates_{}; #endif #ifdef USE_LIGHT - std::vector lights_{}; + static_vector lights_{}; #endif #ifdef USE_NUMBER - std::vector numbers_{}; + static_vector numbers_{}; #endif #ifdef USE_DATETIME_DATE - std::vector dates_{}; + static_vector dates_{}; #endif #ifdef USE_DATETIME_TIME - std::vector times_{}; + static_vector times_{}; #endif #ifdef USE_DATETIME_DATETIME - std::vector datetimes_{}; + static_vector datetimes_{}; #endif #ifdef USE_SELECT - std::vector selects_{}; + static_vector selects_{}; #endif #ifdef USE_TEXT - std::vector texts_{}; + static_vector texts_{}; #endif #ifdef USE_LOCK - std::vector locks_{}; + static_vector locks_{}; #endif #ifdef USE_VALVE - std::vector valves_{}; + static_vector valves_{}; #endif #ifdef USE_MEDIA_PLAYER - std::vector media_players_{}; + static_vector media_players_{}; #endif #ifdef USE_ALARM_CONTROL_PANEL - std::vector alarm_control_panels_{}; + static_vector + alarm_control_panels_{}; #endif #ifdef USE_UPDATE - std::vector updates_{}; + static_vector updates_{}; #endif #ifdef USE_SOCKET_SELECT_SUPPORT diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index 1e8f670d8b..e012412ab6 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -17,17 +17,21 @@ void ComponentIterator::begin(bool include_internal) { this->include_internal_ = include_internal; } -template -void ComponentIterator::process_platform_item_(const std::vector &items, - bool (ComponentIterator::*on_item)(PlatformItem *)) { - if (this->at_ >= items.size()) { - this->advance_platform_(); - } else { - PlatformItem *item = items[this->at_]; - if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) { - this->at_++; +template +void ComponentIterator::process_platform_item_(const Container &items, + bool (ComponentIterator::*on_item)(typename Container::value_type)) { + // Since static_vector doesn't have size(), we need to iterate differently + size_t index = 0; + for (auto *item : items) { + if (index++ == this->at_) { + if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) { + this->at_++; + } + return; } } + // If we get here, we've reached the end + this->advance_platform_(); } void ComponentIterator::advance_platform_() { diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 7a9771b8f2..6b44990529 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -172,9 +172,9 @@ class ComponentIterator { uint16_t at_{0}; // Supports up to 65,535 entities per type bool include_internal_{false}; - template - void process_platform_item_(const std::vector &items, - bool (ComponentIterator::*on_item)(PlatformItem *)); + template + void process_platform_item_(const Container &items, + bool (ComponentIterator::*on_item)(typename Container::value_type)); void advance_platform_(); }; diff --git a/esphome/core/config.py b/esphome/core/config.py index 6d93117164..3bc030ad50 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -421,8 +421,10 @@ async def _add_automations(config): @coroutine_with_priority(-100.0) async def _add_platform_reserves() -> None: + # Generate compile-time entity count defines for static_entity_vector for platform_name, count in sorted(CORE.platform_counts.items()): - cg.add(cg.RawStatement(f"App.reserve_{platform_name}({count});"), prepend=True) + define_name = f"ESPHOME_ENTITY_{platform_name.upper()}_COUNT" + cg.add_define(define_name, count) @coroutine_with_priority(100.0) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 5204804e1e..05e57a267e 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -91,6 +91,37 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); ///@} +/// @name Container utilities +///@{ + +/// Minimal static vector - saves memory by avoiding std::vector overhead +template class static_vector { + public: + using value_type = T; + using iterator = typename std::array::iterator; + using const_iterator = typename std::array::const_iterator; + + private: + std::array data_{}; + size_t count_{0}; + + public: + // Minimal vector-compatible interface - only what we actually use + void push_back(const T &value) { + if (count_ < N) { + data_[count_++] = value; + } + } + + // For range-based for loops + iterator begin() { return data_.begin(); } + iterator end() { return data_.begin() + count_; } + const_iterator begin() const { return data_.begin(); } + const_iterator end() const { return data_.begin() + count_; } +}; + +///@} + /// @name Mathematics ///@{ From 8bf3d52fb0b0059c6216ba09f6a189314ca69eca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 15:25:10 -1000 Subject: [PATCH 0248/1626] tidy --- esphome/core/application.h | 42 +++++++++++++++++++------------------- esphome/core/helpers.h | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index c91cba8d19..b7824a254b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -493,68 +493,68 @@ class Application { std::vector areas_{}; #endif #ifdef USE_BINARY_SENSOR - static_vector binary_sensors_{}; + StaticVector binary_sensors_{}; #endif #ifdef USE_SWITCH - static_vector switches_{}; + StaticVector switches_{}; #endif #ifdef USE_BUTTON - static_vector buttons_{}; + StaticVector buttons_{}; #endif #ifdef USE_EVENT - static_vector events_{}; + StaticVector events_{}; #endif #ifdef USE_SENSOR - static_vector sensors_{}; + StaticVector sensors_{}; #endif #ifdef USE_TEXT_SENSOR - static_vector text_sensors_{}; + StaticVector text_sensors_{}; #endif #ifdef USE_FAN - static_vector fans_{}; + StaticVector fans_{}; #endif #ifdef USE_COVER - static_vector covers_{}; + StaticVector covers_{}; #endif #ifdef USE_CLIMATE - static_vector climates_{}; + StaticVector climates_{}; #endif #ifdef USE_LIGHT - static_vector lights_{}; + StaticVector lights_{}; #endif #ifdef USE_NUMBER - static_vector numbers_{}; + StaticVector numbers_{}; #endif #ifdef USE_DATETIME_DATE - static_vector dates_{}; + StaticVector dates_{}; #endif #ifdef USE_DATETIME_TIME - static_vector times_{}; + StaticVector times_{}; #endif #ifdef USE_DATETIME_DATETIME - static_vector datetimes_{}; + StaticVector datetimes_{}; #endif #ifdef USE_SELECT - static_vector selects_{}; + StaticVector selects_{}; #endif #ifdef USE_TEXT - static_vector texts_{}; + StaticVector texts_{}; #endif #ifdef USE_LOCK - static_vector locks_{}; + StaticVector locks_{}; #endif #ifdef USE_VALVE - static_vector valves_{}; + StaticVector valves_{}; #endif #ifdef USE_MEDIA_PLAYER - static_vector media_players_{}; + StaticVector media_players_{}; #endif #ifdef USE_ALARM_CONTROL_PANEL - static_vector + StaticVector alarm_control_panels_{}; #endif #ifdef USE_UPDATE - static_vector updates_{}; + StaticVector updates_{}; #endif #ifdef USE_SOCKET_SELECT_SUPPORT diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 05e57a267e..e937c3bdf2 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -95,7 +95,7 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); ///@{ /// Minimal static vector - saves memory by avoiding std::vector overhead -template class static_vector { +template class StaticVector { public: using value_type = T; using iterator = typename std::array::iterator; From 13c749ceda7c0de4fd5ccfcd68a4c2c5a71d3125 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 15:26:04 -1000 Subject: [PATCH 0249/1626] preen --- esphome/core/component_iterator.cpp | 16 ++++++---------- esphome/core/helpers.h | 2 ++ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index e012412ab6..583f9c39af 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -20,18 +20,14 @@ void ComponentIterator::begin(bool include_internal) { template void ComponentIterator::process_platform_item_(const Container &items, bool (ComponentIterator::*on_item)(typename Container::value_type)) { - // Since static_vector doesn't have size(), we need to iterate differently - size_t index = 0; - for (auto *item : items) { - if (index++ == this->at_) { - if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) { - this->at_++; - } - return; + if (this->at_ >= items.size()) { + this->advance_platform_(); + } else { + auto *item = items[this->at_]; + if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) { + this->at_++; } } - // If we get here, we've reached the end - this->advance_platform_(); } void ComponentIterator::advance_platform_() { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index e937c3bdf2..04cac5072f 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -113,6 +113,8 @@ template class StaticVector { } } + size_t size() const { return count_; } + // For range-based for loops iterator begin() { return data_.begin(); } iterator end() { return data_.begin() + count_; } From a25edf93d68943c7995bf000d1beb1804ebd677d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 15:26:13 -1000 Subject: [PATCH 0250/1626] preen --- esphome/core/helpers.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 04cac5072f..b05cc11029 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -115,6 +115,9 @@ template class StaticVector { size_t size() const { return count_; } + T &operator[](size_t i) { return data_[i]; } + const T &operator[](size_t i) const { return data_[i]; } + // For range-based for loops iterator begin() { return data_.begin(); } iterator end() { return data_.begin() + count_; } From 7e25846cada60d9795840f158bd036de2381ab70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 15:26:51 -1000 Subject: [PATCH 0251/1626] preen --- esphome/core/component_iterator.cpp | 8 ++++---- esphome/core/component_iterator.h | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index 583f9c39af..1e8f670d8b 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -17,13 +17,13 @@ void ComponentIterator::begin(bool include_internal) { this->include_internal_ = include_internal; } -template -void ComponentIterator::process_platform_item_(const Container &items, - bool (ComponentIterator::*on_item)(typename Container::value_type)) { +template +void ComponentIterator::process_platform_item_(const std::vector &items, + bool (ComponentIterator::*on_item)(PlatformItem *)) { if (this->at_ >= items.size()) { this->advance_platform_(); } else { - auto *item = items[this->at_]; + PlatformItem *item = items[this->at_]; if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) { this->at_++; } diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 6b44990529..7a9771b8f2 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -172,9 +172,9 @@ class ComponentIterator { uint16_t at_{0}; // Supports up to 65,535 entities per type bool include_internal_{false}; - template - void process_platform_item_(const Container &items, - bool (ComponentIterator::*on_item)(typename Container::value_type)); + template + void process_platform_item_(const std::vector &items, + bool (ComponentIterator::*on_item)(PlatformItem *)); void advance_platform_(); }; From 591b9ce87b468de86a5119c5fafd524c50d934e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 15:28:48 -1000 Subject: [PATCH 0252/1626] preen --- esphome/core/component_iterator.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 7a9771b8f2..6b44990529 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -172,9 +172,9 @@ class ComponentIterator { uint16_t at_{0}; // Supports up to 65,535 entities per type bool include_internal_{false}; - template - void process_platform_item_(const std::vector &items, - bool (ComponentIterator::*on_item)(PlatformItem *)); + template + void process_platform_item_(const Container &items, + bool (ComponentIterator::*on_item)(typename Container::value_type)); void advance_platform_(); }; From 4de68ded794acfb80b4c86aa1167447c6487b52c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 15:31:01 -1000 Subject: [PATCH 0253/1626] preen --- esphome/core/component_iterator.cpp | 13 ------------- esphome/core/component_iterator.h | 12 +++++++++++- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index 1e8f670d8b..668c4a1fda 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -17,19 +17,6 @@ void ComponentIterator::begin(bool include_internal) { this->include_internal_ = include_internal; } -template -void ComponentIterator::process_platform_item_(const std::vector &items, - bool (ComponentIterator::*on_item)(PlatformItem *)) { - if (this->at_ >= items.size()) { - this->advance_platform_(); - } else { - PlatformItem *item = items[this->at_]; - if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) { - this->at_++; - } - } -} - void ComponentIterator::advance_platform_() { this->state_ = static_cast(static_cast(this->state_) + 1); this->at_ = 0; diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 6b44990529..fdc30485bc 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -174,7 +174,17 @@ class ComponentIterator { template void process_platform_item_(const Container &items, - bool (ComponentIterator::*on_item)(typename Container::value_type)); + bool (ComponentIterator::*on_item)(typename Container::value_type)) { + if (this->at_ >= items.size()) { + this->advance_platform_(); + } else { + typename Container::value_type item = items[this->at_]; + if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) { + this->at_++; + } + } + } + void advance_platform_(); }; From d8d9123c5848c40ab66fce5ccb05252e2f56f98b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 15:41:33 -1000 Subject: [PATCH 0254/1626] fix clang-tiy --- esphome/core/defines.h | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e226f748a8..1c83afb036 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -238,3 +238,26 @@ // #define USE_BSEC2 // Requires a library with proprietary license #define USE_DASHBOARD_IMPORT + +// Default entity counts for static analysis +#define ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT 1 +#define ESPHOME_ENTITY_BINARY_SENSOR_COUNT 1 +#define ESPHOME_ENTITY_BUTTON_COUNT 1 +#define ESPHOME_ENTITY_CLIMATE_COUNT 1 +#define ESPHOME_ENTITY_COVER_COUNT 1 +#define ESPHOME_ENTITY_DATE_COUNT 1 +#define ESPHOME_ENTITY_DATETIME_COUNT 1 +#define ESPHOME_ENTITY_EVENT_COUNT 1 +#define ESPHOME_ENTITY_FAN_COUNT 1 +#define ESPHOME_ENTITY_LIGHT_COUNT 1 +#define ESPHOME_ENTITY_LOCK_COUNT 1 +#define ESPHOME_ENTITY_MEDIA_PLAYER_COUNT 1 +#define ESPHOME_ENTITY_NUMBER_COUNT 1 +#define ESPHOME_ENTITY_SELECT_COUNT 1 +#define ESPHOME_ENTITY_SENSOR_COUNT 1 +#define ESPHOME_ENTITY_SWITCH_COUNT 1 +#define ESPHOME_ENTITY_TEXT_COUNT 1 +#define ESPHOME_ENTITY_TEXT_SENSOR_COUNT 1 +#define ESPHOME_ENTITY_TIME_COUNT 1 +#define ESPHOME_ENTITY_UPDATE_COUNT 1 +#define ESPHOME_ENTITY_VALVE_COUNT 1 From 00d9baed11fa3c8a7330f88c963cd7d87a624817 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 20:26:00 -1000 Subject: [PATCH 0255/1626] [bluetooth_proxy] Eliminate heap allocations in connection state reporting (#10010) --- esphome/components/api/api.proto | 5 ++- esphome/components/api/api_connection.cpp | 6 +-- esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 10 +++-- esphome/components/api/api_pb2.h | 4 +- .../components/bluetooth_proxy/__init__.py | 4 ++ .../bluetooth_proxy/bluetooth_connection.cpp | 24 +++++++++++ .../bluetooth_proxy/bluetooth_connection.h | 3 ++ .../bluetooth_proxy/bluetooth_proxy.cpp | 33 ++++---------- .../bluetooth_proxy/bluetooth_proxy.h | 8 ++-- .../esp32_ble_client/ble_client_base.h | 2 +- esphome/core/defines.h | 1 + script/api_protobuf/api_protobuf.py | 43 ++++++++++++++++++- 13 files changed, 104 insertions(+), 40 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4aa5cc4be0..e0b2c19a21 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1621,7 +1621,10 @@ message BluetoothConnectionsFreeResponse { uint32 free = 1; uint32 limit = 2; - repeated uint64 allocated = 3; + repeated uint64 allocated = 3 [ + (fixed_array_size_define) = "BLUETOOTH_PROXY_MAX_CONNECTIONS", + (fixed_array_skip_zero) = true + ]; } message BluetoothGATTErrorResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 8ac6c3b71e..5fff270c99 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1105,10 +1105,8 @@ void APIConnection::bluetooth_gatt_notify(const BluetoothGATTNotifyRequest &msg) bool APIConnection::send_subscribe_bluetooth_connections_free_response( const SubscribeBluetoothConnectionsFreeRequest &msg) { - BluetoothConnectionsFreeResponse resp; - resp.free = bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_connections_free(); - resp.limit = bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_connections_limit(); - return this->send_message(resp, BluetoothConnectionsFreeResponse::MESSAGE_TYPE); + bluetooth_proxy::global_bluetooth_proxy->send_connections_free(this); + return true; } void APIConnection::bluetooth_scanner_set_mode(const BluetoothScannerSetModeRequest &msg) { diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index d4b5700024..ed0e0d7455 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -29,6 +29,7 @@ extend google.protobuf.FieldOptions { optional uint32 fixed_array_size = 50007; optional bool no_zero_copy = 50008 [default=false]; optional bool fixed_array_skip_zero = 50009 [default=false]; + optional string fixed_array_size_define = 50010; // container_pointer: Zero-copy optimization for repeated fields. // diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 29d0f2842c..8c14153155 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2073,15 +2073,17 @@ void BluetoothGATTNotifyDataResponse::calculate_size(ProtoSize &size) const { void BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->free); buffer.encode_uint32(2, this->limit); - for (auto &it : this->allocated) { - buffer.encode_uint64(3, it, true); + for (const auto &it : this->allocated) { + if (it != 0) { + buffer.encode_uint64(3, it, true); + } } } void BluetoothConnectionsFreeResponse::calculate_size(ProtoSize &size) const { size.add_uint32(1, this->free); size.add_uint32(1, this->limit); - if (!this->allocated.empty()) { - for (const auto &it : this->allocated) { + for (const auto &it : this->allocated) { + if (it != 0) { size.add_uint64_force(1, it); } } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 524674e6ef..0bc75ef00b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -2076,13 +2076,13 @@ class SubscribeBluetoothConnectionsFreeRequest : public ProtoMessage { class BluetoothConnectionsFreeResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 81; - static constexpr uint8_t ESTIMATED_SIZE = 16; + static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_connections_free_response"; } #endif uint32_t free{0}; uint32_t limit{0}; - std::vector allocated{}; + std::array allocated{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index a1e9d464df..ec1df6a06c 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -87,6 +87,10 @@ async def to_code(config): cg.add(var.set_active(config[CONF_ACTIVE])) await esp32_ble_tracker.register_raw_ble_device(var, config) + # Define max connections for protobuf fixed array + connection_count = len(config.get(CONF_CONNECTIONS, [])) + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) + for connection_conf in config.get(CONF_CONNECTIONS, []): connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) await cg.register_component(connection_var, connection_conf) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index fd1324dcdc..01c2aa3d22 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -78,6 +78,30 @@ void BluetoothConnection::dump_config() { BLEClientBase::dump_config(); } +void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) { + auto &allocated = this->proxy_->connections_free_response_.allocated; + auto *it = std::find(allocated.begin(), allocated.end(), find_value); + if (it != allocated.end()) { + *it = set_value; + } +} + +void BluetoothConnection::set_address(uint64_t address) { + // If we're clearing an address (disconnecting), update the pre-allocated message + if (address == 0 && this->address_ != 0) { + this->proxy_->connections_free_response_.free++; + this->update_allocated_slot_(this->address_, 0); + } + // If we're setting a new address (connecting), update the pre-allocated message + else if (address != 0 && this->address_ == 0) { + this->proxy_->connections_free_response_.free--; + this->update_allocated_slot_(0, address); + } + + // Call parent implementation to actually set the address + BLEClientBase::set_address(address); +} + void BluetoothConnection::loop() { BLEClientBase::loop(); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 622d257bf8..042868e7a4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -24,12 +24,15 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { esp_err_t notify_characteristic(uint16_t handle, bool enable); + void set_address(uint64_t address) override; + protected: friend class BluetoothProxy; bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); + void update_allocated_slot_(uint64_t find_value, uint64_t set_value); // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index de5508c777..a59a33117a 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -35,6 +35,9 @@ void BluetoothProxy::setup() { // Don't pre-allocate pool - let it grow only if needed in busy environments // Many devices in quiet areas will never need the overflow pool + this->connections_free_response_.limit = this->connections_.size(); + this->connections_free_response_.free = this->connections_.size(); + this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { this->send_bluetooth_scanner_state_(state); @@ -134,20 +137,6 @@ void BluetoothProxy::dump_config() { YESNO(this->active_), this->connections_.size()); } -int BluetoothProxy::get_bluetooth_connections_free() { - int free = 0; - for (auto *connection : this->connections_) { - if (connection->address_ == 0) { - free++; - ESP_LOGV(TAG, "[%d] Free connection", connection->get_connection_index()); - } else { - ESP_LOGV(TAG, "[%d] Used connection by [%s]", connection->get_connection_index(), - connection->address_str().c_str()); - } - } - return free; -} - void BluetoothProxy::loop() { if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { for (auto *connection : this->connections_) { @@ -439,17 +428,13 @@ void BluetoothProxy::send_device_connection(uint64_t address, bool connected, ui this->api_connection_->send_message(call, api::BluetoothDeviceConnectionResponse::MESSAGE_TYPE); } void BluetoothProxy::send_connections_free() { - if (this->api_connection_ == nullptr) - return; - api::BluetoothConnectionsFreeResponse call; - call.free = this->get_bluetooth_connections_free(); - call.limit = this->get_bluetooth_connections_limit(); - for (auto *connection : this->connections_) { - if (connection->address_ != 0) { - call.allocated.push_back(connection->address_); - } + if (this->api_connection_ != nullptr) { + this->send_connections_free(this->api_connection_); } - this->api_connection_->send_message(call, api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE); +} + +void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { + api_connection->send_message(this->connections_free_response_, api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE); } void BluetoothProxy::send_gatt_services_done(uint64_t address) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d249515fdf..70deef1ebd 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -49,6 +49,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { }; class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Component { + friend class BluetoothConnection; // Allow connection to update connections_free_response_ public: BluetoothProxy(); #ifdef USE_ESP32_BLE_DEVICE @@ -74,15 +75,13 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg); void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg); - int get_bluetooth_connections_free(); - int get_bluetooth_connections_limit() { return this->connections_.size(); } - void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags); void unsubscribe_api_connection(api::APIConnection *api_connection); api::APIConnection *get_api_connection() { return this->api_connection_; } void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, esp_err_t error = ESP_OK); void send_connections_free(); + void send_connections_free(api::APIConnection *api_connection); void send_gatt_services_done(uint64_t address); void send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error); void send_device_pairing(uint64_t address, bool paired, esp_err_t error = ESP_OK); @@ -149,6 +148,9 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 3: 4-byte types uint32_t last_advertisement_flush_time_{0}; + // Pre-allocated response message - always ready to send + api::BluetoothConnectionsFreeResponse connections_free_response_; + // Group 4: 1-byte types grouped together bool active_; uint8_t advertisement_count_{0}; diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 457a88ec1d..0a2fda4476 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -48,7 +48,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; } - void set_address(uint64_t address) { + virtual void set_address(uint64_t address) { this->address_ = address; this->remote_bda_[0] = (address >> 40) & 0xFF; this->remote_bda_[1] = (address >> 32) & 0xFF; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e226f748a8..55652e443e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -147,6 +147,7 @@ #define USE_ESPHOME_TASK_LOG_BUFFER #define USE_BLUETOOTH_PROXY +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #define USE_CAPTIVE_PORTAL #define USE_ESP32_BLE #define USE_ESP32_BLE_CLIENT diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 24e2b25e90..fa2f87d98d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -342,6 +342,11 @@ def create_field_type_info( # Check if this repeated field has fixed_array_size option if (fixed_size := get_field_opt(field, pb.fixed_array_size)) is not None: return FixedArrayRepeatedType(field, fixed_size) + # Check if this repeated field has fixed_array_size_define option + if ( + size_define := get_field_opt(field, pb.fixed_array_size_define) + ) is not None: + return FixedArrayRepeatedType(field, size_define) return RepeatedTypeInfo(field) # Check for fixed_array_size option on bytes fields @@ -1066,9 +1071,10 @@ class FixedArrayRepeatedType(TypeInfo): control how many items we receive when decoding. """ - def __init__(self, field: descriptor.FieldDescriptorProto, size: int) -> None: + def __init__(self, field: descriptor.FieldDescriptorProto, size: int | str) -> None: super().__init__(field) self.array_size = size + self.is_define = isinstance(size, str) # Check if we should skip encoding when all elements are zero # Use getattr to handle older versions of api_options_pb2 self.skip_zero = get_field_opt( @@ -1113,6 +1119,14 @@ class FixedArrayRepeatedType(TypeInfo): # If skip_zero is enabled, wrap encoding in a zero check if self.skip_zero: + if self.is_define: + # When using a define, we need to use a loop-based approach + o = f"for (const auto &it : this->{self.field_name}) {{\n" + o += " if (it != 0) {\n" + o += f" {encode_element('it')}\n" + o += " }\n" + o += "}" + return o # Build the condition to check if at least one element is non-zero non_zero_checks = " || ".join( [f"this->{self.field_name}[{i}] != 0" for i in range(self.array_size)] @@ -1123,6 +1137,13 @@ class FixedArrayRepeatedType(TypeInfo): ] return f"if ({non_zero_checks}) {{\n" + "\n".join(encode_lines) + "\n}" + # When using a define, always use loop-based approach + if self.is_define: + o = f"for (const auto &it : this->{self.field_name}) {{\n" + o += f" {encode_element('it')}\n" + o += "}" + return o + # Unroll small arrays for efficiency if self.array_size == 1: return encode_element(f"this->{self.field_name}[0]") @@ -1153,6 +1174,14 @@ class FixedArrayRepeatedType(TypeInfo): def get_size_calculation(self, name: str, force: bool = False) -> str: # If skip_zero is enabled, wrap size calculation in a zero check if self.skip_zero: + if self.is_define: + # When using a define, we need to use a loop-based approach + o = f"for (const auto &it : {name}) {{\n" + o += " if (it != 0) {\n" + o += f" {self._ti.get_size_calculation('it', True)}\n" + o += " }\n" + o += "}" + return o # Build the condition to check if at least one element is non-zero non_zero_checks = " || ".join( [f"{name}[{i}] != 0" for i in range(self.array_size)] @@ -1163,6 +1192,13 @@ class FixedArrayRepeatedType(TypeInfo): ] return f"if ({non_zero_checks}) {{\n" + "\n".join(size_lines) + "\n}" + # When using a define, always use loop-based approach + if self.is_define: + o = f"for (const auto &it : {name}) {{\n" + o += f" {self._ti.get_size_calculation('it', True)}\n" + o += "}" + return o + # For fixed arrays, we always encode all elements # Special case for single-element arrays - no loop needed @@ -1186,6 +1222,11 @@ class FixedArrayRepeatedType(TypeInfo): def get_estimated_size(self) -> int: # For fixed arrays, estimate underlying type size * array size underlying_size = self._ti.get_estimated_size() + if self.is_define: + # When using a define, we don't know the actual size so just guess 3 + # This is only used for documentation and never actually used since + # fixed arrays are only for SOURCE_SERVER (encode-only) messages + return underlying_size * 3 return underlying_size * self.array_size From 4f58e1c8b92f233af368f7d1698bcead16f9c8e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 20:26:22 -1000 Subject: [PATCH 0256/1626] [core] Convert entity vectors to static allocation for reduced memory usage (#10018) --- esphome/core/application.h | 150 ++++++++-------------------- esphome/core/component_iterator.cpp | 13 --- esphome/core/component_iterator.h | 16 ++- esphome/core/config.py | 4 +- esphome/core/defines.h | 23 +++++ esphome/core/helpers.h | 36 +++++++ 6 files changed, 118 insertions(+), 124 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index a83789837f..b7824a254b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -216,69 +216,6 @@ class Application { /// Reserve space for components to avoid memory fragmentation void reserve_components(size_t count) { this->components_.reserve(count); } -#ifdef USE_BINARY_SENSOR - void reserve_binary_sensor(size_t count) { this->binary_sensors_.reserve(count); } -#endif -#ifdef USE_SWITCH - void reserve_switch(size_t count) { this->switches_.reserve(count); } -#endif -#ifdef USE_BUTTON - void reserve_button(size_t count) { this->buttons_.reserve(count); } -#endif -#ifdef USE_SENSOR - void reserve_sensor(size_t count) { this->sensors_.reserve(count); } -#endif -#ifdef USE_TEXT_SENSOR - void reserve_text_sensor(size_t count) { this->text_sensors_.reserve(count); } -#endif -#ifdef USE_FAN - void reserve_fan(size_t count) { this->fans_.reserve(count); } -#endif -#ifdef USE_COVER - void reserve_cover(size_t count) { this->covers_.reserve(count); } -#endif -#ifdef USE_CLIMATE - void reserve_climate(size_t count) { this->climates_.reserve(count); } -#endif -#ifdef USE_LIGHT - void reserve_light(size_t count) { this->lights_.reserve(count); } -#endif -#ifdef USE_NUMBER - void reserve_number(size_t count) { this->numbers_.reserve(count); } -#endif -#ifdef USE_DATETIME_DATE - void reserve_date(size_t count) { this->dates_.reserve(count); } -#endif -#ifdef USE_DATETIME_TIME - void reserve_time(size_t count) { this->times_.reserve(count); } -#endif -#ifdef USE_DATETIME_DATETIME - void reserve_datetime(size_t count) { this->datetimes_.reserve(count); } -#endif -#ifdef USE_SELECT - void reserve_select(size_t count) { this->selects_.reserve(count); } -#endif -#ifdef USE_TEXT - void reserve_text(size_t count) { this->texts_.reserve(count); } -#endif -#ifdef USE_LOCK - void reserve_lock(size_t count) { this->locks_.reserve(count); } -#endif -#ifdef USE_VALVE - void reserve_valve(size_t count) { this->valves_.reserve(count); } -#endif -#ifdef USE_MEDIA_PLAYER - void reserve_media_player(size_t count) { this->media_players_.reserve(count); } -#endif -#ifdef USE_ALARM_CONTROL_PANEL - void reserve_alarm_control_panel(size_t count) { this->alarm_control_panels_.reserve(count); } -#endif -#ifdef USE_EVENT - void reserve_event(size_t count) { this->events_.reserve(count); } -#endif -#ifdef USE_UPDATE - void reserve_update(size_t count) { this->updates_.reserve(count); } -#endif #ifdef USE_AREAS void reserve_area(size_t count) { this->areas_.reserve(count); } #endif @@ -394,92 +331,90 @@ class Application { const std::vector &get_areas() { return this->areas_; } #endif #ifdef USE_BINARY_SENSOR - const std::vector &get_binary_sensors() { return this->binary_sensors_; } + auto &get_binary_sensors() const { return this->binary_sensors_; } GET_ENTITY_METHOD(binary_sensor::BinarySensor, binary_sensor, binary_sensors) #endif #ifdef USE_SWITCH - const std::vector &get_switches() { return this->switches_; } + auto &get_switches() const { return this->switches_; } GET_ENTITY_METHOD(switch_::Switch, switch, switches) #endif #ifdef USE_BUTTON - const std::vector &get_buttons() { return this->buttons_; } + auto &get_buttons() const { return this->buttons_; } GET_ENTITY_METHOD(button::Button, button, buttons) #endif #ifdef USE_SENSOR - const std::vector &get_sensors() { return this->sensors_; } + auto &get_sensors() const { return this->sensors_; } GET_ENTITY_METHOD(sensor::Sensor, sensor, sensors) #endif #ifdef USE_TEXT_SENSOR - const std::vector &get_text_sensors() { return this->text_sensors_; } + auto &get_text_sensors() const { return this->text_sensors_; } GET_ENTITY_METHOD(text_sensor::TextSensor, text_sensor, text_sensors) #endif #ifdef USE_FAN - const std::vector &get_fans() { return this->fans_; } + auto &get_fans() const { return this->fans_; } GET_ENTITY_METHOD(fan::Fan, fan, fans) #endif #ifdef USE_COVER - const std::vector &get_covers() { return this->covers_; } + auto &get_covers() const { return this->covers_; } GET_ENTITY_METHOD(cover::Cover, cover, covers) #endif #ifdef USE_LIGHT - const std::vector &get_lights() { return this->lights_; } + auto &get_lights() const { return this->lights_; } GET_ENTITY_METHOD(light::LightState, light, lights) #endif #ifdef USE_CLIMATE - const std::vector &get_climates() { return this->climates_; } + auto &get_climates() const { return this->climates_; } GET_ENTITY_METHOD(climate::Climate, climate, climates) #endif #ifdef USE_NUMBER - const std::vector &get_numbers() { return this->numbers_; } + auto &get_numbers() const { return this->numbers_; } GET_ENTITY_METHOD(number::Number, number, numbers) #endif #ifdef USE_DATETIME_DATE - const std::vector &get_dates() { return this->dates_; } + auto &get_dates() const { return this->dates_; } GET_ENTITY_METHOD(datetime::DateEntity, date, dates) #endif #ifdef USE_DATETIME_TIME - const std::vector &get_times() { return this->times_; } + auto &get_times() const { return this->times_; } GET_ENTITY_METHOD(datetime::TimeEntity, time, times) #endif #ifdef USE_DATETIME_DATETIME - const std::vector &get_datetimes() { return this->datetimes_; } + auto &get_datetimes() const { return this->datetimes_; } GET_ENTITY_METHOD(datetime::DateTimeEntity, datetime, datetimes) #endif #ifdef USE_TEXT - const std::vector &get_texts() { return this->texts_; } + auto &get_texts() const { return this->texts_; } GET_ENTITY_METHOD(text::Text, text, texts) #endif #ifdef USE_SELECT - const std::vector &get_selects() { return this->selects_; } + auto &get_selects() const { return this->selects_; } GET_ENTITY_METHOD(select::Select, select, selects) #endif #ifdef USE_LOCK - const std::vector &get_locks() { return this->locks_; } + auto &get_locks() const { return this->locks_; } GET_ENTITY_METHOD(lock::Lock, lock, locks) #endif #ifdef USE_VALVE - const std::vector &get_valves() { return this->valves_; } + auto &get_valves() const { return this->valves_; } GET_ENTITY_METHOD(valve::Valve, valve, valves) #endif #ifdef USE_MEDIA_PLAYER - const std::vector &get_media_players() { return this->media_players_; } + auto &get_media_players() const { return this->media_players_; } GET_ENTITY_METHOD(media_player::MediaPlayer, media_player, media_players) #endif #ifdef USE_ALARM_CONTROL_PANEL - const std::vector &get_alarm_control_panels() { - return this->alarm_control_panels_; - } + auto &get_alarm_control_panels() const { return this->alarm_control_panels_; } GET_ENTITY_METHOD(alarm_control_panel::AlarmControlPanel, alarm_control_panel, alarm_control_panels) #endif #ifdef USE_EVENT - const std::vector &get_events() { return this->events_; } + auto &get_events() const { return this->events_; } GET_ENTITY_METHOD(event::Event, event, events) #endif #ifdef USE_UPDATE - const std::vector &get_updates() { return this->updates_; } + auto &get_updates() const { return this->updates_; } GET_ENTITY_METHOD(update::UpdateEntity, update, updates) #endif @@ -558,67 +493,68 @@ class Application { std::vector areas_{}; #endif #ifdef USE_BINARY_SENSOR - std::vector binary_sensors_{}; + StaticVector binary_sensors_{}; #endif #ifdef USE_SWITCH - std::vector switches_{}; + StaticVector switches_{}; #endif #ifdef USE_BUTTON - std::vector buttons_{}; + StaticVector buttons_{}; #endif #ifdef USE_EVENT - std::vector events_{}; + StaticVector events_{}; #endif #ifdef USE_SENSOR - std::vector sensors_{}; + StaticVector sensors_{}; #endif #ifdef USE_TEXT_SENSOR - std::vector text_sensors_{}; + StaticVector text_sensors_{}; #endif #ifdef USE_FAN - std::vector fans_{}; + StaticVector fans_{}; #endif #ifdef USE_COVER - std::vector covers_{}; + StaticVector covers_{}; #endif #ifdef USE_CLIMATE - std::vector climates_{}; + StaticVector climates_{}; #endif #ifdef USE_LIGHT - std::vector lights_{}; + StaticVector lights_{}; #endif #ifdef USE_NUMBER - std::vector numbers_{}; + StaticVector numbers_{}; #endif #ifdef USE_DATETIME_DATE - std::vector dates_{}; + StaticVector dates_{}; #endif #ifdef USE_DATETIME_TIME - std::vector times_{}; + StaticVector times_{}; #endif #ifdef USE_DATETIME_DATETIME - std::vector datetimes_{}; + StaticVector datetimes_{}; #endif #ifdef USE_SELECT - std::vector selects_{}; + StaticVector selects_{}; #endif #ifdef USE_TEXT - std::vector texts_{}; + StaticVector texts_{}; #endif #ifdef USE_LOCK - std::vector locks_{}; + StaticVector locks_{}; #endif #ifdef USE_VALVE - std::vector valves_{}; + StaticVector valves_{}; #endif #ifdef USE_MEDIA_PLAYER - std::vector media_players_{}; + StaticVector media_players_{}; #endif #ifdef USE_ALARM_CONTROL_PANEL - std::vector alarm_control_panels_{}; + StaticVector + alarm_control_panels_{}; #endif #ifdef USE_UPDATE - std::vector updates_{}; + StaticVector updates_{}; #endif #ifdef USE_SOCKET_SELECT_SUPPORT diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index 1e8f670d8b..668c4a1fda 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -17,19 +17,6 @@ void ComponentIterator::begin(bool include_internal) { this->include_internal_ = include_internal; } -template -void ComponentIterator::process_platform_item_(const std::vector &items, - bool (ComponentIterator::*on_item)(PlatformItem *)) { - if (this->at_ >= items.size()) { - this->advance_platform_(); - } else { - PlatformItem *item = items[this->at_]; - if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) { - this->at_++; - } - } -} - void ComponentIterator::advance_platform_() { this->state_ = static_cast(static_cast(this->state_) + 1); this->at_ = 0; diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 7a9771b8f2..fdc30485bc 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -172,9 +172,19 @@ class ComponentIterator { uint16_t at_{0}; // Supports up to 65,535 entities per type bool include_internal_{false}; - template - void process_platform_item_(const std::vector &items, - bool (ComponentIterator::*on_item)(PlatformItem *)); + template + void process_platform_item_(const Container &items, + bool (ComponentIterator::*on_item)(typename Container::value_type)) { + if (this->at_ >= items.size()) { + this->advance_platform_(); + } else { + typename Container::value_type item = items[this->at_]; + if ((item->is_internal() && !this->include_internal_) || (this->*on_item)(item)) { + this->at_++; + } + } + } + void advance_platform_(); }; diff --git a/esphome/core/config.py b/esphome/core/config.py index 6d93117164..3bc030ad50 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -421,8 +421,10 @@ async def _add_automations(config): @coroutine_with_priority(-100.0) async def _add_platform_reserves() -> None: + # Generate compile-time entity count defines for static_entity_vector for platform_name, count in sorted(CORE.platform_counts.items()): - cg.add(cg.RawStatement(f"App.reserve_{platform_name}({count});"), prepend=True) + define_name = f"ESPHOME_ENTITY_{platform_name.upper()}_COUNT" + cg.add_define(define_name, count) @coroutine_with_priority(100.0) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 55652e443e..3ed0af91eb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -239,3 +239,26 @@ // #define USE_BSEC2 // Requires a library with proprietary license #define USE_DASHBOARD_IMPORT + +// Default entity counts for static analysis +#define ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT 1 +#define ESPHOME_ENTITY_BINARY_SENSOR_COUNT 1 +#define ESPHOME_ENTITY_BUTTON_COUNT 1 +#define ESPHOME_ENTITY_CLIMATE_COUNT 1 +#define ESPHOME_ENTITY_COVER_COUNT 1 +#define ESPHOME_ENTITY_DATE_COUNT 1 +#define ESPHOME_ENTITY_DATETIME_COUNT 1 +#define ESPHOME_ENTITY_EVENT_COUNT 1 +#define ESPHOME_ENTITY_FAN_COUNT 1 +#define ESPHOME_ENTITY_LIGHT_COUNT 1 +#define ESPHOME_ENTITY_LOCK_COUNT 1 +#define ESPHOME_ENTITY_MEDIA_PLAYER_COUNT 1 +#define ESPHOME_ENTITY_NUMBER_COUNT 1 +#define ESPHOME_ENTITY_SELECT_COUNT 1 +#define ESPHOME_ENTITY_SENSOR_COUNT 1 +#define ESPHOME_ENTITY_SWITCH_COUNT 1 +#define ESPHOME_ENTITY_TEXT_COUNT 1 +#define ESPHOME_ENTITY_TEXT_SENSOR_COUNT 1 +#define ESPHOME_ENTITY_TIME_COUNT 1 +#define ESPHOME_ENTITY_UPDATE_COUNT 1 +#define ESPHOME_ENTITY_VALVE_COUNT 1 diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 5204804e1e..b05cc11029 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -91,6 +91,42 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); ///@} +/// @name Container utilities +///@{ + +/// Minimal static vector - saves memory by avoiding std::vector overhead +template class StaticVector { + public: + using value_type = T; + using iterator = typename std::array::iterator; + using const_iterator = typename std::array::const_iterator; + + private: + std::array data_{}; + size_t count_{0}; + + public: + // Minimal vector-compatible interface - only what we actually use + void push_back(const T &value) { + if (count_ < N) { + data_[count_++] = value; + } + } + + size_t size() const { return count_; } + + T &operator[](size_t i) { return data_[i]; } + const T &operator[](size_t i) const { return data_[i]; } + + // For range-based for loops + iterator begin() { return data_.begin(); } + iterator end() { return data_.begin() + count_; } + const_iterator begin() const { return data_.begin(); } + const_iterator end() const { return data_.begin() + count_; } +}; + +///@} + /// @name Mathematics ///@{ From 20959c2366f82c599e5c3e1ad0dba41fa3427f9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 14:12:08 -1000 Subject: [PATCH 0257/1626] [bluetooth_proxy] Optimize memory usage with fixed-size array and const string references --- .../bluetooth_proxy/bluetooth_proxy.cpp | 15 +++++++++------ .../components/bluetooth_proxy/bluetooth_proxy.h | 14 +++++++++----- .../components/esp32_ble_client/ble_client_base.h | 2 +- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index a59a33117a..302945bc12 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -35,8 +35,8 @@ void BluetoothProxy::setup() { // Don't pre-allocate pool - let it grow only if needed in busy environments // Many devices in quiet areas will never need the overflow pool - this->connections_free_response_.limit = this->connections_.size(); - this->connections_free_response_.free = this->connections_.size(); + this->connections_free_response_.limit = this->connection_count_; + this->connections_free_response_.free = this->connection_count_; this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { @@ -134,12 +134,13 @@ void BluetoothProxy::dump_config() { ESP_LOGCONFIG(TAG, " Active: %s\n" " Connections: %d", - YESNO(this->active_), this->connections_.size()); + YESNO(this->active_), this->connection_count_); } void BluetoothProxy::loop() { if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { - for (auto *connection : this->connections_) { + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; if (connection->get_address() != 0 && !connection->disconnect_pending()) { connection->disconnect(); } @@ -162,7 +163,8 @@ esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_par } BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { - for (auto *connection : this->connections_) { + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; if (connection->get_address() == address) return connection; } @@ -170,7 +172,8 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese if (!reserve) return nullptr; - for (auto *connection : this->connections_) { + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; if (connection->get_address() == 0) { connection->send_service_ = DONE_SENDING_SERVICES; connection->set_address(address); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 70deef1ebd..d367dad438 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -2,6 +2,7 @@ #ifdef USE_ESP32 +#include #include #include @@ -63,8 +64,10 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; void register_connection(BluetoothConnection *connection) { - this->connections_.push_back(connection); - connection->proxy_ = this; + if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { + this->connections_[this->connection_count_++] = connection; + connection->proxy_ = this; + } } void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); @@ -138,8 +141,8 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; - // Group 2: Container types (typically 12 bytes on 32-bit) - std::vector connections_{}; + // Group 2: Fixed-size array of connection pointers + std::array connections_{}; // BLE advertisement batching std::vector advertisement_pool_; @@ -154,7 +157,8 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 4: 1-byte types grouped together bool active_; uint8_t advertisement_count_{0}; - // 2 bytes used, 2 bytes padding + uint8_t connection_count_{0}; + // 3 bytes used, 1 byte padding }; extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 0a2fda4476..0bbff8d3c6 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -66,7 +66,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { (uint8_t) (this->address_ >> 0) & 0xff); } } - std::string address_str() const { return this->address_str_; } + const std::string &address_str() const { return this->address_str_; } BLEService *get_service(espbt::ESPBTUUID uuid); BLEService *get_service(uint16_t uuid); From f1650fc64735e4e040e4337faea3dcad3e9416e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 20:54:41 -1000 Subject: [PATCH 0258/1626] static comp, areas, devices --- esphome/core/application.h | 18 +++++------------- esphome/core/config.py | 12 +++++------- esphome/core/defines.h | 5 ++++- esphome/core/helpers.h | 10 ++++++++++ 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index b7824a254b..7aafc0e05f 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -214,14 +214,6 @@ class Application { #endif /// Reserve space for components to avoid memory fragmentation - void reserve_components(size_t count) { this->components_.reserve(count); } - -#ifdef USE_AREAS - void reserve_area(size_t count) { this->areas_.reserve(count); } -#endif -#ifdef USE_DEVICES - void reserve_device(size_t count) { this->devices_.reserve(count); } -#endif /// Register the component in this Application instance. template C *register_component(C *c) { @@ -316,7 +308,7 @@ class Application { } \ return nullptr; \ } - const std::vector &get_devices() { return this->devices_; } + const auto &get_devices() { return this->devices_; } #else #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ @@ -328,7 +320,7 @@ class Application { } #endif // USE_DEVICES #ifdef USE_AREAS - const std::vector &get_areas() { return this->areas_; } + const auto &get_areas() { return this->areas_; } #endif #ifdef USE_BINARY_SENSOR auto &get_binary_sensors() const { return this->binary_sensors_; } @@ -466,7 +458,7 @@ class Application { size_t dump_config_at_{SIZE_MAX}; // Vectors (largest members) - std::vector components_{}; + StaticVector components_{}; // Partitioned vector design for looping components // ================================================= @@ -487,10 +479,10 @@ class Application { std::vector looping_components_{}; #ifdef USE_DEVICES - std::vector devices_{}; + StaticVector devices_{}; #endif #ifdef USE_AREAS - std::vector areas_{}; + StaticVector areas_{}; #endif #ifdef USE_BINARY_SENSOR StaticVector binary_sensors_{}; diff --git a/esphome/core/config.py b/esphome/core/config.py index 3bc030ad50..a3cf600fce 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -444,10 +444,8 @@ async def to_code(config: ConfigType) -> None: config[CONF_NAME_ADD_MAC_SUFFIX], ) ) - # Reserve space for components to avoid reallocation during registration - cg.add( - cg.RawStatement(f"App.reserve_components({len(CORE.component_ids)});"), - ) + # Define component count for static allocation + cg.add_define("ESPHOME_COMPONENT_COUNT", len(CORE.component_ids)) CORE.add_job(_add_platform_reserves) @@ -516,8 +514,8 @@ async def to_code(config: ConfigType) -> None: all_areas.extend(config[CONF_AREAS]) if all_areas: - cg.add(cg.RawStatement(f"App.reserve_area({len(all_areas)});")) cg.add_define("USE_AREAS") + cg.add_define("ESPHOME_AREA_COUNT", len(all_areas)) for area_conf in all_areas: area_id: core.ID = area_conf[CONF_ID] @@ -534,9 +532,9 @@ async def to_code(config: ConfigType) -> None: if not devices: return - # Reserve space for devices - cg.add(cg.RawStatement(f"App.reserve_device({len(devices)});")) + # Define device count for static allocation cg.add_define("USE_DEVICES") + cg.add_define("ESPHOME_DEVICE_COUNT", len(devices)) # Process each device for dev_conf in devices: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3ed0af91eb..996dbc7e8d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -240,7 +240,10 @@ #define USE_DASHBOARD_IMPORT -// Default entity counts for static analysis +// Default counts for static analysis +#define ESPHOME_COMPONENT_COUNT 50 +#define ESPHOME_DEVICE_COUNT 10 +#define ESPHOME_AREA_COUNT 10 #define ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT 1 #define ESPHOME_ENTITY_BINARY_SENSOR_COUNT 1 #define ESPHOME_ENTITY_BUTTON_COUNT 1 diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b05cc11029..b5fe59c4fd 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -100,6 +101,8 @@ template class StaticVector { using value_type = T; using iterator = typename std::array::iterator; using const_iterator = typename std::array::const_iterator; + using reverse_iterator = std::reverse_iterator; + using const_reverse_iterator = std::reverse_iterator; private: std::array data_{}; @@ -114,6 +117,7 @@ template class StaticVector { } size_t size() const { return count_; } + bool empty() const { return count_ == 0; } T &operator[](size_t i) { return data_[i]; } const T &operator[](size_t i) const { return data_[i]; } @@ -123,6 +127,12 @@ template class StaticVector { iterator end() { return data_.begin() + count_; } const_iterator begin() const { return data_.begin(); } const_iterator end() const { return data_.begin() + count_; } + + // Reverse iterators + reverse_iterator rbegin() { return reverse_iterator(end()); } + reverse_iterator rend() { return reverse_iterator(begin()); } + const_reverse_iterator rbegin() const { return const_reverse_iterator(end()); } + const_reverse_iterator rend() const { return const_reverse_iterator(begin()); } }; ///@} From 015bb6f6026d9053b6b5a8b5301007df8f69e18e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 21:05:28 -1000 Subject: [PATCH 0259/1626] reorder --- esphome/core/application.h | 84 +++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 7aafc0e05f..4eb4984f71 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -454,12 +454,7 @@ class Application { const char *comment_{nullptr}; const char *compilation_time_{nullptr}; - // size_t members - size_t dump_config_at_{SIZE_MAX}; - - // Vectors (largest members) - StaticVector components_{}; - + // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components // ================================================= // Components are partitioned into [active | inactive] sections: @@ -477,6 +472,48 @@ class Application { // and active_end_ is incremented // - This eliminates branch mispredictions from flag checking in the hot loop std::vector looping_components_{}; +#ifdef USE_SOCKET_SELECT_SUPPORT + std::vector socket_fds_; // Vector of all monitored socket file descriptors +#endif + + // std::string members (typically 24-32 bytes each) + std::string name_; + std::string friendly_name_; + + // size_t members + size_t dump_config_at_{SIZE_MAX}; + + // 4-byte members + uint32_t last_loop_{0}; + uint32_t loop_component_start_time_{0}; + +#ifdef USE_SOCKET_SELECT_SUPPORT + int max_fd_{-1}; // Highest file descriptor number for select() +#endif + + // 2-byte members (grouped together for alignment) + uint16_t loop_interval_{16}; // Loop interval in ms (max 65535ms = 65.5 seconds) + uint16_t looping_components_active_end_{0}; // Index marking end of active components in looping_components_ + uint16_t current_loop_index_{0}; // For safe reentrant modifications during iteration + + // 1-byte members (grouped together to minimize padding) + uint8_t app_state_{0}; + bool name_add_mac_suffix_; + bool in_loop_{false}; + volatile bool has_pending_enable_loop_requests_{false}; + +#ifdef USE_SOCKET_SELECT_SUPPORT + bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes +#endif + +#ifdef USE_SOCKET_SELECT_SUPPORT + // Variable-sized members + fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes + fd_set read_fds_{}; // Working fd_set for select(), copied from base_read_fds_ +#endif + + // StaticVectors (largest members - contain actual array data inline) + StaticVector components_{}; #ifdef USE_DEVICES StaticVector devices_{}; @@ -548,41 +585,6 @@ class Application { #ifdef USE_UPDATE StaticVector updates_{}; #endif - -#ifdef USE_SOCKET_SELECT_SUPPORT - std::vector socket_fds_; // Vector of all monitored socket file descriptors -#endif - - // String members - std::string name_; - std::string friendly_name_; - - // 4-byte members - uint32_t last_loop_{0}; - uint32_t loop_component_start_time_{0}; - -#ifdef USE_SOCKET_SELECT_SUPPORT - int max_fd_{-1}; // Highest file descriptor number for select() -#endif - - // 2-byte members (grouped together for alignment) - uint16_t loop_interval_{16}; // Loop interval in ms (max 65535ms = 65.5 seconds) - uint16_t looping_components_active_end_{0}; - uint16_t current_loop_index_{0}; // For safe reentrant modifications during iteration - - // 1-byte members (grouped together to minimize padding) - uint8_t app_state_{0}; - bool name_add_mac_suffix_; - bool in_loop_{false}; - volatile bool has_pending_enable_loop_requests_{false}; - -#ifdef USE_SOCKET_SELECT_SUPPORT - bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes - - // Variable-sized members at end - fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes - fd_set read_fds_{}; // Working fd_set for select(), copied from base_read_fds_ -#endif }; /// Global storage of Application pointer - only one Application can exist. From a8f4b5c4e22e3ad589a27977ee6b0aafc0e4548e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 21:25:58 -1000 Subject: [PATCH 0260/1626] fixes --- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 302945bc12..97b0884dda 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -35,8 +35,8 @@ void BluetoothProxy::setup() { // Don't pre-allocate pool - let it grow only if needed in busy environments // Many devices in quiet areas will never need the overflow pool - this->connections_free_response_.limit = this->connection_count_; - this->connections_free_response_.free = this->connection_count_; + this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; + this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { From e50135ef8a09d8cb16816e35058055f0aba01b63 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 22:37:35 -1000 Subject: [PATCH 0261/1626] [web_server] Conditionally compile authentication code to save flash memory --- esphome/components/web_server/__init__.py | 1 + esphome/components/web_server_base/web_server_base.cpp | 2 ++ esphome/components/web_server_base/web_server_base.h | 6 ++++++ esphome/components/web_server_idf/web_server_idf.cpp | 2 ++ esphome/components/web_server_idf/web_server_idf.h | 2 ++ 5 files changed, 13 insertions(+) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 8ead14dcac..695757e137 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -298,6 +298,7 @@ async def to_code(config): if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if CONF_AUTH in config: + cg.add_define("USE_WEBSERVER_AUTH") cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD])) if CONF_CSS_INCLUDE in config: diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index e1c2bc0b25..6e7097338c 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -14,9 +14,11 @@ WebServerBase *global_web_server_base = nullptr; // NOLINT(cppcoreguidelines-av void WebServerBase::add_handler(AsyncWebHandler *handler) { // remove all handlers +#ifdef USE_WEBSERVER_AUTH if (!credentials_.username.empty()) { handler = new internal::AuthMiddlewareHandler(handler, &credentials_); } +#endif this->handlers_.push_back(handler); if (this->server_ != nullptr) { this->server_->addHandler(handler); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index a475238a37..cfca776ee1 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -41,6 +41,7 @@ class MiddlewareHandler : public AsyncWebHandler { AsyncWebHandler *next_; }; +#ifdef USE_WEBSERVER_AUTH struct Credentials { std::string username; std::string password; @@ -79,6 +80,7 @@ class AuthMiddlewareHandler : public MiddlewareHandler { protected: Credentials *credentials_; }; +#endif } // namespace internal @@ -108,8 +110,10 @@ class WebServerBase : public Component { std::shared_ptr get_server() const { return server_; } float get_setup_priority() const override; +#ifdef USE_WEBSERVER_AUTH void set_auth_username(std::string auth_username) { credentials_.username = std::move(auth_username); } void set_auth_password(std::string auth_password) { credentials_.password = std::move(auth_password); } +#endif void add_handler(AsyncWebHandler *handler); @@ -121,7 +125,9 @@ class WebServerBase : public Component { uint16_t port_{80}; std::shared_ptr server_{nullptr}; std::vector handlers_; +#ifdef USE_WEBSERVER_AUTH internal::Credentials credentials_; +#endif }; } // namespace web_server_base diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 734259093e..10c6660766 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -223,6 +223,7 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code this->rsp_ = rsp; } +#ifdef USE_WEBSERVER_AUTH bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const { if (username == nullptr || password == nullptr || *username == 0) { return true; @@ -261,6 +262,7 @@ void AsyncWebServerRequest::requestAuthentication(const char *realm) const { httpd_resp_set_hdr(*this, "WWW-Authenticate", auth_val.c_str()); httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr); } +#endif AsyncWebParameter *AsyncWebServerRequest::getParam(const std::string &name) { auto find = this->params_.find(name); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index e8e40ef9b0..76540ef232 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -115,9 +115,11 @@ class AsyncWebServerRequest { // NOLINTNEXTLINE(readability-identifier-naming) size_t contentLength() const { return this->req_->content_len; } +#ifdef USE_WEBSERVER_AUTH bool authenticate(const char *username, const char *password) const; // NOLINTNEXTLINE(readability-identifier-naming) void requestAuthentication(const char *realm = nullptr) const; +#endif void redirect(const std::string &url); From c28147b3a4fe6db695e51c0f42ba79bdbda20d30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 22:43:42 -1000 Subject: [PATCH 0262/1626] test --- tests/components/web_server/test.esp32-idf.yaml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 7e6658e20e..5e6a3f0a14 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -1 +1,9 @@ -<<: !include common_v2.yaml +packages: + device_base: !include common.yaml + +web_server: + port: 8080 + version: 2 + auth: + username: admin + password: password From 204b54ce381921a99b4a495fe723fccf0353965a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 22:53:17 -1000 Subject: [PATCH 0263/1626] preen --- tests/components/web_server/test.esp32-idf.yaml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 5e6a3f0a14..24b292d0d6 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -1,9 +1,6 @@ -packages: - device_base: !include common.yaml +<<: !include common_v2.yaml web_server: - port: 8080 - version: 2 auth: username: admin password: password From fefa35a418e4e06cea2c7d9cfe7db063a5990751 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 22:57:26 -1000 Subject: [PATCH 0264/1626] define --- esphome/core/defines.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3ed0af91eb..2c729ce7ea 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -163,6 +163,7 @@ #define USE_SPI #define USE_VOICE_ASSISTANT #define USE_WEBSERVER +#define USE_WEBSERVER_AUTH #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_SORTING From 3986399e9357107b083a21043f083aed207d338e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 23:07:37 -1000 Subject: [PATCH 0265/1626] missed one --- esphome/core/defines.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 2c729ce7ea..8d0cd469a9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -211,6 +211,7 @@ {} #define USE_WEBSERVER +#define USE_WEBSERVER_AUTH #define USE_WEBSERVER_PORT 80 // NOLINT #endif From 49b5dd329985df7a5a34331b86e5ca6dda85b824 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 23:08:03 -1000 Subject: [PATCH 0266/1626] missed one --- esphome/core/defines.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8d0cd469a9..1f18256335 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -228,6 +228,7 @@ #define USE_SOCKET_IMPL_LWIP_SOCKETS #define USE_SOCKET_SELECT_SUPPORT #define USE_WEBSERVER +#define USE_WEBSERVER_AUTH #define USE_WEBSERVER_PORT 80 // NOLINT #endif From bba63625a483ffbb8f48af74ea5390ac7082a37a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Aug 2025 23:58:32 -1000 Subject: [PATCH 0267/1626] [core] Fix compilation errors when platform sections have no entities --- .../alarm_control_panel/__init__.py | 1 - esphome/components/binary_sensor/__init__.py | 1 - esphome/components/button/__init__.py | 1 - esphome/components/climate/__init__.py | 1 - esphome/components/cover/__init__.py | 1 - esphome/components/datetime/__init__.py | 2 -- esphome/components/event/__init__.py | 1 - esphome/components/fan/__init__.py | 1 - esphome/components/light/__init__.py | 1 - esphome/components/lock/__init__.py | 1 - esphome/components/number/__init__.py | 1 - esphome/components/select/__init__.py | 1 - esphome/components/sensor/__init__.py | 1 - esphome/components/switch/__init__.py | 1 - esphome/components/text/__init__.py | 1 - esphome/components/text_sensor/__init__.py | 1 - esphome/components/update/__init__.py | 1 - esphome/components/valve/__init__.py | 1 - esphome/core/config.py | 21 ++++++++++++++++--- tests/components/datetime/common.yaml | 2 ++ 20 files changed, 20 insertions(+), 22 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index b076175eb8..058e061d1e 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -348,4 +348,3 @@ async def alarm_control_panel_is_armed_to_code( @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(alarm_control_panel_ns.using) - cg.add_define("USE_ALARM_CONTROL_PANEL") diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 376a399637..b56fde1ffd 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -654,7 +654,6 @@ async def binary_sensor_is_off_to_code(config, condition_id, template_arg, args) @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_BINARY_SENSOR") cg.add_global(binary_sensor_ns.using) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index ed2670a5c5..a23958989e 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -137,4 +137,3 @@ async def button_press_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(button_ns.using) - cg.add_define("USE_BUTTON") diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 9530ecdcca..4af3a619b5 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -519,5 +519,4 @@ async def climate_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_CLIMATE") cg.add_global(climate_ns.using) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index cd97a38ecc..0e01eb336f 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -265,5 +265,4 @@ async def cover_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_COVER") cg.add_global(cover_ns.using) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 4788810965..1d84b75f26 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -164,7 +164,6 @@ async def register_datetime(var, config): cg.add(getattr(cg.App, f"register_{entity_type}")(var)) CORE.register_platform_component(entity_type, var) await setup_datetime_core_(var, config) - cg.add_define(f"USE_DATETIME_{config[CONF_TYPE]}") async def new_datetime(config, *args): @@ -175,7 +174,6 @@ async def new_datetime(config, *args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_DATETIME") cg.add_global(datetime_ns.using) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 3aff96a48e..1948570ecd 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -145,5 +145,4 @@ async def event_fire_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_EVENT") cg.add_global(event_ns.using) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 0b1d39575d..3fb217a24e 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -400,5 +400,4 @@ async def fan_is_on_off_to_code(config, condition_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_FAN") cg.add_global(fan_ns.using) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 7ab899edb2..fa39721ee2 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -285,5 +285,4 @@ async def new_light(config, *args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_LIGHT") cg.add_global(light_ns.using) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index e62d9f3e2b..7977efd264 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -158,4 +158,3 @@ async def lock_is_off_to_code(config, condition_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(lock_ns.using) - cg.add_define("USE_LOCK") diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 90a1619e4c..4a83d5fc5f 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -323,7 +323,6 @@ async def number_in_range_to_code(config, condition_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_NUMBER") cg.add_global(number_ns.using) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index ed1f6c020d..dd3feccab5 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -126,7 +126,6 @@ async def new_select(config, *, options: list[str]): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_SELECT") cg.add_global(select_ns.using) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 23e6ad0f2c..2275027004 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1139,5 +1139,4 @@ def _lstsq(a, b): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_SENSOR") cg.add_global(sensor_ns.using) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index c09675069f..a595d43445 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -202,4 +202,3 @@ async def switch_is_off_to_code(config, condition_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(switch_ns.using) - cg.add_define("USE_SWITCH") diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 8362e09ac0..aa831d1f06 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -151,7 +151,6 @@ async def new_text( @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_TEXT") cg.add_global(text_ns.using) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 0341ab2f71..e4aa701a7b 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -232,7 +232,6 @@ async def new_text_sensor(config, *args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_TEXT_SENSOR") cg.add_global(text_sensor_ns.using) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 758267f412..50d8aaf139 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -126,7 +126,6 @@ async def new_update(config): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_UPDATE") cg.add_global(update_ns.using) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index cb27546120..53254068af 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -235,5 +235,4 @@ async def valve_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_VALVE") cg.add_global(valve_ns.using) diff --git a/esphome/core/config.py b/esphome/core/config.py index 3bc030ad50..6a87bab730 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -419,13 +419,28 @@ async def _add_automations(config): await automation.build_automation(trigger, [], conf) +# Datetime component has special subtypes that need additional defines +DATETIME_SUBTYPES = {"date", "time", "datetime"} + + @coroutine_with_priority(-100.0) -async def _add_platform_reserves() -> None: - # Generate compile-time entity count defines for static_entity_vector +async def _add_platform_defines() -> None: + # Generate compile-time defines for platforms that have actual entities + # Only add USE_* and count defines when there are entities for platform_name, count in sorted(CORE.platform_counts.items()): + if count <= 0: + continue + define_name = f"ESPHOME_ENTITY_{platform_name.upper()}_COUNT" cg.add_define(define_name, count) + # Datetime subtypes only use USE_DATETIME_* defines + if platform_name in DATETIME_SUBTYPES: + cg.add_define(f"USE_DATETIME_{platform_name.upper()}") + else: + # Regular platforms use USE_* defines + cg.add_define(f"USE_{platform_name.upper()}") + @coroutine_with_priority(100.0) async def to_code(config: ConfigType) -> None: @@ -449,7 +464,7 @@ async def to_code(config: ConfigType) -> None: cg.RawStatement(f"App.reserve_components({len(CORE.component_ids)});"), ) - CORE.add_job(_add_platform_reserves) + CORE.add_job(_add_platform_defines) CORE.add_job(_add_automations, config) diff --git a/tests/components/datetime/common.yaml b/tests/components/datetime/common.yaml index 4e26b68121..aa469dee76 100644 --- a/tests/components/datetime/common.yaml +++ b/tests/components/datetime/common.yaml @@ -1,3 +1,5 @@ datetime: +date: + time: From 2c01c06828d3942f831047215d3a19162e81f899 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 07:53:14 -1000 Subject: [PATCH 0268/1626] remove test --- tests/components/datetime/common.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/components/datetime/common.yaml b/tests/components/datetime/common.yaml index aa469dee76..4e26b68121 100644 --- a/tests/components/datetime/common.yaml +++ b/tests/components/datetime/common.yaml @@ -1,5 +1,3 @@ datetime: -date: - time: From ed2e8466c8ab9d86cf1697758949571c7c352bf7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 08:42:17 -1000 Subject: [PATCH 0269/1626] [esp32] Add framework migration warning for upcoming ESP-IDF default change --- esphome/components/esp32/__init__.py | 54 ++++++++++++++++++++++++++++ esphome/util.py | 8 ++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 05a79553a4..a657f02f5d 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -680,6 +680,58 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All( ) +def _show_framework_migration_message(name: str, _shown: list[bool] = []) -> None: + """Show a friendly message about framework migration when defaulting to Arduino.""" + if _shown: + return + _shown.append(True) + + from esphome.log import AnsiFore, color + + message = ( + color( + AnsiFore.BOLD_CYAN, + f"💡 IMPORTANT: {name} doesn't have a framework specified!", + ) + + "\n\n" + + "Currently, ESP32 defaults to the Arduino framework.\n" + + color(AnsiFore.YELLOW, "This will change to ESP-IDF in ESPHome 2026.1.0.\n") + + "\n" + + "Why change? ESP-IDF offers:\n" + + color(AnsiFore.GREEN, " ✨ Up to 40% smaller binaries\n") + + color(AnsiFore.GREEN, " 🚀 Better performance and optimization\n") + + color(AnsiFore.GREEN, " 📦 Custom-built firmware for your exact needs\n") + + color( + AnsiFore.GREEN, + " 🔧 Active development and testing by ESPHome developers\n", + ) + + "\n" + + "Trade-offs:\n" + + color(AnsiFore.YELLOW, " ⏱️ Compile times are ~25% longer\n") + + color(AnsiFore.YELLOW, " 🔄 Some components need migration\n") + + "\n" + + "What should I do?\n" + + color(AnsiFore.CYAN, " Option 1") + + ": Migrate to ESP-IDF (recommended)\n" + + " Add this to your YAML under 'esp32:':\n" + + color(AnsiFore.WHITE, " framework:\n") + + color(AnsiFore.WHITE, " type: esp-idf\n") + + "\n" + + color(AnsiFore.CYAN, " Option 2") + + ": Keep using Arduino (100% supported)\n" + + " Add this to your YAML under 'esp32:':\n" + + color(AnsiFore.WHITE, " framework:\n") + + color(AnsiFore.WHITE, " type: arduino\n") + + "\n" + + "Need help? Check out the migration guide:\n" + + color( + AnsiFore.BLUE, + "https://esphome.io/guides/esp32_arduino_to_idf.html", + ) + ) + _LOGGER.warning(message) + + def _set_default_framework(config): if CONF_FRAMEWORK not in config: config = config.copy() @@ -688,6 +740,8 @@ def _set_default_framework(config): if variant in ARDUINO_ALLOWED_VARIANTS: config[CONF_FRAMEWORK] = ARDUINO_FRAMEWORK_SCHEMA({}) config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ARDUINO + # Show the migration message + _show_framework_migration_message(config.get(CONF_NAME, "Your device")) else: config[CONF_FRAMEWORK] = ESP_IDF_FRAMEWORK_SCHEMA({}) config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ESP_IDF diff --git a/esphome/util.py b/esphome/util.py index 3b346371bc..9aa0f6b9d8 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -345,5 +345,11 @@ def get_esp32_arduino_flash_error_help() -> str | None: + "2. Clean build files and compile again\n" + "\n" + "Note: ESP-IDF uses less flash space and provides better performance.\n" - + "Some Arduino-specific libraries may need alternatives.\n\n" + + "Some Arduino-specific libraries may need alternatives.\n" + + "\n" + + "For detailed migration instructions, see:\n" + + color( + AnsiFore.BLUE, + "https://esphome.io/guides/esp32_arduino_to_idf.html\n\n", + ) ) From 30f988c5f358baa41c26a3e9ea5652c3f70fecc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 08:42:46 -1000 Subject: [PATCH 0270/1626] [esp32] Add framework migration warning for upcoming ESP-IDF default change --- esphome/components/esp32/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a657f02f5d..5019a192eb 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -680,11 +680,15 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All( ) -def _show_framework_migration_message(name: str, _shown: list[bool] = []) -> None: +class _FrameworkMigrationWarning: + shown = False + + +def _show_framework_migration_message(name: str) -> None: """Show a friendly message about framework migration when defaulting to Arduino.""" - if _shown: + if _FrameworkMigrationWarning.shown: return - _shown.append(True) + _FrameworkMigrationWarning.shown = True from esphome.log import AnsiFore, color From a1e7317f5ea08b78d564362fc5b3e254d0538a17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 08:46:08 -1000 Subject: [PATCH 0271/1626] [esp32] Add framework migration warning for upcoming ESP-IDF default change --- esphome/components/esp32/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5019a192eb..f1fcae6c55 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -684,7 +684,7 @@ class _FrameworkMigrationWarning: shown = False -def _show_framework_migration_message(name: str) -> None: +def _show_framework_migration_message(name: str, variant: str) -> None: """Show a friendly message about framework migration when defaulting to Arduino.""" if _FrameworkMigrationWarning.shown: return @@ -698,9 +698,11 @@ def _show_framework_migration_message(name: str) -> None: f"💡 IMPORTANT: {name} doesn't have a framework specified!", ) + "\n\n" - + "Currently, ESP32 defaults to the Arduino framework.\n" + + f"Currently, {variant} defaults to the Arduino framework.\n" + color(AnsiFore.YELLOW, "This will change to ESP-IDF in ESPHome 2026.1.0.\n") + "\n" + + "Note: Newer ESP32 variants (C6, H2, P4, etc.) already use ESP-IDF by default.\n" + + "\n" + "Why change? ESP-IDF offers:\n" + color(AnsiFore.GREEN, " ✨ Up to 40% smaller binaries\n") + color(AnsiFore.GREEN, " 🚀 Better performance and optimization\n") @@ -745,7 +747,9 @@ def _set_default_framework(config): config[CONF_FRAMEWORK] = ARDUINO_FRAMEWORK_SCHEMA({}) config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ARDUINO # Show the migration message - _show_framework_migration_message(config.get(CONF_NAME, "Your device")) + _show_framework_migration_message( + config.get(CONF_NAME, "Your device"), variant + ) else: config[CONF_FRAMEWORK] = ESP_IDF_FRAMEWORK_SCHEMA({}) config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ESP_IDF From e4db32d73e0593eca133a93a2e00a59f34d73a30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 09:21:57 -1000 Subject: [PATCH 0272/1626] tweak --- esphome/components/esp32/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f1fcae6c55..29dc190fc9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -724,7 +724,7 @@ def _show_framework_migration_message(name: str, variant: str) -> None: + color(AnsiFore.WHITE, " type: esp-idf\n") + "\n" + color(AnsiFore.CYAN, " Option 2") - + ": Keep using Arduino (100% supported)\n" + + ": Keep using Arduino (still supported)\n" + " Add this to your YAML under 'esp32:':\n" + color(AnsiFore.WHITE, " framework:\n") + color(AnsiFore.WHITE, " type: arduino\n") From 716c25366c32a8971ca701d9a8cf1937dc329395 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 10:01:36 -1000 Subject: [PATCH 0273/1626] do the others --- esphome/__main__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 9a79c0bde2..5e45b7f213 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -279,16 +279,16 @@ def upload_using_esptool(config, port, file, speed): cmd = [ "esptool", "--before", - "default_reset", + "default-reset", "--after", - "hard_reset", + "hard-reset", "--baud", str(baud_rate), "--port", port, "--chip", mcu, - "write_flash", + "write-flash", "-z", "--flash-size", "detect", From 894565a97f087dda41f029d198a9ac232b1f76e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 10:15:41 -1000 Subject: [PATCH 0274/1626] [web_server] Reduce binary size by using EntityBase and minimizing template instantiations --- esphome/components/web_server/web_server.cpp | 36 ++++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 880145a2a1..34a4684816 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -376,23 +376,31 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { } #endif -#define set_json_id(root, obj, sensor, start_config) \ - (root)["id"] = sensor; \ - if (((start_config) == DETAIL_ALL)) { \ - (root)["name"] = (obj)->get_name(); \ - (root)["icon"] = (obj)->get_icon(); \ - (root)["entity_category"] = (obj)->get_entity_category(); \ - if ((obj)->is_disabled_by_default()) \ - (root)["is_disabled_by_default"] = (obj)->is_disabled_by_default(); \ +// Helper functions to reduce code size by avoiding macro expansion +static void set_json_id(JsonObject &root, EntityBase *obj, const std::string &id, JsonDetail start_config) { + root["id"] = id; + if (start_config == DETAIL_ALL) { + root["name"] = obj->get_name(); + root["icon"] = obj->get_icon(); + root["entity_category"] = obj->get_entity_category(); + if (obj->is_disabled_by_default()) + root["is_disabled_by_default"] = obj->is_disabled_by_default(); } +} -#define set_json_value(root, obj, sensor, value, start_config) \ - set_json_id((root), (obj), sensor, start_config); \ - (root)["value"] = value; +template +static void set_json_value(JsonObject &root, EntityBase *obj, const std::string &id, const T &value, + JsonDetail start_config) { + set_json_id(root, obj, id, start_config); + root["value"] = value; +} -#define set_json_icon_state_value(root, obj, sensor, state, value, start_config) \ - set_json_value(root, obj, sensor, value, start_config); \ - (root)["state"] = state; +template +static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const std::string &id, + const std::string &state, const T &value, JsonDetail start_config) { + set_json_value(root, obj, id, value, start_config); + root["state"] = state; +} // Helper to get request detail parameter static JsonDetail get_request_detail(AsyncWebServerRequest *request) { From c89bc0bfd7559196b7b8e0042636b695c35cf3b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 12:02:30 -1000 Subject: [PATCH 0275/1626] [core] Replace std::stable_sort with insertion sort to save 1.3KB flash --- esphome/core/application.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 3ac17849dd..05fa85c112 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -69,8 +69,20 @@ void Application::setup() { if (component->can_proceed()) continue; - std::stable_sort(this->components_.begin(), this->components_.begin() + i + 1, - [](Component *a, Component *b) { return a->get_loop_priority() > b->get_loop_priority(); }); + // Using insertion sort instead of std::stable_sort saves ~1.3KB of flash + // by avoiding std::rotate, std::stable_sort, and lambda template instantiations. + // Insertion sort is efficient for small arrays and maintains stability + for (int32_t j = 1; j <= static_cast(i); j++) { + Component *key = this->components_[j]; + float key_priority = key->get_loop_priority(); + int32_t k = j - 1; + + while (k >= 0 && this->components_[k]->get_loop_priority() < key_priority) { + this->components_[k + 1] = this->components_[k]; + k--; + } + this->components_[k + 1] = key; + } do { uint8_t new_app_state = STATUS_LED_WARNING; From 36eab00eac174bb04a6940c39ce7ff4e5afbb327 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 12:14:17 -1000 Subject: [PATCH 0276/1626] preen --- esphome/core/application.cpp | 54 ++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 05fa85c112..85200d8d6c 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -34,6 +34,38 @@ namespace esphome { static const char *const TAG = "app"; +// Helper function for insertion sort of components by setup priority +// Using insertion sort instead of std::stable_sort saves ~1.3KB of flash +// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) +static void insertion_sort_by_setup_priority(Component **components, size_t size) { + for (size_t i = 1; i < size; i++) { + Component *key = components[i]; + float key_priority = key->get_actual_setup_priority(); + int32_t j = i - 1; + + while (j >= 0 && components[j]->get_actual_setup_priority() < key_priority) { + components[j + 1] = components[j]; + j--; + } + components[j + 1] = key; + } +} + +// Helper function for insertion sort of components by loop priority +static void insertion_sort_by_loop_priority(Component **components, size_t size) { + for (size_t i = 1; i < size; i++) { + Component *key = components[i]; + float key_priority = key->get_loop_priority(); + int32_t j = i - 1; + + while (j >= 0 && components[j]->get_loop_priority() < key_priority) { + components[j + 1] = components[j]; + j--; + } + components[j + 1] = key; + } +} + void Application::register_component_(Component *comp) { if (comp == nullptr) { ESP_LOGW(TAG, "Tried to register null component!"); @@ -51,9 +83,9 @@ void Application::register_component_(Component *comp) { void Application::setup() { ESP_LOGI(TAG, "Running through setup()"); ESP_LOGV(TAG, "Sorting components by setup priority"); - std::stable_sort(this->components_.begin(), this->components_.end(), [](const Component *a, const Component *b) { - return a->get_actual_setup_priority() > b->get_actual_setup_priority(); - }); + + // Sort by setup priority using our helper function + insertion_sort_by_setup_priority(this->components_.data(), this->components_.size()); // Initialize looping_components_ early so enable_pending_loops_() works during setup this->calculate_looping_components_(); @@ -69,20 +101,8 @@ void Application::setup() { if (component->can_proceed()) continue; - // Using insertion sort instead of std::stable_sort saves ~1.3KB of flash - // by avoiding std::rotate, std::stable_sort, and lambda template instantiations. - // Insertion sort is efficient for small arrays and maintains stability - for (int32_t j = 1; j <= static_cast(i); j++) { - Component *key = this->components_[j]; - float key_priority = key->get_loop_priority(); - int32_t k = j - 1; - - while (k >= 0 && this->components_[k]->get_loop_priority() < key_priority) { - this->components_[k + 1] = this->components_[k]; - k--; - } - this->components_[k + 1] = key; - } + // Sort components 0 through i by loop priority + insertion_sort_by_loop_priority(this->components_.data(), i + 1); do { uint8_t new_app_state = STATUS_LED_WARNING; From fd442cc4856574b6b3d2ef00b72abfea81bb11e9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 Aug 2025 08:21:54 +1000 Subject: [PATCH 0277/1626] [syslog] Fix RFC3164 timestamp compliance for single-digit days (#10034) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/syslog/esphome_syslog.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index e322a6951d..71468fa932 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -35,7 +35,7 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t severity = LOG_LEVEL_TO_SYSLOG_SEVERITY[level]; } int pri = this->facility_ * 8 + severity; - auto timestamp = this->time_->now().strftime("%b %d %H:%M:%S"); + auto timestamp = this->time_->now().strftime("%b %e %H:%M:%S"); size_t len = message_len; // remove color formatting if (this->strip_ && message[0] == 0x1B && len > 11) { From 40dcee594bdc39af5ee48ede24ecc51ca7c1f324 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 12:27:54 -1000 Subject: [PATCH 0278/1626] preen --- esphome/core/application.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 85200d8d6c..e5103802db 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -37,12 +37,15 @@ static const char *const TAG = "app"; // Helper function for insertion sort of components by setup priority // Using insertion sort instead of std::stable_sort saves ~1.3KB of flash // by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) +// IMPORTANT: This sort is stable (preserves relative order of equal elements), +// which is necessary to maintain user-defined component order for same priority static void insertion_sort_by_setup_priority(Component **components, size_t size) { for (size_t i = 1; i < size; i++) { Component *key = components[i]; float key_priority = key->get_actual_setup_priority(); int32_t j = i - 1; + // Using '<' (not '<=') ensures stability - equal priority components keep their order while (j >= 0 && components[j]->get_actual_setup_priority() < key_priority) { components[j + 1] = components[j]; j--; @@ -52,12 +55,15 @@ static void insertion_sort_by_setup_priority(Component **components, size_t size } // Helper function for insertion sort of components by loop priority +// IMPORTANT: This sort is stable (preserves relative order of equal elements), +// which is required when components are re-sorted during setup() if they block static void insertion_sort_by_loop_priority(Component **components, size_t size) { for (size_t i = 1; i < size; i++) { Component *key = components[i]; float key_priority = key->get_loop_priority(); int32_t j = i - 1; + // Using '<' (not '<=') ensures stability - equal priority components keep their order while (j >= 0 && components[j]->get_loop_priority() < key_priority) { components[j + 1] = components[j]; j--; From 9c76847acacd9a550aea36f3ad4bf01d6741d153 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 12:45:44 -1000 Subject: [PATCH 0279/1626] [wifi] Replace std::stable_sort with insertion sort to save 2.4KB flash --- esphome/components/wifi/wifi_component.cpp | 74 +++++++++++++++------- 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 98f75894f4..e7ca3629a5 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -505,6 +505,54 @@ void WiFiComponent::start_scanning() { this->state_ = WIFI_COMPONENT_STATE_STA_SCANNING; } +// Helper function for WiFi scan result comparison +// Returns true if 'a' should be placed before 'b' in the sorted order +static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) { + // Matching networks always come before non-matching + if (a.get_matches() && !b.get_matches()) + return true; + if (!a.get_matches() && b.get_matches()) + return false; + + if (a.get_matches() && b.get_matches()) { + // For APs with the same SSID, always prefer stronger signal + // This helps with mesh networks and multiple APs + if (a.get_ssid() == b.get_ssid()) { + return a.get_rssi() > b.get_rssi(); + } + + // For different SSIDs, check priority first + if (a.get_priority() != b.get_priority()) + return a.get_priority() > b.get_priority(); + // If priorities are equal, prefer stronger signal + return a.get_rssi() > b.get_rssi(); + } + + // Both don't match - sort by signal strength + return a.get_rssi() > b.get_rssi(); +} + +// Helper function for insertion sort of WiFi scan results +// Using insertion sort instead of std::stable_sort saves flash memory +// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) +// IMPORTANT: This sort is stable (preserves relative order of equal elements) +static void insertion_sort_scan_results(std::vector &results) { + const size_t size = results.size(); + for (size_t i = 1; i < size; i++) { + // Make a copy to avoid issues with move semantics during comparison + WiFiScanResult key = results[i]; + int32_t j = i - 1; + + // Move elements that are worse than key to the right + // For stability, we only move if key is strictly better than results[j] + while (j >= 0 && wifi_scan_result_is_better(key, results[j])) { + results[j + 1] = results[j]; + j--; + } + results[j + 1] = key; + } +} + void WiFiComponent::check_scanning_finished() { if (!this->scan_done_) { if (millis() - this->action_started_ > 30000) { @@ -535,30 +583,8 @@ void WiFiComponent::check_scanning_finished() { } } - std::stable_sort(this->scan_result_.begin(), this->scan_result_.end(), - [](const WiFiScanResult &a, const WiFiScanResult &b) { - // return true if a is better than b - if (a.get_matches() && !b.get_matches()) - return true; - if (!a.get_matches() && b.get_matches()) - return false; - - if (a.get_matches() && b.get_matches()) { - // For APs with the same SSID, always prefer stronger signal - // This helps with mesh networks and multiple APs - if (a.get_ssid() == b.get_ssid()) { - return a.get_rssi() > b.get_rssi(); - } - - // For different SSIDs, check priority first - if (a.get_priority() != b.get_priority()) - return a.get_priority() > b.get_priority(); - // If priorities are equal, prefer stronger signal - return a.get_rssi() > b.get_rssi(); - } - - return a.get_rssi() > b.get_rssi(); - }); + // Sort scan results using insertion sort for better memory efficiency + insertion_sort_scan_results(this->scan_result_); for (auto &res : this->scan_result_) { char bssid_s[18]; From 67abbc833fa16c28da1c12f4ed7f0d0ae3bb43da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 12:49:03 -1000 Subject: [PATCH 0280/1626] flex --- esphome/core/application.cpp | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index e5103802db..f7f9dce2dd 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -39,36 +39,36 @@ static const char *const TAG = "app"; // by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) // IMPORTANT: This sort is stable (preserves relative order of equal elements), // which is necessary to maintain user-defined component order for same priority -static void insertion_sort_by_setup_priority(Component **components, size_t size) { - for (size_t i = 1; i < size; i++) { - Component *key = components[i]; +template static void insertion_sort_by_setup_priority(Iterator first, Iterator last) { + for (auto it = first + 1; it != last; ++it) { + auto key = *it; float key_priority = key->get_actual_setup_priority(); - int32_t j = i - 1; + auto j = it - 1; // Using '<' (not '<=') ensures stability - equal priority components keep their order - while (j >= 0 && components[j]->get_actual_setup_priority() < key_priority) { - components[j + 1] = components[j]; + while (j >= first && (*j)->get_actual_setup_priority() < key_priority) { + *(j + 1) = *j; j--; } - components[j + 1] = key; + *(j + 1) = key; } } // Helper function for insertion sort of components by loop priority // IMPORTANT: This sort is stable (preserves relative order of equal elements), // which is required when components are re-sorted during setup() if they block -static void insertion_sort_by_loop_priority(Component **components, size_t size) { - for (size_t i = 1; i < size; i++) { - Component *key = components[i]; +template static void insertion_sort_by_loop_priority(Iterator first, Iterator last) { + for (auto it = first + 1; it != last; ++it) { + auto key = *it; float key_priority = key->get_loop_priority(); - int32_t j = i - 1; + auto j = it - 1; // Using '<' (not '<=') ensures stability - equal priority components keep their order - while (j >= 0 && components[j]->get_loop_priority() < key_priority) { - components[j + 1] = components[j]; + while (j >= first && (*j)->get_loop_priority() < key_priority) { + *(j + 1) = *j; j--; } - components[j + 1] = key; + *(j + 1) = key; } } @@ -91,7 +91,7 @@ void Application::setup() { ESP_LOGV(TAG, "Sorting components by setup priority"); // Sort by setup priority using our helper function - insertion_sort_by_setup_priority(this->components_.data(), this->components_.size()); + insertion_sort_by_setup_priority(this->components_.begin(), this->components_.end()); // Initialize looping_components_ early so enable_pending_loops_() works during setup this->calculate_looping_components_(); @@ -108,7 +108,7 @@ void Application::setup() { continue; // Sort components 0 through i by loop priority - insertion_sort_by_loop_priority(this->components_.data(), i + 1); + insertion_sort_by_loop_priority(this->components_.begin(), this->components_.begin() + i + 1); do { uint8_t new_app_state = STATUS_LED_WARNING; From 61c97b029c0e15e881f3a3701f93d28e7226497f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 13:01:03 -1000 Subject: [PATCH 0281/1626] preen --- esphome/components/wifi/wifi_component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index e7ca3629a5..f815ab73c2 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -507,7 +507,7 @@ void WiFiComponent::start_scanning() { // Helper function for WiFi scan result comparison // Returns true if 'a' should be placed before 'b' in the sorted order -static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) { +[[nodiscard]] inline static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) { // Matching networks always come before non-matching if (a.get_matches() && !b.get_matches()) return true; From 296442d8f16c83bc68fb1c71c3d5ed03be155d8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 13:59:20 -1000 Subject: [PATCH 0282/1626] [core] Fix compilation errors when platform sections have no entities (#10023) --- .../alarm_control_panel/__init__.py | 1 - esphome/components/binary_sensor/__init__.py | 1 - esphome/components/button/__init__.py | 1 - esphome/components/climate/__init__.py | 1 - esphome/components/cover/__init__.py | 1 - esphome/components/datetime/__init__.py | 2 -- esphome/components/event/__init__.py | 1 - esphome/components/fan/__init__.py | 1 - esphome/components/light/__init__.py | 1 - esphome/components/lock/__init__.py | 1 - esphome/components/number/__init__.py | 1 - esphome/components/select/__init__.py | 1 - esphome/components/sensor/__init__.py | 1 - esphome/components/switch/__init__.py | 1 - esphome/components/text/__init__.py | 1 - esphome/components/text_sensor/__init__.py | 1 - esphome/components/update/__init__.py | 1 - esphome/components/valve/__init__.py | 1 - esphome/core/config.py | 21 ++++++++++++++++--- 19 files changed, 18 insertions(+), 22 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index b076175eb8..058e061d1e 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -348,4 +348,3 @@ async def alarm_control_panel_is_armed_to_code( @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(alarm_control_panel_ns.using) - cg.add_define("USE_ALARM_CONTROL_PANEL") diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 376a399637..b56fde1ffd 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -654,7 +654,6 @@ async def binary_sensor_is_off_to_code(config, condition_id, template_arg, args) @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_BINARY_SENSOR") cg.add_global(binary_sensor_ns.using) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index ed2670a5c5..a23958989e 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -137,4 +137,3 @@ async def button_press_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(button_ns.using) - cg.add_define("USE_BUTTON") diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 9530ecdcca..4af3a619b5 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -519,5 +519,4 @@ async def climate_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_CLIMATE") cg.add_global(climate_ns.using) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index cd97a38ecc..0e01eb336f 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -265,5 +265,4 @@ async def cover_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_COVER") cg.add_global(cover_ns.using) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 4788810965..1d84b75f26 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -164,7 +164,6 @@ async def register_datetime(var, config): cg.add(getattr(cg.App, f"register_{entity_type}")(var)) CORE.register_platform_component(entity_type, var) await setup_datetime_core_(var, config) - cg.add_define(f"USE_DATETIME_{config[CONF_TYPE]}") async def new_datetime(config, *args): @@ -175,7 +174,6 @@ async def new_datetime(config, *args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_DATETIME") cg.add_global(datetime_ns.using) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 3aff96a48e..1948570ecd 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -145,5 +145,4 @@ async def event_fire_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_EVENT") cg.add_global(event_ns.using) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 0b1d39575d..3fb217a24e 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -400,5 +400,4 @@ async def fan_is_on_off_to_code(config, condition_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_FAN") cg.add_global(fan_ns.using) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 7ab899edb2..fa39721ee2 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -285,5 +285,4 @@ async def new_light(config, *args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_LIGHT") cg.add_global(light_ns.using) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index e62d9f3e2b..7977efd264 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -158,4 +158,3 @@ async def lock_is_off_to_code(config, condition_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(lock_ns.using) - cg.add_define("USE_LOCK") diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 90a1619e4c..4a83d5fc5f 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -323,7 +323,6 @@ async def number_in_range_to_code(config, condition_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_NUMBER") cg.add_global(number_ns.using) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index ed1f6c020d..dd3feccab5 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -126,7 +126,6 @@ async def new_select(config, *, options: list[str]): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_SELECT") cg.add_global(select_ns.using) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 23e6ad0f2c..2275027004 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1139,5 +1139,4 @@ def _lstsq(a, b): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_SENSOR") cg.add_global(sensor_ns.using) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index c09675069f..a595d43445 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -202,4 +202,3 @@ async def switch_is_off_to_code(config, condition_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): cg.add_global(switch_ns.using) - cg.add_define("USE_SWITCH") diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 8362e09ac0..aa831d1f06 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -151,7 +151,6 @@ async def new_text( @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_TEXT") cg.add_global(text_ns.using) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 0341ab2f71..e4aa701a7b 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -232,7 +232,6 @@ async def new_text_sensor(config, *args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_TEXT_SENSOR") cg.add_global(text_sensor_ns.using) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 758267f412..50d8aaf139 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -126,7 +126,6 @@ async def new_update(config): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_UPDATE") cg.add_global(update_ns.using) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index cb27546120..53254068af 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -235,5 +235,4 @@ async def valve_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(100.0) async def to_code(config): - cg.add_define("USE_VALVE") cg.add_global(valve_ns.using) diff --git a/esphome/core/config.py b/esphome/core/config.py index 3bc030ad50..6a87bab730 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -419,13 +419,28 @@ async def _add_automations(config): await automation.build_automation(trigger, [], conf) +# Datetime component has special subtypes that need additional defines +DATETIME_SUBTYPES = {"date", "time", "datetime"} + + @coroutine_with_priority(-100.0) -async def _add_platform_reserves() -> None: - # Generate compile-time entity count defines for static_entity_vector +async def _add_platform_defines() -> None: + # Generate compile-time defines for platforms that have actual entities + # Only add USE_* and count defines when there are entities for platform_name, count in sorted(CORE.platform_counts.items()): + if count <= 0: + continue + define_name = f"ESPHOME_ENTITY_{platform_name.upper()}_COUNT" cg.add_define(define_name, count) + # Datetime subtypes only use USE_DATETIME_* defines + if platform_name in DATETIME_SUBTYPES: + cg.add_define(f"USE_DATETIME_{platform_name.upper()}") + else: + # Regular platforms use USE_* defines + cg.add_define(f"USE_{platform_name.upper()}") + @coroutine_with_priority(100.0) async def to_code(config: ConfigType) -> None: @@ -449,7 +464,7 @@ async def to_code(config: ConfigType) -> None: cg.RawStatement(f"App.reserve_components({len(CORE.component_ids)});"), ) - CORE.add_job(_add_platform_reserves) + CORE.add_job(_add_platform_defines) CORE.add_job(_add_automations, config) From 7391bbc6eec540cd85f682331d08ca76dabc417d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 14:20:18 -1000 Subject: [PATCH 0283/1626] suggestion --- esphome/components/web_server/web_server.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 34a4684816..a8d94d80da 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -383,8 +383,9 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const std::string &id root["name"] = obj->get_name(); root["icon"] = obj->get_icon(); root["entity_category"] = obj->get_entity_category(); - if (obj->is_disabled_by_default()) - root["is_disabled_by_default"] = obj->is_disabled_by_default(); + bool is_disabled = obj->is_disabled_by_default(); + if (is_disabled) + root["is_disabled_by_default"] = is_disabled; } } From 88f251b29c7ac28a4a90a5f290a78f0ccba873cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 14:48:51 -1000 Subject: [PATCH 0284/1626] [api] Use static allocation for areas and devices in DeviceInfoResponse --- esphome/components/api/api.proto | 4 ++-- esphome/components/api/api_connection.cpp | 12 ++++++++---- esphome/components/api/api_pb2.cpp | 12 ++++++++---- esphome/components/api/api_pb2.h | 6 +++--- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index e0b2c19a21..67e91cc8e3 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -250,8 +250,8 @@ message DeviceInfoResponse { // Supports receiving and saving api encryption key bool api_encryption_supported = 19 [(field_ifdef) = "USE_API_NOISE"]; - repeated DeviceInfo devices = 20 [(field_ifdef) = "USE_DEVICES"]; - repeated AreaInfo areas = 21 [(field_ifdef) = "USE_AREAS"]; + repeated DeviceInfo devices = 20 [(field_ifdef) = "USE_DEVICES", (fixed_array_size_define) = "ESPHOME_DEVICE_COUNT"]; + repeated AreaInfo areas = 21 [(field_ifdef) = "USE_AREAS", (fixed_array_size_define) = "ESPHOME_AREA_COUNT"]; // Top-level area info to phase out suggested_area AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 5fff270c99..cdeabb5cac 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1462,18 +1462,22 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { resp.api_encryption_supported = true; #endif #ifdef USE_DEVICES + size_t device_index = 0; for (auto const &device : App.get_devices()) { - resp.devices.emplace_back(); - auto &device_info = resp.devices.back(); + if (device_index >= ESPHOME_DEVICE_COUNT) + break; + auto &device_info = resp.devices[device_index++]; device_info.device_id = device->get_device_id(); device_info.set_name(StringRef(device->get_name())); device_info.area_id = device->get_area_id(); } #endif #ifdef USE_AREAS + size_t area_index = 0; for (auto const &area : App.get_areas()) { - resp.areas.emplace_back(); - auto &area_info = resp.areas.back(); + if (area_index >= ESPHOME_AREA_COUNT) + break; + auto &area_info = resp.areas[area_index++]; area_info.area_id = area->get_area_id(); area_info.set_name(StringRef(area->get_name())); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 8c14153155..5dddc79b49 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -115,12 +115,12 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(19, this->api_encryption_supported); #endif #ifdef USE_DEVICES - for (auto &it : this->devices) { + for (const auto &it : this->devices) { buffer.encode_message(20, it, true); } #endif #ifdef USE_AREAS - for (auto &it : this->areas) { + for (const auto &it : this->areas) { buffer.encode_message(21, it, true); } #endif @@ -167,10 +167,14 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const { size.add_bool(2, this->api_encryption_supported); #endif #ifdef USE_DEVICES - size.add_repeated_message(2, this->devices); + for (const auto &it : this->devices) { + size.add_message_object_force(2, it); + } #endif #ifdef USE_AREAS - size.add_repeated_message(2, this->areas); + for (const auto &it : this->areas) { + size.add_message_object_force(2, it); + } #endif #ifdef USE_AREAS size.add_message_object(2, this->area); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 0bc75ef00b..d43d3c61b7 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -490,7 +490,7 @@ class DeviceInfo : public ProtoMessage { class DeviceInfoResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint8_t ESTIMATED_SIZE = 211; + static constexpr uint8_t ESTIMATED_SIZE = 247; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "device_info_response"; } #endif @@ -543,10 +543,10 @@ class DeviceInfoResponse : public ProtoMessage { bool api_encryption_supported{false}; #endif #ifdef USE_DEVICES - std::vector devices{}; + std::array devices{}; #endif #ifdef USE_AREAS - std::vector areas{}; + std::array areas{}; #endif #ifdef USE_AREAS AreaInfo area{}; From 7a8b2feec62b4bb8c5ca45e8c1848ec124e404ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 15:56:47 -1000 Subject: [PATCH 0285/1626] [core] Optimize Application::pre_setup() to reduce duplicate MAC address operations --- esphome/core/application.h | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index b7824a254b..c60c9a3d66 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -101,12 +101,9 @@ class Application { arch_init(); this->name_add_mac_suffix_ = name_add_mac_suffix; if (name_add_mac_suffix) { - this->name_ = name + "-" + get_mac_address().substr(6); - if (friendly_name.empty()) { - this->friendly_name_ = ""; - } else { - this->friendly_name_ = friendly_name + " " + get_mac_address().substr(6); - } + const std::string mac_suffix = get_mac_address().substr(6); + this->name_ = name + "-" + mac_suffix; + this->friendly_name_ = friendly_name.empty() ? "" : friendly_name + " " + mac_suffix; } else { this->name_ = name; this->friendly_name_ = friendly_name; From b667cc45ccac79dff2a91c6ce0f1c71347d51647 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 17:22:41 -1000 Subject: [PATCH 0286/1626] [web_server_idf] Replace std::find_if with simple loop to reduce binary size --- .../components/web_server_idf/web_server_idf.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 734259093e..e027440f97 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -423,14 +423,14 @@ void AsyncEventSourceResponse::destroy(void *ptr) { void AsyncEventSourceResponse::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) { DeferredEvent item(source, message_generator); - auto iter = std::find_if(this->deferred_queue_.begin(), this->deferred_queue_.end(), - [&item](const DeferredEvent &test) -> bool { return test == item; }); - - if (iter != this->deferred_queue_.end()) { - (*iter) = item; - } else { - this->deferred_queue_.push_back(item); + // Replace std::find_if with simple loop to reduce binary size + for (auto &event : this->deferred_queue_) { + if (event == item) { + event = item; + return; + } } + this->deferred_queue_.push_back(item); } void AsyncEventSourceResponse::process_deferred_queue_() { From 451095eef4c0b1669e4760ab1d147cabdcba22fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 21:46:56 -1000 Subject: [PATCH 0287/1626] [core] Replace std::find and std::max_element with simple loops to reduce binary size --- esphome/core/application.cpp | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 3ac17849dd..3e6ddb56ed 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -459,24 +459,24 @@ void Application::unregister_socket_fd(int fd) { if (fd < 0) return; - auto it = std::find(this->socket_fds_.begin(), this->socket_fds_.end(), fd); - if (it != this->socket_fds_.end()) { + for (size_t i = 0; i < this->socket_fds_.size(); i++) { + if (this->socket_fds_[i] != fd) + continue; + // Swap with last element and pop - O(1) removal since order doesn't matter - if (it != this->socket_fds_.end() - 1) { - std::swap(*it, this->socket_fds_.back()); - } + if (i < this->socket_fds_.size() - 1) + this->socket_fds_[i] = this->socket_fds_.back(); this->socket_fds_.pop_back(); this->socket_fds_changed_ = true; // Only recalculate max_fd if we removed the current max if (fd == this->max_fd_) { - if (this->socket_fds_.empty()) { - this->max_fd_ = -1; - } else { - // Find new max using std::max_element - this->max_fd_ = *std::max_element(this->socket_fds_.begin(), this->socket_fds_.end()); - } + this->max_fd_ = -1; + for (int sock_fd : this->socket_fds_) + if (sock_fd > this->max_fd_) + this->max_fd_ = sock_fd; } + return; } } From b1b0638fabef80ac5dc2186e0be8f600018eafba Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 3 Aug 2025 18:35:52 +1000 Subject: [PATCH 0288/1626] [config] Fix reversion of excessive yaml output after error (#10043) Co-authored-by: J. Nick Koston --- esphome/log.py | 2 + tests/unit_tests/test_log.py | 80 ++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 tests/unit_tests/test_log.py diff --git a/esphome/log.py b/esphome/log.py index 8831b1b2b3..bfd1875b55 100644 --- a/esphome/log.py +++ b/esphome/log.py @@ -37,6 +37,8 @@ class AnsiStyle(Enum): def color(col: AnsiFore, msg: str, reset: bool = True) -> str: + if col == AnsiFore.KEEP: + return msg s = col.value + msg if reset and col: s += AnsiStyle.RESET_ALL.value diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py new file mode 100644 index 0000000000..02798f1029 --- /dev/null +++ b/tests/unit_tests/test_log.py @@ -0,0 +1,80 @@ +import pytest + +from esphome.log import AnsiFore, AnsiStyle, color + + +def test_color_keep_returns_unchanged_message() -> None: + """Test that AnsiFore.KEEP returns the message unchanged.""" + msg = "test message" + result = color(AnsiFore.KEEP, msg) + assert result == msg + + +def test_color_keep_ignores_reset_parameter() -> None: + """Test that reset parameter is ignored when using AnsiFore.KEEP.""" + msg = "test message" + result_with_reset = color(AnsiFore.KEEP, msg, reset=True) + result_without_reset = color(AnsiFore.KEEP, msg, reset=False) + assert result_with_reset == msg + assert result_without_reset == msg + + +def test_color_applies_color_code() -> None: + """Test that color codes are properly applied to messages.""" + msg = "test message" + result = color(AnsiFore.RED, msg, reset=False) + assert result == f"{AnsiFore.RED.value}{msg}" + + +def test_color_applies_reset_when_requested() -> None: + """Test that RESET_ALL is added when reset=True.""" + msg = "test message" + result = color(AnsiFore.GREEN, msg, reset=True) + expected = f"{AnsiFore.GREEN.value}{msg}{AnsiStyle.RESET_ALL.value}" + assert result == expected + + +def test_color_no_reset_when_not_requested() -> None: + """Test that RESET_ALL is not added when reset=False.""" + msg = "test message" + result = color(AnsiFore.BLUE, msg, reset=False) + expected = f"{AnsiFore.BLUE.value}{msg}" + assert result == expected + + +def test_color_with_empty_message() -> None: + """Test color function with empty message.""" + result = color(AnsiFore.YELLOW, "", reset=True) + expected = f"{AnsiFore.YELLOW.value}{AnsiStyle.RESET_ALL.value}" + assert result == expected + + +@pytest.mark.parametrize( + "col", + [ + AnsiFore.BLACK, + AnsiFore.RED, + AnsiFore.GREEN, + AnsiFore.YELLOW, + AnsiFore.BLUE, + AnsiFore.MAGENTA, + AnsiFore.CYAN, + AnsiFore.WHITE, + AnsiFore.RESET, + ], +) +def test_all_ansi_colors(col: AnsiFore) -> None: + """Test that all AnsiFore colors work correctly.""" + msg = "test" + result = color(col, msg, reset=True) + expected = f"{col.value}{msg}{AnsiStyle.RESET_ALL.value}" + assert result == expected + + +def test_ansi_fore_keep_is_enum_member() -> None: + """Ensure AnsiFore.KEEP is an Enum member and evaluates to truthy.""" + assert isinstance(AnsiFore.KEEP, AnsiFore) + # Enum members are truthy, even with empty string values + assert bool(AnsiFore.KEEP) is True + # But the value itself is still an empty string + assert AnsiFore.KEEP.value == "" From 53449f298e1592b48c4027b12c54db6f6b533c99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Aug 2025 22:45:13 -1000 Subject: [PATCH 0289/1626] lint --- esphome/core/application.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 3e6ddb56ed..0467b0b57f 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -472,9 +472,10 @@ void Application::unregister_socket_fd(int fd) { // Only recalculate max_fd if we removed the current max if (fd == this->max_fd_) { this->max_fd_ = -1; - for (int sock_fd : this->socket_fds_) + for (int sock_fd : this->socket_fds_) { if (sock_fd > this->max_fd_) this->max_fd_ = sock_fd; + } } return; } From be7b63898ff6878eb192cd762d0a1daf0cf8dbd3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 13:43:15 -1000 Subject: [PATCH 0290/1626] [api] Fix OTA progress updates not being sent when main loop is blocked --- esphome/components/api/api_connection.h | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 21688e601c..d15ad58114 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -703,10 +703,13 @@ class APIConnection : public APIServerConnection { bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type, uint8_t estimated_size) { // Try to send immediately if: - // 1. We should try to send immediately (should_try_send_immediately = true) - // 2. Batch delay is 0 (user has opted in to immediate sending) - // 3. Buffer has space available - if (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0 && + // 1. It's an UpdateStateResponse (always send immediately to handle cases where + // the main loop is blocked, e.g., during OTA updates) + // 2. OR: We should try to send immediately (should_try_send_immediately = true) + // AND Batch delay is 0 (user has opted in to immediate sending) + // 3. AND: Buffer has space available + if ((message_type == UpdateStateResponse::MESSAGE_TYPE || + (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)) && this->helper_->can_write_without_blocking()) { // Now actually encode and send if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) && From 3e69f41b42a71f6ec81617744e3ee6ac879e6442 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 13:54:03 -1000 Subject: [PATCH 0291/1626] needs ifdef --- esphome/components/api/api_connection.h | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index d15ad58114..f0f308c248 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -708,8 +708,11 @@ class APIConnection : public APIServerConnection { // 2. OR: We should try to send immediately (should_try_send_immediately = true) // AND Batch delay is 0 (user has opted in to immediate sending) // 3. AND: Buffer has space available - if ((message_type == UpdateStateResponse::MESSAGE_TYPE || - (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)) && + if (( +#ifdef USE_UPDATE + message_type == UpdateStateResponse::MESSAGE_TYPE || +#endif + (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)) && this->helper_->can_write_without_blocking()) { // Now actually encode and send if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) && From d69e98e15d1d250ffac6be0a130cd7fbb741efe1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 14:23:45 -1000 Subject: [PATCH 0292/1626] [api] Fix OTA progress updates not being sent when main loop is blocked (#10049) --- esphome/components/api/api_connection.h | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 21688e601c..f0f308c248 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -703,10 +703,16 @@ class APIConnection : public APIServerConnection { bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type, uint8_t estimated_size) { // Try to send immediately if: - // 1. We should try to send immediately (should_try_send_immediately = true) - // 2. Batch delay is 0 (user has opted in to immediate sending) - // 3. Buffer has space available - if (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0 && + // 1. It's an UpdateStateResponse (always send immediately to handle cases where + // the main loop is blocked, e.g., during OTA updates) + // 2. OR: We should try to send immediately (should_try_send_immediately = true) + // AND Batch delay is 0 (user has opted in to immediate sending) + // 3. AND: Buffer has space available + if (( +#ifdef USE_UPDATE + message_type == UpdateStateResponse::MESSAGE_TYPE || +#endif + (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)) && this->helper_->can_write_without_blocking()) { // Now actually encode and send if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) && From 339c26c815f58e8702981b09fe471e2edded44b8 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 4 Aug 2025 10:51:34 +1000 Subject: [PATCH 0293/1626] [color][lvgl] Allow Color to be used for lv_color_t (#10016) --- esphome/core/color.h | 10 ++++++++++ tests/components/lvgl/lvgl-package.yaml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/core/color.h b/esphome/core/color.h index 2b307bb438..5dce58a485 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -1,8 +1,13 @@ #pragma once +#include "defines.h" #include "component.h" #include "helpers.h" +#ifdef USE_LVGL +#include "esphome/components/lvgl/lvgl_proxy.h" +#endif // USE_LVGL + namespace esphome { inline static constexpr uint8_t esp_scale8(uint8_t i, uint8_t scale) { @@ -33,6 +38,11 @@ struct Color { uint32_t raw_32; }; +#ifdef USE_LVGL + // convenience function for Color to get a lv_color_t representation + operator lv_color_t() const { return lv_color_make(this->r, this->g, this->b); } +#endif + inline constexpr Color() ESPHOME_ALWAYS_INLINE : raw_32(0) {} // NOLINT inline constexpr Color(uint8_t red, uint8_t green, uint8_t blue) ESPHOME_ALWAYS_INLINE : r(red), g(green), diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 853466c9cc..7cd2e2b93e 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -78,7 +78,7 @@ lvgl: - id: date_style text_font: roboto10 align: center - text_color: color_id2 + text_color: !lambda return color_id2; bg_opa: cover radius: 4 pad_all: 2 From 0f13af007654d3412fe1f3c63e86c957c2bb0d9f Mon Sep 17 00:00:00 2001 From: "@RubenKelevra" Date: Mon, 4 Aug 2025 03:08:11 +0200 Subject: [PATCH 0294/1626] Update esp32-camera library version to 2.1.0 (#9527) --- esphome/components/esp32_camera/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 43e71df432..bfb66ff83a 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -345,7 +345,7 @@ async def to_code(config): cg.add_define("USE_CAMERA") if CORE.using_esp_idf: - add_idf_component(name="espressif/esp32-camera", ref="2.0.15") + add_idf_component(name="espressif/esp32-camera", ref="2.1.0") for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c43b622684..419a9797e3 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.0.15 + version: 2.1.0 espressif/mdns: version: 1.8.2 espressif/esp_wifi_remote: From b44d2183aa3790b54be9282248226b3b4ff8eb82 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 01:19:12 +0000 Subject: [PATCH 0295/1626] Bump aioesphomeapi from 37.2.3 to 37.2.4 (#10050) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bfdb08323e..6f79e86bdf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.2.3 +aioesphomeapi==37.2.4 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 137df4ff20500734a139c4c87cce1102a84d0212 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 15:45:03 -1000 Subject: [PATCH 0296/1626] [esp32_ble_client] Connect immediately on READY_TO_CONNECT to reduce latency --- esphome/components/esp32_ble_client/ble_client_base.cpp | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 94f2a6073c..644c822e06 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -45,8 +45,10 @@ void BLEClientBase::set_state(espbt::ClientState st) { ESPBTClient::set_state(st); if (st == espbt::ClientState::READY_TO_CONNECT) { - // Enable loop when we need to connect + // Enable loop for state processing this->enable_loop(); + // Connect immediately instead of waiting for next loop + this->connect(); } } @@ -63,11 +65,6 @@ void BLEClientBase::loop() { } this->set_state(espbt::ClientState::IDLE); } - // READY_TO_CONNECT means we have discovered the device - // and the scanner has been stopped by the tracker. - else if (this->state_ == espbt::ClientState::READY_TO_CONNECT) { - this->connect(); - } // If its idle, we can disable the loop as set_state // will enable it again when we need to connect. else if (this->state_ == espbt::ClientState::IDLE) { From dbaf2cdd5030de58b495236e940e00d2f6a13feb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 15:46:06 -1000 Subject: [PATCH 0297/1626] [core] Replace std::find and std::max_element with simple loops to reduce binary size (#10044) --- esphome/core/application.cpp | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 3ac17849dd..0467b0b57f 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -459,24 +459,25 @@ void Application::unregister_socket_fd(int fd) { if (fd < 0) return; - auto it = std::find(this->socket_fds_.begin(), this->socket_fds_.end(), fd); - if (it != this->socket_fds_.end()) { + for (size_t i = 0; i < this->socket_fds_.size(); i++) { + if (this->socket_fds_[i] != fd) + continue; + // Swap with last element and pop - O(1) removal since order doesn't matter - if (it != this->socket_fds_.end() - 1) { - std::swap(*it, this->socket_fds_.back()); - } + if (i < this->socket_fds_.size() - 1) + this->socket_fds_[i] = this->socket_fds_.back(); this->socket_fds_.pop_back(); this->socket_fds_changed_ = true; // Only recalculate max_fd if we removed the current max if (fd == this->max_fd_) { - if (this->socket_fds_.empty()) { - this->max_fd_ = -1; - } else { - // Find new max using std::max_element - this->max_fd_ = *std::max_element(this->socket_fds_.begin(), this->socket_fds_.end()); + this->max_fd_ = -1; + for (int sock_fd : this->socket_fds_) { + if (sock_fd > this->max_fd_) + this->max_fd_ = sock_fd; } } + return; } } From e17a200b7c93ed3293951a9b367cf8f70efd9e2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 15:51:02 -1000 Subject: [PATCH 0298/1626] [esp32_ble_client] Use FAST connection parameters for all v3 connections --- .../esp32_ble_client/ble_client_base.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 94f2a6073c..031cb41e6d 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -16,8 +16,8 @@ static const char *const TAG = "esp32_ble_client"; // Intermediate connection parameters for standard operation // ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies, // causing disconnections. These medium parameters balance responsiveness with bandwidth usage. -static const uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x08; // 8 * 1.25ms = 10ms -static const uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x0A; // 10 * 1.25ms = 12.5ms +static const uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms +static const uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms // The timeout value was increased from 6s to 8s to address stability issues observed // in certain BLE devices when operating through WiFi-based BLE proxies. The longer // timeout reduces the likelihood of disconnections during periods of high latency. @@ -157,12 +157,13 @@ void BLEClientBase::connect() { this->set_state(espbt::ClientState::CONNECTING); // Always set connection parameters to ensure stable operation - // Use FAST for V3_WITHOUT_CACHE (devices that need lowest latency) - // Use MEDIUM for all other connections (balanced performance) + // Use FAST for all V3 connections (better latency and reliability) + // Use MEDIUM for V1/legacy connections (balanced performance) uint16_t min_interval, max_interval, timeout; const char *param_type; - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || + this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { min_interval = FAST_MIN_CONN_INTERVAL; max_interval = FAST_MAX_CONN_INTERVAL; timeout = FAST_CONN_TIMEOUT; @@ -411,9 +412,10 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); - // For non-cached connections, restore to medium connection parameters after service discovery + // For V3 connections, restore to medium connection parameters after service discovery // This balances performance with bandwidth usage after the critical discovery phase - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || + this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; From d86e1e29a9af9063429d25629cf872d1a682d9b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 15:51:50 -1000 Subject: [PATCH 0299/1626] [core] Convert components, devices, and areas vectors to static allocation (#10020) --- esphome/core/application.h | 100 +++++++++++++++++-------------------- esphome/core/config.py | 12 ++--- esphome/core/defines.h | 5 +- esphome/core/helpers.h | 10 ++++ 4 files changed, 66 insertions(+), 61 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index b7824a254b..4eb4984f71 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -214,14 +214,6 @@ class Application { #endif /// Reserve space for components to avoid memory fragmentation - void reserve_components(size_t count) { this->components_.reserve(count); } - -#ifdef USE_AREAS - void reserve_area(size_t count) { this->areas_.reserve(count); } -#endif -#ifdef USE_DEVICES - void reserve_device(size_t count) { this->devices_.reserve(count); } -#endif /// Register the component in this Application instance. template C *register_component(C *c) { @@ -316,7 +308,7 @@ class Application { } \ return nullptr; \ } - const std::vector &get_devices() { return this->devices_; } + const auto &get_devices() { return this->devices_; } #else #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ @@ -328,7 +320,7 @@ class Application { } #endif // USE_DEVICES #ifdef USE_AREAS - const std::vector &get_areas() { return this->areas_; } + const auto &get_areas() { return this->areas_; } #endif #ifdef USE_BINARY_SENSOR auto &get_binary_sensors() const { return this->binary_sensors_; } @@ -462,12 +454,7 @@ class Application { const char *comment_{nullptr}; const char *compilation_time_{nullptr}; - // size_t members - size_t dump_config_at_{SIZE_MAX}; - - // Vectors (largest members) - std::vector components_{}; - + // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components // ================================================= // Components are partitioned into [active | inactive] sections: @@ -485,12 +472,54 @@ class Application { // and active_end_ is incremented // - This eliminates branch mispredictions from flag checking in the hot loop std::vector looping_components_{}; +#ifdef USE_SOCKET_SELECT_SUPPORT + std::vector socket_fds_; // Vector of all monitored socket file descriptors +#endif + + // std::string members (typically 24-32 bytes each) + std::string name_; + std::string friendly_name_; + + // size_t members + size_t dump_config_at_{SIZE_MAX}; + + // 4-byte members + uint32_t last_loop_{0}; + uint32_t loop_component_start_time_{0}; + +#ifdef USE_SOCKET_SELECT_SUPPORT + int max_fd_{-1}; // Highest file descriptor number for select() +#endif + + // 2-byte members (grouped together for alignment) + uint16_t loop_interval_{16}; // Loop interval in ms (max 65535ms = 65.5 seconds) + uint16_t looping_components_active_end_{0}; // Index marking end of active components in looping_components_ + uint16_t current_loop_index_{0}; // For safe reentrant modifications during iteration + + // 1-byte members (grouped together to minimize padding) + uint8_t app_state_{0}; + bool name_add_mac_suffix_; + bool in_loop_{false}; + volatile bool has_pending_enable_loop_requests_{false}; + +#ifdef USE_SOCKET_SELECT_SUPPORT + bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes +#endif + +#ifdef USE_SOCKET_SELECT_SUPPORT + // Variable-sized members + fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes + fd_set read_fds_{}; // Working fd_set for select(), copied from base_read_fds_ +#endif + + // StaticVectors (largest members - contain actual array data inline) + StaticVector components_{}; #ifdef USE_DEVICES - std::vector devices_{}; + StaticVector devices_{}; #endif #ifdef USE_AREAS - std::vector areas_{}; + StaticVector areas_{}; #endif #ifdef USE_BINARY_SENSOR StaticVector binary_sensors_{}; @@ -556,41 +585,6 @@ class Application { #ifdef USE_UPDATE StaticVector updates_{}; #endif - -#ifdef USE_SOCKET_SELECT_SUPPORT - std::vector socket_fds_; // Vector of all monitored socket file descriptors -#endif - - // String members - std::string name_; - std::string friendly_name_; - - // 4-byte members - uint32_t last_loop_{0}; - uint32_t loop_component_start_time_{0}; - -#ifdef USE_SOCKET_SELECT_SUPPORT - int max_fd_{-1}; // Highest file descriptor number for select() -#endif - - // 2-byte members (grouped together for alignment) - uint16_t loop_interval_{16}; // Loop interval in ms (max 65535ms = 65.5 seconds) - uint16_t looping_components_active_end_{0}; - uint16_t current_loop_index_{0}; // For safe reentrant modifications during iteration - - // 1-byte members (grouped together to minimize padding) - uint8_t app_state_{0}; - bool name_add_mac_suffix_; - bool in_loop_{false}; - volatile bool has_pending_enable_loop_requests_{false}; - -#ifdef USE_SOCKET_SELECT_SUPPORT - bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes - - // Variable-sized members at end - fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes - fd_set read_fds_{}; // Working fd_set for select(), copied from base_read_fds_ -#endif }; /// Global storage of Application pointer - only one Application can exist. diff --git a/esphome/core/config.py b/esphome/core/config.py index 6a87bab730..90768a4b09 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -459,10 +459,8 @@ async def to_code(config: ConfigType) -> None: config[CONF_NAME_ADD_MAC_SUFFIX], ) ) - # Reserve space for components to avoid reallocation during registration - cg.add( - cg.RawStatement(f"App.reserve_components({len(CORE.component_ids)});"), - ) + # Define component count for static allocation + cg.add_define("ESPHOME_COMPONENT_COUNT", len(CORE.component_ids)) CORE.add_job(_add_platform_defines) @@ -531,8 +529,8 @@ async def to_code(config: ConfigType) -> None: all_areas.extend(config[CONF_AREAS]) if all_areas: - cg.add(cg.RawStatement(f"App.reserve_area({len(all_areas)});")) cg.add_define("USE_AREAS") + cg.add_define("ESPHOME_AREA_COUNT", len(all_areas)) for area_conf in all_areas: area_id: core.ID = area_conf[CONF_ID] @@ -549,9 +547,9 @@ async def to_code(config: ConfigType) -> None: if not devices: return - # Reserve space for devices - cg.add(cg.RawStatement(f"App.reserve_device({len(devices)});")) + # Define device count for static allocation cg.add_define("USE_DEVICES") + cg.add_define("ESPHOME_DEVICE_COUNT", len(devices)) # Process each device for dev_conf in devices: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3ed0af91eb..996dbc7e8d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -240,7 +240,10 @@ #define USE_DASHBOARD_IMPORT -// Default entity counts for static analysis +// Default counts for static analysis +#define ESPHOME_COMPONENT_COUNT 50 +#define ESPHOME_DEVICE_COUNT 10 +#define ESPHOME_AREA_COUNT 10 #define ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT 1 #define ESPHOME_ENTITY_BINARY_SENSOR_COUNT 1 #define ESPHOME_ENTITY_BUTTON_COUNT 1 diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b05cc11029..b5fe59c4fd 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -100,6 +101,8 @@ template class StaticVector { using value_type = T; using iterator = typename std::array::iterator; using const_iterator = typename std::array::const_iterator; + using reverse_iterator = std::reverse_iterator; + using const_reverse_iterator = std::reverse_iterator; private: std::array data_{}; @@ -114,6 +117,7 @@ template class StaticVector { } size_t size() const { return count_; } + bool empty() const { return count_ == 0; } T &operator[](size_t i) { return data_[i]; } const T &operator[](size_t i) const { return data_[i]; } @@ -123,6 +127,12 @@ template class StaticVector { iterator end() { return data_.begin() + count_; } const_iterator begin() const { return data_.begin(); } const_iterator end() const { return data_.begin() + count_; } + + // Reverse iterators + reverse_iterator rbegin() { return reverse_iterator(end()); } + reverse_iterator rend() { return reverse_iterator(begin()); } + const_reverse_iterator rbegin() const { return const_reverse_iterator(end()); } + const_reverse_iterator rend() const { return const_reverse_iterator(begin()); } }; ///@} From cd6cf074d9b6fdcc9b06c7e309f8b1d2ac11dda2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 15:56:06 -1000 Subject: [PATCH 0300/1626] [core] Replace std::stable_sort with insertion sort to save 3.5KB flash (#10035) --- esphome/core/application.cpp | 48 ++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 0467b0b57f..73bf13ab7c 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -34,6 +34,44 @@ namespace esphome { static const char *const TAG = "app"; +// Helper function for insertion sort of components by setup priority +// Using insertion sort instead of std::stable_sort saves ~1.3KB of flash +// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) +// IMPORTANT: This sort is stable (preserves relative order of equal elements), +// which is necessary to maintain user-defined component order for same priority +template static void insertion_sort_by_setup_priority(Iterator first, Iterator last) { + for (auto it = first + 1; it != last; ++it) { + auto key = *it; + float key_priority = key->get_actual_setup_priority(); + auto j = it - 1; + + // Using '<' (not '<=') ensures stability - equal priority components keep their order + while (j >= first && (*j)->get_actual_setup_priority() < key_priority) { + *(j + 1) = *j; + j--; + } + *(j + 1) = key; + } +} + +// Helper function for insertion sort of components by loop priority +// IMPORTANT: This sort is stable (preserves relative order of equal elements), +// which is required when components are re-sorted during setup() if they block +template static void insertion_sort_by_loop_priority(Iterator first, Iterator last) { + for (auto it = first + 1; it != last; ++it) { + auto key = *it; + float key_priority = key->get_loop_priority(); + auto j = it - 1; + + // Using '<' (not '<=') ensures stability - equal priority components keep their order + while (j >= first && (*j)->get_loop_priority() < key_priority) { + *(j + 1) = *j; + j--; + } + *(j + 1) = key; + } +} + void Application::register_component_(Component *comp) { if (comp == nullptr) { ESP_LOGW(TAG, "Tried to register null component!"); @@ -51,9 +89,9 @@ void Application::register_component_(Component *comp) { void Application::setup() { ESP_LOGI(TAG, "Running through setup()"); ESP_LOGV(TAG, "Sorting components by setup priority"); - std::stable_sort(this->components_.begin(), this->components_.end(), [](const Component *a, const Component *b) { - return a->get_actual_setup_priority() > b->get_actual_setup_priority(); - }); + + // Sort by setup priority using our helper function + insertion_sort_by_setup_priority(this->components_.begin(), this->components_.end()); // Initialize looping_components_ early so enable_pending_loops_() works during setup this->calculate_looping_components_(); @@ -69,8 +107,8 @@ void Application::setup() { if (component->can_proceed()) continue; - std::stable_sort(this->components_.begin(), this->components_.begin() + i + 1, - [](Component *a, Component *b) { return a->get_loop_priority() > b->get_loop_priority(); }); + // Sort components 0 through i by loop priority + insertion_sort_by_loop_priority(this->components_.begin(), this->components_.begin() + i + 1); do { uint8_t new_app_state = STATUS_LED_WARNING; From 3fbbdb4589ef306aeac29ea672320860ad15821d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:00:56 -1000 Subject: [PATCH 0301/1626] [web_server_idf] Replace std::find_if with simple loop to reduce binary size (#10042) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/web_server_idf/web_server_idf.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 734259093e..483fae4f08 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -423,14 +423,14 @@ void AsyncEventSourceResponse::destroy(void *ptr) { void AsyncEventSourceResponse::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) { DeferredEvent item(source, message_generator); - auto iter = std::find_if(this->deferred_queue_.begin(), this->deferred_queue_.end(), - [&item](const DeferredEvent &test) -> bool { return test == item; }); - - if (iter != this->deferred_queue_.end()) { - (*iter) = item; - } else { - this->deferred_queue_.push_back(item); + // Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size + for (auto &event : this->deferred_queue_) { + if (event == item) { + event = item; + return; + } } + this->deferred_queue_.push_back(item); } void AsyncEventSourceResponse::process_deferred_queue_() { From c9d865a0610e5023dbd5a6d9b85068b580a3c3ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:02:10 -1000 Subject: [PATCH 0302/1626] [core] Optimize Application::pre_setup() to reduce duplicate MAC address operations (#10039) --- esphome/core/application.h | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 4eb4984f71..4120afff53 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -101,12 +101,9 @@ class Application { arch_init(); this->name_add_mac_suffix_ = name_add_mac_suffix; if (name_add_mac_suffix) { - this->name_ = name + "-" + get_mac_address().substr(6); - if (friendly_name.empty()) { - this->friendly_name_ = ""; - } else { - this->friendly_name_ = friendly_name + " " + get_mac_address().substr(6); - } + const std::string mac_suffix = get_mac_address().substr(6); + this->name_ = name + "-" + mac_suffix; + this->friendly_name_ = friendly_name.empty() ? "" : friendly_name + " " + mac_suffix; } else { this->name_ = name; this->friendly_name_ = friendly_name; From a75f73dbf0efec8d2ab886e5dbabb2931721ba10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:03:37 -1000 Subject: [PATCH 0303/1626] [web_server] Reduce binary size by using EntityBase and minimizing template instantiations (#10033) --- esphome/components/web_server/web_server.cpp | 37 ++++++++++++-------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 880145a2a1..a8d94d80da 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -376,23 +376,32 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { } #endif -#define set_json_id(root, obj, sensor, start_config) \ - (root)["id"] = sensor; \ - if (((start_config) == DETAIL_ALL)) { \ - (root)["name"] = (obj)->get_name(); \ - (root)["icon"] = (obj)->get_icon(); \ - (root)["entity_category"] = (obj)->get_entity_category(); \ - if ((obj)->is_disabled_by_default()) \ - (root)["is_disabled_by_default"] = (obj)->is_disabled_by_default(); \ +// Helper functions to reduce code size by avoiding macro expansion +static void set_json_id(JsonObject &root, EntityBase *obj, const std::string &id, JsonDetail start_config) { + root["id"] = id; + if (start_config == DETAIL_ALL) { + root["name"] = obj->get_name(); + root["icon"] = obj->get_icon(); + root["entity_category"] = obj->get_entity_category(); + bool is_disabled = obj->is_disabled_by_default(); + if (is_disabled) + root["is_disabled_by_default"] = is_disabled; } +} -#define set_json_value(root, obj, sensor, value, start_config) \ - set_json_id((root), (obj), sensor, start_config); \ - (root)["value"] = value; +template +static void set_json_value(JsonObject &root, EntityBase *obj, const std::string &id, const T &value, + JsonDetail start_config) { + set_json_id(root, obj, id, start_config); + root["value"] = value; +} -#define set_json_icon_state_value(root, obj, sensor, state, value, start_config) \ - set_json_value(root, obj, sensor, value, start_config); \ - (root)["state"] = state; +template +static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const std::string &id, + const std::string &state, const T &value, JsonDetail start_config) { + set_json_value(root, obj, id, value, start_config); + root["state"] = state; +} // Helper to get request detail parameter static JsonDetail get_request_detail(AsyncWebServerRequest *request) { From 494a1a216c4b7ec77fb76745320d74ee32055bd5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:09:12 -1000 Subject: [PATCH 0304/1626] [web_server] Conditionally compile authentication code to save flash memory (#10022) --- esphome/components/web_server/__init__.py | 1 + esphome/components/web_server_base/web_server_base.cpp | 2 ++ esphome/components/web_server_base/web_server_base.h | 6 ++++++ esphome/components/web_server_idf/web_server_idf.cpp | 2 ++ esphome/components/web_server_idf/web_server_idf.h | 2 ++ esphome/core/defines.h | 3 +++ tests/components/web_server/test.esp32-idf.yaml | 5 +++++ 7 files changed, 21 insertions(+) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 8ead14dcac..695757e137 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -298,6 +298,7 @@ async def to_code(config): if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if CONF_AUTH in config: + cg.add_define("USE_WEBSERVER_AUTH") cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD])) if CONF_CSS_INCLUDE in config: diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index e1c2bc0b25..6e7097338c 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -14,9 +14,11 @@ WebServerBase *global_web_server_base = nullptr; // NOLINT(cppcoreguidelines-av void WebServerBase::add_handler(AsyncWebHandler *handler) { // remove all handlers +#ifdef USE_WEBSERVER_AUTH if (!credentials_.username.empty()) { handler = new internal::AuthMiddlewareHandler(handler, &credentials_); } +#endif this->handlers_.push_back(handler); if (this->server_ != nullptr) { this->server_->addHandler(handler); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index a475238a37..cfca776ee1 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -41,6 +41,7 @@ class MiddlewareHandler : public AsyncWebHandler { AsyncWebHandler *next_; }; +#ifdef USE_WEBSERVER_AUTH struct Credentials { std::string username; std::string password; @@ -79,6 +80,7 @@ class AuthMiddlewareHandler : public MiddlewareHandler { protected: Credentials *credentials_; }; +#endif } // namespace internal @@ -108,8 +110,10 @@ class WebServerBase : public Component { std::shared_ptr get_server() const { return server_; } float get_setup_priority() const override; +#ifdef USE_WEBSERVER_AUTH void set_auth_username(std::string auth_username) { credentials_.username = std::move(auth_username); } void set_auth_password(std::string auth_password) { credentials_.password = std::move(auth_password); } +#endif void add_handler(AsyncWebHandler *handler); @@ -121,7 +125,9 @@ class WebServerBase : public Component { uint16_t port_{80}; std::shared_ptr server_{nullptr}; std::vector handlers_; +#ifdef USE_WEBSERVER_AUTH internal::Credentials credentials_; +#endif }; } // namespace web_server_base diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 483fae4f08..40fb015b99 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -223,6 +223,7 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code this->rsp_ = rsp; } +#ifdef USE_WEBSERVER_AUTH bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const { if (username == nullptr || password == nullptr || *username == 0) { return true; @@ -261,6 +262,7 @@ void AsyncWebServerRequest::requestAuthentication(const char *realm) const { httpd_resp_set_hdr(*this, "WWW-Authenticate", auth_val.c_str()); httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr); } +#endif AsyncWebParameter *AsyncWebServerRequest::getParam(const std::string &name) { auto find = this->params_.find(name); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index e8e40ef9b0..76540ef232 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -115,9 +115,11 @@ class AsyncWebServerRequest { // NOLINTNEXTLINE(readability-identifier-naming) size_t contentLength() const { return this->req_->content_len; } +#ifdef USE_WEBSERVER_AUTH bool authenticate(const char *username, const char *password) const; // NOLINTNEXTLINE(readability-identifier-naming) void requestAuthentication(const char *realm = nullptr) const; +#endif void redirect(const std::string &url); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 996dbc7e8d..56de0127a6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -163,6 +163,7 @@ #define USE_SPI #define USE_VOICE_ASSISTANT #define USE_WEBSERVER +#define USE_WEBSERVER_AUTH #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_SORTING @@ -210,6 +211,7 @@ {} #define USE_WEBSERVER +#define USE_WEBSERVER_AUTH #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -226,6 +228,7 @@ #define USE_SOCKET_IMPL_LWIP_SOCKETS #define USE_SOCKET_SELECT_SUPPORT #define USE_WEBSERVER +#define USE_WEBSERVER_AUTH #define USE_WEBSERVER_PORT 80 // NOLINT #endif diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 7e6658e20e..24b292d0d6 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -1 +1,6 @@ <<: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password From 9aad0733efa87a2e5c9bca42cbf48e899aefb76d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:14:17 -1000 Subject: [PATCH 0305/1626] [core] Update to esptool 5.0+ command syntax (#10011) --- esphome/__main__.py | 10 +++++----- esphome/components/esp32/post_build.py.script | 6 +++--- esphome/platformio_api.py | 1 + 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 341c1fa893..5e45b7f213 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -277,20 +277,20 @@ def upload_using_esptool(config, port, file, speed): def run_esptool(baud_rate): cmd = [ - "esptool.py", + "esptool", "--before", - "default_reset", + "default-reset", "--after", - "hard_reset", + "hard-reset", "--baud", str(baud_rate), "--port", port, "--chip", mcu, - "write_flash", + "write-flash", "-z", - "--flash_size", + "--flash-size", "detect", ] for img in flash_images: diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index 586f12e00b..c995214232 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -93,8 +93,8 @@ def merge_factory_bin(source, target, env): "esptool", "--chip", chip, - "merge_bin", - "--flash_size", + "merge-bin", + "--flash-size", flash_size, "--output", str(output_path), @@ -110,7 +110,7 @@ def merge_factory_bin(source, target, env): if result == 0: print(f"Successfully created {output_path}") else: - print(f"Error: esptool merge_bin failed with code {result}") + print(f"Error: esptool merge-bin failed with code {result}") def esp32_copy_ota_bin(source, target, env): diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 7415ec9794..21124fc859 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -61,6 +61,7 @@ FILTER_PLATFORMIO_LINES = [ r"Advanced Memory Usage is available via .*", r"Merged .* ELF section", r"esptool.py v.*", + r"esptool v.*", r"Checking size .*", r"Retrieving maximum program size .*", r"PLATFORM: .*", From 2e08285570a32e81710cb7de1ccaaac6a92558dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:14:34 -1000 Subject: [PATCH 0306/1626] [esp32_ble_tracker] Remove unnecessary STOPPED scanner state to reduce latency --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 64 +++++++++---------- .../esp32_ble_tracker/esp32_ble_tracker.h | 14 ++-- 2 files changed, 34 insertions(+), 44 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index e0029ad15b..8885f2efe1 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -185,9 +185,6 @@ void ESP32BLETracker::loop() { ESP_LOGW(TAG, "Dropped %zu BLE scan results due to buffer overflow", dropped); } } - if (this->scanner_state_ == ScannerState::STOPPED) { - this->end_of_scan_(); // Change state to IDLE - } if (this->scanner_state_ == ScannerState::FAILED || (this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) { this->stop_scan_(); @@ -278,8 +275,6 @@ void ESP32BLETracker::stop_scan_() { ESP_LOGE(TAG, "Scan is starting while trying to stop."); } else if (this->scanner_state_ == ScannerState::STOPPING) { ESP_LOGE(TAG, "Scan is already stopping while trying to stop."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan is already stopped while trying to stop."); } return; } @@ -306,8 +301,6 @@ void ESP32BLETracker::start_scan_(bool first) { ESP_LOGE(TAG, "Cannot start scan while already stopping."); } else if (this->scanner_state_ == ScannerState::FAILED) { ESP_LOGE(TAG, "Cannot start scan while already failed."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Cannot start scan while already stopped."); } return; } @@ -342,21 +335,6 @@ void ESP32BLETracker::start_scan_(bool first) { } } -void ESP32BLETracker::end_of_scan_() { - // The lock must be held when calling this function. - if (this->scanner_state_ != ScannerState::STOPPED) { - ESP_LOGE(TAG, "end_of_scan_ called while scanner is not stopped."); - return; - } - ESP_LOGD(TAG, "End of scan, set scanner state to IDLE."); - this->already_discovered_.clear(); - this->cancel_timeout("scan"); - - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->set_scanner_state_(ScannerState::IDLE); -} - void ESP32BLETracker::register_client(ESPBTClient *client) { client->app_id = ++this->app_id_; this->clients_.push_back(client); @@ -389,6 +367,8 @@ void ESP32BLETracker::recalculate_advertisement_parser_types() { } void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + // Note: This handler is called from the main loop context, not directly from the BT task. + // The esp32_ble component queues events via enqueue_ble_event() and processes them in loop(). switch (event) { case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: this->gap_scan_set_param_complete_(param->scan_param_cmpl); @@ -409,11 +389,13 @@ void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_ga } void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { + // Note: This handler is called from the main loop context via esp32_ble's event queue. + // However, we still use a lock-free ring buffer to batch results efficiently. ESP_LOGV(TAG, "gap_scan_result - event %d", scan_result.search_evt); if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { - // Lock-free SPSC ring buffer write (Producer side) - // This runs in the ESP-IDF Bluetooth stack callback thread + // Ring buffer write (Producer side) + // Even though we're in the main loop, the ring buffer design allows efficient batching // IMPORTANT: Only this thread writes to ring_write_index_ // Load our own index with relaxed ordering (we're the only writer) @@ -445,15 +427,22 @@ void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { ESP_LOGE(TAG, "Scan was in failed state when scan completed."); } else if (this->scanner_state_ == ScannerState::IDLE) { ESP_LOGE(TAG, "Scan was idle when scan completed."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan was stopped when scan completed."); } } - this->set_scanner_state_(ScannerState::STOPPED); + // Scan completed naturally, perform cleanup and transition to IDLE + ESP_LOGD(TAG, "Scan completed, set scanner state to IDLE."); + this->already_discovered_.clear(); + this->cancel_timeout("scan"); + + for (auto *listener : this->listeners_) + listener->on_scan_end(); + + this->set_scanner_state_(ScannerState::IDLE); } } void ESP32BLETracker::gap_scan_set_param_complete_(const esp_ble_gap_cb_param_t::ble_scan_param_cmpl_evt_param ¶m) { + // Called from main loop context via gap_event_handler after being queued from BT task ESP_LOGV(TAG, "gap_scan_set_param_complete - status %d", param.status); if (param.status == ESP_BT_STATUS_DONE) { this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; @@ -463,6 +452,7 @@ void ESP32BLETracker::gap_scan_set_param_complete_(const esp_ble_gap_cb_param_t: } void ESP32BLETracker::gap_scan_start_complete_(const esp_ble_gap_cb_param_t::ble_scan_start_cmpl_evt_param ¶m) { + // Called from main loop context via gap_event_handler after being queued from BT task ESP_LOGV(TAG, "gap_scan_start_complete - status %d", param.status); this->scan_start_failed_ = param.status; if (this->scanner_state_ != ScannerState::STARTING) { @@ -474,8 +464,6 @@ void ESP32BLETracker::gap_scan_start_complete_(const esp_ble_gap_cb_param_t::ble ESP_LOGE(TAG, "Scan was in failed state when start complete."); } else if (this->scanner_state_ == ScannerState::IDLE) { ESP_LOGE(TAG, "Scan was idle when start complete."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan was stopped when start complete."); } } if (param.status == ESP_BT_STATUS_SUCCESS) { @@ -490,6 +478,8 @@ void ESP32BLETracker::gap_scan_start_complete_(const esp_ble_gap_cb_param_t::ble } void ESP32BLETracker::gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_scan_stop_cmpl_evt_param ¶m) { + // Called from main loop context via gap_event_handler after being queued from BT task + // This allows us to safely transition to IDLE state and perform cleanup without race conditions ESP_LOGV(TAG, "gap_scan_stop_complete - status %d", param.status); if (this->scanner_state_ != ScannerState::STOPPING) { if (this->scanner_state_ == ScannerState::RUNNING) { @@ -500,11 +490,18 @@ void ESP32BLETracker::gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_ ESP_LOGE(TAG, "Scan was in failed state when stop complete."); } else if (this->scanner_state_ == ScannerState::IDLE) { ESP_LOGE(TAG, "Scan was idle when stop complete."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan was stopped when stop complete."); } } - this->set_scanner_state_(ScannerState::STOPPED); + + // Perform cleanup and transition to IDLE + ESP_LOGD(TAG, "Scan stop complete, set scanner state to IDLE."); + this->already_discovered_.clear(); + this->cancel_timeout("scan"); + + for (auto *listener : this->listeners_) + listener->on_scan_end(); + + this->set_scanner_state_(ScannerState::IDLE); } void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, @@ -794,9 +791,6 @@ void ESP32BLETracker::dump_config() { case ScannerState::STOPPING: ESP_LOGCONFIG(TAG, " Scanner State: STOPPING"); break; - case ScannerState::STOPPED: - ESP_LOGCONFIG(TAG, " Scanner State: STOPPED"); - break; case ScannerState::FAILED: ESP_LOGCONFIG(TAG, " Scanner State: FAILED"); break; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index e1119c0e18..a2edb24cef 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -158,18 +158,16 @@ enum class ClientState : uint8_t { }; enum class ScannerState { - // Scanner is idle, init state, set from the main loop when processing STOPPED + // Scanner is idle, init state IDLE, - // Scanner is starting, set from the main loop only + // Scanner is starting STARTING, - // Scanner is running, set from the ESP callback only + // Scanner is running RUNNING, - // Scanner failed to start, set from the ESP callback only + // Scanner failed to start FAILED, - // Scanner is stopping, set from the main loop only + // Scanner is stopping STOPPING, - // Scanner is stopped, set from the ESP callback only - STOPPED, }; enum class ConnectionType : uint8_t { @@ -262,8 +260,6 @@ class ESP32BLETracker : public Component, void stop_scan_(); /// Start a single scan by setting up the parameters and doing some esp-idf calls. void start_scan_(bool first); - /// Called when a scan ends - void end_of_scan_(); /// Called when a `ESP_GAP_BLE_SCAN_RESULT_EVT` event is received. void gap_scan_result_(const esp_ble_gap_cb_param_t::ble_scan_result_evt_param ¶m); /// Called when a `ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT` event is received. From ef372eeeb7f6b22bfa8fe7a42e387e56ac0ecaf8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:19:24 -1000 Subject: [PATCH 0307/1626] [wifi] Replace std::stable_sort with insertion sort to save 2.4KB flash (#10037) --- esphome/components/wifi/wifi_component.cpp | 74 +++++++++++++++------- 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 98f75894f4..f815ab73c2 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -505,6 +505,54 @@ void WiFiComponent::start_scanning() { this->state_ = WIFI_COMPONENT_STATE_STA_SCANNING; } +// Helper function for WiFi scan result comparison +// Returns true if 'a' should be placed before 'b' in the sorted order +[[nodiscard]] inline static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) { + // Matching networks always come before non-matching + if (a.get_matches() && !b.get_matches()) + return true; + if (!a.get_matches() && b.get_matches()) + return false; + + if (a.get_matches() && b.get_matches()) { + // For APs with the same SSID, always prefer stronger signal + // This helps with mesh networks and multiple APs + if (a.get_ssid() == b.get_ssid()) { + return a.get_rssi() > b.get_rssi(); + } + + // For different SSIDs, check priority first + if (a.get_priority() != b.get_priority()) + return a.get_priority() > b.get_priority(); + // If priorities are equal, prefer stronger signal + return a.get_rssi() > b.get_rssi(); + } + + // Both don't match - sort by signal strength + return a.get_rssi() > b.get_rssi(); +} + +// Helper function for insertion sort of WiFi scan results +// Using insertion sort instead of std::stable_sort saves flash memory +// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) +// IMPORTANT: This sort is stable (preserves relative order of equal elements) +static void insertion_sort_scan_results(std::vector &results) { + const size_t size = results.size(); + for (size_t i = 1; i < size; i++) { + // Make a copy to avoid issues with move semantics during comparison + WiFiScanResult key = results[i]; + int32_t j = i - 1; + + // Move elements that are worse than key to the right + // For stability, we only move if key is strictly better than results[j] + while (j >= 0 && wifi_scan_result_is_better(key, results[j])) { + results[j + 1] = results[j]; + j--; + } + results[j + 1] = key; + } +} + void WiFiComponent::check_scanning_finished() { if (!this->scan_done_) { if (millis() - this->action_started_ > 30000) { @@ -535,30 +583,8 @@ void WiFiComponent::check_scanning_finished() { } } - std::stable_sort(this->scan_result_.begin(), this->scan_result_.end(), - [](const WiFiScanResult &a, const WiFiScanResult &b) { - // return true if a is better than b - if (a.get_matches() && !b.get_matches()) - return true; - if (!a.get_matches() && b.get_matches()) - return false; - - if (a.get_matches() && b.get_matches()) { - // For APs with the same SSID, always prefer stronger signal - // This helps with mesh networks and multiple APs - if (a.get_ssid() == b.get_ssid()) { - return a.get_rssi() > b.get_rssi(); - } - - // For different SSIDs, check priority first - if (a.get_priority() != b.get_priority()) - return a.get_priority() > b.get_priority(); - // If priorities are equal, prefer stronger signal - return a.get_rssi() > b.get_rssi(); - } - - return a.get_rssi() > b.get_rssi(); - }); + // Sort scan results using insertion sort for better memory efficiency + insertion_sort_scan_results(this->scan_result_); for (auto &res : this->scan_result_) { char bssid_s[18]; From 6a5eb460efef63c2fa91c2ba1df0c53f245ef4e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:27:05 -1000 Subject: [PATCH 0308/1626] [esp32] Add framework migration warning for upcoming ESP-IDF default change (#10030) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 62 ++++++++++++++++++++++++++++ esphome/util.py | 8 +++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 05a79553a4..c43cafc100 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -680,6 +680,64 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All( ) +class _FrameworkMigrationWarning: + shown = False + + +def _show_framework_migration_message(name: str, variant: str) -> None: + """Show a friendly message about framework migration when defaulting to Arduino.""" + if _FrameworkMigrationWarning.shown: + return + _FrameworkMigrationWarning.shown = True + + from esphome.log import AnsiFore, color + + message = ( + color( + AnsiFore.BOLD_CYAN, + f"💡 IMPORTANT: {name} doesn't have a framework specified!", + ) + + "\n\n" + + f"Currently, {variant} defaults to the Arduino framework.\n" + + color(AnsiFore.YELLOW, "This will change to ESP-IDF in ESPHome 2026.1.0.\n") + + "\n" + + "Note: Newer ESP32 variants (C6, H2, P4, etc.) already use ESP-IDF by default.\n" + + "\n" + + "Why change? ESP-IDF offers:\n" + + color(AnsiFore.GREEN, " ✨ Up to 40% smaller binaries\n") + + color(AnsiFore.GREEN, " 🚀 Better performance and optimization\n") + + color(AnsiFore.GREEN, " 📦 Custom-built firmware for your exact needs\n") + + color( + AnsiFore.GREEN, + " 🔧 Active development and testing by ESPHome developers\n", + ) + + "\n" + + "Trade-offs:\n" + + color(AnsiFore.YELLOW, " ⏱️ Compile times are ~25% longer\n") + + color(AnsiFore.YELLOW, " 🔄 Some components need migration\n") + + "\n" + + "What should I do?\n" + + color(AnsiFore.CYAN, " Option 1") + + ": Migrate to ESP-IDF (recommended)\n" + + " Add this to your YAML under 'esp32:':\n" + + color(AnsiFore.WHITE, " framework:\n") + + color(AnsiFore.WHITE, " type: esp-idf\n") + + "\n" + + color(AnsiFore.CYAN, " Option 2") + + ": Keep using Arduino (still supported)\n" + + " Add this to your YAML under 'esp32:':\n" + + color(AnsiFore.WHITE, " framework:\n") + + color(AnsiFore.WHITE, " type: arduino\n") + + "\n" + + "Need help? Check out the migration guide:\n" + + color( + AnsiFore.BLUE, + "https://esphome.io/guides/esp32_arduino_to_idf.html", + ) + ) + _LOGGER.warning(message) + + def _set_default_framework(config): if CONF_FRAMEWORK not in config: config = config.copy() @@ -688,6 +746,10 @@ def _set_default_framework(config): if variant in ARDUINO_ALLOWED_VARIANTS: config[CONF_FRAMEWORK] = ARDUINO_FRAMEWORK_SCHEMA({}) config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ARDUINO + # Show the migration message + _show_framework_migration_message( + config.get(CONF_NAME, "This device"), variant + ) else: config[CONF_FRAMEWORK] = ESP_IDF_FRAMEWORK_SCHEMA({}) config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ESP_IDF diff --git a/esphome/util.py b/esphome/util.py index 3b346371bc..9aa0f6b9d8 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -345,5 +345,11 @@ def get_esp32_arduino_flash_error_help() -> str | None: + "2. Clean build files and compile again\n" + "\n" + "Note: ESP-IDF uses less flash space and provides better performance.\n" - + "Some Arduino-specific libraries may need alternatives.\n\n" + + "Some Arduino-specific libraries may need alternatives.\n" + + "\n" + + "For detailed migration instructions, see:\n" + + color( + AnsiFore.BLUE, + "https://esphome.io/guides/esp32_arduino_to_idf.html\n\n", + ) ) From 9ff89dfb8131744a279b1ca5493fd48f66ffd142 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:29:31 -1000 Subject: [PATCH 0309/1626] dry --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 29 +++++++++---------- .../esp32_ble_tracker/esp32_ble_tracker.h | 2 ++ 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 8885f2efe1..254eddd1d9 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -430,14 +430,7 @@ void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { } } // Scan completed naturally, perform cleanup and transition to IDLE - ESP_LOGD(TAG, "Scan completed, set scanner state to IDLE."); - this->already_discovered_.clear(); - this->cancel_timeout("scan"); - - for (auto *listener : this->listeners_) - listener->on_scan_end(); - - this->set_scanner_state_(ScannerState::IDLE); + this->cleanup_scan_state_(false); } } @@ -494,14 +487,7 @@ void ESP32BLETracker::gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_ } // Perform cleanup and transition to IDLE - ESP_LOGD(TAG, "Scan stop complete, set scanner state to IDLE."); - this->already_discovered_.clear(); - this->cancel_timeout("scan"); - - for (auto *listener : this->listeners_) - listener->on_scan_end(); - - this->set_scanner_state_(ScannerState::IDLE); + this->cleanup_scan_state_(true); } void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, @@ -875,6 +861,17 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { } #endif // USE_ESP32_BLE_DEVICE +void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { + ESP_LOGD(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); + this->already_discovered_.clear(); + this->cancel_timeout("scan"); + + for (auto *listener : this->listeners_) + listener->on_scan_end(); + + this->set_scanner_state_(ScannerState::IDLE); +} + } // namespace esphome::esp32_ble_tracker #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index a2edb24cef..c274e64b12 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -270,6 +270,8 @@ class ESP32BLETracker : public Component, void gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_scan_stop_cmpl_evt_param ¶m); /// Called to set the scanner state. Will also call callbacks to let listeners know when state is changed. void set_scanner_state_(ScannerState state); + /// Common cleanup logic when transitioning scanner to IDLE state + void cleanup_scan_state_(bool is_stop_complete); uint8_t app_id_{0}; From c0c0a423626b66abc5e35dbe6c02e116c566328d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:37:47 -1000 Subject: [PATCH 0310/1626] [api] Use static allocation for areas and devices in DeviceInfoResponse (#10038) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/api/api.proto | 4 ++-- esphome/components/api/api_connection.cpp | 12 ++++++++---- esphome/components/api/api_pb2.cpp | 12 ++++++++---- esphome/components/api/api_pb2.h | 6 +++--- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index e0b2c19a21..67e91cc8e3 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -250,8 +250,8 @@ message DeviceInfoResponse { // Supports receiving and saving api encryption key bool api_encryption_supported = 19 [(field_ifdef) = "USE_API_NOISE"]; - repeated DeviceInfo devices = 20 [(field_ifdef) = "USE_DEVICES"]; - repeated AreaInfo areas = 21 [(field_ifdef) = "USE_AREAS"]; + repeated DeviceInfo devices = 20 [(field_ifdef) = "USE_DEVICES", (fixed_array_size_define) = "ESPHOME_DEVICE_COUNT"]; + repeated AreaInfo areas = 21 [(field_ifdef) = "USE_AREAS", (fixed_array_size_define) = "ESPHOME_AREA_COUNT"]; // Top-level area info to phase out suggested_area AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 5fff270c99..cdeabb5cac 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1462,18 +1462,22 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { resp.api_encryption_supported = true; #endif #ifdef USE_DEVICES + size_t device_index = 0; for (auto const &device : App.get_devices()) { - resp.devices.emplace_back(); - auto &device_info = resp.devices.back(); + if (device_index >= ESPHOME_DEVICE_COUNT) + break; + auto &device_info = resp.devices[device_index++]; device_info.device_id = device->get_device_id(); device_info.set_name(StringRef(device->get_name())); device_info.area_id = device->get_area_id(); } #endif #ifdef USE_AREAS + size_t area_index = 0; for (auto const &area : App.get_areas()) { - resp.areas.emplace_back(); - auto &area_info = resp.areas.back(); + if (area_index >= ESPHOME_AREA_COUNT) + break; + auto &area_info = resp.areas[area_index++]; area_info.area_id = area->get_area_id(); area_info.set_name(StringRef(area->get_name())); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 8c14153155..5dddc79b49 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -115,12 +115,12 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(19, this->api_encryption_supported); #endif #ifdef USE_DEVICES - for (auto &it : this->devices) { + for (const auto &it : this->devices) { buffer.encode_message(20, it, true); } #endif #ifdef USE_AREAS - for (auto &it : this->areas) { + for (const auto &it : this->areas) { buffer.encode_message(21, it, true); } #endif @@ -167,10 +167,14 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const { size.add_bool(2, this->api_encryption_supported); #endif #ifdef USE_DEVICES - size.add_repeated_message(2, this->devices); + for (const auto &it : this->devices) { + size.add_message_object_force(2, it); + } #endif #ifdef USE_AREAS - size.add_repeated_message(2, this->areas); + for (const auto &it : this->areas) { + size.add_message_object_force(2, it); + } #endif #ifdef USE_AREAS size.add_message_object(2, this->area); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 0bc75ef00b..d43d3c61b7 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -490,7 +490,7 @@ class DeviceInfo : public ProtoMessage { class DeviceInfoResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint8_t ESTIMATED_SIZE = 211; + static constexpr uint8_t ESTIMATED_SIZE = 247; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "device_info_response"; } #endif @@ -543,10 +543,10 @@ class DeviceInfoResponse : public ProtoMessage { bool api_encryption_supported{false}; #endif #ifdef USE_DEVICES - std::vector devices{}; + std::array devices{}; #endif #ifdef USE_AREAS - std::vector areas{}; + std::array areas{}; #endif #ifdef USE_AREAS AreaInfo area{}; From 4d683d5a69d0f3e54e0a5a827c9a6b2e02cc4cb1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:45:35 +1200 Subject: [PATCH 0311/1626] [AI] Add note about the defines.h file needing to include all new defines added (#10054) --- .ai/instructions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.ai/instructions.md b/.ai/instructions.md index 6504c7370d..6c002f9617 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -168,6 +168,8 @@ This document provides essential context for AI models interacting with this pro * `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers. * `.pre-commit-config.yaml`: Configures the pre-commit hooks for linting and formatting. * **CI/CD Pipeline:** Defined in `.github/workflows`. +* **Static Analysis & Development:** + * `esphome/core/defines.h`: A comprehensive header file containing all `#define` directives that can be added by components using `cg.add_define()` in Python. This file is used exclusively for development, static analysis tools, and CI testing - it is not used during runtime compilation. When developing components that add new defines, they must be added to this file to ensure proper IDE support and static analysis coverage. The file includes feature flags, build configurations, and platform-specific defines that help static analyzers understand the complete codebase without needing to compile for specific platforms. ## 6. Development & Testing Workflow From 5a695267aa8ff3bd4babad63e57ba09956057be9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 17:49:05 -1000 Subject: [PATCH 0312/1626] [esp32_ble_tracker] Eliminate redundant ring buffer for lower latency --- esphome/components/esp32_ble/ble.h | 15 +- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 170 ++++++++---------- .../esp32_ble_tracker/esp32_ble_tracker.h | 18 +- 3 files changed, 84 insertions(+), 119 deletions(-) diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 543b2f26a3..3f40c557f1 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -23,21 +23,14 @@ namespace esphome::esp32_ble { -// Maximum number of BLE scan results to buffer -// Sized to handle bursts of advertisements while allowing for processing delays -// With 16 advertisements per batch and some safety margin: -// - Without PSRAM: 24 entries (1.5× batch size) -// - With PSRAM: 36 entries (2.25× batch size) -// The reduced structure size (~80 bytes vs ~400 bytes) allows for larger buffers +// Maximum size of the BLE event queue +// Increased to absorb the ring buffer capacity from esp32_ble_tracker #ifdef USE_PSRAM -static constexpr uint8_t SCAN_RESULT_BUFFER_SIZE = 36; +static constexpr uint8_t MAX_BLE_QUEUE_SIZE = 100; // 64 + 36 (ring buffer size with PSRAM) #else -static constexpr uint8_t SCAN_RESULT_BUFFER_SIZE = 24; +static constexpr uint8_t MAX_BLE_QUEUE_SIZE = 88; // 64 + 24 (ring buffer size without PSRAM) #endif -// Maximum size of the BLE event queue - must be power of 2 for lock-free queue -static constexpr size_t MAX_BLE_QUEUE_SIZE = 64; - uint64_t ble_addr_to_uint64(const esp_bd_addr_t address); // NOLINTNEXTLINE(modernize-use-using) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 254eddd1d9..b714630a74 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -49,13 +49,6 @@ void ESP32BLETracker::setup() { ESP_LOGE(TAG, "BLE Tracker was marked failed by ESP32BLE"); return; } - RAMAllocator allocator; - this->scan_ring_buffer_ = allocator.allocate(SCAN_RESULT_BUFFER_SIZE); - - if (this->scan_ring_buffer_ == nullptr) { - ESP_LOGE(TAG, "Could not allocate ring buffer for BLE Tracker!"); - this->mark_failed(); - } global_esp32_ble_tracker = this; @@ -117,74 +110,8 @@ void ESP32BLETracker::loop() { } bool promote_to_connecting = discovered && !searching && !connecting; - // Process scan results from lock-free SPSC ring buffer - // Consumer side: This runs in the main loop thread - if (this->scanner_state_ == ScannerState::RUNNING) { - // Load our own index with relaxed ordering (we're the only writer) - uint8_t read_idx = this->ring_read_index_.load(std::memory_order_relaxed); - - // Load producer's index with acquire to see their latest writes - uint8_t write_idx = this->ring_write_index_.load(std::memory_order_acquire); - - while (read_idx != write_idx) { - // Calculate how many contiguous results we can process in one batch - // If write > read: process all results from read to write - // If write <= read (wraparound): process from read to end of buffer first - size_t batch_size = (write_idx > read_idx) ? (write_idx - read_idx) : (SCAN_RESULT_BUFFER_SIZE - read_idx); - - // Process the batch for raw advertisements - if (this->raw_advertisements_) { - for (auto *listener : this->listeners_) { - listener->parse_devices(&this->scan_ring_buffer_[read_idx], batch_size); - } - for (auto *client : this->clients_) { - client->parse_devices(&this->scan_ring_buffer_[read_idx], batch_size); - } - } - - // Process individual results for parsed advertisements - if (this->parse_advertisements_) { -#ifdef USE_ESP32_BLE_DEVICE - for (size_t i = 0; i < batch_size; i++) { - BLEScanResult &scan_result = this->scan_ring_buffer_[read_idx + i]; - ESPBTDevice device; - device.parse_scan_rst(scan_result); - - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) - found = true; - } - - for (auto *client : this->clients_) { - if (client->parse_device(device)) { - found = true; - if (!connecting && client->state() == ClientState::DISCOVERED) { - promote_to_connecting = true; - } - } - } - - if (!found && !this->scan_continuous_) { - this->print_bt_device_info(device); - } - } -#endif // USE_ESP32_BLE_DEVICE - } - - // Update read index for entire batch - read_idx = (read_idx + batch_size) % SCAN_RESULT_BUFFER_SIZE; - - // Store with release to ensure reads complete before index update - this->ring_read_index_.store(read_idx, std::memory_order_release); - } - - // Log dropped results periodically - size_t dropped = this->scan_results_dropped_.exchange(0, std::memory_order_relaxed); - if (dropped > 0) { - ESP_LOGW(TAG, "Dropped %zu BLE scan results due to buffer overflow", dropped); - } - } + // All scan result processing is now done immediately in gap_scan_event_handler + // No ring buffer processing needed here if (this->scanner_state_ == ScannerState::FAILED || (this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) { this->stop_scan_(); @@ -229,8 +156,10 @@ void ESP32BLETracker::loop() { } // If there is a discovered client and no connecting // clients and no clients using the scanner to search for - // devices, then stop scanning and promote the discovered - // client to ready to connect. + // devices, then promote the discovered client to ready to connect. + // Note: Scanning is already stopped by gap_scan_event_handler when + // a discovered client is found, so we only need to handle promotion + // when the scanner is IDLE. if (promote_to_connecting && (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::IDLE)) { for (auto *client : this->clients_) { @@ -390,31 +319,18 @@ void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_ga void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { // Note: This handler is called from the main loop context via esp32_ble's event queue. - // However, we still use a lock-free ring buffer to batch results efficiently. + // We process advertisements immediately instead of buffering them. ESP_LOGV(TAG, "gap_scan_result - event %d", scan_result.search_evt); if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { - // Ring buffer write (Producer side) - // Even though we're in the main loop, the ring buffer design allows efficient batching - // IMPORTANT: Only this thread writes to ring_write_index_ + // Process the scan result immediately + bool found_discovered_client = this->process_scan_result_(scan_result); - // Load our own index with relaxed ordering (we're the only writer) - uint8_t write_idx = this->ring_write_index_.load(std::memory_order_relaxed); - uint8_t next_write_idx = (write_idx + 1) % SCAN_RESULT_BUFFER_SIZE; - - // Load consumer's index with acquire to see their latest updates - uint8_t read_idx = this->ring_read_index_.load(std::memory_order_acquire); - - // Check if buffer is full - if (next_write_idx != read_idx) { - // Write to ring buffer - this->scan_ring_buffer_[write_idx] = scan_result; - - // Store with release to ensure the write is visible before index update - this->ring_write_index_.store(next_write_idx, std::memory_order_release); - } else { - // Buffer full, track dropped results - this->scan_results_dropped_.fetch_add(1, std::memory_order_relaxed); + // If we found a discovered client that needs promotion, stop scanning + // This replaces the promote_to_connecting logic from loop() + if (found_discovered_client && this->scanner_state_ == ScannerState::RUNNING) { + ESP_LOGD(TAG, "Found discovered client, stopping scan for connection"); + this->stop_scan_(); } } else if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { // Scan finished on its own @@ -859,8 +775,66 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); } + +bool ESP32BLETracker::has_connecting_clients_() const { + for (auto *client : this->clients_) { + auto state = client->state(); + if (state == ClientState::CONNECTING || state == ClientState::READY_TO_CONNECT) { + return true; + } + } + return false; +} #endif // USE_ESP32_BLE_DEVICE +bool ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { + bool found_discovered_client = false; + + // Process raw advertisements + if (this->raw_advertisements_) { + for (auto *listener : this->listeners_) { + listener->parse_devices(&scan_result, 1); + } + for (auto *client : this->clients_) { + client->parse_devices(&scan_result, 1); + } + } + + // Process parsed advertisements + if (this->parse_advertisements_) { +#ifdef USE_ESP32_BLE_DEVICE + ESPBTDevice device; + device.parse_scan_rst(scan_result); + + bool found = false; + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) + found = true; + } + + for (auto *client : this->clients_) { + if (client->parse_device(device)) { + found = true; + // Check if this client is discovered and needs promotion + if (client->state() == ClientState::DISCOVERED) { + // Only check for connecting clients if we found a discovered client + // This matches the original logic: !connecting && client->state() == DISCOVERED + if (!this->has_connecting_clients_()) { + found_discovered_client = true; + } + } + } + } + + if (!found && !this->scan_continuous_) { + this->print_bt_device_info(device); + } +#endif // USE_ESP32_BLE_DEVICE + } + + return found_discovered_client; +} + void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { ESP_LOGD(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); this->already_discovered_.clear(); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index c274e64b12..1c28bc7a7d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -6,7 +6,6 @@ #include "esphome/core/helpers.h" #include -#include #include #include @@ -21,6 +20,7 @@ #include "esphome/components/esp32_ble/ble.h" #include "esphome/components/esp32_ble/ble_uuid.h" +#include "esphome/components/esp32_ble/ble_scan_result.h" namespace esphome::esp32_ble_tracker { @@ -272,6 +272,13 @@ class ESP32BLETracker : public Component, void set_scanner_state_(ScannerState state); /// Common cleanup logic when transitioning scanner to IDLE state void cleanup_scan_state_(bool is_stop_complete); + /// Process a single scan result immediately + /// Returns true if a discovered client needs promotion to READY_TO_CONNECT + bool process_scan_result_(const BLEScanResult &scan_result); +#ifdef USE_ESP32_BLE_DEVICE + /// Check if any clients are in connecting or ready to connect state + bool has_connecting_clients_() const; +#endif uint8_t app_id_{0}; @@ -295,15 +302,6 @@ class ESP32BLETracker : public Component, bool raw_advertisements_{false}; bool parse_advertisements_{false}; - // Lock-free Single-Producer Single-Consumer (SPSC) ring buffer for scan results - // Producer: ESP-IDF Bluetooth stack callback (gap_scan_event_handler) - // Consumer: ESPHome main loop (loop() method) - // This design ensures zero blocking in the BT callback and prevents scan result loss - BLEScanResult *scan_ring_buffer_; - std::atomic ring_write_index_{0}; // Written only by BT callback (producer) - std::atomic ring_read_index_{0}; // Written only by main loop (consumer) - std::atomic scan_results_dropped_{0}; // Tracks buffer overflow events - esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; int connecting_{0}; From a5f1661643aa04f534d90690b76cd08313e9e5e8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:43:04 +1200 Subject: [PATCH 0313/1626] [nfc] Rename ``binary_sensor`` source files (#10053) --- .../binary_sensor/{binary_sensor.cpp => nfc_binary_sensor.cpp} | 2 +- .../nfc/binary_sensor/{binary_sensor.h => nfc_binary_sensor.h} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename esphome/components/nfc/binary_sensor/{binary_sensor.cpp => nfc_binary_sensor.cpp} (99%) rename esphome/components/nfc/binary_sensor/{binary_sensor.h => nfc_binary_sensor.h} (100%) diff --git a/esphome/components/nfc/binary_sensor/binary_sensor.cpp b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp similarity index 99% rename from esphome/components/nfc/binary_sensor/binary_sensor.cpp rename to esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp index 8f1f6acd51..bc19fa7213 100644 --- a/esphome/components/nfc/binary_sensor/binary_sensor.cpp +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp @@ -1,4 +1,4 @@ -#include "binary_sensor.h" +#include "nfc_binary_sensor.h" #include "../nfc_helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/nfc/binary_sensor/binary_sensor.h b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h similarity index 100% rename from esphome/components/nfc/binary_sensor/binary_sensor.h rename to esphome/components/nfc/binary_sensor/nfc_binary_sensor.h From 3007ca4d57de1dce20e05c471a336bffea2f8bb4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:55:46 +1200 Subject: [PATCH 0314/1626] [core] Move docs url generator to helpers.py (#10056) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/config_validation.py | 12 ++---------- esphome/helpers.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 84ffd9941e..9aaeb9f9e8 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -87,7 +87,7 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) -from esphome.helpers import add_class_to_obj, list_starts_with +from esphome.helpers import add_class_to_obj, docs_url, list_starts_with from esphome.schema_extractors import ( SCHEMA_EXTRACT, schema_extractor, @@ -666,14 +666,6 @@ def only_with_framework( if suggestions is None: suggestions = {} - version = Version.parse(ESPHOME_VERSION) - if version.is_beta: - docs_format = "https://beta.esphome.io/components/{path}" - elif version.is_dev: - docs_format = "https://next.esphome.io/components/{path}" - else: - docs_format = "https://esphome.io/components/{path}" - def validator_(obj): if CORE.target_framework not in frameworks: err_str = f"This feature is only available with framework(s) {', '.join([framework.value for framework in frameworks])}" @@ -681,7 +673,7 @@ def only_with_framework( (component, docs_path) = suggestion err_str += f"\nPlease use '{component}'" if docs_path: - err_str += f": {docs_format.format(path=docs_path)}" + err_str += f": {docs_url(path=f'components/{docs_path}')}" raise Invalid(err_str) return obj diff --git a/esphome/helpers.py b/esphome/helpers.py index d1f3080e34..f722dc3f7c 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -9,6 +9,8 @@ import re import tempfile from urllib.parse import urlparse +from esphome.const import __version__ as ESPHOME_VERSION + _LOGGER = logging.getLogger(__name__) IS_MACOS = platform.system() == "Darwin" @@ -503,3 +505,20 @@ _DISALLOWED_CHARS = re.compile(r"[^a-zA-Z0-9-_]") def sanitize(value): """Same behaviour as `helpers.cpp` method `str_sanitize`.""" return _DISALLOWED_CHARS.sub("_", value) + + +def docs_url(path: str) -> str: + """Return the URL to the documentation for a given path.""" + # Local import to avoid circular import + from esphome.config_validation import Version + + version = Version.parse(ESPHOME_VERSION) + if version.is_beta: + docs_format = "https://beta.esphome.io/{path}" + elif version.is_dev: + docs_format = "https://next.esphome.io/{path}" + else: + docs_format = "https://esphome.io/{path}" + + path = path.removeprefix("/") + return docs_format.format(path=path) From bb3ebaf95572ff48f5633a4bd8777de106e856ce Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:55:54 +1000 Subject: [PATCH 0315/1626] [font] Catch file load exception (#10058) Co-authored-by: clydeps --- esphome/components/font/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 7d9a35647e..4ecc76c561 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -15,6 +15,7 @@ from freetype import ( FT_LOAD_RENDER, FT_LOAD_TARGET_MONO, Face, + FT_Exception, ft_pixel_mode_mono, ) import requests @@ -94,7 +95,14 @@ class FontCache(MutableMapping): return self.store[self._keytransform(item)] def __setitem__(self, key, value): - self.store[self._keytransform(key)] = Face(str(value)) + transformed = self._keytransform(key) + try: + self.store[transformed] = Face(str(value)) + except FT_Exception as exc: + file = transformed.split(":", 1) + raise cv.Invalid( + f"{file[0].capitalize()} {file[1]} is not a valid font file" + ) from exc FONT_CACHE = FontCache() From 7c297366c72709000a573ba76c6fa019f0f86166 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 18:57:59 -1000 Subject: [PATCH 0316/1626] [esp32_ble_tracker] Remove unnecessary STOPPED scanner state to reduce latency (#10055) --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 61 ++++++++----------- .../esp32_ble_tracker/esp32_ble_tracker.h | 16 +++-- 2 files changed, 33 insertions(+), 44 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index e0029ad15b..254eddd1d9 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -185,9 +185,6 @@ void ESP32BLETracker::loop() { ESP_LOGW(TAG, "Dropped %zu BLE scan results due to buffer overflow", dropped); } } - if (this->scanner_state_ == ScannerState::STOPPED) { - this->end_of_scan_(); // Change state to IDLE - } if (this->scanner_state_ == ScannerState::FAILED || (this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) { this->stop_scan_(); @@ -278,8 +275,6 @@ void ESP32BLETracker::stop_scan_() { ESP_LOGE(TAG, "Scan is starting while trying to stop."); } else if (this->scanner_state_ == ScannerState::STOPPING) { ESP_LOGE(TAG, "Scan is already stopping while trying to stop."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan is already stopped while trying to stop."); } return; } @@ -306,8 +301,6 @@ void ESP32BLETracker::start_scan_(bool first) { ESP_LOGE(TAG, "Cannot start scan while already stopping."); } else if (this->scanner_state_ == ScannerState::FAILED) { ESP_LOGE(TAG, "Cannot start scan while already failed."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Cannot start scan while already stopped."); } return; } @@ -342,21 +335,6 @@ void ESP32BLETracker::start_scan_(bool first) { } } -void ESP32BLETracker::end_of_scan_() { - // The lock must be held when calling this function. - if (this->scanner_state_ != ScannerState::STOPPED) { - ESP_LOGE(TAG, "end_of_scan_ called while scanner is not stopped."); - return; - } - ESP_LOGD(TAG, "End of scan, set scanner state to IDLE."); - this->already_discovered_.clear(); - this->cancel_timeout("scan"); - - for (auto *listener : this->listeners_) - listener->on_scan_end(); - this->set_scanner_state_(ScannerState::IDLE); -} - void ESP32BLETracker::register_client(ESPBTClient *client) { client->app_id = ++this->app_id_; this->clients_.push_back(client); @@ -389,6 +367,8 @@ void ESP32BLETracker::recalculate_advertisement_parser_types() { } void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + // Note: This handler is called from the main loop context, not directly from the BT task. + // The esp32_ble component queues events via enqueue_ble_event() and processes them in loop(). switch (event) { case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: this->gap_scan_set_param_complete_(param->scan_param_cmpl); @@ -409,11 +389,13 @@ void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_ga } void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { + // Note: This handler is called from the main loop context via esp32_ble's event queue. + // However, we still use a lock-free ring buffer to batch results efficiently. ESP_LOGV(TAG, "gap_scan_result - event %d", scan_result.search_evt); if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { - // Lock-free SPSC ring buffer write (Producer side) - // This runs in the ESP-IDF Bluetooth stack callback thread + // Ring buffer write (Producer side) + // Even though we're in the main loop, the ring buffer design allows efficient batching // IMPORTANT: Only this thread writes to ring_write_index_ // Load our own index with relaxed ordering (we're the only writer) @@ -445,15 +427,15 @@ void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { ESP_LOGE(TAG, "Scan was in failed state when scan completed."); } else if (this->scanner_state_ == ScannerState::IDLE) { ESP_LOGE(TAG, "Scan was idle when scan completed."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan was stopped when scan completed."); } } - this->set_scanner_state_(ScannerState::STOPPED); + // Scan completed naturally, perform cleanup and transition to IDLE + this->cleanup_scan_state_(false); } } void ESP32BLETracker::gap_scan_set_param_complete_(const esp_ble_gap_cb_param_t::ble_scan_param_cmpl_evt_param ¶m) { + // Called from main loop context via gap_event_handler after being queued from BT task ESP_LOGV(TAG, "gap_scan_set_param_complete - status %d", param.status); if (param.status == ESP_BT_STATUS_DONE) { this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; @@ -463,6 +445,7 @@ void ESP32BLETracker::gap_scan_set_param_complete_(const esp_ble_gap_cb_param_t: } void ESP32BLETracker::gap_scan_start_complete_(const esp_ble_gap_cb_param_t::ble_scan_start_cmpl_evt_param ¶m) { + // Called from main loop context via gap_event_handler after being queued from BT task ESP_LOGV(TAG, "gap_scan_start_complete - status %d", param.status); this->scan_start_failed_ = param.status; if (this->scanner_state_ != ScannerState::STARTING) { @@ -474,8 +457,6 @@ void ESP32BLETracker::gap_scan_start_complete_(const esp_ble_gap_cb_param_t::ble ESP_LOGE(TAG, "Scan was in failed state when start complete."); } else if (this->scanner_state_ == ScannerState::IDLE) { ESP_LOGE(TAG, "Scan was idle when start complete."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan was stopped when start complete."); } } if (param.status == ESP_BT_STATUS_SUCCESS) { @@ -490,6 +471,8 @@ void ESP32BLETracker::gap_scan_start_complete_(const esp_ble_gap_cb_param_t::ble } void ESP32BLETracker::gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_scan_stop_cmpl_evt_param ¶m) { + // Called from main loop context via gap_event_handler after being queued from BT task + // This allows us to safely transition to IDLE state and perform cleanup without race conditions ESP_LOGV(TAG, "gap_scan_stop_complete - status %d", param.status); if (this->scanner_state_ != ScannerState::STOPPING) { if (this->scanner_state_ == ScannerState::RUNNING) { @@ -500,11 +483,11 @@ void ESP32BLETracker::gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_ ESP_LOGE(TAG, "Scan was in failed state when stop complete."); } else if (this->scanner_state_ == ScannerState::IDLE) { ESP_LOGE(TAG, "Scan was idle when stop complete."); - } else if (this->scanner_state_ == ScannerState::STOPPED) { - ESP_LOGE(TAG, "Scan was stopped when stop complete."); } } - this->set_scanner_state_(ScannerState::STOPPED); + + // Perform cleanup and transition to IDLE + this->cleanup_scan_state_(true); } void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, @@ -794,9 +777,6 @@ void ESP32BLETracker::dump_config() { case ScannerState::STOPPING: ESP_LOGCONFIG(TAG, " Scanner State: STOPPING"); break; - case ScannerState::STOPPED: - ESP_LOGCONFIG(TAG, " Scanner State: STOPPED"); - break; case ScannerState::FAILED: ESP_LOGCONFIG(TAG, " Scanner State: FAILED"); break; @@ -881,6 +861,17 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { } #endif // USE_ESP32_BLE_DEVICE +void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { + ESP_LOGD(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); + this->already_discovered_.clear(); + this->cancel_timeout("scan"); + + for (auto *listener : this->listeners_) + listener->on_scan_end(); + + this->set_scanner_state_(ScannerState::IDLE); +} + } // namespace esphome::esp32_ble_tracker #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index e1119c0e18..c274e64b12 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -158,18 +158,16 @@ enum class ClientState : uint8_t { }; enum class ScannerState { - // Scanner is idle, init state, set from the main loop when processing STOPPED + // Scanner is idle, init state IDLE, - // Scanner is starting, set from the main loop only + // Scanner is starting STARTING, - // Scanner is running, set from the ESP callback only + // Scanner is running RUNNING, - // Scanner failed to start, set from the ESP callback only + // Scanner failed to start FAILED, - // Scanner is stopping, set from the main loop only + // Scanner is stopping STOPPING, - // Scanner is stopped, set from the ESP callback only - STOPPED, }; enum class ConnectionType : uint8_t { @@ -262,8 +260,6 @@ class ESP32BLETracker : public Component, void stop_scan_(); /// Start a single scan by setting up the parameters and doing some esp-idf calls. void start_scan_(bool first); - /// Called when a scan ends - void end_of_scan_(); /// Called when a `ESP_GAP_BLE_SCAN_RESULT_EVT` event is received. void gap_scan_result_(const esp_ble_gap_cb_param_t::ble_scan_result_evt_param ¶m); /// Called when a `ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT` event is received. @@ -274,6 +270,8 @@ class ESP32BLETracker : public Component, void gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_scan_stop_cmpl_evt_param ¶m); /// Called to set the scanner state. Will also call callbacks to let listeners know when state is changed. void set_scanner_state_(ScannerState state); + /// Common cleanup logic when transitioning scanner to IDLE state + void cleanup_scan_state_(bool is_stop_complete); uint8_t app_id_{0}; From 989058e6a94b97dd76472ac40b2d6f7cccd4f9b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 19:12:06 -1000 Subject: [PATCH 0317/1626] [esp32_ble_client] Use FAST connection parameters for all v3 connections (#10052) --- .../esp32_ble_client/ble_client_base.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 94f2a6073c..031cb41e6d 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -16,8 +16,8 @@ static const char *const TAG = "esp32_ble_client"; // Intermediate connection parameters for standard operation // ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies, // causing disconnections. These medium parameters balance responsiveness with bandwidth usage. -static const uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x08; // 8 * 1.25ms = 10ms -static const uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x0A; // 10 * 1.25ms = 12.5ms +static const uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms +static const uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms // The timeout value was increased from 6s to 8s to address stability issues observed // in certain BLE devices when operating through WiFi-based BLE proxies. The longer // timeout reduces the likelihood of disconnections during periods of high latency. @@ -157,12 +157,13 @@ void BLEClientBase::connect() { this->set_state(espbt::ClientState::CONNECTING); // Always set connection parameters to ensure stable operation - // Use FAST for V3_WITHOUT_CACHE (devices that need lowest latency) - // Use MEDIUM for all other connections (balanced performance) + // Use FAST for all V3 connections (better latency and reliability) + // Use MEDIUM for V1/legacy connections (balanced performance) uint16_t min_interval, max_interval, timeout; const char *param_type; - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || + this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { min_interval = FAST_MIN_CONN_INTERVAL; max_interval = FAST_MAX_CONN_INTERVAL; timeout = FAST_CONN_TIMEOUT; @@ -411,9 +412,10 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); - // For non-cached connections, restore to medium connection parameters after service discovery + // For V3 connections, restore to medium connection parameters after service discovery // This balances performance with bandwidth usage after the critical discovery phase - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || + this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; From 6be22a5ea9c9b03ee125e4d77aceb90a77154a1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 19:15:28 -1000 Subject: [PATCH 0318/1626] [esp32_ble_client] Connect immediately on READY_TO_CONNECT to reduce latency (#10051) --- esphome/components/esp32_ble_client/ble_client_base.cpp | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 031cb41e6d..2c13995f76 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -45,8 +45,10 @@ void BLEClientBase::set_state(espbt::ClientState st) { ESPBTClient::set_state(st); if (st == espbt::ClientState::READY_TO_CONNECT) { - // Enable loop when we need to connect + // Enable loop for state processing this->enable_loop(); + // Connect immediately instead of waiting for next loop + this->connect(); } } @@ -63,11 +65,6 @@ void BLEClientBase::loop() { } this->set_state(espbt::ClientState::IDLE); } - // READY_TO_CONNECT means we have discovered the device - // and the scanner has been stopped by the tracker. - else if (this->state_ == espbt::ClientState::READY_TO_CONNECT) { - this->connect(); - } // If its idle, we can disable the loop as set_state // will enable it again when we need to connect. else if (this->state_ == espbt::ClientState::IDLE) { From 36c44303172b60acdd2339b224ebfda6957f4b0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 19:41:41 -1000 Subject: [PATCH 0319/1626] [esp32_ble] Fix BLE connection slot waste by aligning ESP-IDF timeout with client timeout (#10013) --- esphome/components/esp32_ble/__init__.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 93bb643596..1c7c075cfa 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -6,7 +6,7 @@ import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ESPHOME, CONF_ID, CONF_NAME -from esphome.core import CORE +from esphome.core import CORE, TimePeriod from esphome.core.config import CONF_NAME_ADD_MAC_SUFFIX import esphome.final_validate as fv @@ -117,6 +117,7 @@ CONF_BLE_ID = "ble_id" CONF_IO_CAPABILITY = "io_capability" CONF_ADVERTISING_CYCLE_TIME = "advertising_cycle_time" CONF_DISABLE_BT_LOGS = "disable_bt_logs" +CONF_CONNECTION_TIMEOUT = "connection_timeout" NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] @@ -167,6 +168,11 @@ CONFIG_SCHEMA = cv.Schema( cv.SplitDefault(CONF_DISABLE_BT_LOGS, esp32_idf=True): cv.All( cv.only_with_esp_idf, cv.boolean ), + cv.SplitDefault(CONF_CONNECTION_TIMEOUT, esp32_idf="20s"): cv.All( + cv.only_with_esp_idf, + cv.positive_time_period_seconds, + cv.Range(min=TimePeriod(seconds=10), max=TimePeriod(seconds=180)), + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -255,6 +261,17 @@ async def to_code(config): if logger not in _required_loggers: add_idf_sdkconfig_option(f"{logger.value}_NONE", True) + # Set BLE connection establishment timeout to match aioesphomeapi/bleak-retry-connector + # Default is 20 seconds instead of ESP-IDF's 30 seconds. Because there is no way to + # cancel a BLE connection in progress, when aioesphomeapi times out at 20 seconds, + # the connection slot remains occupied for the remaining time, preventing new connection + # attempts and wasting valuable connection slots. + if CONF_CONNECTION_TIMEOUT in config: + timeout_seconds = int(config[CONF_CONNECTION_TIMEOUT].total_seconds) + add_idf_sdkconfig_option( + "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds + ) + cg.add_define("USE_ESP32_BLE") From fffa9b813cebe9238187249a9c2349e67b7e8704 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 19:51:18 -1000 Subject: [PATCH 0320/1626] [esp32_ble_client] Fix connection parameter timing by setting preferences before connection --- .../esp32_ble_client/ble_client_base.cpp | 86 +++++++++++-------- .../esp32_ble_client/ble_client_base.h | 1 + 2 files changed, 49 insertions(+), 38 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 2c13995f76..ea5f5d73c6 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -145,6 +145,36 @@ void BLEClientBase::connect() { this->remote_addr_type_); this->paired_ = false; + // Set preferred connection parameters before connecting + // Use FAST for all V3 connections (better latency and reliability) + // Use MEDIUM for V1/legacy connections (balanced performance) + uint16_t min_interval, max_interval, timeout; + const char *param_type; + + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || + this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { + min_interval = FAST_MIN_CONN_INTERVAL; + max_interval = FAST_MAX_CONN_INTERVAL; + timeout = FAST_CONN_TIMEOUT; + param_type = "fast"; + } else { + min_interval = MEDIUM_MIN_CONN_INTERVAL; + max_interval = MEDIUM_MAX_CONN_INTERVAL; + timeout = MEDIUM_CONN_TIMEOUT; + param_type = "medium"; + } + + auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, + 0, // latency: 0 + timeout); + if (param_ret != ESP_OK) { + ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, + this->address_str_.c_str(), param_ret); + } else { + ESP_LOGD(TAG, "[%d] [%s] Set %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); + } + + // Now open the connection auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); if (ret) { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_open error, status=%d", this->connection_index_, this->address_str_.c_str(), @@ -152,35 +182,6 @@ void BLEClientBase::connect() { this->set_state(espbt::ClientState::IDLE); } else { this->set_state(espbt::ClientState::CONNECTING); - - // Always set connection parameters to ensure stable operation - // Use FAST for all V3 connections (better latency and reliability) - // Use MEDIUM for V1/legacy connections (balanced performance) - uint16_t min_interval, max_interval, timeout; - const char *param_type; - - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - min_interval = FAST_MIN_CONN_INTERVAL; - max_interval = FAST_MAX_CONN_INTERVAL; - timeout = FAST_CONN_TIMEOUT; - param_type = "fast"; - } else { - min_interval = MEDIUM_MIN_CONN_INTERVAL; - max_interval = MEDIUM_MAX_CONN_INTERVAL; - timeout = MEDIUM_CONN_TIMEOUT; - param_type = "medium"; - } - - auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, - 0, // latency: 0 - timeout); - if (param_ret != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, - this->address_str_.c_str(), param_ret); - } else { - ESP_LOGD(TAG, "[%d] [%s] Set %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); - } } } @@ -255,6 +256,19 @@ void BLEClientBase::log_event_(const char *name) { ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), name); } +void BLEClientBase::restore_medium_conn_params_() { + // Restore to medium connection parameters after initial connection phase + // This balances performance with bandwidth usage for normal operation + esp_ble_conn_update_params_t conn_params = {{0}}; + memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; + conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; + conn_params.latency = 0; + conn_params.timeout = MEDIUM_CONN_TIMEOUT; + ESP_LOGD(TAG, "[%d] [%s] Restoring medium conn params", this->connection_index_, this->address_str_.c_str()); + esp_ble_gap_update_conn_params(&conn_params); +} + bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, esp_ble_gattc_cb_param_t *param) { if (event == ESP_GATTC_REG_EVT && this->app_id != param->reg.app_id) @@ -326,6 +340,10 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { ESP_LOGI(TAG, "[%d] [%s] Using cached services", this->connection_index_, this->address_str_.c_str()); + + // Restore to medium connection parameters for cached connections too + this->restore_medium_conn_params_(); + // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; @@ -413,15 +431,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // This balances performance with bandwidth usage after the critical discovery phase if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - esp_ble_conn_update_params_t conn_params = {{0}}; - memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); - conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; - conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; - conn_params.latency = 0; - conn_params.timeout = MEDIUM_CONN_TIMEOUT; - ESP_LOGD(TAG, "[%d] [%s] Restored medium conn params after service discovery", this->connection_index_, - this->address_str_.c_str()); - esp_ble_gap_update_conn_params(&conn_params); + this->restore_medium_conn_params_(); } this->state_ = espbt::ClientState::ESTABLISHED; diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 0a2fda4476..b30e9cd444 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -127,6 +127,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { // 6 bytes used, 2 bytes padding void log_event_(const char *name); + void restore_medium_conn_params_(); }; } // namespace esp32_ble_client From 8d4f1802fb8de02f61ab03450f25caed613ad2c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 20:57:02 -1000 Subject: [PATCH 0321/1626] [esp32_ble_tracker] Optimize connection by promoting client immediately after scan stop trigger --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 254eddd1d9..ef4e6802cc 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -238,19 +238,19 @@ void ESP32BLETracker::loop() { if (this->scanner_state_ == ScannerState::RUNNING) { ESP_LOGD(TAG, "Stopping scan to make connection"); this->stop_scan_(); - } else if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGD(TAG, "Promoting client to connect"); - // We only want to promote one client at a time. - // once the scanner is fully stopped. -#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); - if (!this->coex_prefer_ble_) { - this->coex_prefer_ble_ = true; - esp_coex_preference_set(ESP_COEX_PREFER_BT); // Prioritize Bluetooth - } -#endif - client->set_state(ClientState::READY_TO_CONNECT); + // Don't wait for scan stop complete - promote immediately + // The BLE stack processes commands in order through its queue } + + ESP_LOGD(TAG, "Promoting client to connect"); +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); + if (!this->coex_prefer_ble_) { + this->coex_prefer_ble_ = true; + esp_coex_preference_set(ESP_COEX_PREFER_BT); // Prioritize Bluetooth + } +#endif + client->set_state(ClientState::READY_TO_CONNECT); break; } } From dd80fcdb62d240ad82ec0999fb959b474f4eda6f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 21:22:38 -1000 Subject: [PATCH 0322/1626] Update esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index ef4e6802cc..9e41fc80c5 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -238,8 +238,10 @@ void ESP32BLETracker::loop() { if (this->scanner_state_ == ScannerState::RUNNING) { ESP_LOGD(TAG, "Stopping scan to make connection"); this->stop_scan_(); - // Don't wait for scan stop complete - promote immediately - // The BLE stack processes commands in order through its queue + // Don't wait for scan stop complete - promote immediately. + // This is safe because ESP-IDF processes BLE commands sequentially through its internal mailbox queue. + // This guarantees that the stop scan command will be fully processed before any subsequent connect command, + // preventing race conditions or overlapping operations. } ESP_LOGD(TAG, "Promoting client to connect"); From e6629f662c8de6f490ed8405e2fe87439df2cdb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 21:41:15 -1000 Subject: [PATCH 0323/1626] [esp32_ble_client] Start MTU negotiation earlier following ESP-IDF examples --- .../esp32_ble_client/ble_client_base.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 2c13995f76..9b07033cfc 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -283,7 +283,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (!this->check_addr(param->open.remote_bda)) return false; this->log_event_("ESP_GATTC_OPEN_EVT"); - this->conn_id_ = param->open.conn_id; + // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; if (this->state_ != espbt::ClientState::CONNECTING) { // This should not happen but lets log it in case it does @@ -317,11 +317,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->conn_id_ = UNSET_CONN_ID; break; } - auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->open.conn_id); - if (ret) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_send_mtu_req failed, status=%x", this->connection_index_, - this->address_str_.c_str(), ret); - } + // MTU negotiation already started in ESP_GATTC_CONNECT_EVT this->set_state(espbt::ClientState::CONNECTED); ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { @@ -338,6 +334,16 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (!this->check_addr(param->connect.remote_bda)) return false; this->log_event_("ESP_GATTC_CONNECT_EVT"); + this->conn_id_ = param->connect.conn_id; + // Start MTU negotiation immediately as recommended by ESP-IDF examples + // (gatt_client, ble_throughput) which call esp_ble_gattc_send_mtu_req in + // ESP_GATTC_CONNECT_EVT instead of waiting for ESP_GATTC_OPEN_EVT. + // This saves ~3ms in the connection process. + auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id); + if (ret) { + ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_send_mtu_req failed, status=%x", this->connection_index_, + this->address_str_.c_str(), ret); + } break; } case ESP_GATTC_DISCONNECT_EVT: { From 2bc77be5abb8105110907c3cff1e04008993e0c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 22:16:40 -1000 Subject: [PATCH 0324/1626] [bluetooth_proxy] Warn about BLE connection timeout mismatch on Arduino framework --- .../components/bluetooth_proxy/__init__.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index ec1df6a06c..f1b9ab3a2a 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -1,14 +1,20 @@ +import logging + import esphome.codegen as cg from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.esp32_ble import BTLoggers import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_ID +from esphome.core import CORE +from esphome.log import AnsiFore, color AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"] DEPENDENCIES = ["api", "esp32"] CODEOWNERS = ["@jesserockz"] +_LOGGER = logging.getLogger(__name__) + CONF_CONNECTION_SLOTS = "connection_slots" CONF_CACHE_SERVICES = "cache_services" CONF_CONNECTIONS = "connections" @@ -41,6 +47,26 @@ def validate_connections(config): esp32_ble_tracker.consume_connection_slots(connection_slots, "bluetooth_proxy")( config ) + + # Warn about connection slot waste when using Arduino framework + if CORE.using_arduino and connection_slots: + _LOGGER.warning( + "Bluetooth Proxy with active connections on Arduino framework has suboptimal performance.\n" + "If BLE connections fail, they can waste connection slots for 10 seconds because\n" + "Arduino doesn't allow configuring the BLE connection timeout (fixed at 30s).\n" + "ESP-IDF framework allows setting it to 20s to match client timeouts.\n" + "\n" + "To switch to ESP-IDF, add this to your YAML:\n" + " esp32:\n" + " framework:\n" + " type: esp-idf\n" + "\n" + "For detailed migration instructions, see:\n" + + color( + AnsiFore.BLUE, "https://esphome.io/guides/esp32_arduino_to_idf.html" + ) + ) + return { **config, CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)], From 081f0a187162bee1de0fc7b17308d824b3a2c121 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 22:19:48 -1000 Subject: [PATCH 0325/1626] Update esphome/components/bluetooth_proxy/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/bluetooth_proxy/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index f1b9ab3a2a..4673620ed9 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -62,9 +62,7 @@ def validate_connections(config): " type: esp-idf\n" "\n" "For detailed migration instructions, see:\n" - + color( - AnsiFore.BLUE, "https://esphome.io/guides/esp32_arduino_to_idf.html" - ) + f"{color(AnsiFore.BLUE, 'https://esphome.io/guides/esp32_arduino_to_idf.html')}" ) return { From 74c0e63a1df5571e3115b55c500fba123c5ccbba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 22:31:27 -1000 Subject: [PATCH 0326/1626] cleanup --- esphome/components/bluetooth_proxy/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 4673620ed9..4087255410 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -62,7 +62,10 @@ def validate_connections(config): " type: esp-idf\n" "\n" "For detailed migration instructions, see:\n" - f"{color(AnsiFore.BLUE, 'https://esphome.io/guides/esp32_arduino_to_idf.html')}" + "%s", + color( + AnsiFore.BLUE, "https://esphome.io/guides/esp32_arduino_to_idf.html" + ), ) return { From fbbb791b0dda790e6e6443766f0a04991bf2d73d Mon Sep 17 00:00:00 2001 From: Djordje Mandic <6750655+DjordjeMandic@users.noreply.github.com> Date: Mon, 4 Aug 2025 10:37:43 +0200 Subject: [PATCH 0327/1626] [gt911] Use timeout instead of delay, shortened log msg (#10024) Co-authored-by: Keith Burzinski --- .../gt911/touchscreen/gt911_touchscreen.cpp | 30 +++++++++++++------ .../gt911/touchscreen/gt911_touchscreen.h | 28 +++++++++++++++-- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 0319b083ef..07218843dd 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -20,12 +20,11 @@ static const size_t MAX_BUTTONS = 4; // max number of buttons scanned #define ERROR_CHECK(err) \ if ((err) != i2c::ERROR_OK) { \ - this->status_set_warning("Communication failure"); \ + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); \ return; \ } void GT911Touchscreen::setup() { - i2c::ErrorCode err; if (this->reset_pin_ != nullptr) { this->reset_pin_->setup(); this->reset_pin_->digital_write(false); @@ -35,9 +34,14 @@ void GT911Touchscreen::setup() { this->interrupt_pin_->digital_write(false); } delay(2); - this->reset_pin_->digital_write(true); - delay(50); // NOLINT + this->reset_pin_->digital_write(true); // wait 50ms after reset + this->set_timeout(50, [this] { this->setup_internal_(); }); + return; } + this->setup_internal_(); +} + +void GT911Touchscreen::setup_internal_() { if (this->interrupt_pin_ != nullptr) { // set pre-configured input mode this->interrupt_pin_->setup(); @@ -45,7 +49,7 @@ void GT911Touchscreen::setup() { // check the configuration of the int line. uint8_t data[4]; - err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES)); + i2c::ErrorCode err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES)); if (err != i2c::ERROR_OK && this->address_ == PRIMARY_ADDRESS) { this->address_ = SECONDARY_ADDRESS; err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES)); @@ -53,7 +57,7 @@ void GT911Touchscreen::setup() { if (err == i2c::ERROR_OK) { err = this->read(data, 1); if (err == i2c::ERROR_OK) { - ESP_LOGD(TAG, "Read from switches at address 0x%02X: 0x%02X", this->address_, data[0]); + ESP_LOGD(TAG, "Switches ADDR: 0x%02X DATA: 0x%02X", this->address_, data[0]); if (this->interrupt_pin_ != nullptr) { this->attach_interrupt_(this->interrupt_pin_, (data[0] & 1) ? gpio::INTERRUPT_FALLING_EDGE : gpio::INTERRUPT_RISING_EDGE); @@ -75,16 +79,24 @@ void GT911Touchscreen::setup() { } } if (err != i2c::ERROR_OK) { - this->mark_failed("Failed to read calibration"); + this->mark_failed("Calibration error"); return; } } + if (err != i2c::ERROR_OK) { - this->mark_failed("Failed to communicate"); + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + return; } + this->setup_done_ = true; } void GT911Touchscreen::update_touches() { + this->skip_update_ = true; // skip send touch events by default, set to false after successful error checks + if (!this->setup_done_) { + return; + } + i2c::ErrorCode err; uint8_t touch_state = 0; uint8_t data[MAX_TOUCHES + 1][8]; // 8 bytes each for each point, plus extra space for the key byte @@ -97,7 +109,6 @@ void GT911Touchscreen::update_touches() { uint8_t num_of_touches = touch_state & 0x07; if ((touch_state & 0x80) == 0 || num_of_touches > MAX_TOUCHES) { - this->skip_update_ = true; // skip send touch events, touchscreen is not ready yet. return; } @@ -107,6 +118,7 @@ void GT911Touchscreen::update_touches() { err = this->read(data[0], sizeof(data[0]) * num_of_touches + 1); ERROR_CHECK(err); + this->skip_update_ = false; // All error checks passed, send touch events for (uint8_t i = 0; i != num_of_touches; i++) { uint16_t id = data[i][0]; uint16_t x = encode_uint16(data[i][2], data[i][1]); diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.h b/esphome/components/gt911/touchscreen/gt911_touchscreen.h index 17636a2ada..85025b5522 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.h +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.h @@ -15,8 +15,20 @@ class GT911ButtonListener { class GT911Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { public: + /// @brief Initialize the GT911 touchscreen. + /// + /// If @ref reset_pin_ is set, the touchscreen will be hardware reset, + /// and the rest of the setup will be scheduled to run 50ms later using @ref set_timeout() + /// to allow the device to stabilize after reset. + /// + /// If @ref interrupt_pin_ is set, it will be temporarily configured during reset + /// to control I2C address selection. + /// + /// After the timeout, or immediately if no reset is performed, @ref setup_internal_() + /// is called to complete the initialization. void setup() override; void dump_config() override; + bool can_proceed() override { return this->setup_done_; } void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } @@ -25,8 +37,20 @@ class GT911Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice protected: void update_touches() override; - InternalGPIOPin *interrupt_pin_{}; - GPIOPin *reset_pin_{}; + /// @brief Perform the internal setup routine for the GT911 touchscreen. + /// + /// This function checks the I2C address, configures the interrupt pin (if available), + /// reads the touchscreen mode from the controller, and attempts to read calibration + /// data (maximum X and Y values) if not already set. + /// + /// On success, sets @ref setup_done_ to true. + /// On failure, calls @ref mark_failed() with an appropriate error message. + void setup_internal_(); + /// @brief True if the touchscreen setup has completed successfully. + bool setup_done_{false}; + + InternalGPIOPin *interrupt_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; std::vector button_listeners_; uint8_t button_state_{0xFF}; // last button state. Initial FF guarantees first update. }; From 608cc4f0d1368762c6f188b57cc47accf59173e6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 4 Aug 2025 08:45:04 -0400 Subject: [PATCH 0328/1626] Fix 5.5 compile issues --- esphome/components/espnow/espnow_packet.h | 2 +- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h index d39f7d2c24..b6192a0d41 100644 --- a/esphome/components/espnow/espnow_packet.h +++ b/esphome/components/espnow/espnow_packet.h @@ -49,7 +49,7 @@ class ESPNowPacket { #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) // Constructor for sent data ESPNowPacket(const esp_now_send_info_t *info, esp_now_send_status_t status) { - this->init_sent_data(info->src_addr, status); + this->init_sent_data_(info->src_addr, status); } #else // Constructor for sent data diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 734259093e..a8c5887f6b 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -116,7 +116,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } // Handle regular form data - if (r->content_len > HTTPD_MAX_REQ_HDR_LEN) { + if (r->content_len > CONFIG_HTTPD_MAX_REQ_HDR_LEN) { ESP_LOGW(TAG, "Request size is to big: %zu", r->content_len); httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); return ESP_FAIL; From d59476d0e102ba833eaa3a0d242d931a44fa245a Mon Sep 17 00:00:00 2001 From: Chris Beswick Date: Mon, 4 Aug 2025 15:43:44 +0100 Subject: [PATCH 0329/1626] [i2s_audio] Use high-pass filter for dc offset correction (#10005) --- .../microphone/i2s_audio_microphone.cpp | 64 +++++++++++++------ .../microphone/i2s_audio_microphone.h | 3 +- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 5ca33b3493..cdebc214e2 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -24,9 +24,6 @@ static const uint32_t READ_DURATION_MS = 16; static const size_t TASK_STACK_SIZE = 4096; static const ssize_t TASK_PRIORITY = 23; -// Use an exponential moving average to correct a DC offset with weight factor 1/1000 -static const int32_t DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR = 1000; - static const char *const TAG = "i2s_audio.microphone"; enum MicrophoneEventGroupBits : uint32_t { @@ -381,26 +378,57 @@ void I2SAudioMicrophone::mic_task(void *params) { } void I2SAudioMicrophone::fix_dc_offset_(std::vector &data) { + /** + * From https://www.musicdsp.org/en/latest/Filters/135-dc-filter.html: + * + * y(n) = x(n) - x(n-1) + R * y(n-1) + * R = 1 - (pi * 2 * frequency / samplerate) + * + * From https://en.wikipedia.org/wiki/Hearing_range: + * The human range is commonly given as 20Hz up. + * + * From https://en.wikipedia.org/wiki/High-resolution_audio: + * A reasonable upper bound for sample rate seems to be 96kHz. + * + * Calculate R value for 20Hz on a 96kHz sample rate: + * R = 1 - (pi * 2 * 20 / 96000) + * R = 0.9986910031 + * + * Transform floating point to bit-shifting approximation: + * output = input - prev_input + R * prev_output + * output = input - prev_input + (prev_output - (prev_output >> S)) + * + * Approximate bit-shift value S from R: + * R = 1 - (1 >> S) + * R = 1 - (1 / 2^S) + * R = 1 - 2^-S + * 0.9986910031 = 1 - 2^-S + * S = 9.57732 ~= 10 + * + * Actual R from S: + * R = 1 - 2^-10 = 0.9990234375 + * + * Confirm this has effect outside human hearing on 96000kHz sample: + * 0.9990234375 = 1 - (pi * 2 * f / 96000) + * f = 14.9208Hz + * + * Confirm this has effect outside human hearing on PDM 16kHz sample: + * 0.9990234375 = 1 - (pi * 2 * f / 16000) + * f = 2.4868Hz + * + */ + const uint8_t dc_filter_shift = 10; const size_t bytes_per_sample = this->audio_stream_info_.samples_to_bytes(1); const uint32_t total_samples = this->audio_stream_info_.bytes_to_samples(data.size()); - - if (total_samples == 0) { - return; - } - - int64_t offset_accumulator = 0; for (uint32_t sample_index = 0; sample_index < total_samples; ++sample_index) { const uint32_t byte_index = sample_index * bytes_per_sample; - int32_t sample = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample); - offset_accumulator += sample; - sample -= this->dc_offset_; - audio::pack_q31_as_audio_sample(sample, &data[byte_index], bytes_per_sample); + int32_t input = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample); + int32_t output = input - this->dc_offset_prev_input_ + + (this->dc_offset_prev_output_ - (this->dc_offset_prev_output_ >> dc_filter_shift)); + this->dc_offset_prev_input_ = input; + this->dc_offset_prev_output_ = output; + audio::pack_q31_as_audio_sample(output, &data[byte_index], bytes_per_sample); } - - const int32_t new_offset = offset_accumulator / total_samples; - this->dc_offset_ = new_offset / DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR + - (DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR - 1) * this->dc_offset_ / - DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR; } size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait) { diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 633bd0e7dd..de272ba23d 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -82,7 +82,8 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub bool correct_dc_offset_; bool locked_driver_{false}; - int32_t dc_offset_{0}; + int32_t dc_offset_prev_input_{0}; + int32_t dc_offset_prev_output_{0}; }; } // namespace i2s_audio From 701e6099aa42cc84be629cc0b6258992fe3aaef2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:56:34 -0400 Subject: [PATCH 0330/1626] [espnow, web_server_idf] Fix IDF 5.5 compile issues (#10068) --- esphome/components/espnow/espnow_packet.h | 2 +- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h index d39f7d2c24..b6192a0d41 100644 --- a/esphome/components/espnow/espnow_packet.h +++ b/esphome/components/espnow/espnow_packet.h @@ -49,7 +49,7 @@ class ESPNowPacket { #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) // Constructor for sent data ESPNowPacket(const esp_now_send_info_t *info, esp_now_send_status_t status) { - this->init_sent_data(info->src_addr, status); + this->init_sent_data_(info->src_addr, status); } #else // Constructor for sent data diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 40fb015b99..3226444b1b 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -116,7 +116,7 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { } // Handle regular form data - if (r->content_len > HTTPD_MAX_REQ_HDR_LEN) { + if (r->content_len > CONFIG_HTTPD_MAX_REQ_HDR_LEN) { ESP_LOGW(TAG, "Request size is to big: %zu", r->content_len); httpd_resp_send_err(r, HTTPD_400_BAD_REQUEST, nullptr); return ESP_FAIL; From 739cc5ff500ca42faba465f928d2b477b6dd9ad5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 09:24:22 -1000 Subject: [PATCH 0331/1626] [esp32_ble_tracker] Refactor loop() method for improved readability and performance --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 314 ++++++++---------- .../esp32_ble_tracker/esp32_ble_tracker.h | 67 +++- 2 files changed, 194 insertions(+), 187 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 254eddd1d9..9143f25a25 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -49,13 +49,6 @@ void ESP32BLETracker::setup() { ESP_LOGE(TAG, "BLE Tracker was marked failed by ESP32BLE"); return; } - RAMAllocator allocator; - this->scan_ring_buffer_ = allocator.allocate(SCAN_RESULT_BUFFER_SIZE); - - if (this->scan_ring_buffer_ == nullptr) { - ESP_LOGE(TAG, "Could not allocate ring buffer for BLE Tracker!"); - this->mark_failed(); - } global_esp32_ble_tracker = this; @@ -83,124 +76,17 @@ void ESP32BLETracker::loop() { this->start_scan(); } } - int connecting = 0; - int discovered = 0; - int searching = 0; - int disconnecting = 0; - for (auto *client : this->clients_) { - switch (client->state()) { - case ClientState::DISCONNECTING: - disconnecting++; - break; - case ClientState::DISCOVERED: - discovered++; - break; - case ClientState::SEARCHING: - searching++; - break; - case ClientState::CONNECTING: - case ClientState::READY_TO_CONNECT: - connecting++; - break; - default: - break; - } + ClientStateCounts counts = this->count_client_states_(); + if (counts != this->client_state_counts_) { + this->client_state_counts_ = counts; + ESP_LOGD(TAG, "connecting: %d, discovered: %d, searching: %d, disconnecting: %d", + this->client_state_counts_.connecting, this->client_state_counts_.discovered, + this->client_state_counts_.searching, this->client_state_counts_.disconnecting); } - if (connecting != connecting_ || discovered != discovered_ || searching != searching_ || - disconnecting != disconnecting_) { - connecting_ = connecting; - discovered_ = discovered; - searching_ = searching; - disconnecting_ = disconnecting; - ESP_LOGD(TAG, "connecting: %d, discovered: %d, searching: %d, disconnecting: %d", connecting_, discovered_, - searching_, disconnecting_); - } - bool promote_to_connecting = discovered && !searching && !connecting; - // Process scan results from lock-free SPSC ring buffer - // Consumer side: This runs in the main loop thread - if (this->scanner_state_ == ScannerState::RUNNING) { - // Load our own index with relaxed ordering (we're the only writer) - uint8_t read_idx = this->ring_read_index_.load(std::memory_order_relaxed); - - // Load producer's index with acquire to see their latest writes - uint8_t write_idx = this->ring_write_index_.load(std::memory_order_acquire); - - while (read_idx != write_idx) { - // Calculate how many contiguous results we can process in one batch - // If write > read: process all results from read to write - // If write <= read (wraparound): process from read to end of buffer first - size_t batch_size = (write_idx > read_idx) ? (write_idx - read_idx) : (SCAN_RESULT_BUFFER_SIZE - read_idx); - - // Process the batch for raw advertisements - if (this->raw_advertisements_) { - for (auto *listener : this->listeners_) { - listener->parse_devices(&this->scan_ring_buffer_[read_idx], batch_size); - } - for (auto *client : this->clients_) { - client->parse_devices(&this->scan_ring_buffer_[read_idx], batch_size); - } - } - - // Process individual results for parsed advertisements - if (this->parse_advertisements_) { -#ifdef USE_ESP32_BLE_DEVICE - for (size_t i = 0; i < batch_size; i++) { - BLEScanResult &scan_result = this->scan_ring_buffer_[read_idx + i]; - ESPBTDevice device; - device.parse_scan_rst(scan_result); - - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) - found = true; - } - - for (auto *client : this->clients_) { - if (client->parse_device(device)) { - found = true; - if (!connecting && client->state() == ClientState::DISCOVERED) { - promote_to_connecting = true; - } - } - } - - if (!found && !this->scan_continuous_) { - this->print_bt_device_info(device); - } - } -#endif // USE_ESP32_BLE_DEVICE - } - - // Update read index for entire batch - read_idx = (read_idx + batch_size) % SCAN_RESULT_BUFFER_SIZE; - - // Store with release to ensure reads complete before index update - this->ring_read_index_.store(read_idx, std::memory_order_release); - } - - // Log dropped results periodically - size_t dropped = this->scan_results_dropped_.exchange(0, std::memory_order_relaxed); - if (dropped > 0) { - ESP_LOGW(TAG, "Dropped %zu BLE scan results due to buffer overflow", dropped); - } - } if (this->scanner_state_ == ScannerState::FAILED || (this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) { - this->stop_scan_(); - if (this->scan_start_fail_count_ == std::numeric_limits::max()) { - ESP_LOGE(TAG, "Scan could not restart after %d attempts, rebooting to restore stack (IDF)", - std::numeric_limits::max()); - App.reboot(); - } - if (this->scan_start_failed_) { - ESP_LOGE(TAG, "Scan start failed: %d", this->scan_start_failed_); - this->scan_start_failed_ = ESP_BT_STATUS_SUCCESS; - } - if (this->scan_set_param_failed_) { - ESP_LOGE(TAG, "Scan set param failed: %d", this->scan_set_param_failed_); - this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; - } + this->handle_scanner_failure_(); } /* @@ -215,13 +101,12 @@ void ESP32BLETracker::loop() { https://github.com/espressif/esp-idf/issues/6688 */ - if (this->scanner_state_ == ScannerState::IDLE && !connecting && !disconnecting && !promote_to_connecting) { + bool promote_to_connecting = counts.discovered && !counts.searching && !counts.connecting; + + if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && + !promote_to_connecting) { #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - if (this->coex_prefer_ble_) { - this->coex_prefer_ble_ = false; - ESP_LOGD(TAG, "Setting coexistence preference to balanced."); - esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); // Reset to default - } + this->update_coex_preference_(false); #endif if (this->scan_continuous_) { this->start_scan_(false); // first = false @@ -229,31 +114,13 @@ void ESP32BLETracker::loop() { } // If there is a discovered client and no connecting // clients and no clients using the scanner to search for - // devices, then stop scanning and promote the discovered - // client to ready to connect. + // devices, then promote the discovered client to ready to connect. + // We check both RUNNING and IDLE states because: + // - RUNNING: gap_scan_event_handler initiates stop_scan_() but promotion can happen immediately + // - IDLE: Scanner has already stopped (naturally or by gap_scan_event_handler) if (promote_to_connecting && (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::IDLE)) { - for (auto *client : this->clients_) { - if (client->state() == ClientState::DISCOVERED) { - if (this->scanner_state_ == ScannerState::RUNNING) { - ESP_LOGD(TAG, "Stopping scan to make connection"); - this->stop_scan_(); - } else if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGD(TAG, "Promoting client to connect"); - // We only want to promote one client at a time. - // once the scanner is fully stopped. -#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); - if (!this->coex_prefer_ble_) { - this->coex_prefer_ble_ = true; - esp_coex_preference_set(ESP_COEX_PREFER_BT); // Prioritize Bluetooth - } -#endif - client->set_state(ClientState::READY_TO_CONNECT); - } - break; - } - } + this->try_promote_discovered_clients_(); } } @@ -390,31 +257,18 @@ void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_ga void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { // Note: This handler is called from the main loop context via esp32_ble's event queue. - // However, we still use a lock-free ring buffer to batch results efficiently. + // We process advertisements immediately instead of buffering them. ESP_LOGV(TAG, "gap_scan_result - event %d", scan_result.search_evt); if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { - // Ring buffer write (Producer side) - // Even though we're in the main loop, the ring buffer design allows efficient batching - // IMPORTANT: Only this thread writes to ring_write_index_ + // Process the scan result immediately + bool found_discovered_client = this->process_scan_result_(scan_result); - // Load our own index with relaxed ordering (we're the only writer) - uint8_t write_idx = this->ring_write_index_.load(std::memory_order_relaxed); - uint8_t next_write_idx = (write_idx + 1) % SCAN_RESULT_BUFFER_SIZE; - - // Load consumer's index with acquire to see their latest updates - uint8_t read_idx = this->ring_read_index_.load(std::memory_order_acquire); - - // Check if buffer is full - if (next_write_idx != read_idx) { - // Write to ring buffer - this->scan_ring_buffer_[write_idx] = scan_result; - - // Store with release to ensure the write is visible before index update - this->ring_write_index_.store(next_write_idx, std::memory_order_release); - } else { - // Buffer full, track dropped results - this->scan_results_dropped_.fetch_add(1, std::memory_order_relaxed); + // If we found a discovered client that needs promotion, stop scanning + // This replaces the promote_to_connecting logic from loop() + if (found_discovered_client && this->scanner_state_ == ScannerState::RUNNING) { + ESP_LOGD(TAG, "Found discovered client, stopping scan for connection"); + this->stop_scan_(); } } else if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { // Scan finished on its own @@ -781,8 +635,9 @@ void ESP32BLETracker::dump_config() { ESP_LOGCONFIG(TAG, " Scanner State: FAILED"); break; } - ESP_LOGCONFIG(TAG, " Connecting: %d, discovered: %d, searching: %d, disconnecting: %d", connecting_, discovered_, - searching_, disconnecting_); + ESP_LOGCONFIG(TAG, " Connecting: %d, discovered: %d, searching: %d, disconnecting: %d", + this->client_state_counts_.connecting, this->client_state_counts_.discovered, + this->client_state_counts_.searching, this->client_state_counts_.disconnecting); if (this->scan_start_fail_count_) { ESP_LOGCONFIG(TAG, " Scan Start Fail Count: %d", this->scan_start_fail_count_); } @@ -859,8 +714,66 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); } + +bool ESP32BLETracker::has_connecting_clients_() const { + for (auto *client : this->clients_) { + auto state = client->state(); + if (state == ClientState::CONNECTING || state == ClientState::READY_TO_CONNECT) { + return true; + } + } + return false; +} #endif // USE_ESP32_BLE_DEVICE +bool ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { + bool found_discovered_client = false; + + // Process raw advertisements + if (this->raw_advertisements_) { + for (auto *listener : this->listeners_) { + listener->parse_devices(&scan_result, 1); + } + for (auto *client : this->clients_) { + client->parse_devices(&scan_result, 1); + } + } + + // Process parsed advertisements + if (this->parse_advertisements_) { +#ifdef USE_ESP32_BLE_DEVICE + ESPBTDevice device; + device.parse_scan_rst(scan_result); + + bool found = false; + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) + found = true; + } + + for (auto *client : this->clients_) { + if (client->parse_device(device)) { + found = true; + // Check if this client is discovered and needs promotion + if (client->state() == ClientState::DISCOVERED) { + // Only check for connecting clients if we found a discovered client + // This matches the original logic: !connecting && client->state() == DISCOVERED + if (!this->has_connecting_clients_()) { + found_discovered_client = true; + } + } + } + } + + if (!found && !this->scan_continuous_) { + this->print_bt_device_info(device); + } +#endif // USE_ESP32_BLE_DEVICE + } + + return found_discovered_client; +} + void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { ESP_LOGD(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); this->already_discovered_.clear(); @@ -872,6 +785,61 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { this->set_scanner_state_(ScannerState::IDLE); } +void ESP32BLETracker::handle_scanner_failure_() { + this->stop_scan_(); + if (this->scan_start_fail_count_ == std::numeric_limits::max()) { + ESP_LOGE(TAG, "Scan could not restart after %d attempts, rebooting to restore stack (IDF)", + std::numeric_limits::max()); + App.reboot(); + } + if (this->scan_start_failed_) { + ESP_LOGE(TAG, "Scan start failed: %d", this->scan_start_failed_); + this->scan_start_failed_ = ESP_BT_STATUS_SUCCESS; + } + if (this->scan_set_param_failed_) { + ESP_LOGE(TAG, "Scan set param failed: %d", this->scan_set_param_failed_); + this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; + } +} + +void ESP32BLETracker::try_promote_discovered_clients_() { + for (auto *client : this->clients_) { + if (client->state() != ClientState::DISCOVERED) { + continue; + } + + if (this->scanner_state_ == ScannerState::RUNNING) { + ESP_LOGD(TAG, "Stopping scan to make connection"); + this->stop_scan_(); + // Don't wait for scan stop complete - promote immediately. + // This is safe because ESP-IDF processes BLE commands sequentially through its internal mailbox queue. + // This guarantees that the stop scan command will be fully processed before any subsequent connect command, + // preventing race conditions or overlapping operations. + } + + ESP_LOGD(TAG, "Promoting client to connect"); +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + this->update_coex_preference_(true); +#endif + client->set_state(ClientState::READY_TO_CONNECT); + break; + } +} + +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE +void ESP32BLETracker::update_coex_preference_(bool force_ble) { + if (force_ble && !this->coex_prefer_ble_) { + ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); + this->coex_prefer_ble_ = true; + esp_coex_preference_set(ESP_COEX_PREFER_BT); // Prioritize Bluetooth + } else if (!force_ble && this->coex_prefer_ble_) { + ESP_LOGD(TAG, "Setting coexistence preference to balanced."); + this->coex_prefer_ble_ = false; + esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); // Reset to default + } +} +#endif + } // namespace esphome::esp32_ble_tracker #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index c274e64b12..77020d3222 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -6,7 +6,6 @@ #include "esphome/core/helpers.h" #include -#include #include #include @@ -21,6 +20,7 @@ #include "esphome/components/esp32_ble/ble.h" #include "esphome/components/esp32_ble/ble_uuid.h" +#include "esphome/components/esp32_ble/ble_scan_result.h" namespace esphome::esp32_ble_tracker { @@ -136,6 +136,18 @@ class ESPBTDeviceListener { ESP32BLETracker *parent_{nullptr}; }; +struct ClientStateCounts { + uint8_t connecting = 0; + uint8_t discovered = 0; + uint8_t searching = 0; + uint8_t disconnecting = 0; + + bool operator!=(const ClientStateCounts &other) const { + return connecting != other.connecting || discovered != other.discovered || searching != other.searching || + disconnecting != other.disconnecting; + } +}; + enum class ClientState : uint8_t { // Connection is allocated INIT, @@ -272,6 +284,45 @@ class ESP32BLETracker : public Component, void set_scanner_state_(ScannerState state); /// Common cleanup logic when transitioning scanner to IDLE state void cleanup_scan_state_(bool is_stop_complete); + /// Process a single scan result immediately + /// Returns true if a discovered client needs promotion to READY_TO_CONNECT + bool process_scan_result_(const BLEScanResult &scan_result); +#ifdef USE_ESP32_BLE_DEVICE + /// Check if any clients are in connecting or ready to connect state + bool has_connecting_clients_() const; +#endif + /// Handle scanner failure states + void handle_scanner_failure_(); + /// Try to promote discovered clients to ready to connect + void try_promote_discovered_clients_(); +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + /// Update BLE coexistence preference + void update_coex_preference_(bool force_ble); +#endif + /// Count clients in each state + ClientStateCounts count_client_states_() const { + ClientStateCounts counts; + for (auto *client : this->clients_) { + switch (client->state()) { + case ClientState::DISCONNECTING: + counts.disconnecting++; + break; + case ClientState::DISCOVERED: + counts.discovered++; + break; + case ClientState::SEARCHING: + counts.searching++; + break; + case ClientState::CONNECTING: + case ClientState::READY_TO_CONNECT: + counts.connecting++; + break; + default: + break; + } + } + return counts; + } uint8_t app_id_{0}; @@ -295,21 +346,9 @@ class ESP32BLETracker : public Component, bool raw_advertisements_{false}; bool parse_advertisements_{false}; - // Lock-free Single-Producer Single-Consumer (SPSC) ring buffer for scan results - // Producer: ESP-IDF Bluetooth stack callback (gap_scan_event_handler) - // Consumer: ESPHome main loop (loop() method) - // This design ensures zero blocking in the BT callback and prevents scan result loss - BLEScanResult *scan_ring_buffer_; - std::atomic ring_write_index_{0}; // Written only by BT callback (producer) - std::atomic ring_read_index_{0}; // Written only by main loop (consumer) - std::atomic scan_results_dropped_{0}; // Tracks buffer overflow events - esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; - int connecting_{0}; - int discovered_{0}; - int searching_{0}; - int disconnecting_{0}; + ClientStateCounts client_state_counts_; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE bool coex_prefer_ble_{false}; #endif From 734b2691c895d2a15cbc05d365689a829d614a4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 11:08:00 -1000 Subject: [PATCH 0332/1626] [api] Add helpful compile-time errors for Custom API Device methods --- esphome/components/api/custom_api_device.h | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index a39947e725..f67a0832be 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -56,6 +56,13 @@ class CustomAPIDevice { auto *service = new CustomAPIDeviceService(name, arg_names, (T *) this, callback); // NOLINT global_api_server->register_user_service(service); } +#else + template + void register_service(void (T::*callback)(Ts...), const std::string &name, + const std::array &arg_names) { + static_assert( + false, "register_service() requires 'custom_services: true' in the 'api:' section of your YAML configuration"); + } #endif /** Register a custom native API service that will show up in Home Assistant. @@ -81,6 +88,11 @@ class CustomAPIDevice { auto *service = new CustomAPIDeviceService(name, {}, (T *) this, callback); // NOLINT global_api_server->register_user_service(service); } +#else + template void register_service(void (T::*callback)(), const std::string &name) { + static_assert( + false, "register_service() requires 'custom_services: true' in the 'api:' section of your YAML configuration"); + } #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -135,6 +147,20 @@ class CustomAPIDevice { auto f = std::bind(callback, (T *) this, entity_id, std::placeholders::_1); global_api_server->subscribe_home_assistant_state(entity_id, optional(attribute), f); } +#else + template + void subscribe_homeassistant_state(void (T::*callback)(std::string), const std::string &entity_id, + const std::string &attribute = "") { + static_assert(false, "subscribe_homeassistant_state() requires 'homeassistant_states: true' in the 'api:' section " + "of your YAML configuration"); + } + + template + void subscribe_homeassistant_state(void (T::*callback)(std::string, std::string), const std::string &entity_id, + const std::string &attribute = "") { + static_assert(false, "subscribe_homeassistant_state() requires 'homeassistant_states: true' in the 'api:' section " + "of your YAML configuration"); + } #endif #ifdef USE_API_HOMEASSISTANT_SERVICES @@ -222,6 +248,26 @@ class CustomAPIDevice { } global_api_server->send_homeassistant_service_call(resp); } +#else + void call_homeassistant_service(const std::string &service_name) { + static_assert(false, "call_homeassistant_service() requires 'homeassistant_services: true' in the 'api:' section " + "of your YAML configuration"); + } + + void call_homeassistant_service(const std::string &service_name, const std::map &data) { + static_assert(false, "call_homeassistant_service() requires 'homeassistant_services: true' in the 'api:' section " + "of your YAML configuration"); + } + + void fire_homeassistant_event(const std::string &event_name) { + static_assert(false, "fire_homeassistant_event() requires 'homeassistant_services: true' in the 'api:' section of " + "your YAML configuration"); + } + + void fire_homeassistant_event(const std::string &service_name, const std::map &data) { + static_assert(false, "fire_homeassistant_event() requires 'homeassistant_services: true' in the 'api:' section of " + "your YAML configuration"); + } #endif }; From 76cdef966bf2559b99deaa08214ac0a124e80531 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 11:14:22 -1000 Subject: [PATCH 0333/1626] fix --- esphome/components/api/custom_api_device.h | 36 ++++++---------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index f67a0832be..2e7bb705af 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -61,7 +61,8 @@ class CustomAPIDevice { void register_service(void (T::*callback)(Ts...), const std::string &name, const std::array &arg_names) { static_assert( - false, "register_service() requires 'custom_services: true' in the 'api:' section of your YAML configuration"); + sizeof(T) == 0, + "register_service() requires 'custom_services: true' in the 'api:' section of your YAML configuration"); } #endif @@ -91,7 +92,8 @@ class CustomAPIDevice { #else template void register_service(void (T::*callback)(), const std::string &name) { static_assert( - false, "register_service() requires 'custom_services: true' in the 'api:' section of your YAML configuration"); + sizeof(T) == 0, + "register_service() requires 'custom_services: true' in the 'api:' section of your YAML configuration"); } #endif @@ -151,15 +153,17 @@ class CustomAPIDevice { template void subscribe_homeassistant_state(void (T::*callback)(std::string), const std::string &entity_id, const std::string &attribute = "") { - static_assert(false, "subscribe_homeassistant_state() requires 'homeassistant_states: true' in the 'api:' section " - "of your YAML configuration"); + static_assert(sizeof(T) == 0, + "subscribe_homeassistant_state() requires 'homeassistant_states: true' in the 'api:' section " + "of your YAML configuration"); } template void subscribe_homeassistant_state(void (T::*callback)(std::string, std::string), const std::string &entity_id, const std::string &attribute = "") { - static_assert(false, "subscribe_homeassistant_state() requires 'homeassistant_states: true' in the 'api:' section " - "of your YAML configuration"); + static_assert(sizeof(T) == 0, + "subscribe_homeassistant_state() requires 'homeassistant_states: true' in the 'api:' section " + "of your YAML configuration"); } #endif @@ -248,26 +252,6 @@ class CustomAPIDevice { } global_api_server->send_homeassistant_service_call(resp); } -#else - void call_homeassistant_service(const std::string &service_name) { - static_assert(false, "call_homeassistant_service() requires 'homeassistant_services: true' in the 'api:' section " - "of your YAML configuration"); - } - - void call_homeassistant_service(const std::string &service_name, const std::map &data) { - static_assert(false, "call_homeassistant_service() requires 'homeassistant_services: true' in the 'api:' section " - "of your YAML configuration"); - } - - void fire_homeassistant_event(const std::string &event_name) { - static_assert(false, "fire_homeassistant_event() requires 'homeassistant_services: true' in the 'api:' section of " - "your YAML configuration"); - } - - void fire_homeassistant_event(const std::string &service_name, const std::map &data) { - static_assert(false, "fire_homeassistant_event() requires 'homeassistant_services: true' in the 'api:' section of " - "your YAML configuration"); - } #endif }; From fb1d2368a91c6deba836d5085c5579f69ebad370 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 11:15:47 -1000 Subject: [PATCH 0334/1626] fix --- esphome/components/api/custom_api_device.h | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index 2e7bb705af..44f9eee571 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -252,6 +252,28 @@ class CustomAPIDevice { } global_api_server->send_homeassistant_service_call(resp); } +#else + template void call_homeassistant_service(const std::string &service_name) { + static_assert(sizeof(T) == 0, "call_homeassistant_service() requires 'homeassistant_services: true' in the 'api:' " + "section of your YAML configuration"); + } + + template + void call_homeassistant_service(const std::string &service_name, const std::map &data) { + static_assert(sizeof(T) == 0, "call_homeassistant_service() requires 'homeassistant_services: true' in the 'api:' " + "section of your YAML configuration"); + } + + template void fire_homeassistant_event(const std::string &event_name) { + static_assert(sizeof(T) == 0, "fire_homeassistant_event() requires 'homeassistant_services: true' in the 'api:' " + "section of your YAML configuration"); + } + + template + void fire_homeassistant_event(const std::string &service_name, const std::map &data) { + static_assert(sizeof(T) == 0, "fire_homeassistant_event() requires 'homeassistant_services: true' in the 'api:' " + "section of your YAML configuration"); + } #endif }; From 83d9c02a1bc80e16176126030ebefbaa03d626b9 Mon Sep 17 00:00:00 2001 From: mschnaubelt <33265999+mschnaubelt@users.noreply.github.com> Date: Tue, 5 Aug 2025 01:41:55 +0200 Subject: [PATCH 0335/1626] Add CO5300 display support (#9739) --- esphome/components/mipi/__init__.py | 2 ++ esphome/components/mipi_spi/models/amoled.py | 18 ++++++++++++++++++ .../components/mipi_spi/models/waveshare.py | 12 ++++++++++++ 3 files changed, 32 insertions(+) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index b9299bb8d7..f610f160b0 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -77,6 +77,7 @@ BRIGHTNESS = 0x51 WRDISBV = 0x51 RDDISBV = 0x52 WRCTRLD = 0x53 +WCE = 0x58 SWIRE1 = 0x5A SWIRE2 = 0x5B IFMODE = 0xB0 @@ -91,6 +92,7 @@ PWCTR2 = 0xC1 PWCTR3 = 0xC2 PWCTR4 = 0xC3 PWCTR5 = 0xC4 +SPIMODESEL = 0xC4 VMCTR1 = 0xC5 IFCTR = 0xC6 VMCTR2 = 0xC7 diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 6fe882b584..bc95fc7f71 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -5,10 +5,13 @@ from esphome.components.mipi import ( PAGESEL, PIXFMT, SLPOUT, + SPIMODESEL, SWIRE1, SWIRE2, TEON, + WCE, WRAM, + WRCTRLD, DriverChip, delay, ) @@ -87,4 +90,19 @@ T4_S3_AMOLED = RM690B0.extend( bus_mode=TYPE_QUAD, ) +CO5300 = DriverChip( + "CO5300", + brightness=0xD0, + color_order=MODE_RGB, + bus_mode=TYPE_QUAD, + initsequence=( + (SLPOUT,), # Requires early SLPOUT + (PAGESEL, 0x00), + (SPIMODESEL, 0x80), + (WRCTRLD, 0x20), + (WCE, 0x00), + ), +) + + models = {} diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 002f81f3a6..7a55027e58 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -1,6 +1,7 @@ from esphome.components.mipi import DriverChip import esphome.config_validation as cv +from .amoled import CO5300 from .ili import ILI9488_A DriverChip( @@ -140,3 +141,14 @@ ILI9488_A.extend( data_rate="20MHz", invert_colors=True, ) + +CO5300.extend( + "WAVESHARE-ESP32-S3-TOUCH-AMOLED-1.75", + width=466, + height=466, + pixel_mode="16bit", + offset_height=0, + offset_width=6, + cs_pin=12, + reset_pin=39, +) From 50f15735dca74989c6cab7860d7f20b725ca286f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 14:57:31 -1000 Subject: [PATCH 0336/1626] [api] Add helpful compile-time errors for Custom API Device methods (#10076) --- esphome/components/api/custom_api_device.h | 52 ++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index a39947e725..44f9eee571 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -56,6 +56,14 @@ class CustomAPIDevice { auto *service = new CustomAPIDeviceService(name, arg_names, (T *) this, callback); // NOLINT global_api_server->register_user_service(service); } +#else + template + void register_service(void (T::*callback)(Ts...), const std::string &name, + const std::array &arg_names) { + static_assert( + sizeof(T) == 0, + "register_service() requires 'custom_services: true' in the 'api:' section of your YAML configuration"); + } #endif /** Register a custom native API service that will show up in Home Assistant. @@ -81,6 +89,12 @@ class CustomAPIDevice { auto *service = new CustomAPIDeviceService(name, {}, (T *) this, callback); // NOLINT global_api_server->register_user_service(service); } +#else + template void register_service(void (T::*callback)(), const std::string &name) { + static_assert( + sizeof(T) == 0, + "register_service() requires 'custom_services: true' in the 'api:' section of your YAML configuration"); + } #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -135,6 +149,22 @@ class CustomAPIDevice { auto f = std::bind(callback, (T *) this, entity_id, std::placeholders::_1); global_api_server->subscribe_home_assistant_state(entity_id, optional(attribute), f); } +#else + template + void subscribe_homeassistant_state(void (T::*callback)(std::string), const std::string &entity_id, + const std::string &attribute = "") { + static_assert(sizeof(T) == 0, + "subscribe_homeassistant_state() requires 'homeassistant_states: true' in the 'api:' section " + "of your YAML configuration"); + } + + template + void subscribe_homeassistant_state(void (T::*callback)(std::string, std::string), const std::string &entity_id, + const std::string &attribute = "") { + static_assert(sizeof(T) == 0, + "subscribe_homeassistant_state() requires 'homeassistant_states: true' in the 'api:' section " + "of your YAML configuration"); + } #endif #ifdef USE_API_HOMEASSISTANT_SERVICES @@ -222,6 +252,28 @@ class CustomAPIDevice { } global_api_server->send_homeassistant_service_call(resp); } +#else + template void call_homeassistant_service(const std::string &service_name) { + static_assert(sizeof(T) == 0, "call_homeassistant_service() requires 'homeassistant_services: true' in the 'api:' " + "section of your YAML configuration"); + } + + template + void call_homeassistant_service(const std::string &service_name, const std::map &data) { + static_assert(sizeof(T) == 0, "call_homeassistant_service() requires 'homeassistant_services: true' in the 'api:' " + "section of your YAML configuration"); + } + + template void fire_homeassistant_event(const std::string &event_name) { + static_assert(sizeof(T) == 0, "fire_homeassistant_event() requires 'homeassistant_services: true' in the 'api:' " + "section of your YAML configuration"); + } + + template + void fire_homeassistant_event(const std::string &service_name, const std::map &data) { + static_assert(sizeof(T) == 0, "fire_homeassistant_event() requires 'homeassistant_services: true' in the 'api:' " + "section of your YAML configuration"); + } #endif }; From 469246b8d8de21fa6ad1d74e34cd2f16cac4a878 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 14:58:41 -1000 Subject: [PATCH 0337/1626] [bluetooth_proxy] Warn about BLE connection timeout mismatch on Arduino framework (#10063) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/bluetooth_proxy/__init__.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index ec1df6a06c..4087255410 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -1,14 +1,20 @@ +import logging + import esphome.codegen as cg from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.esp32_ble import BTLoggers import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_ID +from esphome.core import CORE +from esphome.log import AnsiFore, color AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"] DEPENDENCIES = ["api", "esp32"] CODEOWNERS = ["@jesserockz"] +_LOGGER = logging.getLogger(__name__) + CONF_CONNECTION_SLOTS = "connection_slots" CONF_CACHE_SERVICES = "cache_services" CONF_CONNECTIONS = "connections" @@ -41,6 +47,27 @@ def validate_connections(config): esp32_ble_tracker.consume_connection_slots(connection_slots, "bluetooth_proxy")( config ) + + # Warn about connection slot waste when using Arduino framework + if CORE.using_arduino and connection_slots: + _LOGGER.warning( + "Bluetooth Proxy with active connections on Arduino framework has suboptimal performance.\n" + "If BLE connections fail, they can waste connection slots for 10 seconds because\n" + "Arduino doesn't allow configuring the BLE connection timeout (fixed at 30s).\n" + "ESP-IDF framework allows setting it to 20s to match client timeouts.\n" + "\n" + "To switch to ESP-IDF, add this to your YAML:\n" + " esp32:\n" + " framework:\n" + " type: esp-idf\n" + "\n" + "For detailed migration instructions, see:\n" + "%s", + color( + AnsiFore.BLUE, "https://esphome.io/guides/esp32_arduino_to_idf.html" + ), + ) + return { **config, CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)], From 27ba90ea95320cc8aab5d9da3ac81abc72aa2061 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 14:59:23 -1000 Subject: [PATCH 0338/1626] [esp32_ble_client] Start MTU negotiation earlier following ESP-IDF examples (#10062) --- .../esp32_ble_client/ble_client_base.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 2c13995f76..9b07033cfc 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -283,7 +283,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (!this->check_addr(param->open.remote_bda)) return false; this->log_event_("ESP_GATTC_OPEN_EVT"); - this->conn_id_ = param->open.conn_id; + // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; if (this->state_ != espbt::ClientState::CONNECTING) { // This should not happen but lets log it in case it does @@ -317,11 +317,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->conn_id_ = UNSET_CONN_ID; break; } - auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->open.conn_id); - if (ret) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_send_mtu_req failed, status=%x", this->connection_index_, - this->address_str_.c_str(), ret); - } + // MTU negotiation already started in ESP_GATTC_CONNECT_EVT this->set_state(espbt::ClientState::CONNECTED); ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { @@ -338,6 +334,16 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (!this->check_addr(param->connect.remote_bda)) return false; this->log_event_("ESP_GATTC_CONNECT_EVT"); + this->conn_id_ = param->connect.conn_id; + // Start MTU negotiation immediately as recommended by ESP-IDF examples + // (gatt_client, ble_throughput) which call esp_ble_gattc_send_mtu_req in + // ESP_GATTC_CONNECT_EVT instead of waiting for ESP_GATTC_OPEN_EVT. + // This saves ~3ms in the connection process. + auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id); + if (ret) { + ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_send_mtu_req failed, status=%x", this->connection_index_, + this->address_str_.c_str(), ret); + } break; } case ESP_GATTC_DISCONNECT_EVT: { From fa8c5e880c9f60cfab7282a7faea6a4b1222119c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 15:10:02 -1000 Subject: [PATCH 0339/1626] [esp32_ble_tracker] Optimize connection by promoting client immediately after scan stop trigger (#10061) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 254eddd1d9..9e41fc80c5 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -238,19 +238,21 @@ void ESP32BLETracker::loop() { if (this->scanner_state_ == ScannerState::RUNNING) { ESP_LOGD(TAG, "Stopping scan to make connection"); this->stop_scan_(); - } else if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGD(TAG, "Promoting client to connect"); - // We only want to promote one client at a time. - // once the scanner is fully stopped. -#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); - if (!this->coex_prefer_ble_) { - this->coex_prefer_ble_ = true; - esp_coex_preference_set(ESP_COEX_PREFER_BT); // Prioritize Bluetooth - } -#endif - client->set_state(ClientState::READY_TO_CONNECT); + // Don't wait for scan stop complete - promote immediately. + // This is safe because ESP-IDF processes BLE commands sequentially through its internal mailbox queue. + // This guarantees that the stop scan command will be fully processed before any subsequent connect command, + // preventing race conditions or overlapping operations. } + + ESP_LOGD(TAG, "Promoting client to connect"); +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); + if (!this->coex_prefer_ble_) { + this->coex_prefer_ble_ = true; + esp_coex_preference_set(ESP_COEX_PREFER_BT); // Prioritize Bluetooth + } +#endif + client->set_state(ClientState::READY_TO_CONNECT); break; } } From f7bf1ef52c2e77c60a41c3b56f0c731c1ad00fc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 15:10:32 -1000 Subject: [PATCH 0340/1626] [esp32_ble_tracker] Eliminate redundant ring buffer for lower latency (#10057) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32_ble/ble.h | 15 +- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 170 ++++++++---------- .../esp32_ble_tracker/esp32_ble_tracker.h | 18 +- 3 files changed, 84 insertions(+), 119 deletions(-) diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 543b2f26a3..3f40c557f1 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -23,21 +23,14 @@ namespace esphome::esp32_ble { -// Maximum number of BLE scan results to buffer -// Sized to handle bursts of advertisements while allowing for processing delays -// With 16 advertisements per batch and some safety margin: -// - Without PSRAM: 24 entries (1.5× batch size) -// - With PSRAM: 36 entries (2.25× batch size) -// The reduced structure size (~80 bytes vs ~400 bytes) allows for larger buffers +// Maximum size of the BLE event queue +// Increased to absorb the ring buffer capacity from esp32_ble_tracker #ifdef USE_PSRAM -static constexpr uint8_t SCAN_RESULT_BUFFER_SIZE = 36; +static constexpr uint8_t MAX_BLE_QUEUE_SIZE = 100; // 64 + 36 (ring buffer size with PSRAM) #else -static constexpr uint8_t SCAN_RESULT_BUFFER_SIZE = 24; +static constexpr uint8_t MAX_BLE_QUEUE_SIZE = 88; // 64 + 24 (ring buffer size without PSRAM) #endif -// Maximum size of the BLE event queue - must be power of 2 for lock-free queue -static constexpr size_t MAX_BLE_QUEUE_SIZE = 64; - uint64_t ble_addr_to_uint64(const esp_bd_addr_t address); // NOLINTNEXTLINE(modernize-use-using) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 9e41fc80c5..856ae82dca 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -49,13 +49,6 @@ void ESP32BLETracker::setup() { ESP_LOGE(TAG, "BLE Tracker was marked failed by ESP32BLE"); return; } - RAMAllocator allocator; - this->scan_ring_buffer_ = allocator.allocate(SCAN_RESULT_BUFFER_SIZE); - - if (this->scan_ring_buffer_ == nullptr) { - ESP_LOGE(TAG, "Could not allocate ring buffer for BLE Tracker!"); - this->mark_failed(); - } global_esp32_ble_tracker = this; @@ -117,74 +110,8 @@ void ESP32BLETracker::loop() { } bool promote_to_connecting = discovered && !searching && !connecting; - // Process scan results from lock-free SPSC ring buffer - // Consumer side: This runs in the main loop thread - if (this->scanner_state_ == ScannerState::RUNNING) { - // Load our own index with relaxed ordering (we're the only writer) - uint8_t read_idx = this->ring_read_index_.load(std::memory_order_relaxed); - - // Load producer's index with acquire to see their latest writes - uint8_t write_idx = this->ring_write_index_.load(std::memory_order_acquire); - - while (read_idx != write_idx) { - // Calculate how many contiguous results we can process in one batch - // If write > read: process all results from read to write - // If write <= read (wraparound): process from read to end of buffer first - size_t batch_size = (write_idx > read_idx) ? (write_idx - read_idx) : (SCAN_RESULT_BUFFER_SIZE - read_idx); - - // Process the batch for raw advertisements - if (this->raw_advertisements_) { - for (auto *listener : this->listeners_) { - listener->parse_devices(&this->scan_ring_buffer_[read_idx], batch_size); - } - for (auto *client : this->clients_) { - client->parse_devices(&this->scan_ring_buffer_[read_idx], batch_size); - } - } - - // Process individual results for parsed advertisements - if (this->parse_advertisements_) { -#ifdef USE_ESP32_BLE_DEVICE - for (size_t i = 0; i < batch_size; i++) { - BLEScanResult &scan_result = this->scan_ring_buffer_[read_idx + i]; - ESPBTDevice device; - device.parse_scan_rst(scan_result); - - bool found = false; - for (auto *listener : this->listeners_) { - if (listener->parse_device(device)) - found = true; - } - - for (auto *client : this->clients_) { - if (client->parse_device(device)) { - found = true; - if (!connecting && client->state() == ClientState::DISCOVERED) { - promote_to_connecting = true; - } - } - } - - if (!found && !this->scan_continuous_) { - this->print_bt_device_info(device); - } - } -#endif // USE_ESP32_BLE_DEVICE - } - - // Update read index for entire batch - read_idx = (read_idx + batch_size) % SCAN_RESULT_BUFFER_SIZE; - - // Store with release to ensure reads complete before index update - this->ring_read_index_.store(read_idx, std::memory_order_release); - } - - // Log dropped results periodically - size_t dropped = this->scan_results_dropped_.exchange(0, std::memory_order_relaxed); - if (dropped > 0) { - ESP_LOGW(TAG, "Dropped %zu BLE scan results due to buffer overflow", dropped); - } - } + // All scan result processing is now done immediately in gap_scan_event_handler + // No ring buffer processing needed here if (this->scanner_state_ == ScannerState::FAILED || (this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) { this->stop_scan_(); @@ -229,8 +156,10 @@ void ESP32BLETracker::loop() { } // If there is a discovered client and no connecting // clients and no clients using the scanner to search for - // devices, then stop scanning and promote the discovered - // client to ready to connect. + // devices, then promote the discovered client to ready to connect. + // Note: Scanning is already stopped by gap_scan_event_handler when + // a discovered client is found, so we only need to handle promotion + // when the scanner is IDLE. if (promote_to_connecting && (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::IDLE)) { for (auto *client : this->clients_) { @@ -392,31 +321,18 @@ void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_ga void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { // Note: This handler is called from the main loop context via esp32_ble's event queue. - // However, we still use a lock-free ring buffer to batch results efficiently. + // We process advertisements immediately instead of buffering them. ESP_LOGV(TAG, "gap_scan_result - event %d", scan_result.search_evt); if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { - // Ring buffer write (Producer side) - // Even though we're in the main loop, the ring buffer design allows efficient batching - // IMPORTANT: Only this thread writes to ring_write_index_ + // Process the scan result immediately + bool found_discovered_client = this->process_scan_result_(scan_result); - // Load our own index with relaxed ordering (we're the only writer) - uint8_t write_idx = this->ring_write_index_.load(std::memory_order_relaxed); - uint8_t next_write_idx = (write_idx + 1) % SCAN_RESULT_BUFFER_SIZE; - - // Load consumer's index with acquire to see their latest updates - uint8_t read_idx = this->ring_read_index_.load(std::memory_order_acquire); - - // Check if buffer is full - if (next_write_idx != read_idx) { - // Write to ring buffer - this->scan_ring_buffer_[write_idx] = scan_result; - - // Store with release to ensure the write is visible before index update - this->ring_write_index_.store(next_write_idx, std::memory_order_release); - } else { - // Buffer full, track dropped results - this->scan_results_dropped_.fetch_add(1, std::memory_order_relaxed); + // If we found a discovered client that needs promotion, stop scanning + // This replaces the promote_to_connecting logic from loop() + if (found_discovered_client && this->scanner_state_ == ScannerState::RUNNING) { + ESP_LOGD(TAG, "Found discovered client, stopping scan for connection"); + this->stop_scan_(); } } else if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { // Scan finished on its own @@ -861,8 +777,66 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); } + +bool ESP32BLETracker::has_connecting_clients_() const { + for (auto *client : this->clients_) { + auto state = client->state(); + if (state == ClientState::CONNECTING || state == ClientState::READY_TO_CONNECT) { + return true; + } + } + return false; +} #endif // USE_ESP32_BLE_DEVICE +bool ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { + bool found_discovered_client = false; + + // Process raw advertisements + if (this->raw_advertisements_) { + for (auto *listener : this->listeners_) { + listener->parse_devices(&scan_result, 1); + } + for (auto *client : this->clients_) { + client->parse_devices(&scan_result, 1); + } + } + + // Process parsed advertisements + if (this->parse_advertisements_) { +#ifdef USE_ESP32_BLE_DEVICE + ESPBTDevice device; + device.parse_scan_rst(scan_result); + + bool found = false; + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) + found = true; + } + + for (auto *client : this->clients_) { + if (client->parse_device(device)) { + found = true; + // Check if this client is discovered and needs promotion + if (client->state() == ClientState::DISCOVERED) { + // Only check for connecting clients if we found a discovered client + // This matches the original logic: !connecting && client->state() == DISCOVERED + if (!this->has_connecting_clients_()) { + found_discovered_client = true; + } + } + } + } + + if (!found && !this->scan_continuous_) { + this->print_bt_device_info(device); + } +#endif // USE_ESP32_BLE_DEVICE + } + + return found_discovered_client; +} + void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { ESP_LOGD(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); this->already_discovered_.clear(); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index c274e64b12..1c28bc7a7d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -6,7 +6,6 @@ #include "esphome/core/helpers.h" #include -#include #include #include @@ -21,6 +20,7 @@ #include "esphome/components/esp32_ble/ble.h" #include "esphome/components/esp32_ble/ble_uuid.h" +#include "esphome/components/esp32_ble/ble_scan_result.h" namespace esphome::esp32_ble_tracker { @@ -272,6 +272,13 @@ class ESP32BLETracker : public Component, void set_scanner_state_(ScannerState state); /// Common cleanup logic when transitioning scanner to IDLE state void cleanup_scan_state_(bool is_stop_complete); + /// Process a single scan result immediately + /// Returns true if a discovered client needs promotion to READY_TO_CONNECT + bool process_scan_result_(const BLEScanResult &scan_result); +#ifdef USE_ESP32_BLE_DEVICE + /// Check if any clients are in connecting or ready to connect state + bool has_connecting_clients_() const; +#endif uint8_t app_id_{0}; @@ -295,15 +302,6 @@ class ESP32BLETracker : public Component, bool raw_advertisements_{false}; bool parse_advertisements_{false}; - // Lock-free Single-Producer Single-Consumer (SPSC) ring buffer for scan results - // Producer: ESP-IDF Bluetooth stack callback (gap_scan_event_handler) - // Consumer: ESPHome main loop (loop() method) - // This design ensures zero blocking in the BT callback and prevents scan result loss - BLEScanResult *scan_ring_buffer_; - std::atomic ring_write_index_{0}; // Written only by BT callback (producer) - std::atomic ring_read_index_{0}; // Written only by main loop (consumer) - std::atomic scan_results_dropped_{0}; // Tracks buffer overflow events - esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; int connecting_{0}; From 64c94c144003b1da017eeaec0047d551f2ec7671 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 15:11:32 -1000 Subject: [PATCH 0341/1626] [esp32_ble_client] Fix connection parameter timing by setting preferences before connection (#10059) --- .../esp32_ble_client/ble_client_base.cpp | 86 +++++++++++-------- .../esp32_ble_client/ble_client_base.h | 1 + 2 files changed, 49 insertions(+), 38 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 9b07033cfc..f47642944b 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -145,6 +145,36 @@ void BLEClientBase::connect() { this->remote_addr_type_); this->paired_ = false; + // Set preferred connection parameters before connecting + // Use FAST for all V3 connections (better latency and reliability) + // Use MEDIUM for V1/legacy connections (balanced performance) + uint16_t min_interval, max_interval, timeout; + const char *param_type; + + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || + this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { + min_interval = FAST_MIN_CONN_INTERVAL; + max_interval = FAST_MAX_CONN_INTERVAL; + timeout = FAST_CONN_TIMEOUT; + param_type = "fast"; + } else { + min_interval = MEDIUM_MIN_CONN_INTERVAL; + max_interval = MEDIUM_MAX_CONN_INTERVAL; + timeout = MEDIUM_CONN_TIMEOUT; + param_type = "medium"; + } + + auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, + 0, // latency: 0 + timeout); + if (param_ret != ESP_OK) { + ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, + this->address_str_.c_str(), param_ret); + } else { + ESP_LOGD(TAG, "[%d] [%s] Set %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); + } + + // Now open the connection auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); if (ret) { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_open error, status=%d", this->connection_index_, this->address_str_.c_str(), @@ -152,35 +182,6 @@ void BLEClientBase::connect() { this->set_state(espbt::ClientState::IDLE); } else { this->set_state(espbt::ClientState::CONNECTING); - - // Always set connection parameters to ensure stable operation - // Use FAST for all V3 connections (better latency and reliability) - // Use MEDIUM for V1/legacy connections (balanced performance) - uint16_t min_interval, max_interval, timeout; - const char *param_type; - - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - min_interval = FAST_MIN_CONN_INTERVAL; - max_interval = FAST_MAX_CONN_INTERVAL; - timeout = FAST_CONN_TIMEOUT; - param_type = "fast"; - } else { - min_interval = MEDIUM_MIN_CONN_INTERVAL; - max_interval = MEDIUM_MAX_CONN_INTERVAL; - timeout = MEDIUM_CONN_TIMEOUT; - param_type = "medium"; - } - - auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, - 0, // latency: 0 - timeout); - if (param_ret != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, - this->address_str_.c_str(), param_ret); - } else { - ESP_LOGD(TAG, "[%d] [%s] Set %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); - } } } @@ -255,6 +256,19 @@ void BLEClientBase::log_event_(const char *name) { ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), name); } +void BLEClientBase::restore_medium_conn_params_() { + // Restore to medium connection parameters after initial connection phase + // This balances performance with bandwidth usage for normal operation + esp_ble_conn_update_params_t conn_params = {{0}}; + memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; + conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; + conn_params.latency = 0; + conn_params.timeout = MEDIUM_CONN_TIMEOUT; + ESP_LOGD(TAG, "[%d] [%s] Restoring medium conn params", this->connection_index_, this->address_str_.c_str()); + esp_ble_gap_update_conn_params(&conn_params); +} + bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, esp_ble_gattc_cb_param_t *param) { if (event == ESP_GATTC_REG_EVT && this->app_id != param->reg.app_id) @@ -322,6 +336,10 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { ESP_LOGI(TAG, "[%d] [%s] Using cached services", this->connection_index_, this->address_str_.c_str()); + + // Restore to medium connection parameters for cached connections too + this->restore_medium_conn_params_(); + // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; @@ -419,15 +437,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // This balances performance with bandwidth usage after the critical discovery phase if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - esp_ble_conn_update_params_t conn_params = {{0}}; - memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); - conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; - conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; - conn_params.latency = 0; - conn_params.timeout = MEDIUM_CONN_TIMEOUT; - ESP_LOGD(TAG, "[%d] [%s] Restored medium conn params after service discovery", this->connection_index_, - this->address_str_.c_str()); - esp_ble_gap_update_conn_params(&conn_params); + this->restore_medium_conn_params_(); } this->state_ = espbt::ClientState::ESTABLISHED; diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 0a2fda4476..b30e9cd444 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -127,6 +127,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { // 6 bytes used, 2 bytes padding void log_event_(const char *name); + void restore_medium_conn_params_(); }; } // namespace esp32_ble_client From 52634dac2a467c16a35e0540c76706996a1e1206 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 15:12:05 -1000 Subject: [PATCH 0342/1626] [tests] Add datetime entities to host_mode_many_entities integration test (#10032) --- .../fixtures/host_mode_many_entities.yaml | 17 ++++ .../test_host_mode_many_entities.py | 95 ++++++++++++++++--- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/tests/integration/fixtures/host_mode_many_entities.yaml b/tests/integration/fixtures/host_mode_many_entities.yaml index 5e085a15c9..612186507c 100644 --- a/tests/integration/fixtures/host_mode_many_entities.yaml +++ b/tests/integration/fixtures/host_mode_many_entities.yaml @@ -373,3 +373,20 @@ button: name: "Test Button" on_press: - logger.log: "Button pressed" + +# Date, Time, and DateTime entities +datetime: + - platform: template + type: date + name: "Test Date" + initial_value: "2023-05-13" + optimistic: true + - platform: template + type: time + name: "Test Time" + initial_value: "12:30:00" + optimistic: true + - platform: template + type: datetime + name: "Test DateTime" + optimistic: true diff --git a/tests/integration/test_host_mode_many_entities.py b/tests/integration/test_host_mode_many_entities.py index aaca4555f6..fbe3dc25c8 100644 --- a/tests/integration/test_host_mode_many_entities.py +++ b/tests/integration/test_host_mode_many_entities.py @@ -4,7 +4,17 @@ from __future__ import annotations import asyncio -from aioesphomeapi import ClimateInfo, EntityState, SensorState +from aioesphomeapi import ( + ClimateInfo, + DateInfo, + DateState, + DateTimeInfo, + DateTimeState, + EntityState, + SensorState, + TimeInfo, + TimeState, +) import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -22,34 +32,56 @@ async def test_host_mode_many_entities( async with run_compiled(yaml_config), api_client_connected() as client: # Subscribe to state changes states: dict[int, EntityState] = {} - sensor_count_future: asyncio.Future[int] = loop.create_future() + minimum_states_future: asyncio.Future[None] = loop.create_future() def on_state(state: EntityState) -> None: states[state.key] = state - # Count sensor states specifically + # Check if we have received minimum expected states sensor_states = [ s for s in states.values() if isinstance(s, SensorState) and isinstance(s.state, float) ] - # When we have received states from at least 50 sensors, resolve the future - if len(sensor_states) >= 50 and not sensor_count_future.done(): - sensor_count_future.set_result(len(sensor_states)) + date_states = [s for s in states.values() if isinstance(s, DateState)] + time_states = [s for s in states.values() if isinstance(s, TimeState)] + datetime_states = [ + s for s in states.values() if isinstance(s, DateTimeState) + ] + + # We expect at least 50 sensors and 1 of each datetime entity type + if ( + len(sensor_states) >= 50 + and len(date_states) >= 1 + and len(time_states) >= 1 + and len(datetime_states) >= 1 + and not minimum_states_future.done() + ): + minimum_states_future.set_result(None) client.subscribe_states(on_state) - # Wait for states from at least 50 sensors with timeout + # Wait for minimum states with timeout try: - sensor_count = await asyncio.wait_for(sensor_count_future, timeout=10.0) + await asyncio.wait_for(minimum_states_future, timeout=10.0) except TimeoutError: sensor_states = [ s for s in states.values() if isinstance(s, SensorState) and isinstance(s.state, float) ] + date_states = [s for s in states.values() if isinstance(s, DateState)] + time_states = [s for s in states.values() if isinstance(s, TimeState)] + datetime_states = [ + s for s in states.values() if isinstance(s, DateTimeState) + ] + pytest.fail( - f"Did not receive states from at least 50 sensors within 10 seconds. " - f"Received {len(sensor_states)} sensor states out of {len(states)} total states" + f"Did not receive expected states within 10 seconds. " + f"Received: {len(sensor_states)} sensor states (expected >=50), " + f"{len(date_states)} date states (expected >=1), " + f"{len(time_states)} time states (expected >=1), " + f"{len(datetime_states)} datetime states (expected >=1). " + f"Total states: {len(states)}" ) # Verify we received a good number of entity states @@ -64,13 +96,25 @@ async def test_host_mode_many_entities( if isinstance(s, SensorState) and isinstance(s.state, float) ] - assert sensor_count >= 50, ( - f"Expected at least 50 sensor states, got {sensor_count}" - ) assert len(sensor_states) >= 50, ( f"Expected at least 50 sensor states, got {len(sensor_states)}" ) + # Verify we received datetime entity states + date_states = [s for s in states.values() if isinstance(s, DateState)] + time_states = [s for s in states.values() if isinstance(s, TimeState)] + datetime_states = [s for s in states.values() if isinstance(s, DateTimeState)] + + assert len(date_states) >= 1, ( + f"Expected at least 1 date state, got {len(date_states)}" + ) + assert len(time_states) >= 1, ( + f"Expected at least 1 time state, got {len(time_states)}" + ) + assert len(datetime_states) >= 1, ( + f"Expected at least 1 datetime state, got {len(datetime_states)}" + ) + # Get entity info to verify climate entity details entities = await client.list_entities_services() climate_infos = [e for e in entities[0] if isinstance(e, ClimateInfo)] @@ -89,3 +133,28 @@ async def test_host_mode_many_entities( assert "HOME" in preset_names, f"Expected 'HOME' preset, got {preset_names}" assert "AWAY" in preset_names, f"Expected 'AWAY' preset, got {preset_names}" assert "SLEEP" in preset_names, f"Expected 'SLEEP' preset, got {preset_names}" + + # Verify datetime entities exist + date_infos = [e for e in entities[0] if isinstance(e, DateInfo)] + time_infos = [e for e in entities[0] if isinstance(e, TimeInfo)] + datetime_infos = [e for e in entities[0] if isinstance(e, DateTimeInfo)] + + assert len(date_infos) >= 1, "Expected at least 1 date entity" + assert len(time_infos) >= 1, "Expected at least 1 time entity" + assert len(datetime_infos) >= 1, "Expected at least 1 datetime entity" + + # Verify the entity names + date_info = date_infos[0] + assert date_info.name == "Test Date", ( + f"Expected date entity name 'Test Date', got {date_info.name}" + ) + + time_info = time_infos[0] + assert time_info.name == "Test Time", ( + f"Expected time entity name 'Test Time', got {time_info.name}" + ) + + datetime_info = datetime_infos[0] + assert datetime_info.name == "Test DateTime", ( + f"Expected datetime entity name 'Test DateTime', got {datetime_info.name}" + ) From 93b28447ee3532313e7f6dc29a1ef2c4dc7e1ed0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 15:13:55 -1000 Subject: [PATCH 0343/1626] [bluetooth_proxy] Optimize memory usage with fixed-size array and const string references (#10015) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 15 +++++++++------ .../components/bluetooth_proxy/bluetooth_proxy.h | 14 +++++++++----- .../components/esp32_ble_client/ble_client_base.h | 2 +- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index a59a33117a..97b0884dda 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -35,8 +35,8 @@ void BluetoothProxy::setup() { // Don't pre-allocate pool - let it grow only if needed in busy environments // Many devices in quiet areas will never need the overflow pool - this->connections_free_response_.limit = this->connections_.size(); - this->connections_free_response_.free = this->connections_.size(); + this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; + this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { @@ -134,12 +134,13 @@ void BluetoothProxy::dump_config() { ESP_LOGCONFIG(TAG, " Active: %s\n" " Connections: %d", - YESNO(this->active_), this->connections_.size()); + YESNO(this->active_), this->connection_count_); } void BluetoothProxy::loop() { if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { - for (auto *connection : this->connections_) { + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; if (connection->get_address() != 0 && !connection->disconnect_pending()) { connection->disconnect(); } @@ -162,7 +163,8 @@ esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_par } BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { - for (auto *connection : this->connections_) { + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; if (connection->get_address() == address) return connection; } @@ -170,7 +172,8 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese if (!reserve) return nullptr; - for (auto *connection : this->connections_) { + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; if (connection->get_address() == 0) { connection->send_service_ = DONE_SENDING_SERVICES; connection->set_address(address); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 70deef1ebd..d367dad438 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -2,6 +2,7 @@ #ifdef USE_ESP32 +#include #include #include @@ -63,8 +64,10 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; void register_connection(BluetoothConnection *connection) { - this->connections_.push_back(connection); - connection->proxy_ = this; + if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { + this->connections_[this->connection_count_++] = connection; + connection->proxy_ = this; + } } void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); @@ -138,8 +141,8 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; - // Group 2: Container types (typically 12 bytes on 32-bit) - std::vector connections_{}; + // Group 2: Fixed-size array of connection pointers + std::array connections_{}; // BLE advertisement batching std::vector advertisement_pool_; @@ -154,7 +157,8 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 4: 1-byte types grouped together bool active_; uint8_t advertisement_count_{0}; - // 2 bytes used, 2 bytes padding + uint8_t connection_count_{0}; + // 3 bytes used, 1 byte padding }; extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index b30e9cd444..93260b1c15 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -66,7 +66,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { (uint8_t) (this->address_ >> 0) & 0xff); } } - std::string address_str() const { return this->address_str_; } + const std::string &address_str() const { return this->address_str_; } BLEService *get_service(espbt::ESPBTUUID uuid); BLEService *get_service(uint16_t uuid); From 0aa3c9685e4e9454f0d2ffdde6214bb5a5d41dce Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 29 Jul 2025 06:43:22 +1000 Subject: [PATCH 0344/1626] [lvgl] Bugfix for tileview (#9938) --- esphome/components/lvgl/widgets/tileview.py | 22 ++++++++++++++------- tests/components/lvgl/lvgl-package.yaml | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/widgets/tileview.py b/esphome/components/lvgl/widgets/tileview.py index 3865d404e2..5e3a95f017 100644 --- a/esphome/components/lvgl/widgets/tileview.py +++ b/esphome/components/lvgl/widgets/tileview.py @@ -15,7 +15,7 @@ from ..defines import ( TILE_DIRECTIONS, literal, ) -from ..lv_validation import animated, lv_int +from ..lv_validation import animated, lv_int, lv_pct from ..lvcode import lv, lv_assign, lv_expr, lv_obj, lv_Pvariable from ..schemas import container_schema from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr @@ -41,8 +41,8 @@ TILEVIEW_SCHEMA = cv.Schema( container_schema( obj_spec, { - cv.Required(CONF_ROW): lv_int, - cv.Required(CONF_COLUMN): lv_int, + cv.Required(CONF_ROW): cv.positive_int, + cv.Required(CONF_COLUMN): cv.positive_int, cv.GenerateID(): cv.declare_id(lv_tile_t), cv.Optional(CONF_DIR, default="ALL"): TILE_DIRECTIONS.several_of, }, @@ -63,21 +63,29 @@ class TileviewType(WidgetType): ) async def to_code(self, w: Widget, config: dict): - for tile_conf in config.get(CONF_TILES, ()): + tiles = config[CONF_TILES] + for tile_conf in tiles: w_id = tile_conf[CONF_ID] tile_obj = lv_Pvariable(lv_obj_t, w_id) tile = Widget.create(w_id, tile_obj, tile_spec, tile_conf) dirs = tile_conf[CONF_DIR] if isinstance(dirs, list): dirs = "|".join(dirs) + row_pos = tile_conf[CONF_ROW] + col_pos = tile_conf[CONF_COLUMN] lv_assign( tile_obj, - lv_expr.tileview_add_tile( - w.obj, tile_conf[CONF_COLUMN], tile_conf[CONF_ROW], literal(dirs) - ), + lv_expr.tileview_add_tile(w.obj, col_pos, row_pos, literal(dirs)), ) + # Bugfix for LVGL 8.x + lv_obj.set_pos(tile_obj, lv_pct(col_pos * 100), lv_pct(row_pos * 100)) await set_obj_properties(tile, tile_conf) await add_widgets(tile, tile_conf) + if tiles: + # Set the first tile as active + lv_obj.set_tile_id( + w.obj, tiles[0][CONF_COLUMN], tiles[0][CONF_ROW], literal("LV_ANIM_OFF") + ) tileview_spec = TileviewType() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 46341c266d..853466c9cc 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -738,7 +738,7 @@ lvgl: id: bar_id value: !lambda return (int)((float)rand() / RAND_MAX * 100); start_value: !lambda return (int)((float)rand() / RAND_MAX * 100); - mode: symmetrical + mode: range - logger.log: format: "bar value %f" args: [x] From a9b27d1966015b4eba1e1b9525cf839d37d9420f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 14:23:45 -1000 Subject: [PATCH 0345/1626] [api] Fix OTA progress updates not being sent when main loop is blocked (#10049) --- esphome/components/api/api_connection.h | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 0051a143de..391f4f088f 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -657,10 +657,16 @@ class APIConnection : public APIServerConnection { bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type, uint8_t estimated_size) { // Try to send immediately if: - // 1. We should try to send immediately (should_try_send_immediately = true) - // 2. Batch delay is 0 (user has opted in to immediate sending) - // 3. Buffer has space available - if (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0 && + // 1. It's an UpdateStateResponse (always send immediately to handle cases where + // the main loop is blocked, e.g., during OTA updates) + // 2. OR: We should try to send immediately (should_try_send_immediately = true) + // AND Batch delay is 0 (user has opted in to immediate sending) + // 3. AND: Buffer has space available + if (( +#ifdef USE_UPDATE + message_type == UpdateStateResponse::MESSAGE_TYPE || +#endif + (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)) && this->helper_->can_write_without_blocking()) { // Now actually encode and send if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) && From da573a217dab381749fff96619bd37c2287aab23 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:55:54 +1000 Subject: [PATCH 0346/1626] [font] Catch file load exception (#10058) Co-authored-by: clydeps --- esphome/components/font/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 7d9a35647e..4ecc76c561 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -15,6 +15,7 @@ from freetype import ( FT_LOAD_RENDER, FT_LOAD_TARGET_MONO, Face, + FT_Exception, ft_pixel_mode_mono, ) import requests @@ -94,7 +95,14 @@ class FontCache(MutableMapping): return self.store[self._keytransform(item)] def __setitem__(self, key, value): - self.store[self._keytransform(key)] = Face(str(value)) + transformed = self._keytransform(key) + try: + self.store[transformed] = Face(str(value)) + except FT_Exception as exc: + file = transformed.split(":", 1) + raise cv.Invalid( + f"{file[0].capitalize()} {file[1]} is not a valid font file" + ) from exc FONT_CACHE = FontCache() From 532e3e370f7d8b39f0065ed5dd875456a1c203a1 Mon Sep 17 00:00:00 2001 From: Chris Beswick Date: Mon, 4 Aug 2025 15:43:44 +0100 Subject: [PATCH 0347/1626] [i2s_audio] Use high-pass filter for dc offset correction (#10005) --- .../microphone/i2s_audio_microphone.cpp | 64 +++++++++++++------ .../microphone/i2s_audio_microphone.h | 3 +- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 0477e0682d..f442c74c9f 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -24,9 +24,6 @@ static const uint32_t READ_DURATION_MS = 16; static const size_t TASK_STACK_SIZE = 4096; static const ssize_t TASK_PRIORITY = 23; -// Use an exponential moving average to correct a DC offset with weight factor 1/1000 -static const int32_t DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR = 1000; - static const char *const TAG = "i2s_audio.microphone"; enum MicrophoneEventGroupBits : uint32_t { @@ -382,26 +379,57 @@ void I2SAudioMicrophone::mic_task(void *params) { } void I2SAudioMicrophone::fix_dc_offset_(std::vector &data) { + /** + * From https://www.musicdsp.org/en/latest/Filters/135-dc-filter.html: + * + * y(n) = x(n) - x(n-1) + R * y(n-1) + * R = 1 - (pi * 2 * frequency / samplerate) + * + * From https://en.wikipedia.org/wiki/Hearing_range: + * The human range is commonly given as 20Hz up. + * + * From https://en.wikipedia.org/wiki/High-resolution_audio: + * A reasonable upper bound for sample rate seems to be 96kHz. + * + * Calculate R value for 20Hz on a 96kHz sample rate: + * R = 1 - (pi * 2 * 20 / 96000) + * R = 0.9986910031 + * + * Transform floating point to bit-shifting approximation: + * output = input - prev_input + R * prev_output + * output = input - prev_input + (prev_output - (prev_output >> S)) + * + * Approximate bit-shift value S from R: + * R = 1 - (1 >> S) + * R = 1 - (1 / 2^S) + * R = 1 - 2^-S + * 0.9986910031 = 1 - 2^-S + * S = 9.57732 ~= 10 + * + * Actual R from S: + * R = 1 - 2^-10 = 0.9990234375 + * + * Confirm this has effect outside human hearing on 96000kHz sample: + * 0.9990234375 = 1 - (pi * 2 * f / 96000) + * f = 14.9208Hz + * + * Confirm this has effect outside human hearing on PDM 16kHz sample: + * 0.9990234375 = 1 - (pi * 2 * f / 16000) + * f = 2.4868Hz + * + */ + const uint8_t dc_filter_shift = 10; const size_t bytes_per_sample = this->audio_stream_info_.samples_to_bytes(1); const uint32_t total_samples = this->audio_stream_info_.bytes_to_samples(data.size()); - - if (total_samples == 0) { - return; - } - - int64_t offset_accumulator = 0; for (uint32_t sample_index = 0; sample_index < total_samples; ++sample_index) { const uint32_t byte_index = sample_index * bytes_per_sample; - int32_t sample = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample); - offset_accumulator += sample; - sample -= this->dc_offset_; - audio::pack_q31_as_audio_sample(sample, &data[byte_index], bytes_per_sample); + int32_t input = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample); + int32_t output = input - this->dc_offset_prev_input_ + + (this->dc_offset_prev_output_ - (this->dc_offset_prev_output_ >> dc_filter_shift)); + this->dc_offset_prev_input_ = input; + this->dc_offset_prev_output_ = output; + audio::pack_q31_as_audio_sample(output, &data[byte_index], bytes_per_sample); } - - const int32_t new_offset = offset_accumulator / total_samples; - this->dc_offset_ = new_offset / DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR + - (DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR - 1) * this->dc_offset_ / - DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR; } size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait) { diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 5f66f2e962..acfd535188 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -82,7 +82,8 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub bool correct_dc_offset_; bool locked_driver_{false}; - int32_t dc_offset_{0}; + int32_t dc_offset_prev_input_{0}; + int32_t dc_offset_prev_output_{0}; }; } // namespace i2s_audio From d29cae9c3b79a14d23b4fae1a82d2fdb93c79285 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:21:00 +1200 Subject: [PATCH 0348/1626] Bump version to 2025.7.5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index afd3b582e5..63f48f1238 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.7.4 +PROJECT_NUMBER = 2025.7.5 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 6ed0119411..ca6504d0c9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.7.4" +__version__ = "2025.7.5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 52c450920851bece64c975222a7eff1fe3fec231 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:31:56 +1200 Subject: [PATCH 0349/1626] [esp32_dac] Always use esp-idf APIs (#9833) --- esphome/components/esp32_dac/esp32_dac.cpp | 21 +++------------------ esphome/components/esp32_dac/esp32_dac.h | 12 ++++-------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/esphome/components/esp32_dac/esp32_dac.cpp b/esphome/components/esp32_dac/esp32_dac.cpp index 7d8507c566..8f226a5cc2 100644 --- a/esphome/components/esp32_dac/esp32_dac.cpp +++ b/esphome/components/esp32_dac/esp32_dac.cpp @@ -2,11 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - -#ifdef USE_ARDUINO -#include -#endif +#if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) namespace esphome { namespace esp32_dac { @@ -23,18 +19,12 @@ void ESP32DAC::setup() { this->pin_->setup(); this->turn_off(); -#ifdef USE_ESP_IDF const dac_channel_t channel = this->pin_->get_pin() == DAC0_PIN ? DAC_CHAN_0 : DAC_CHAN_1; const dac_oneshot_config_t oneshot_cfg{channel}; dac_oneshot_new_channel(&oneshot_cfg, &this->dac_handle_); -#endif } -void ESP32DAC::on_safe_shutdown() { -#ifdef USE_ESP_IDF - dac_oneshot_del_channel(this->dac_handle_); -#endif -} +void ESP32DAC::on_safe_shutdown() { dac_oneshot_del_channel(this->dac_handle_); } void ESP32DAC::dump_config() { ESP_LOGCONFIG(TAG, "ESP32 DAC:"); @@ -48,15 +38,10 @@ void ESP32DAC::write_state(float state) { state = state * 255; -#ifdef USE_ESP_IDF dac_oneshot_output_voltage(this->dac_handle_, state); -#endif -#ifdef USE_ARDUINO - dacWrite(this->pin_->get_pin(), state); -#endif } } // namespace esp32_dac } // namespace esphome -#endif +#endif // USE_ESP32_VARIANT_ESP32 || USE_ESP32_VARIANT_ESP32S2 diff --git a/esphome/components/esp32_dac/esp32_dac.h b/esphome/components/esp32_dac/esp32_dac.h index 63d0c914a1..95c687d307 100644 --- a/esphome/components/esp32_dac/esp32_dac.h +++ b/esphome/components/esp32_dac/esp32_dac.h @@ -1,15 +1,13 @@ #pragma once +#include "esphome/components/output/float_output.h" +#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" -#include "esphome/components/output/float_output.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32S2) -#ifdef USE_ESP_IDF #include -#endif namespace esphome { namespace esp32_dac { @@ -29,12 +27,10 @@ class ESP32DAC : public output::FloatOutput, public Component { void write_state(float state) override; InternalGPIOPin *pin_; -#ifdef USE_ESP_IDF dac_oneshot_handle_t dac_handle_; -#endif }; } // namespace esp32_dac } // namespace esphome -#endif +#endif // USE_ESP32_VARIANT_ESP32 || USE_ESP32_VARIANT_ESP32S2 From 396c02c6de4bff1533f3fcbcf682a8ce61d1194a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:33:12 +1200 Subject: [PATCH 0350/1626] [core] Allow extra args on cli and just ignore them (#9814) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index 5e45b7f213..47e1c774ac 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -767,6 +767,12 @@ POST_CONFIG_ACTIONS = { "discover": command_discover, } +SIMPLE_CONFIG_ACTIONS = [ + "clean", + "clean-mqtt", + "config", +] + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) @@ -1032,6 +1038,13 @@ def parse_args(argv): arguments = argv[1:] argcomplete.autocomplete(parser) + + if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS: + args, unknown_args = parser.parse_known_args(arguments) + if unknown_args: + _LOGGER.warning("Ignored unrecognized arguments: %s", unknown_args) + return args + return parser.parse_args(arguments) From c85eb448e4003a28f72c689ef774bb8a08874aba Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:45:52 +1200 Subject: [PATCH 0351/1626] [gpio_expander] Fix bank caching (#10077) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/gpio_expander/cached_gpio.h | 39 +++--- .../gpio_expander_test_component/__init__.py | 25 ++++ .../gpio_expander_test_component.cpp | 38 ++++++ .../gpio_expander_test_component.h | 18 +++ .../fixtures/gpio_expander_cache.yaml | 17 +++ tests/integration/test_gpio_expander_cache.py | 123 ++++++++++++++++++ 6 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.h create mode 100644 tests/integration/fixtures/gpio_expander_cache.yaml create mode 100644 tests/integration/test_gpio_expander_cache.py diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index 78c675cdb2..d7230eb0b3 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -2,10 +2,11 @@ #include #include +#include +#include #include "esphome/core/hal.h" -namespace esphome { -namespace gpio_expander { +namespace esphome::gpio_expander { /// @brief A class to cache the read state of a GPIO expander. /// This class caches reads between GPIO Pins which are on the same bank. @@ -17,12 +18,22 @@ namespace gpio_expander { /// N - Number of pins template class CachedGpioExpander { public: + /// @brief Read the state of the given pin. This will invalidate the cache for the given pin number. + /// @param pin Pin number to read + /// @return Pin state bool digital_read(T pin) { - uint8_t bank = pin / (sizeof(T) * BITS_PER_BYTE); - if (this->read_cache_invalidated_[bank]) { - this->read_cache_invalidated_[bank] = false; + const uint8_t bank = pin / BANK_SIZE; + const T pin_mask = (1 << (pin % BANK_SIZE)); + // Check if specific pin cache is valid + if (this->read_cache_valid_[bank] & pin_mask) { + // Invalidate pin + this->read_cache_valid_[bank] &= ~pin_mask; + } else { + // Read whole bank from hardware if (!this->digital_read_hw(pin)) return false; + // Mark bank cache as valid except the pin that is being returned now + this->read_cache_valid_[bank] = std::numeric_limits::max() & ~pin_mask; } return this->digital_read_cache(pin); } @@ -36,18 +47,16 @@ template class CachedGpioExpander { virtual bool digital_read_cache(T pin) = 0; /// @brief Call component low level function to write GPIO state to device virtual void digital_write_hw(T pin, bool value) = 0; - const uint8_t cache_byte_size_ = N / (sizeof(T) * BITS_PER_BYTE); /// @brief Invalidate cache. This function should be called in component loop(). - void reset_pin_cache_() { - for (T i = 0; i < this->cache_byte_size_; i++) { - this->read_cache_invalidated_[i] = true; - } - } + void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); } - static const uint8_t BITS_PER_BYTE = 8; - std::array read_cache_invalidated_{}; + static constexpr uint8_t BITS_PER_BYTE = 8; + static constexpr uint8_t BANK_SIZE = sizeof(T) * BITS_PER_BYTE; + static constexpr size_t BANKS = N / BANK_SIZE; + static constexpr size_t CACHE_SIZE_BYTES = BANKS * sizeof(T); + + T read_cache_valid_[BANKS]{0}; }; -} // namespace gpio_expander -} // namespace esphome +} // namespace esphome::gpio_expander diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component/__init__.py b/tests/integration/fixtures/external_components/gpio_expander_test_component/__init__.py new file mode 100644 index 0000000000..5672f80004 --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component/__init__.py @@ -0,0 +1,25 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +AUTO_LOAD = ["gpio_expander"] + +gpio_expander_test_component_ns = cg.esphome_ns.namespace( + "gpio_expander_test_component" +) + +GPIOExpanderTestComponent = gpio_expander_test_component_ns.class_( + "GPIOExpanderTestComponent", cg.Component +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(GPIOExpanderTestComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp new file mode 100644 index 0000000000..7e88950592 --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp @@ -0,0 +1,38 @@ +#include "gpio_expander_test_component.h" + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::gpio_expander_test_component { + +static const char *const TAG = "gpio_expander_test"; + +void GPIOExpanderTestComponent::setup() { + for (uint8_t pin = 0; pin < 32; pin++) { + this->digital_read(pin); + } + + this->digital_read(3); + this->digital_read(3); + this->digital_read(4); + this->digital_read(3); + this->digital_read(10); + this->reset_pin_cache_(); // Reset cache to ensure next read is from hardware + this->digital_read(15); + this->digital_read(14); + this->digital_read(14); + + ESP_LOGD(TAG, "DONE"); +} + +bool GPIOExpanderTestComponent::digital_read_hw(uint8_t pin) { + ESP_LOGD(TAG, "digital_read_hw pin=%d", pin); + return true; +} + +bool GPIOExpanderTestComponent::digital_read_cache(uint8_t pin) { + ESP_LOGD(TAG, "digital_read_cache pin=%d", pin); + return true; +} + +} // namespace esphome::gpio_expander_test_component diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.h b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.h new file mode 100644 index 0000000000..ffaee2cd65 --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/core/component.h" + +namespace esphome::gpio_expander_test_component { + +class GPIOExpanderTestComponent : public Component, public esphome::gpio_expander::CachedGpioExpander { + public: + void setup() override; + + protected: + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override{}; +}; + +} // namespace esphome::gpio_expander_test_component diff --git a/tests/integration/fixtures/gpio_expander_cache.yaml b/tests/integration/fixtures/gpio_expander_cache.yaml new file mode 100644 index 0000000000..7d7ca1a876 --- /dev/null +++ b/tests/integration/fixtures/gpio_expander_cache.yaml @@ -0,0 +1,17 @@ +esphome: + name: gpio-expander-cache +host: + +logger: + level: DEBUG + +api: + +# External component that uses gpio_expander::CachedGpioExpander +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [gpio_expander_test_component] + +gpio_expander_test_component: diff --git a/tests/integration/test_gpio_expander_cache.py b/tests/integration/test_gpio_expander_cache.py new file mode 100644 index 0000000000..9353bb1dd6 --- /dev/null +++ b/tests/integration/test_gpio_expander_cache.py @@ -0,0 +1,123 @@ +"""Integration test for CachedGPIOExpander to ensure correct behavior.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_gpio_expander_cache( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test gpio_expander::CachedGpioExpander correctly calls hardware functions.""" + # Get the path to the external components directory + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + + # Replace the placeholder in the YAML config with the actual path + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + logs_done = asyncio.Event() + + # Patterns to match in logs + digital_read_hw_pattern = re.compile(r"digital_read_hw pin=(\d+)") + digital_read_cache_pattern = re.compile(r"digital_read_cache pin=(\d+)") + + # ensure logs are in the expected order + log_order = [ + (digital_read_hw_pattern, 0), + [(digital_read_cache_pattern, i) for i in range(0, 8)], + (digital_read_hw_pattern, 8), + [(digital_read_cache_pattern, i) for i in range(8, 16)], + (digital_read_hw_pattern, 16), + [(digital_read_cache_pattern, i) for i in range(16, 24)], + (digital_read_hw_pattern, 24), + [(digital_read_cache_pattern, i) for i in range(24, 32)], + (digital_read_hw_pattern, 3), + (digital_read_cache_pattern, 3), + (digital_read_hw_pattern, 3), + (digital_read_cache_pattern, 3), + (digital_read_cache_pattern, 4), + (digital_read_hw_pattern, 3), + (digital_read_cache_pattern, 3), + (digital_read_hw_pattern, 10), + (digital_read_cache_pattern, 10), + # full cache reset here for testing + (digital_read_hw_pattern, 15), + (digital_read_cache_pattern, 15), + (digital_read_cache_pattern, 14), + (digital_read_hw_pattern, 14), + (digital_read_cache_pattern, 14), + ] + # Flatten the log order for easier processing + log_order: list[tuple[re.Pattern, int]] = [ + item + for sublist in log_order + for item in (sublist if isinstance(sublist, list) else [sublist]) + ] + + index = 0 + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + nonlocal index + if logs_done.is_set(): + return + + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + + if "digital_read" in clean_line: + if index >= len(log_order): + print(f"Received unexpected log line: {clean_line}") + logs_done.set() + return + + pattern, expected_pin = log_order[index] + match = pattern.search(clean_line) + + if not match: + print(f"Log line did not match next expected pattern: {clean_line}") + logs_done.set() + return + + pin = int(match.group(1)) + if pin != expected_pin: + print(f"Unexpected pin number. Expected {expected_pin}, got {pin}") + logs_done.set() + return + + index += 1 + + elif "DONE" in clean_line: + # Check if we reached the end of the expected log entries + logs_done.set() + + # Run with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "gpio-expander-cache" + + try: + await asyncio.wait_for(logs_done.wait(), timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for logs to complete") + + assert index == len(log_order), ( + f"Expected {len(log_order)} log entries, but got {index}" + ) From 5f9080dac97f5783c34be5e88b1b8d0cac290e26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 15:57:29 -1000 Subject: [PATCH 0352/1626] fix --device OTA --- esphome/__main__.py | 126 ++++++++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 52 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 6bb50864b1..61dee02e49 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -107,30 +107,50 @@ def choose_prompt(options, purpose: str = None): def choose_upload_log_host( - default, check_default, show_ota, show_mqtt, show_api, purpose: str = None -): + default: list[str] | str | None, + check_default: str | None, + show_ota: bool, + show_mqtt: bool, + show_api: bool, + purpose: str | None = None, +) -> list[str]: + # Convert to list for uniform handling + defaults = [default] if isinstance(default, str) else default or [] + + # If devices specified, resolve them + if defaults: + resolved: list[str] = [] + for device in defaults: + if device == "SERIAL": + options = [ + (f"{port.path} ({port.description})", port.path) + for port in get_serial_ports() + ] + resolved.append(choose_prompt(options, purpose=purpose)) + elif device == "OTA": + if (show_ota and "ota" in CORE.config) or ( + show_api and "api" in CORE.config + ): + resolved.append(CORE.address) + elif show_mqtt and has_mqtt_logging(): + resolved.append("MQTT") + else: + resolved.append(device) + return resolved + + # No devices specified, show interactive chooser options = [ (f"{port.path} ({port.description})", port.path) for port in get_serial_ports() ] - if default == "SERIAL": - return choose_prompt(options, purpose=purpose) if (show_ota and "ota" in CORE.config) or (show_api and "api" in CORE.config): options.append((f"Over The Air ({CORE.address})", CORE.address)) - if default == "OTA": - return CORE.address - if ( - show_mqtt - and (mqtt_config := CORE.config.get(CONF_MQTT)) - and mqtt_logging_enabled(mqtt_config) - ): + if show_mqtt and has_mqtt_logging(): + mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) - if default == "OTA": - return "MQTT" - if default is not None: - return default + if check_default is not None and check_default in [opt[1] for opt in options]: - return check_default - return choose_prompt(options, purpose=purpose) + return [check_default] + return [choose_prompt(options, purpose=purpose)] def mqtt_logging_enabled(mqtt_config): @@ -142,6 +162,13 @@ def mqtt_logging_enabled(mqtt_config): return log_topic.get(CONF_LEVEL, None) != "NONE" +def has_mqtt_logging() -> bool: + """Check if MQTT logging is available.""" + return (mqtt_config := CORE.config.get(CONF_MQTT)) and mqtt_logging_enabled( + mqtt_config + ) + + def get_port_type(port: str) -> str: if port.startswith("/") or port.startswith("COM"): return "SERIAL" @@ -507,19 +534,18 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: - # No devices specified, use the interactive chooser - devices: list[str] = args.device or [ - choose_upload_log_host( - default=None, - check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, - purpose="uploading", - ) - ] + # Get devices, resolving special identifiers like OTA + devices = choose_upload_log_host( + default=args.device, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + purpose="uploading", + ) # Try each device until one succeeds + exit_code = 1 for device in devices: _LOGGER.info("Uploading to %s", device) exit_code = upload_program(config, args, device) @@ -542,17 +568,15 @@ def command_discover(args: ArgsProtocol, config: ConfigType) -> int | None: def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: - # No devices specified, use the interactive chooser - devices = args.device or [ - choose_upload_log_host( - default=None, - check_default=None, - show_ota=False, - show_mqtt=True, - show_api=True, - purpose="logging", - ) - ] + # Get devices, resolving special identifiers like OTA + devices = choose_upload_log_host( + default=args.device, + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=True, + purpose="logging", + ) return show_logs(config, args, devices) @@ -573,17 +597,15 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: program_path = idedata.raw["prog_path"] return run_external_process(program_path) - # No devices specified, use the interactive chooser - devices = args.device or [ - choose_upload_log_host( - default=None, - check_default=None, - show_ota=True, - show_mqtt=False, - show_api=True, - purpose="uploading", - ) - ] + # Get devices, resolving special identifiers like OTA + devices = choose_upload_log_host( + default=args.device, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=True, + purpose="uploading", + ) # Try each device for upload until one succeeds successful_device: str | None = None @@ -604,7 +626,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: return 0 # For logs, prefer the device we successfully uploaded to - port = choose_upload_log_host( + devices = choose_upload_log_host( default=successful_device, check_default=successful_device, show_ota=False, @@ -612,7 +634,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: show_api=True, purpose="logging", ) - return show_logs(config, args, [port]) + return show_logs(config, args, devices) def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None: From 655d001d724f6cbeda6f7fe6422ded4b6aa24ec9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 16:26:40 -1000 Subject: [PATCH 0353/1626] address bot comments --- .../components/esp32_ble_tracker/esp32_ble_tracker.cpp | 1 + esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 9143f25a25..a610dacf20 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -803,6 +803,7 @@ void ESP32BLETracker::handle_scanner_failure_() { } void ESP32BLETracker::try_promote_discovered_clients_() { + // Only promote the first discovered client to avoid multiple simultaneous connections for (auto *client : this->clients_) { if (client->state() != ClientState::DISCOVERED) { continue; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 77020d3222..b7245a8fdd 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -142,10 +142,12 @@ struct ClientStateCounts { uint8_t searching = 0; uint8_t disconnecting = 0; - bool operator!=(const ClientStateCounts &other) const { - return connecting != other.connecting || discovered != other.discovered || searching != other.searching || - disconnecting != other.disconnecting; + bool operator==(const ClientStateCounts &other) const { + return connecting == other.connecting && discovered == other.discovered && searching == other.searching && + disconnecting == other.disconnecting; } + + bool operator!=(const ClientStateCounts &other) const { return !(*this == other); } }; enum class ClientState : uint8_t { From 2fddb061e1211844ac13f900a3638076578827e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:51:42 -1000 Subject: [PATCH 0354/1626] Bump aioesphomeapi from 37.2.4 to 37.2.5 (#10080) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6f79e86bdf..134dcde822 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.2.4 +aioesphomeapi==37.2.5 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 06eb1b6014ca3c425086ea969f6ceffc87139089 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 5 Aug 2025 00:09:37 -0400 Subject: [PATCH 0355/1626] [remote_transmitter] Add digital_write automation (#10069) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../components/remote_transmitter/__init__.py | 26 +++++++++++++++++++ .../remote_transmitter/automation.h | 18 +++++++++++++ .../remote_transmitter/remote_transmitter.h | 3 ++- .../remote_transmitter_esp8266.cpp | 2 ++ .../remote_transmitter_libretiny.cpp | 2 ++ .../remote_transmitter/common-buttons.yaml | 6 +++++ 6 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 esphome/components/remote_transmitter/automation.h diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 47a46ff56b..e79437013f 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_PIN, CONF_RMT_SYMBOLS, CONF_USE_DMA, + CONF_VALUE, PlatformFramework, ) from esphome.core import CORE @@ -22,11 +23,17 @@ AUTO_LOAD = ["remote_base"] CONF_EOT_LEVEL = "eot_level" CONF_ON_TRANSMIT = "on_transmit" CONF_ON_COMPLETE = "on_complete" +CONF_TRANSMITTER_ID = remote_base.CONF_TRANSMITTER_ID remote_transmitter_ns = cg.esphome_ns.namespace("remote_transmitter") RemoteTransmitterComponent = remote_transmitter_ns.class_( "RemoteTransmitterComponent", remote_base.RemoteTransmitterBase, cg.Component ) +DigitalWriteAction = remote_transmitter_ns.class_( + "DigitalWriteAction", + automation.Action, + cg.Parented.template(RemoteTransmitterComponent), +) MULTI_CONF = True CONFIG_SCHEMA = cv.Schema( @@ -63,6 +70,25 @@ CONFIG_SCHEMA = cv.Schema( } ).extend(cv.COMPONENT_SCHEMA) +DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(CONF_TRANSMITTER_ID): cv.use_id(RemoteTransmitterComponent), + cv.Required(CONF_VALUE): cv.templatable(cv.boolean), + }, + key=CONF_VALUE, +) + + +@automation.register_action( + "remote_transmitter.digital_write", DigitalWriteAction, DIGITAL_WRITE_ACTION_SCHEMA +) +async def digital_write_action_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_TRANSMITTER_ID]) + template_ = await cg.templatable(config[CONF_VALUE], args, bool) + cg.add(var.set_value(template_)) + return var + async def to_code(config): pin = await cg.gpio_pin_expression(config[CONF_PIN]) diff --git a/esphome/components/remote_transmitter/automation.h b/esphome/components/remote_transmitter/automation.h new file mode 100644 index 0000000000..75b017ec61 --- /dev/null +++ b/esphome/components/remote_transmitter/automation.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/remote_transmitter/remote_transmitter.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace remote_transmitter { + +template class DigitalWriteAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, value) + void play(Ts... x) override { this->parent_->digital_write(this->value_.value(x...)); } +}; + +} // namespace remote_transmitter +} // namespace esphome diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index f0dab2aaf8..aa1f54911d 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -30,10 +30,11 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, void set_carrier_duty_percent(uint8_t carrier_duty_percent) { this->carrier_duty_percent_ = carrier_duty_percent; } + void digital_write(bool value); + #if defined(USE_ESP32) void set_with_dma(bool with_dma) { this->with_dma_ = with_dma; } void set_eot_level(bool eot_level) { this->eot_level_ = eot_level; } - void digital_write(bool value); #endif Trigger<> *get_transmit_trigger() const { return this->transmit_trigger_; }; diff --git a/esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp b/esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp index 73a1a7754f..fdd4198773 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp @@ -73,6 +73,8 @@ void RemoteTransmitterComponent::space_(uint32_t usec) { this->target_time_ += usec; } +void RemoteTransmitterComponent::digital_write(bool value) { this->pin_->digital_write(value); } + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { ESP_LOGD(TAG, "Sending remote code"); uint32_t on_time, off_time; diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp index 42bf5bd95b..9ba850090d 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp @@ -75,6 +75,8 @@ void RemoteTransmitterComponent::space_(uint32_t usec) { this->target_time_ += usec; } +void RemoteTransmitterComponent::digital_write(bool value) { this->pin_->digital_write(value); } + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { ESP_LOGD(TAG, "Sending remote code"); uint32_t on_time, off_time; diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 29f48d995d..3be4bf3cca 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -204,3 +204,9 @@ button: command: 0xEC rc_code_1: 0x0D rc_code_2: 0x0D + - platform: template + name: Digital Write + on_press: + - remote_transmitter.digital_write: true + - remote_transmitter.digital_write: + value: false From 969034b61a36cca73831f2f9a4cb278e37c53cf3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 16:18:42 +1200 Subject: [PATCH 0356/1626] Bump docker/login-action from 3.4.0 to 3.5.0 in the docker-actions group (#10081) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44919a6270..9d955df85f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,12 +102,12 @@ jobs: uses: docker/setup-buildx-action@v3.11.1 - name: Log in to docker hub - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -182,13 +182,13 @@ jobs: - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.actor }} From bc03538e25a954cd78bac6b35de08a149988b026 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 18:40:46 -1000 Subject: [PATCH 0357/1626] Support multiple --device arguments for address fallback (#10003) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/__main__.py | 224 +++++++++++++++++++++---------- esphome/components/api/client.py | 18 ++- esphome/dashboard/web_server.py | 44 +++--- esphome/util.py | 11 +- 4 files changed, 201 insertions(+), 96 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 47e1c774ac..7cc8296e7e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -9,6 +9,7 @@ import os import re import sys import time +from typing import Protocol import argcomplete @@ -44,6 +45,7 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError, coroutine from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log +from esphome.types import ConfigType from esphome.util import ( get_serial_ports, list_yaml_files, @@ -55,6 +57,23 @@ from esphome.util import ( _LOGGER = logging.getLogger(__name__) +class ArgsProtocol(Protocol): + device: list[str] | None + reset: bool + username: str | None + password: str | None + client_id: str | None + topic: str | None + file: str | None + no_logs: bool + only_generate: bool + show_secrets: bool + dashboard: bool + configuration: str + name: str + upload_speed: str | None + + def choose_prompt(options, purpose: str = None): if not options: raise EsphomeError( @@ -88,30 +107,54 @@ def choose_prompt(options, purpose: str = None): def choose_upload_log_host( - default, check_default, show_ota, show_mqtt, show_api, purpose: str = None -): + default: list[str] | str | None, + check_default: str | None, + show_ota: bool, + show_mqtt: bool, + show_api: bool, + purpose: str | None = None, +) -> list[str]: + # Convert to list for uniform handling + defaults = [default] if isinstance(default, str) else default or [] + + # If devices specified, resolve them + if defaults: + resolved: list[str] = [] + for device in defaults: + if device == "SERIAL": + serial_ports = get_serial_ports() + if not serial_ports: + _LOGGER.warning("No serial ports found, skipping SERIAL device") + continue + options = [ + (f"{port.path} ({port.description})", port.path) + for port in serial_ports + ] + resolved.append(choose_prompt(options, purpose=purpose)) + elif device == "OTA": + if (show_ota and "ota" in CORE.config) or ( + show_api and "api" in CORE.config + ): + resolved.append(CORE.address) + elif show_mqtt and has_mqtt_logging(): + resolved.append("MQTT") + else: + resolved.append(device) + return resolved + + # No devices specified, show interactive chooser options = [ (f"{port.path} ({port.description})", port.path) for port in get_serial_ports() ] - if default == "SERIAL": - return choose_prompt(options, purpose=purpose) if (show_ota and "ota" in CORE.config) or (show_api and "api" in CORE.config): options.append((f"Over The Air ({CORE.address})", CORE.address)) - if default == "OTA": - return CORE.address - if ( - show_mqtt - and (mqtt_config := CORE.config.get(CONF_MQTT)) - and mqtt_logging_enabled(mqtt_config) - ): + if show_mqtt and has_mqtt_logging(): + mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) - if default == "OTA": - return "MQTT" - if default is not None: - return default + if check_default is not None and check_default in [opt[1] for opt in options]: - return check_default - return choose_prompt(options, purpose=purpose) + return [check_default] + return [choose_prompt(options, purpose=purpose)] def mqtt_logging_enabled(mqtt_config): @@ -123,7 +166,14 @@ def mqtt_logging_enabled(mqtt_config): return log_topic.get(CONF_LEVEL, None) != "NONE" -def get_port_type(port): +def has_mqtt_logging() -> bool: + """Check if MQTT logging is available.""" + return (mqtt_config := CORE.config.get(CONF_MQTT)) and mqtt_logging_enabled( + mqtt_config + ) + + +def get_port_type(port: str) -> str: if port.startswith("/") or port.startswith("COM"): return "SERIAL" if port == "MQTT": @@ -131,7 +181,7 @@ def get_port_type(port): return "NETWORK" -def run_miniterm(config, port, args): +def run_miniterm(config: ConfigType, port: str, args) -> int: from aioesphomeapi import LogParser import serial @@ -208,7 +258,7 @@ def wrap_to_code(name, comp): return wrapped -def write_cpp(config): +def write_cpp(config: ConfigType) -> int: if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() @@ -216,7 +266,7 @@ def write_cpp(config): return write_cpp_file() -def generate_cpp_contents(config): +def generate_cpp_contents(config: ConfigType) -> None: _LOGGER.info("Generating C++ source...") for name, component, conf in iter_component_configs(CORE.config): @@ -227,7 +277,7 @@ def generate_cpp_contents(config): CORE.flush_tasks() -def write_cpp_file(): +def write_cpp_file() -> int: code_s = indent(CORE.cpp_main_section) writer.write_cpp(code_s) @@ -238,7 +288,7 @@ def write_cpp_file(): return 0 -def compile_program(args, config): +def compile_program(args: ArgsProtocol, config: ConfigType) -> int: from esphome import platformio_api _LOGGER.info("Compiling app...") @@ -249,7 +299,9 @@ def compile_program(args, config): return 0 if idedata is not None else 1 -def upload_using_esptool(config, port, file, speed): +def upload_using_esptool( + config: ConfigType, port: str, file: str, speed: int +) -> str | int: from esphome import platformio_api first_baudrate = speed or config[CONF_ESPHOME][CONF_PLATFORMIO_OPTIONS].get( @@ -314,7 +366,7 @@ def upload_using_esptool(config, port, file, speed): return run_esptool(115200) -def upload_using_platformio(config, port): +def upload_using_platformio(config: ConfigType, port: str): from esphome import platformio_api upload_args = ["-t", "upload", "-t", "nobuild"] @@ -323,7 +375,7 @@ def upload_using_platformio(config, port): return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args) -def check_permissions(port): +def check_permissions(port: str): if os.name == "posix" and get_port_type(port) == "SERIAL": # Check if we can open selected serial port if not os.access(port, os.F_OK): @@ -341,7 +393,7 @@ def check_permissions(port): ) -def upload_program(config, args, host): +def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | str: try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): @@ -356,7 +408,7 @@ def upload_program(config, args, host): return upload_using_esptool(config, host, file, args.upload_speed) if CORE.target_platform in (PLATFORM_RP2040): - return upload_using_platformio(config, args.device) + return upload_using_platformio(config, host) if CORE.is_libretiny: return upload_using_platformio(config, host) @@ -379,9 +431,12 @@ def upload_program(config, args, host): remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD, "") + # Check if we should use MQTT for address resolution + # This happens when no device was specified, or the current host is "MQTT"/"OTA" + devices: list[str] = args.device or [] if ( CONF_MQTT in config # pylint: disable=too-many-boolean-expressions - and (not args.device or args.device in ("MQTT", "OTA")) + and (not devices or host in ("MQTT", "OTA")) and ( ((config[CONF_MDNS][CONF_DISABLED]) and not is_ip_address(CORE.address)) or get_port_type(host) == "MQTT" @@ -399,23 +454,28 @@ def upload_program(config, args, host): return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) -def show_logs(config, args, port): +def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: if "logger" not in config: raise EsphomeError("Logger is not configured!") + + port = devices[0] + if get_port_type(port) == "SERIAL": check_permissions(port) return run_miniterm(config, port, args) if get_port_type(port) == "NETWORK" and "api" in config: + addresses_to_use = devices if config[CONF_MDNS][CONF_DISABLED] and CONF_MQTT in config: from esphome import mqtt - port = mqtt.get_esphome_device_ip( + mqtt_address = mqtt.get_esphome_device_ip( config, args.username, args.password, args.client_id )[0] + addresses_to_use = [mqtt_address] from esphome.components.api.client import run_logs - return run_logs(config, port) + return run_logs(config, addresses_to_use) if get_port_type(port) == "MQTT" and "mqtt" in config: from esphome import mqtt @@ -426,7 +486,7 @@ def show_logs(config, args, port): raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)") -def clean_mqtt(config, args): +def clean_mqtt(config: ConfigType, args: ArgsProtocol) -> int | None: from esphome import mqtt return mqtt.clear_topic( @@ -434,13 +494,13 @@ def clean_mqtt(config, args): ) -def command_wizard(args): +def command_wizard(args: ArgsProtocol) -> int | None: from esphome import wizard return wizard.wizard(args.configuration) -def command_config(args, config): +def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: if not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) @@ -455,7 +515,7 @@ def command_config(args, config): return 0 -def command_vscode(args): +def command_vscode(args: ArgsProtocol) -> int | None: from esphome import vscode logging.disable(logging.INFO) @@ -463,7 +523,7 @@ def command_vscode(args): vscode.read_config(args) -def command_compile(args, config): +def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: exit_code = write_cpp(config) if exit_code != 0: return exit_code @@ -477,8 +537,9 @@ def command_compile(args, config): return 0 -def command_upload(args, config): - port = choose_upload_log_host( +def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: + # Get devices, resolving special identifiers like OTA + devices = choose_upload_log_host( default=args.device, check_default=None, show_ota=True, @@ -486,14 +547,22 @@ def command_upload(args, config): show_api=False, purpose="uploading", ) - exit_code = upload_program(config, args, port) - if exit_code != 0: - return exit_code - _LOGGER.info("Successfully uploaded program.") - return 0 + + # Try each device until one succeeds + exit_code = 1 + for device in devices: + _LOGGER.info("Uploading to %s", device) + exit_code = upload_program(config, args, device) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + return 0 + if len(devices) > 1: + _LOGGER.warning("Failed to upload to %s", device) + + return exit_code -def command_discover(args, config): +def command_discover(args: ArgsProtocol, config: ConfigType) -> int | None: if "mqtt" in config: from esphome import mqtt @@ -502,8 +571,9 @@ def command_discover(args, config): raise EsphomeError("No discover method configured (mqtt)") -def command_logs(args, config): - port = choose_upload_log_host( +def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: + # Get devices, resolving special identifiers like OTA + devices = choose_upload_log_host( default=args.device, check_default=None, show_ota=False, @@ -511,10 +581,10 @@ def command_logs(args, config): show_api=True, purpose="logging", ) - return show_logs(config, args, port) + return show_logs(config, args, devices) -def command_run(args, config): +def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: exit_code = write_cpp(config) if exit_code != 0: return exit_code @@ -531,7 +601,8 @@ def command_run(args, config): program_path = idedata.raw["prog_path"] return run_external_process(program_path) - port = choose_upload_log_host( + # Get devices, resolving special identifiers like OTA + devices = choose_upload_log_host( default=args.device, check_default=None, show_ota=True, @@ -539,39 +610,53 @@ def command_run(args, config): show_api=True, purpose="uploading", ) - exit_code = upload_program(config, args, port) - if exit_code != 0: + + # Try each device for upload until one succeeds + successful_device: str | None = None + for device in devices: + _LOGGER.info("Uploading to %s", device) + exit_code = upload_program(config, args, device) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + successful_device = device + break + if len(devices) > 1: + _LOGGER.warning("Failed to upload to %s", device) + + if successful_device is None: return exit_code - _LOGGER.info("Successfully uploaded program.") + if args.no_logs: return 0 - port = choose_upload_log_host( - default=args.device, - check_default=port, + + # For logs, prefer the device we successfully uploaded to + devices = choose_upload_log_host( + default=successful_device, + check_default=successful_device, show_ota=False, show_mqtt=True, show_api=True, purpose="logging", ) - return show_logs(config, args, port) + return show_logs(config, args, devices) -def command_clean_mqtt(args, config): +def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None: return clean_mqtt(config, args) -def command_mqtt_fingerprint(args, config): +def command_mqtt_fingerprint(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import mqtt return mqtt.get_fingerprint(config) -def command_version(args): +def command_version(args: ArgsProtocol) -> int | None: safe_print(f"Version: {const.__version__}") return 0 -def command_clean(args, config): +def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: try: writer.clean_build() except OSError as err: @@ -581,13 +666,13 @@ def command_clean(args, config): return 0 -def command_dashboard(args): +def command_dashboard(args: ArgsProtocol) -> int | None: from esphome.dashboard import dashboard return dashboard.start_dashboard(args) -def command_update_all(args): +def command_update_all(args: ArgsProtocol) -> int | None: import click success = {} @@ -634,7 +719,7 @@ def command_update_all(args): return failed -def command_idedata(args, config): +def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json from esphome import platformio_api @@ -650,7 +735,7 @@ def command_idedata(args, config): return 0 -def command_rename(args, config): +def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: for c in args.name: if c not in ALLOWED_NAME_CHARS: print( @@ -860,7 +945,8 @@ def parse_args(argv): ) parser_upload.add_argument( "--device", - help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", + action="append", + help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.", ) parser_upload.add_argument( "--upload_speed", @@ -882,7 +968,8 @@ def parse_args(argv): ) parser_logs.add_argument( "--device", - help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", + action="append", + help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.", ) parser_logs.add_argument( "--reset", @@ -911,7 +998,8 @@ def parse_args(argv): ) parser_run.add_argument( "--device", - help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", + action="append", + help="Manually specify the serial port/address to use, for example /dev/ttyUSB0. Can be specified multiple times for fallback addresses.", ) parser_run.add_argument( "--upload_speed", diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 5239e07435..ce018b3b98 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -async def async_run_logs(config: dict[str, Any], address: str) -> None: +async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: """Run the logs command in the event loop.""" conf = config["api"] name = config["esphome"]["name"] @@ -39,13 +39,21 @@ async def async_run_logs(config: dict[str, Any], address: str) -> None: noise_psk: str | None = None if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)): noise_psk = key - _LOGGER.info("Starting log output from %s using esphome API", address) + + if len(addresses) == 1: + _LOGGER.info("Starting log output from %s using esphome API", addresses[0]) + else: + _LOGGER.info( + "Starting log output from %s using esphome API", " or ".join(addresses) + ) + cli = APIClient( - address, + addresses[0], # Primary address for compatibility port, password, client_info=f"ESPHome Logs {__version__}", noise_psk=noise_psk, + addresses=addresses, # Pass all addresses for automatic retry ) dashboard = CORE.dashboard @@ -66,7 +74,7 @@ async def async_run_logs(config: dict[str, Any], address: str) -> None: await stop() -def run_logs(config: dict[str, Any], address: str) -> None: +def run_logs(config: dict[str, Any], addresses: list[str]) -> None: """Run the logs command.""" with contextlib.suppress(KeyboardInterrupt): - asyncio.run(async_run_logs(config, address)) + asyncio.run(async_run_logs(config, addresses)) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 286dc9e1d7..46f09336bb 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -324,39 +324,47 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): configuration = json_message["configuration"] config_file = settings.rel_path(configuration) port = json_message["port"] + addresses: list[str] = [port] if ( port == "OTA" # pylint: disable=too-many-boolean-expressions and (entry := entries.get(config_file)) and entry.loaded_integrations and "api" in entry.loaded_integrations ): - if (mdns := dashboard.mdns_status) and ( - address_list := await mdns.async_resolve_host(entry.name) - ): - # Use the IP address if available but only - # if the API is loaded and the device is online - # since MQTT logging will not work otherwise - port = sort_ip_addresses(address_list)[0] - elif ( - entry.address + addresses = [] + # First priority: entry.address AKA use_address + if ( + (use_address := entry.address) and ( address_list := await dashboard.dns_cache.async_resolve( - entry.address, time.monotonic() + use_address, time.monotonic() ) ) and not isinstance(address_list, Exception) ): - # If mdns is not available, try to use the DNS cache - port = sort_ip_addresses(address_list)[0] + addresses.extend(sort_ip_addresses(address_list)) - return [ - *DASHBOARD_COMMAND, - *args, - config_file, - "--device", - port, + # Second priority: mDNS + if ( + (mdns := dashboard.mdns_status) + and (address_list := await mdns.async_resolve_host(entry.name)) + and ( + new_addresses := [ + addr for addr in address_list if addr not in addresses + ] + ) + ): + # Use the IP address if available but only + # if the API is loaded and the device is online + # since MQTT logging will not work otherwise + addresses.extend(sort_ip_addresses(new_addresses)) + + device_args: list[str] = [ + arg for address in addresses for arg in ("--device", address) ] + return [*DASHBOARD_COMMAND, *args, config_file, *device_args] + class EsphomeLogsHandler(EsphomePortCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: diff --git a/esphome/util.py b/esphome/util.py index 9aa0f6b9d8..ed9ab4a446 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -6,6 +6,7 @@ from pathlib import Path import re import subprocess import sys +from typing import Any from esphome import const @@ -110,7 +111,7 @@ class RedirectText: def __getattr__(self, item): return getattr(self._out, item) - def _write_color_replace(self, s): + def _write_color_replace(self, s: str | bytes) -> None: from esphome.core import CORE if CORE.dashboard: @@ -121,7 +122,7 @@ class RedirectText: s = s.replace("\033", "\\033") self._out.write(s) - def write(self, s): + def write(self, s: str | bytes) -> int: # s is usually a str already (self._out is of type TextIOWrapper) # However, s is sometimes also a bytes object in python3. Let's make sure it's a # str @@ -223,7 +224,7 @@ def run_external_command( return retval -def run_external_process(*cmd, **kwargs): +def run_external_process(*cmd: str, **kwargs: Any) -> int | str: full_cmd = " ".join(shlex_quote(x) for x in cmd) _LOGGER.debug("Running: %s", full_cmd) filter_lines = kwargs.get("filter_lines") @@ -266,7 +267,7 @@ class OrderedDict(collections.OrderedDict): return dict(self).__repr__() -def list_yaml_files(folders): +def list_yaml_files(folders: list[str]) -> list[str]: files = filter_yaml_files( [os.path.join(folder, p) for folder in folders for p in os.listdir(folder)] ) @@ -274,7 +275,7 @@ def list_yaml_files(folders): return files -def filter_yaml_files(files): +def filter_yaml_files(files: list[str]) -> list[str]: return [ f for f in files From ba9cf1b5f6035260b58dcfceeedbe9e4b008b03a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 19:56:22 -1000 Subject: [PATCH 0358/1626] Add myself to multiple bluetooth codeowners --- CODEOWNERS | 7 ++++--- esphome/components/bluetooth_proxy/__init__.py | 2 +- esphome/components/esp32_ble/__init__.py | 2 +- esphome/components/esp32_ble_client/__init__.py | 2 +- esphome/components/esp32_ble_tracker/__init__.py | 1 + 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index e40be9a737..5ef08d711a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -69,7 +69,7 @@ esphome/components/bl0939/* @ziceva esphome/components/bl0940/* @tobias- esphome/components/bl0942/* @dbuezas @dwmw2 esphome/components/ble_client/* @buxtronix @clydebarrow -esphome/components/bluetooth_proxy/* @jesserockz +esphome/components/bluetooth_proxy/* @bdraco @jesserockz esphome/components/bme280_base/* @esphome/core esphome/components/bme280_spi/* @apbodrov esphome/components/bme680_bsec/* @trvrnrth @@ -144,9 +144,10 @@ esphome/components/es8156/* @kbx81 esphome/components/es8311/* @kahrendt @kroimon esphome/components/es8388/* @P4uLT esphome/components/esp32/* @esphome/core -esphome/components/esp32_ble/* @Rapsssito @jesserockz -esphome/components/esp32_ble_client/* @jesserockz +esphome/components/esp32_ble/* @Rapsssito @bdraco @jesserockz +esphome/components/esp32_ble_client/* @bdraco @jesserockz esphome/components/esp32_ble_server/* @Rapsssito @clydebarrow @jesserockz +esphome/components/esp32_ble_tracker/* @bdraco esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron esphome/components/esp32_hosted/* @swoboda1337 diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 4087255410..fb7f7a37c0 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -11,7 +11,7 @@ from esphome.log import AnsiFore, color AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"] DEPENDENCIES = ["api", "esp32"] -CODEOWNERS = ["@jesserockz"] +CODEOWNERS = ["@jesserockz", "@bdraco"] _LOGGER = logging.getLogger(__name__) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 1c7c075cfa..f208fda34c 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -11,7 +11,7 @@ from esphome.core.config import CONF_NAME_ADD_MAC_SUFFIX import esphome.final_validate as fv DEPENDENCIES = ["esp32"] -CODEOWNERS = ["@jesserockz", "@Rapsssito"] +CODEOWNERS = ["@jesserockz", "@Rapsssito", "@bdraco"] class BTLoggers(Enum): diff --git a/esphome/components/esp32_ble_client/__init__.py b/esphome/components/esp32_ble_client/__init__.py index 25957ed0da..55619f1fc0 100644 --- a/esphome/components/esp32_ble_client/__init__.py +++ b/esphome/components/esp32_ble_client/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import esp32_ble_tracker AUTO_LOAD = ["esp32_ble_tracker"] -CODEOWNERS = ["@jesserockz"] +CODEOWNERS = ["@jesserockz", "@bdraco"] DEPENDENCIES = ["esp32"] esp32_ble_client_ns = cg.esphome_ns.namespace("esp32_ble_client") diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 9daa6ee34e..e1abdd8490 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -36,6 +36,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble"] DEPENDENCIES = ["esp32"] +CODEOWNERS = ["@bdraco"] KEY_ESP32_BLE_TRACKER = "esp32_ble_tracker" KEY_USED_CONNECTION_SLOTS = "used_connection_slots" From acdcf514b939dc1e0d9c5bcc8f925b9aa2088fb7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 20:14:30 -1000 Subject: [PATCH 0359/1626] [esp32_ble_tracker] Add missing USE_ESP32_BLE_DEVICE guard for already_discovered_ member --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 4 ++++ esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 2 ++ 2 files changed, 6 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 856ae82dca..6180c53ad8 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -241,7 +241,9 @@ void ESP32BLETracker::start_scan_(bool first) { for (auto *listener : this->listeners_) listener->on_scan_end(); } +#ifdef USE_ESP32_BLE_DEVICE this->already_discovered_.clear(); +#endif this->scan_params_.scan_type = this->scan_active_ ? BLE_SCAN_TYPE_ACTIVE : BLE_SCAN_TYPE_PASSIVE; this->scan_params_.own_addr_type = BLE_ADDR_TYPE_PUBLIC; this->scan_params_.scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL; @@ -839,7 +841,9 @@ bool ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { ESP_LOGD(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); +#ifdef USE_ESP32_BLE_DEVICE this->already_discovered_.clear(); +#endif this->cancel_timeout("scan"); for (auto *listener : this->listeners_) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 1c28bc7a7d..b46c88b4de 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -282,8 +282,10 @@ class ESP32BLETracker : public Component, uint8_t app_id_{0}; +#ifdef USE_ESP32_BLE_DEVICE /// Vector of addresses that have already been printed in print_bt_device_info std::vector already_discovered_; +#endif std::vector listeners_; /// Client parameters. std::vector clients_; From 49a46883ed5895cb76641128aae2bd66a7f247b4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:24:24 +1200 Subject: [PATCH 0360/1626] [core] Update core component codeowners to ``@esphome/core`` (#10082) --- CODEOWNERS | 14 +++++++------- esphome/components/api/__init__.py | 2 +- esphome/components/async_tcp/__init__.py | 2 +- esphome/components/captive_portal/__init__.py | 2 +- esphome/components/debug/__init__.py | 2 +- esphome/components/json/__init__.py | 2 +- esphome/components/time/__init__.py | 2 +- esphome/components/web_server_base/__init__.py | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index e40be9a737..708bfaa0e8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -40,11 +40,11 @@ esphome/components/analog_threshold/* @ianchi esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix esphome/components/apds9306/* @aodrenah -esphome/components/api/* @OttoWinter +esphome/components/api/* @esphome/core esphome/components/as5600/* @ammmze esphome/components/as5600/sensor/* @ammmze esphome/components/as7341/* @mrgnr -esphome/components/async_tcp/* @OttoWinter +esphome/components/async_tcp/* @esphome/core esphome/components/at581x/* @X-Ryl669 esphome/components/atc_mithermometer/* @ahpohl esphome/components/atm90e26/* @danieltwagner @@ -91,7 +91,7 @@ esphome/components/bytebuffer/* @clydebarrow esphome/components/camera/* @DT-art1 @bdraco esphome/components/canbus/* @danielschramm @mvturnho esphome/components/cap1188/* @mreditor97 -esphome/components/captive_portal/* @OttoWinter +esphome/components/captive_portal/* @esphome/core esphome/components/ccs811/* @habbie esphome/components/cd74hc4067/* @asoehlke esphome/components/ch422g/* @clydebarrow @jesterret @@ -118,7 +118,7 @@ esphome/components/dallas_temp/* @ssieb esphome/components/daly_bms/* @s1lvi0 esphome/components/dashboard_import/* @esphome/core esphome/components/datetime/* @jesserockz @rfdarter -esphome/components/debug/* @OttoWinter +esphome/components/debug/* @esphome/core esphome/components/delonghi/* @grob6000 esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber @@ -237,7 +237,7 @@ esphome/components/integration/* @OttoWinter esphome/components/internal_temperature/* @Mat931 esphome/components/interval/* @esphome/core esphome/components/jsn_sr04t/* @Mafus1 -esphome/components/json/* @OttoWinter +esphome/components/json/* @esphome/core esphome/components/kamstrup_kmp/* @cfeenstra1024 esphome/components/key_collector/* @ssieb esphome/components/key_provider/* @ssieb @@ -467,7 +467,7 @@ esphome/components/template/event/* @nohat esphome/components/template/fan/* @ssieb esphome/components/text/* @mauritskorse esphome/components/thermostat/* @kbx81 -esphome/components/time/* @OttoWinter +esphome/components/time/* @esphome/core esphome/components/tlc5947/* @rnauber esphome/components/tlc5971/* @IJIJI esphome/components/tm1621/* @Philippe12 @@ -511,7 +511,7 @@ esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher esphome/components/waveshare_epaper/* @clydebarrow esphome/components/web_server/ota/* @esphome/core -esphome/components/web_server_base/* @OttoWinter +esphome/components/web_server_base/* @esphome/core esphome/components/web_server_idf/* @dentra esphome/components/weikai/* @DrCoolZic esphome/components/weikai_i2c/* @DrCoolZic diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 5d398a4e23..ba5f994e9a 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -29,7 +29,7 @@ from esphome.core import CORE, coroutine_with_priority DOMAIN = "api" DEPENDENCIES = ["network"] AUTO_LOAD = ["socket"] -CODEOWNERS = ["@OttoWinter"] +CODEOWNERS = ["@esphome/core"] api_ns = cg.esphome_ns.namespace("api") APIServer = api_ns.class_("APIServer", cg.Component, cg.Controller) diff --git a/esphome/components/async_tcp/__init__.py b/esphome/components/async_tcp/__init__.py index 4a469fa0e0..942d5bc8e5 100644 --- a/esphome/components/async_tcp/__init__.py +++ b/esphome/components/async_tcp/__init__.py @@ -10,7 +10,7 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority -CODEOWNERS = ["@OttoWinter"] +CODEOWNERS = ["@esphome/core"] CONFIG_SCHEMA = cv.All( cv.Schema({}), diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 7e8afd8fab..cd69b67c78 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -14,7 +14,7 @@ from esphome.core import CORE, coroutine_with_priority AUTO_LOAD = ["web_server_base", "ota.web_server"] DEPENDENCIES = ["wifi"] -CODEOWNERS = ["@OttoWinter"] +CODEOWNERS = ["@esphome/core"] captive_portal_ns = cg.esphome_ns.namespace("captive_portal") CaptivePortal = captive_portal_ns.class_("CaptivePortal", cg.Component) diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index b8dabc3374..9dffe00bf4 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -13,7 +13,7 @@ from esphome.const import ( ) from esphome.core import CORE -CODEOWNERS = ["@OttoWinter"] +CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["logger"] CONF_DEBUG_ID = "debug_id" diff --git a/esphome/components/json/__init__.py b/esphome/components/json/__init__.py index 9773bf67ce..87aa823c0d 100644 --- a/esphome/components/json/__init__.py +++ b/esphome/components/json/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.core import coroutine_with_priority -CODEOWNERS = ["@OttoWinter"] +CODEOWNERS = ["@esphome/core"] json_ns = cg.esphome_ns.namespace("json") CONFIG_SCHEMA = cv.All( diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 63d4ba17f2..a38ad4eae3 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -30,7 +30,7 @@ from esphome.core import CORE, coroutine_with_priority _LOGGER = logging.getLogger(__name__) -CODEOWNERS = ["@OttoWinter"] +CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True time_ns = cg.esphome_ns.namespace("time") diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 9f3371c233..50ae6b92fa 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority -CODEOWNERS = ["@OttoWinter"] +CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] From 58a088e06bd1343ff997cd3055da2e70e1f68d2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Aug 2025 23:00:04 -1000 Subject: [PATCH 0361/1626] Add myself to multiple bluetooth codeowners (#10083) --- CODEOWNERS | 7 ++++--- esphome/components/bluetooth_proxy/__init__.py | 2 +- esphome/components/esp32_ble/__init__.py | 2 +- esphome/components/esp32_ble_client/__init__.py | 2 +- esphome/components/esp32_ble_tracker/__init__.py | 1 + 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 708bfaa0e8..509b0c0b9e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -69,7 +69,7 @@ esphome/components/bl0939/* @ziceva esphome/components/bl0940/* @tobias- esphome/components/bl0942/* @dbuezas @dwmw2 esphome/components/ble_client/* @buxtronix @clydebarrow -esphome/components/bluetooth_proxy/* @jesserockz +esphome/components/bluetooth_proxy/* @bdraco @jesserockz esphome/components/bme280_base/* @esphome/core esphome/components/bme280_spi/* @apbodrov esphome/components/bme680_bsec/* @trvrnrth @@ -144,9 +144,10 @@ esphome/components/es8156/* @kbx81 esphome/components/es8311/* @kahrendt @kroimon esphome/components/es8388/* @P4uLT esphome/components/esp32/* @esphome/core -esphome/components/esp32_ble/* @Rapsssito @jesserockz -esphome/components/esp32_ble_client/* @jesserockz +esphome/components/esp32_ble/* @Rapsssito @bdraco @jesserockz +esphome/components/esp32_ble_client/* @bdraco @jesserockz esphome/components/esp32_ble_server/* @Rapsssito @clydebarrow @jesserockz +esphome/components/esp32_ble_tracker/* @bdraco esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron esphome/components/esp32_hosted/* @swoboda1337 diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 4087255410..fb7f7a37c0 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -11,7 +11,7 @@ from esphome.log import AnsiFore, color AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"] DEPENDENCIES = ["api", "esp32"] -CODEOWNERS = ["@jesserockz"] +CODEOWNERS = ["@jesserockz", "@bdraco"] _LOGGER = logging.getLogger(__name__) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 1c7c075cfa..f208fda34c 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -11,7 +11,7 @@ from esphome.core.config import CONF_NAME_ADD_MAC_SUFFIX import esphome.final_validate as fv DEPENDENCIES = ["esp32"] -CODEOWNERS = ["@jesserockz", "@Rapsssito"] +CODEOWNERS = ["@jesserockz", "@Rapsssito", "@bdraco"] class BTLoggers(Enum): diff --git a/esphome/components/esp32_ble_client/__init__.py b/esphome/components/esp32_ble_client/__init__.py index 25957ed0da..55619f1fc0 100644 --- a/esphome/components/esp32_ble_client/__init__.py +++ b/esphome/components/esp32_ble_client/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import esp32_ble_tracker AUTO_LOAD = ["esp32_ble_tracker"] -CODEOWNERS = ["@jesserockz"] +CODEOWNERS = ["@jesserockz", "@bdraco"] DEPENDENCIES = ["esp32"] esp32_ble_client_ns = cg.esphome_ns.namespace("esp32_ble_client") diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 9daa6ee34e..e1abdd8490 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -36,6 +36,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble"] DEPENDENCIES = ["esp32"] +CODEOWNERS = ["@bdraco"] KEY_ESP32_BLE_TRACKER = "esp32_ble_tracker" KEY_USED_CONNECTION_SLOTS = "used_connection_slots" From d443a97dd8dbe0fc79d363cd9bee8d8c42484388 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 5 Aug 2025 19:55:40 +0100 Subject: [PATCH 0362/1626] [speaker] Media player fixes for IDF5.4 (#10088) --- .../speaker/media_player/__init__.py | 38 +++++++------------ .../speaker/media_player/audio_pipeline.cpp | 11 ++---- 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 1c2e7dc0e1..3ae7b980d3 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -315,31 +315,19 @@ async def to_code(config): cg.add_define("USE_AUDIO_FLAC_SUPPORT", True) cg.add_define("USE_AUDIO_MP3_SUPPORT", True) - # Wifi settings based on https://github.com/espressif/esp-adf/issues/297#issuecomment-783811702 - esp32.add_idf_sdkconfig_option("CONFIG_ESP32_WIFI_STATIC_RX_BUFFER_NUM", 16) - esp32.add_idf_sdkconfig_option("CONFIG_ESP32_WIFI_DYNAMIC_RX_BUFFER_NUM", 512) - esp32.add_idf_sdkconfig_option("CONFIG_ESP32_WIFI_STATIC_TX_BUFFER", True) - esp32.add_idf_sdkconfig_option("CONFIG_ESP32_WIFI_TX_BUFFER_TYPE", 0) - esp32.add_idf_sdkconfig_option("CONFIG_ESP32_WIFI_STATIC_TX_BUFFER_NUM", 8) - esp32.add_idf_sdkconfig_option("CONFIG_ESP32_WIFI_CACHE_TX_BUFFER_NUM", 32) - esp32.add_idf_sdkconfig_option("CONFIG_ESP32_WIFI_AMPDU_TX_ENABLED", True) - esp32.add_idf_sdkconfig_option("CONFIG_ESP32_WIFI_TX_BA_WIN", 16) - esp32.add_idf_sdkconfig_option("CONFIG_ESP32_WIFI_AMPDU_RX_ENABLED", True) - esp32.add_idf_sdkconfig_option("CONFIG_ESP32_WIFI_RX_BA_WIN", 32) - esp32.add_idf_sdkconfig_option("CONFIG_LWIP_MAX_ACTIVE_TCP", 16) - esp32.add_idf_sdkconfig_option("CONFIG_LWIP_MAX_LISTENING_TCP", 16) - esp32.add_idf_sdkconfig_option("CONFIG_TCP_MAXRTX", 12) - esp32.add_idf_sdkconfig_option("CONFIG_TCP_SYNMAXRTX", 6) - esp32.add_idf_sdkconfig_option("CONFIG_TCP_MSS", 1436) - esp32.add_idf_sdkconfig_option("CONFIG_TCP_MSL", 60000) - esp32.add_idf_sdkconfig_option("CONFIG_TCP_SND_BUF_DEFAULT", 65535) - esp32.add_idf_sdkconfig_option("CONFIG_TCP_WND_DEFAULT", 512000) - esp32.add_idf_sdkconfig_option("CONFIG_TCP_RECVMBOX_SIZE", 512) - esp32.add_idf_sdkconfig_option("CONFIG_TCP_QUEUE_OOSEQ", True) - esp32.add_idf_sdkconfig_option("CONFIG_TCP_OVERSIZE_MSS", True) - esp32.add_idf_sdkconfig_option("CONFIG_LWIP_WND_SCALE", True) - esp32.add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RCV_SCALE", 3) - esp32.add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 512) + # Based on https://github.com/espressif/esp-idf/blob/release/v5.4/examples/wifi/iperf/sdkconfig.defaults.esp32 + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM", 16) + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM", 64) + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM", 64) + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_TX_ENABLED", True) + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_TX_BA_WIN", 32) + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_RX_ENABLED", True) + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_RX_BA_WIN", 32) + + esp32.add_idf_sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT", 65534) + esp32.add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", 65534) + esp32.add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64) + esp32.add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 64) # Allocate wifi buffers in PSRAM esp32.add_idf_sdkconfig_option("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP", True) diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 8811ea1644..dc8572ae43 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -259,13 +259,10 @@ esp_err_t AudioPipeline::allocate_communications_() { esp_err_t AudioPipeline::start_tasks_() { if (this->read_task_handle_ == nullptr) { if (this->read_task_stack_buffer_ == nullptr) { - if (this->task_stack_in_psram_) { - RAMAllocator stack_allocator(RAMAllocator::ALLOC_EXTERNAL); - this->read_task_stack_buffer_ = stack_allocator.allocate(READ_TASK_STACK_SIZE); - } else { - RAMAllocator stack_allocator(RAMAllocator::ALLOC_INTERNAL); - this->read_task_stack_buffer_ = stack_allocator.allocate(READ_TASK_STACK_SIZE); - } + // Reader task uses the AudioReader class which uses esp_http_client. This crashes on IDF 5.4 if the task stack is + // in PSRAM. As a workaround, always allocate the read task in internal memory. + RAMAllocator stack_allocator(RAMAllocator::ALLOC_INTERNAL); + this->read_task_stack_buffer_ = stack_allocator.allocate(READ_TASK_STACK_SIZE); } if (this->read_task_stack_buffer_ == nullptr) { From bd2b3b9da52923141cb9521239125d54b7c34b57 Mon Sep 17 00:00:00 2001 From: NP v/d Spek Date: Tue, 5 Aug 2025 21:46:40 +0200 Subject: [PATCH 0363/1626] [espnow] Small changes and fixes (#10014) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/espnow/__init__.py | 11 ------ esphome/components/espnow/automation.h | 12 +++---- .../components/espnow/espnow_component.cpp | 34 +++++++++---------- esphome/components/espnow/espnow_component.h | 1 + 4 files changed, 24 insertions(+), 34 deletions(-) diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index d15817cf92..9d2f17440c 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -65,15 +65,6 @@ CONF_WAIT_FOR_SENT = "wait_for_sent" MAX_ESPNOW_PACKET_SIZE = 250 # Maximum size of the payload in bytes -def _validate_unknown_peer(config): - if config[CONF_AUTO_ADD_PEER] and config.get(CONF_ON_UNKNOWN_PEER): - raise cv.Invalid( - f"'{CONF_ON_UNKNOWN_PEER}' cannot be used when '{CONF_AUTO_ADD_PEER}' is enabled.", - path=[CONF_ON_UNKNOWN_PEER], - ) - return config - - CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -103,7 +94,6 @@ CONFIG_SCHEMA = cv.All( }, ).extend(cv.COMPONENT_SCHEMA), cv.only_on_esp32, - _validate_unknown_peer, ) @@ -124,7 +114,6 @@ async def _trigger_to_code(config): async def to_code(config): - print(config) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index ad534b279a..2416377859 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -40,20 +40,20 @@ template class SendAction : public Action, public Parente this->num_running_++; send_callback_t send_callback = [this, x...](esp_err_t status) { if (status == ESP_OK) { - if (this->sent_.empty() && this->flags_.wait_for_sent) { - this->play_next_(x...); - } else if (!this->sent_.empty()) { + if (!this->sent_.empty()) { this->sent_.play(x...); + } else if (this->flags_.wait_for_sent) { + this->play_next_(x...); } } else { - if (this->error_.empty() && this->flags_.wait_for_sent) { + if (!this->error_.empty()) { + this->error_.play(x...); + } else if (this->flags_.wait_for_sent) { if (this->flags_.continue_on_error) { this->play_next_(x...); } else { this->stop_complex(); } - } else if (!this->error_.empty()) { - this->error_.play(x...); } } }; diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index dab8e2b726..82f8e3230e 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -154,7 +154,7 @@ void ESPNowComponent::setup() { } void ESPNowComponent::enable() { - if (this->state_ != ESPNOW_STATE_ENABLED) + if (this->state_ == ESPNOW_STATE_ENABLED) return; ESP_LOGD(TAG, "Enabling"); @@ -178,11 +178,7 @@ void ESPNowComponent::enable_() { this->apply_wifi_channel(); } -#ifdef USE_WIFI - else { - this->wifi_channel_ = wifi::global_wifi_component->get_wifi_channel(); - } -#endif + this->get_wifi_channel(); esp_err_t err = esp_now_init(); if (err != ESP_OK) { @@ -215,6 +211,7 @@ void ESPNowComponent::enable_() { for (auto peer : this->peers_) { this->add_peer(peer.address); } + this->state_ = ESPNOW_STATE_ENABLED; } @@ -228,10 +225,6 @@ void ESPNowComponent::disable() { esp_now_unregister_recv_cb(); esp_now_unregister_send_cb(); - for (auto peer : this->peers_) { - this->del_peer(peer.address); - } - esp_err_t err = esp_now_deinit(); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_now_deinit failed! 0x%x", err); @@ -267,7 +260,6 @@ void ESPNowComponent::loop() { } } #endif - // Process received packets ESPNowPacket *packet = this->receive_packet_queue_.pop(); while (packet != nullptr) { @@ -275,14 +267,16 @@ void ESPNowComponent::loop() { case ESPNowPacket::RECEIVED: { const ESPNowRecvInfo info = packet->get_receive_info(); if (!esp_now_is_peer_exist(info.src_addr)) { - if (this->auto_add_peer_) { - this->add_peer(info.src_addr); - } else { - for (auto *handler : this->unknown_peer_handlers_) { - if (handler->on_unknown_peer(info, packet->packet_.receive.data, packet->packet_.receive.size)) - break; // If a handler returns true, stop processing further handlers + bool handled = false; + for (auto *handler : this->unknown_peer_handlers_) { + if (handler->on_unknown_peer(info, packet->packet_.receive.data, packet->packet_.receive.size)) { + handled = true; + break; // If a handler returns true, stop processing further handlers } } + if (!handled && this->auto_add_peer_) { + this->add_peer(info.src_addr); + } } // Intentionally left as if instead of else in case the peer is added above if (esp_now_is_peer_exist(info.src_addr)) { @@ -343,6 +337,12 @@ void ESPNowComponent::loop() { } } +uint8_t ESPNowComponent::get_wifi_channel() { + wifi_second_chan_t dummy; + esp_wifi_get_channel(&this->wifi_channel_, &dummy); + return this->wifi_channel_; +} + esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &callback) { if (this->state_ != ESPNOW_STATE_ENABLED) { diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index 3a523d1f7e..9941e97227 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -110,6 +110,7 @@ class ESPNowComponent : public Component { void set_wifi_channel(uint8_t channel) { this->wifi_channel_ = channel; } void apply_wifi_channel(); + uint8_t get_wifi_channel(); void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; } From c308e03e921f7a7dbbb98cdb763f94f50d5e78e8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 Aug 2025 08:09:36 +1200 Subject: [PATCH 0364/1626] [select] Fix new_select() not forwarding constructor args while preserving keyword-only options parameter (#10036) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: jesserockz <3060199+jesserockz@users.noreply.github.com> --- esphome/components/select/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index dd3feccab5..756e98c906 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -118,8 +118,8 @@ async def register_select(var, config, *, options: list[str]): await setup_select_core_(var, config, options=options) -async def new_select(config, *, options: list[str]): - var = cg.new_Pvariable(config[CONF_ID]) +async def new_select(config, *args, options: list[str]): + var = cg.new_Pvariable(config[CONF_ID], *args) await register_select(var, config, options=options) return var From 3edd746c6c6a089daf3b1fe04d9fde1acf44c698 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 6 Aug 2025 11:01:57 +1200 Subject: [PATCH 0365/1626] [mcp23xxx] Use CachedGpioExpander (#10078) --- esphome/components/mcp23008/__init__.py | 2 +- esphome/components/mcp23017/__init__.py | 2 +- esphome/components/mcp23s08/__init__.py | 2 +- esphome/components/mcp23s17/__init__.py | 2 +- esphome/components/mcp23x08_base/__init__.py | 2 ++ .../mcp23x08_base/mcp23x08_base.cpp | 16 ++++++----- .../components/mcp23x08_base/mcp23x08_base.h | 13 ++++++--- esphome/components/mcp23x17_base/__init__.py | 2 ++ .../mcp23x17_base/mcp23x17_base.cpp | 27 ++++++++++++++----- .../components/mcp23x17_base/mcp23x17_base.h | 13 ++++++--- esphome/components/mcp23xxx_base/__init__.py | 16 +++++++---- .../mcp23xxx_base/mcp23xxx_base.cpp | 27 ++++++++++--------- .../components/mcp23xxx_base/mcp23xxx_base.h | 15 ++++++----- 13 files changed, 89 insertions(+), 50 deletions(-) diff --git a/esphome/components/mcp23008/__init__.py b/esphome/components/mcp23008/__init__.py index ed48eb06a6..8ff938114a 100644 --- a/esphome/components/mcp23008/__init__.py +++ b/esphome/components/mcp23008/__init__.py @@ -24,5 +24,5 @@ CONFIG_SCHEMA = ( async def to_code(config): - var = await mcp23xxx_base.register_mcp23xxx(config) + var = await mcp23xxx_base.register_mcp23xxx(config, mcp23x08_base.NUM_PINS) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/mcp23017/__init__.py b/esphome/components/mcp23017/__init__.py index 33b8a680cf..e5cc1856eb 100644 --- a/esphome/components/mcp23017/__init__.py +++ b/esphome/components/mcp23017/__init__.py @@ -24,5 +24,5 @@ CONFIG_SCHEMA = ( async def to_code(config): - var = await mcp23xxx_base.register_mcp23xxx(config) + var = await mcp23xxx_base.register_mcp23xxx(config, mcp23x17_base.NUM_PINS) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/mcp23s08/__init__.py b/esphome/components/mcp23s08/__init__.py index c6152d58c0..3d4e304f9b 100644 --- a/esphome/components/mcp23s08/__init__.py +++ b/esphome/components/mcp23s08/__init__.py @@ -27,6 +27,6 @@ CONFIG_SCHEMA = ( async def to_code(config): - var = await mcp23xxx_base.register_mcp23xxx(config) + var = await mcp23xxx_base.register_mcp23xxx(config, mcp23x08_base.NUM_PINS) cg.add(var.set_device_address(config[CONF_DEVICEADDRESS])) await spi.register_spi_device(var, config) diff --git a/esphome/components/mcp23s17/__init__.py b/esphome/components/mcp23s17/__init__.py index 9a763d09b0..ea8433af2e 100644 --- a/esphome/components/mcp23s17/__init__.py +++ b/esphome/components/mcp23s17/__init__.py @@ -27,6 +27,6 @@ CONFIG_SCHEMA = ( async def to_code(config): - var = await mcp23xxx_base.register_mcp23xxx(config) + var = await mcp23xxx_base.register_mcp23xxx(config, mcp23x17_base.NUM_PINS) cg.add(var.set_device_address(config[CONF_DEVICEADDRESS])) await spi.register_spi_device(var, config) diff --git a/esphome/components/mcp23x08_base/__init__.py b/esphome/components/mcp23x08_base/__init__.py index ba44917202..a3c12165f0 100644 --- a/esphome/components/mcp23x08_base/__init__.py +++ b/esphome/components/mcp23x08_base/__init__.py @@ -4,5 +4,7 @@ from esphome.components import mcp23xxx_base AUTO_LOAD = ["mcp23xxx_base"] CODEOWNERS = ["@jesserockz"] +NUM_PINS = 8 + mcp23x08_base_ns = cg.esphome_ns.namespace("mcp23x08_base") MCP23X08Base = mcp23x08_base_ns.class_("MCP23X08Base", mcp23xxx_base.MCP23XXXBase) diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.cpp b/esphome/components/mcp23x08_base/mcp23x08_base.cpp index 0c20e902c4..e4fb51174b 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.cpp +++ b/esphome/components/mcp23x08_base/mcp23x08_base.cpp @@ -6,19 +6,21 @@ namespace mcp23x08_base { static const char *const TAG = "mcp23x08_base"; -bool MCP23X08Base::digital_read(uint8_t pin) { - uint8_t bit = pin % 8; - uint8_t reg_addr = mcp23x08_base::MCP23X08_GPIO; - uint8_t value = 0; - this->read_reg(reg_addr, &value); - return value & (1 << bit); +bool MCP23X08Base::digital_read_hw(uint8_t pin) { + if (!this->read_reg(mcp23x08_base::MCP23X08_GPIO, &this->input_mask_)) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return false; + } + return true; } -void MCP23X08Base::digital_write(uint8_t pin, bool value) { +void MCP23X08Base::digital_write_hw(uint8_t pin, bool value) { uint8_t reg_addr = mcp23x08_base::MCP23X08_OLAT; this->update_reg(pin, value, reg_addr); } +bool MCP23X08Base::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } + void MCP23X08Base::pin_mode(uint8_t pin, gpio::Flags flags) { uint8_t iodir = mcp23x08_base::MCP23X08_IODIR; uint8_t gppu = mcp23x08_base::MCP23X08_GPPU; diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.h b/esphome/components/mcp23x08_base/mcp23x08_base.h index 910519119b..6eee8274b1 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.h +++ b/esphome/components/mcp23x08_base/mcp23x08_base.h @@ -1,7 +1,7 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/components/mcp23xxx_base/mcp23xxx_base.h" +#include "esphome/core/component.h" #include "esphome/core/hal.h" namespace esphome { @@ -22,10 +22,12 @@ enum MCP23S08GPIORegisters { MCP23X08_OLAT = 0x0A, }; -class MCP23X08Base : public mcp23xxx_base::MCP23XXXBase { +class MCP23X08Base : public mcp23xxx_base::MCP23XXXBase<8> { public: - bool digital_read(uint8_t pin) override; - void digital_write(uint8_t pin, bool value) override; + bool digital_read_hw(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + bool digital_read_cache(uint8_t pin) override; + void pin_mode(uint8_t pin, gpio::Flags flags) override; void pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterruptMode interrupt_mode) override; @@ -33,6 +35,9 @@ class MCP23X08Base : public mcp23xxx_base::MCP23XXXBase { void update_reg(uint8_t pin, bool pin_value, uint8_t reg_a) override; uint8_t olat_{0x00}; + + /// State read in digital_read_hw + uint8_t input_mask_{0x00}; }; } // namespace mcp23x08_base diff --git a/esphome/components/mcp23x17_base/__init__.py b/esphome/components/mcp23x17_base/__init__.py index 97e0b3823d..1b93d16ff3 100644 --- a/esphome/components/mcp23x17_base/__init__.py +++ b/esphome/components/mcp23x17_base/__init__.py @@ -4,5 +4,7 @@ from esphome.components import mcp23xxx_base AUTO_LOAD = ["mcp23xxx_base"] CODEOWNERS = ["@jesserockz"] +NUM_PINS = 16 + mcp23x17_base_ns = cg.esphome_ns.namespace("mcp23x17_base") MCP23X17Base = mcp23x17_base_ns.class_("MCP23X17Base", mcp23xxx_base.MCP23XXXBase) diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index 99064f8880..020b8a5ddf 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -1,4 +1,5 @@ #include "mcp23x17_base.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -6,19 +7,31 @@ namespace mcp23x17_base { static const char *const TAG = "mcp23x17_base"; -bool MCP23X17Base::digital_read(uint8_t pin) { - uint8_t bit = pin % 8; - uint8_t reg_addr = pin < 8 ? mcp23x17_base::MCP23X17_GPIOA : mcp23x17_base::MCP23X17_GPIOB; - uint8_t value = 0; - this->read_reg(reg_addr, &value); - return value & (1 << bit); +bool MCP23X17Base::digital_read_hw(uint8_t pin) { + uint8_t data; + if (pin < 8) { + if (!this->read_reg(mcp23x17_base::MCP23X17_GPIOA, &data)) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return false; + } + this->input_mask_ = encode_uint16(this->input_mask_ >> 8, data); + } else { + if (!this->read_reg(mcp23x17_base::MCP23X17_GPIOB, &data)) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return false; + } + this->input_mask_ = encode_uint16(data, this->input_mask_ & 0xFF); + } + return true; } -void MCP23X17Base::digital_write(uint8_t pin, bool value) { +void MCP23X17Base::digital_write_hw(uint8_t pin, bool value) { uint8_t reg_addr = pin < 8 ? mcp23x17_base::MCP23X17_OLATA : mcp23x17_base::MCP23X17_OLATB; this->update_reg(pin, value, reg_addr); } +bool MCP23X17Base::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } + void MCP23X17Base::pin_mode(uint8_t pin, gpio::Flags flags) { uint8_t iodir = pin < 8 ? mcp23x17_base::MCP23X17_IODIRA : mcp23x17_base::MCP23X17_IODIRB; uint8_t gppu = pin < 8 ? mcp23x17_base::MCP23X17_GPPUA : mcp23x17_base::MCP23X17_GPPUB; diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.h b/esphome/components/mcp23x17_base/mcp23x17_base.h index 3d50ee8c03..bdd66503e2 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.h +++ b/esphome/components/mcp23x17_base/mcp23x17_base.h @@ -1,7 +1,7 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/components/mcp23xxx_base/mcp23xxx_base.h" +#include "esphome/core/component.h" #include "esphome/core/hal.h" namespace esphome { @@ -34,10 +34,12 @@ enum MCP23X17GPIORegisters { MCP23X17_OLATB = 0x15, }; -class MCP23X17Base : public mcp23xxx_base::MCP23XXXBase { +class MCP23X17Base : public mcp23xxx_base::MCP23XXXBase<16> { public: - bool digital_read(uint8_t pin) override; - void digital_write(uint8_t pin, bool value) override; + bool digital_read_hw(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + bool digital_read_cache(uint8_t pin) override; + void pin_mode(uint8_t pin, gpio::Flags flags) override; void pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterruptMode interrupt_mode) override; @@ -46,6 +48,9 @@ class MCP23X17Base : public mcp23xxx_base::MCP23XXXBase { uint8_t olat_a_{0x00}; uint8_t olat_b_{0x00}; + + /// State read in digital_read_hw + uint16_t input_mask_{0x00}; }; } // namespace mcp23x17_base diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 8cf0ebcd44..d6e82101ad 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -12,8 +12,9 @@ from esphome.const import ( CONF_OUTPUT, CONF_PULLUP, ) -from esphome.core import coroutine +from esphome.core import CORE, ID, coroutine +AUTO_LOAD = ["gpio_expander"] CODEOWNERS = ["@jesserockz"] mcp23xxx_base_ns = cg.esphome_ns.namespace("mcp23xxx_base") @@ -36,9 +37,11 @@ MCP23XXX_CONFIG_SCHEMA = cv.Schema( @coroutine -async def register_mcp23xxx(config): - var = cg.new_Pvariable(config[CONF_ID]) +async def register_mcp23xxx(config, num_pins): + id: ID = config[CONF_ID] + var = cg.new_Pvariable(id) await cg.register_component(var, config) + CORE.data.setdefault(CONF_MCP23XXX, {})[id.id] = num_pins cg.add(var.set_open_drain_ints(config[CONF_OPEN_DRAIN_INTERRUPT])) return var @@ -73,9 +76,12 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23XXX, MCP23XXX_PIN_SCHEMA) async def mcp23xxx_pin_to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_MCP23XXX]) + parent_id: ID = config[CONF_MCP23XXX] + parent = await cg.get_variable(parent_id) + num_pins = cg.TemplateArguments(CORE.data[CONF_MCP23XXX][parent_id.id]) + + var = cg.new_Pvariable(config[CONF_ID], num_pins) cg.add(var.set_parent(parent)) num = config[CONF_NUMBER] diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp index fc49f216ee..81324e794f 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp @@ -1,24 +1,27 @@ #include "mcp23xxx_base.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { namespace mcp23xxx_base { -float MCP23XXXBase::get_setup_priority() const { return setup_priority::IO; } - -void MCP23XXXGPIOPin::setup() { - pin_mode(flags_); +template void MCP23XXXGPIOPin::setup() { + this->pin_mode(flags_); this->parent_->pin_interrupt_mode(this->pin_, this->interrupt_mode_); } - -void MCP23XXXGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool MCP23XXXGPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void MCP23XXXGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -std::string MCP23XXXGPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "%u via MCP23XXX", pin_); - return buffer; +template void MCP23XXXGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } +template bool MCP23XXXGPIOPin::digital_read() { + return this->parent_->digital_read(this->pin_) != this->inverted_; } +template void MCP23XXXGPIOPin::digital_write(bool value) { + this->parent_->digital_write(this->pin_, value != this->inverted_); +} +template std::string MCP23XXXGPIOPin::dump_summary() const { + return str_snprintf("%u via MCP23XXX", 15, pin_); +} + +template class MCP23XXXGPIOPin<8>; +template class MCP23XXXGPIOPin<16>; } // namespace mcp23xxx_base } // namespace esphome diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 9686c9fd33..ab7f8ec398 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -1,5 +1,6 @@ #pragma once +#include "esphome/components/gpio_expander/cached_gpio.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -8,15 +9,15 @@ namespace mcp23xxx_base { enum MCP23XXXInterruptMode : uint8_t { MCP23XXX_NO_INTERRUPT = 0, MCP23XXX_CHANGE, MCP23XXX_RISING, MCP23XXX_FALLING }; -class MCP23XXXBase : public Component { +template class MCP23XXXBase : public Component, public gpio_expander::CachedGpioExpander { public: - virtual bool digital_read(uint8_t pin); - virtual void digital_write(uint8_t pin, bool value); virtual void pin_mode(uint8_t pin, gpio::Flags flags); virtual void pin_interrupt_mode(uint8_t pin, MCP23XXXInterruptMode interrupt_mode); void set_open_drain_ints(const bool value) { this->open_drain_ints_ = value; } - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::IO; } + + void loop() override { this->reset_pin_cache_(); } protected: // read a given register @@ -29,7 +30,7 @@ class MCP23XXXBase : public Component { bool open_drain_ints_; }; -class MCP23XXXGPIOPin : public GPIOPin { +template class MCP23XXXGPIOPin : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; @@ -37,7 +38,7 @@ class MCP23XXXGPIOPin : public GPIOPin { void digital_write(bool value) override; std::string dump_summary() const override; - void set_parent(MCP23XXXBase *parent) { parent_ = parent; } + void set_parent(MCP23XXXBase *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } void set_flags(gpio::Flags flags) { flags_ = flags; } @@ -46,7 +47,7 @@ class MCP23XXXGPIOPin : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } protected: - MCP23XXXBase *parent_; + MCP23XXXBase *parent_; uint8_t pin_; bool inverted_; gpio::Flags flags_; From 96bbb58f342c9253724b4ad0e2afe4405743f1e8 Mon Sep 17 00:00:00 2001 From: "@RubenKelevra" Date: Wed, 6 Aug 2025 02:33:15 +0200 Subject: [PATCH 0366/1626] update espressif's esp32-camera library to 2.1.1 (#10090) --- esphome/components/esp32_camera/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index bfb66ff83a..6206fe4682 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -345,7 +345,7 @@ async def to_code(config): cg.add_define("USE_CAMERA") if CORE.using_esp_idf: - add_idf_component(name="espressif/esp32-camera", ref="2.1.0") + add_idf_component(name="espressif/esp32-camera", ref="2.1.1") for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 419a9797e3..687efd2b49 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.1.0 + version: 2.1.1 espressif/mdns: version: 1.8.2 espressif/esp_wifi_remote: From cfb22e33c948d57be518a4ecb0bd43f4f2705ad8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 16:22:32 -1000 Subject: [PATCH 0367/1626] [esp32_ble_tracker] Add missing USE_ESP32_BLE_DEVICE guard for already_discovered_ member (#10085) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 4 ++++ esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 2 ++ 2 files changed, 6 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 856ae82dca..6180c53ad8 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -241,7 +241,9 @@ void ESP32BLETracker::start_scan_(bool first) { for (auto *listener : this->listeners_) listener->on_scan_end(); } +#ifdef USE_ESP32_BLE_DEVICE this->already_discovered_.clear(); +#endif this->scan_params_.scan_type = this->scan_active_ ? BLE_SCAN_TYPE_ACTIVE : BLE_SCAN_TYPE_PASSIVE; this->scan_params_.own_addr_type = BLE_ADDR_TYPE_PUBLIC; this->scan_params_.scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL; @@ -839,7 +841,9 @@ bool ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { ESP_LOGD(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); +#ifdef USE_ESP32_BLE_DEVICE this->already_discovered_.clear(); +#endif this->cancel_timeout("scan"); for (auto *listener : this->listeners_) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 1c28bc7a7d..b46c88b4de 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -282,8 +282,10 @@ class ESP32BLETracker : public Component, uint8_t app_id_{0}; +#ifdef USE_ESP32_BLE_DEVICE /// Vector of addresses that have already been printed in print_bt_device_info std::vector already_discovered_; +#endif std::vector listeners_; /// Client parameters. std::vector clients_; From b01f03cc24c5b20006d8956f61d86745c5463909 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 16:26:11 -1000 Subject: [PATCH 0368/1626] [esp32_ble_tracker] Refactor loop() method for improved readability and performance (#10074) --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 157 +++++++++--------- .../esp32_ble_tracker/esp32_ble_tracker.h | 51 +++++- 2 files changed, 122 insertions(+), 86 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 6180c53ad8..02f24e9286 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -76,58 +76,17 @@ void ESP32BLETracker::loop() { this->start_scan(); } } - int connecting = 0; - int discovered = 0; - int searching = 0; - int disconnecting = 0; - for (auto *client : this->clients_) { - switch (client->state()) { - case ClientState::DISCONNECTING: - disconnecting++; - break; - case ClientState::DISCOVERED: - discovered++; - break; - case ClientState::SEARCHING: - searching++; - break; - case ClientState::CONNECTING: - case ClientState::READY_TO_CONNECT: - connecting++; - break; - default: - break; - } + ClientStateCounts counts = this->count_client_states_(); + if (counts != this->client_state_counts_) { + this->client_state_counts_ = counts; + ESP_LOGD(TAG, "connecting: %d, discovered: %d, searching: %d, disconnecting: %d", + this->client_state_counts_.connecting, this->client_state_counts_.discovered, + this->client_state_counts_.searching, this->client_state_counts_.disconnecting); } - if (connecting != connecting_ || discovered != discovered_ || searching != searching_ || - disconnecting != disconnecting_) { - connecting_ = connecting; - discovered_ = discovered; - searching_ = searching; - disconnecting_ = disconnecting; - ESP_LOGD(TAG, "connecting: %d, discovered: %d, searching: %d, disconnecting: %d", connecting_, discovered_, - searching_, disconnecting_); - } - bool promote_to_connecting = discovered && !searching && !connecting; - // All scan result processing is now done immediately in gap_scan_event_handler - // No ring buffer processing needed here if (this->scanner_state_ == ScannerState::FAILED || (this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) { - this->stop_scan_(); - if (this->scan_start_fail_count_ == std::numeric_limits::max()) { - ESP_LOGE(TAG, "Scan could not restart after %d attempts, rebooting to restore stack (IDF)", - std::numeric_limits::max()); - App.reboot(); - } - if (this->scan_start_failed_) { - ESP_LOGE(TAG, "Scan start failed: %d", this->scan_start_failed_); - this->scan_start_failed_ = ESP_BT_STATUS_SUCCESS; - } - if (this->scan_set_param_failed_) { - ESP_LOGE(TAG, "Scan set param failed: %d", this->scan_set_param_failed_); - this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; - } + this->handle_scanner_failure_(); } /* @@ -142,13 +101,12 @@ void ESP32BLETracker::loop() { https://github.com/espressif/esp-idf/issues/6688 */ - if (this->scanner_state_ == ScannerState::IDLE && !connecting && !disconnecting && !promote_to_connecting) { + bool promote_to_connecting = counts.discovered && !counts.searching && !counts.connecting; + + if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && + !promote_to_connecting) { #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - if (this->coex_prefer_ble_) { - this->coex_prefer_ble_ = false; - ESP_LOGD(TAG, "Setting coexistence preference to balanced."); - esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); // Reset to default - } + this->update_coex_preference_(false); #endif if (this->scan_continuous_) { this->start_scan_(false); // first = false @@ -157,34 +115,12 @@ void ESP32BLETracker::loop() { // If there is a discovered client and no connecting // clients and no clients using the scanner to search for // devices, then promote the discovered client to ready to connect. - // Note: Scanning is already stopped by gap_scan_event_handler when - // a discovered client is found, so we only need to handle promotion - // when the scanner is IDLE. + // We check both RUNNING and IDLE states because: + // - RUNNING: gap_scan_event_handler initiates stop_scan_() but promotion can happen immediately + // - IDLE: Scanner has already stopped (naturally or by gap_scan_event_handler) if (promote_to_connecting && (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::IDLE)) { - for (auto *client : this->clients_) { - if (client->state() == ClientState::DISCOVERED) { - if (this->scanner_state_ == ScannerState::RUNNING) { - ESP_LOGD(TAG, "Stopping scan to make connection"); - this->stop_scan_(); - // Don't wait for scan stop complete - promote immediately. - // This is safe because ESP-IDF processes BLE commands sequentially through its internal mailbox queue. - // This guarantees that the stop scan command will be fully processed before any subsequent connect command, - // preventing race conditions or overlapping operations. - } - - ESP_LOGD(TAG, "Promoting client to connect"); -#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); - if (!this->coex_prefer_ble_) { - this->coex_prefer_ble_ = true; - esp_coex_preference_set(ESP_COEX_PREFER_BT); // Prioritize Bluetooth - } -#endif - client->set_state(ClientState::READY_TO_CONNECT); - break; - } - } + this->try_promote_discovered_clients_(); } } @@ -701,8 +637,9 @@ void ESP32BLETracker::dump_config() { ESP_LOGCONFIG(TAG, " Scanner State: FAILED"); break; } - ESP_LOGCONFIG(TAG, " Connecting: %d, discovered: %d, searching: %d, disconnecting: %d", connecting_, discovered_, - searching_, disconnecting_); + ESP_LOGCONFIG(TAG, " Connecting: %d, discovered: %d, searching: %d, disconnecting: %d", + this->client_state_counts_.connecting, this->client_state_counts_.discovered, + this->client_state_counts_.searching, this->client_state_counts_.disconnecting); if (this->scan_start_fail_count_) { ESP_LOGCONFIG(TAG, " Scan Start Fail Count: %d", this->scan_start_fail_count_); } @@ -852,6 +789,62 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { this->set_scanner_state_(ScannerState::IDLE); } +void ESP32BLETracker::handle_scanner_failure_() { + this->stop_scan_(); + if (this->scan_start_fail_count_ == std::numeric_limits::max()) { + ESP_LOGE(TAG, "Scan could not restart after %d attempts, rebooting to restore stack (IDF)", + std::numeric_limits::max()); + App.reboot(); + } + if (this->scan_start_failed_) { + ESP_LOGE(TAG, "Scan start failed: %d", this->scan_start_failed_); + this->scan_start_failed_ = ESP_BT_STATUS_SUCCESS; + } + if (this->scan_set_param_failed_) { + ESP_LOGE(TAG, "Scan set param failed: %d", this->scan_set_param_failed_); + this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; + } +} + +void ESP32BLETracker::try_promote_discovered_clients_() { + // Only promote the first discovered client to avoid multiple simultaneous connections + for (auto *client : this->clients_) { + if (client->state() != ClientState::DISCOVERED) { + continue; + } + + if (this->scanner_state_ == ScannerState::RUNNING) { + ESP_LOGD(TAG, "Stopping scan to make connection"); + this->stop_scan_(); + // Don't wait for scan stop complete - promote immediately. + // This is safe because ESP-IDF processes BLE commands sequentially through its internal mailbox queue. + // This guarantees that the stop scan command will be fully processed before any subsequent connect command, + // preventing race conditions or overlapping operations. + } + + ESP_LOGD(TAG, "Promoting client to connect"); +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + this->update_coex_preference_(true); +#endif + client->set_state(ClientState::READY_TO_CONNECT); + break; + } +} + +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE +void ESP32BLETracker::update_coex_preference_(bool force_ble) { + if (force_ble && !this->coex_prefer_ble_) { + ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); + this->coex_prefer_ble_ = true; + esp_coex_preference_set(ESP_COEX_PREFER_BT); // Prioritize Bluetooth + } else if (!force_ble && this->coex_prefer_ble_) { + ESP_LOGD(TAG, "Setting coexistence preference to balanced."); + this->coex_prefer_ble_ = false; + esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); // Reset to default + } +} +#endif + } // namespace esphome::esp32_ble_tracker #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index b46c88b4de..4d318b4cf6 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -136,6 +136,20 @@ class ESPBTDeviceListener { ESP32BLETracker *parent_{nullptr}; }; +struct ClientStateCounts { + uint8_t connecting = 0; + uint8_t discovered = 0; + uint8_t searching = 0; + uint8_t disconnecting = 0; + + bool operator==(const ClientStateCounts &other) const { + return connecting == other.connecting && discovered == other.discovered && searching == other.searching && + disconnecting == other.disconnecting; + } + + bool operator!=(const ClientStateCounts &other) const { return !(*this == other); } +}; + enum class ClientState : uint8_t { // Connection is allocated INIT, @@ -279,6 +293,38 @@ class ESP32BLETracker : public Component, /// Check if any clients are in connecting or ready to connect state bool has_connecting_clients_() const; #endif + /// Handle scanner failure states + void handle_scanner_failure_(); + /// Try to promote discovered clients to ready to connect + void try_promote_discovered_clients_(); +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + /// Update BLE coexistence preference + void update_coex_preference_(bool force_ble); +#endif + /// Count clients in each state + ClientStateCounts count_client_states_() const { + ClientStateCounts counts; + for (auto *client : this->clients_) { + switch (client->state()) { + case ClientState::DISCONNECTING: + counts.disconnecting++; + break; + case ClientState::DISCOVERED: + counts.discovered++; + break; + case ClientState::SEARCHING: + counts.searching++; + break; + case ClientState::CONNECTING: + case ClientState::READY_TO_CONNECT: + counts.connecting++; + break; + default: + break; + } + } + return counts; + } uint8_t app_id_{0}; @@ -306,10 +352,7 @@ class ESP32BLETracker : public Component, esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; - int connecting_{0}; - int discovered_{0}; - int searching_{0}; - int disconnecting_{0}; + ClientStateCounts client_state_counts_; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE bool coex_prefer_ble_{false}; #endif From ef271cbd3b5c332929d887eddc09f8f862279975 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 17:01:53 -1000 Subject: [PATCH 0369/1626] [esp32_ble_tracker] Simplify state machine guards with helper functions --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 70 +++++++------------ .../esp32_ble_tracker/esp32_ble_tracker.h | 4 ++ 2 files changed, 31 insertions(+), 43 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 02f24e9286..9e97fe84c9 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -136,13 +136,7 @@ void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); void ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { - if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGE(TAG, "Scan is already stopped while trying to stop."); - } else if (this->scanner_state_ == ScannerState::STARTING) { - ESP_LOGE(TAG, "Scan is starting while trying to stop."); - } else if (this->scanner_state_ == ScannerState::STOPPING) { - ESP_LOGE(TAG, "Scan is already stopping while trying to stop."); - } + ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); return; } this->cancel_timeout("scan"); @@ -160,15 +154,7 @@ void ESP32BLETracker::start_scan_(bool first) { return; } if (this->scanner_state_ != ScannerState::IDLE) { - if (this->scanner_state_ == ScannerState::STARTING) { - ESP_LOGE(TAG, "Cannot start scan while already starting."); - } else if (this->scanner_state_ == ScannerState::RUNNING) { - ESP_LOGE(TAG, "Cannot start scan while already running."); - } else if (this->scanner_state_ == ScannerState::STOPPING) { - ESP_LOGE(TAG, "Cannot start scan while already stopping."); - } else if (this->scanner_state_ == ScannerState::FAILED) { - ESP_LOGE(TAG, "Cannot start scan while already failed."); - } + this->log_unexpected_state_("start scan", ScannerState::IDLE); return; } this->set_scanner_state_(ScannerState::STARTING); @@ -275,15 +261,7 @@ void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { } else if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { // Scan finished on its own if (this->scanner_state_ != ScannerState::RUNNING) { - if (this->scanner_state_ == ScannerState::STOPPING) { - ESP_LOGE(TAG, "Scan was not running when scan completed."); - } else if (this->scanner_state_ == ScannerState::STARTING) { - ESP_LOGE(TAG, "Scan was not started when scan completed."); - } else if (this->scanner_state_ == ScannerState::FAILED) { - ESP_LOGE(TAG, "Scan was in failed state when scan completed."); - } else if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGE(TAG, "Scan was idle when scan completed."); - } + this->log_unexpected_state_("scan complete", ScannerState::RUNNING); } // Scan completed naturally, perform cleanup and transition to IDLE this->cleanup_scan_state_(false); @@ -305,15 +283,7 @@ void ESP32BLETracker::gap_scan_start_complete_(const esp_ble_gap_cb_param_t::ble ESP_LOGV(TAG, "gap_scan_start_complete - status %d", param.status); this->scan_start_failed_ = param.status; if (this->scanner_state_ != ScannerState::STARTING) { - if (this->scanner_state_ == ScannerState::RUNNING) { - ESP_LOGE(TAG, "Scan was already running when start complete."); - } else if (this->scanner_state_ == ScannerState::STOPPING) { - ESP_LOGE(TAG, "Scan was stopping when start complete."); - } else if (this->scanner_state_ == ScannerState::FAILED) { - ESP_LOGE(TAG, "Scan was in failed state when start complete."); - } else if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGE(TAG, "Scan was idle when start complete."); - } + this->log_unexpected_state_("start complete", ScannerState::STARTING); } if (param.status == ESP_BT_STATUS_SUCCESS) { this->scan_start_fail_count_ = 0; @@ -331,15 +301,7 @@ void ESP32BLETracker::gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_ // This allows us to safely transition to IDLE state and perform cleanup without race conditions ESP_LOGV(TAG, "gap_scan_stop_complete - status %d", param.status); if (this->scanner_state_ != ScannerState::STOPPING) { - if (this->scanner_state_ == ScannerState::RUNNING) { - ESP_LOGE(TAG, "Scan was not running when stop complete."); - } else if (this->scanner_state_ == ScannerState::STARTING) { - ESP_LOGE(TAG, "Scan was not started when stop complete."); - } else if (this->scanner_state_ == ScannerState::FAILED) { - ESP_LOGE(TAG, "Scan was in failed state when stop complete."); - } else if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGE(TAG, "Scan was idle when stop complete."); - } + this->log_unexpected_state_("stop complete", ScannerState::STOPPING); } // Perform cleanup and transition to IDLE @@ -831,6 +793,28 @@ void ESP32BLETracker::try_promote_discovered_clients_() { } } +const char *ESP32BLETracker::scanner_state_to_string_(ScannerState state) const { + switch (state) { + case ScannerState::IDLE: + return "idle"; + case ScannerState::STARTING: + return "starting"; + case ScannerState::RUNNING: + return "running"; + case ScannerState::STOPPING: + return "stopping"; + case ScannerState::FAILED: + return "failed"; + default: + return "unknown"; + } +} + +void ESP32BLETracker::log_unexpected_state_(const char *operation, ScannerState expected_state) const { + ESP_LOGE(TAG, "Unexpected state: %s on %s, expected: %s", this->scanner_state_to_string_(this->scanner_state_), + operation, this->scanner_state_to_string_(expected_state)); +} + #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE void ESP32BLETracker::update_coex_preference_(bool force_ble) { if (force_ble && !this->coex_prefer_ble_) { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 4d318b4cf6..d2423c43ba 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -297,6 +297,10 @@ class ESP32BLETracker : public Component, void handle_scanner_failure_(); /// Try to promote discovered clients to ready to connect void try_promote_discovered_clients_(); + /// Convert scanner state enum to string for logging + const char *scanner_state_to_string_(ScannerState state) const; + /// Log an unexpected scanner state + void log_unexpected_state_(const char *operation, ScannerState expected_state) const; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE /// Update BLE coexistence preference void update_coex_preference_(bool force_ble); From c5d5e66f300cbb20913e925884e958937f4dd906 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 17:04:02 -1000 Subject: [PATCH 0370/1626] dry --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 9e97fe84c9..460267a264 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -582,23 +582,7 @@ void ESP32BLETracker::dump_config() { " Continuous Scanning: %s", this->scan_duration_, this->scan_interval_ * 0.625f, this->scan_window_ * 0.625f, this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); - switch (this->scanner_state_) { - case ScannerState::IDLE: - ESP_LOGCONFIG(TAG, " Scanner State: IDLE"); - break; - case ScannerState::STARTING: - ESP_LOGCONFIG(TAG, " Scanner State: STARTING"); - break; - case ScannerState::RUNNING: - ESP_LOGCONFIG(TAG, " Scanner State: RUNNING"); - break; - case ScannerState::STOPPING: - ESP_LOGCONFIG(TAG, " Scanner State: STOPPING"); - break; - case ScannerState::FAILED: - ESP_LOGCONFIG(TAG, " Scanner State: FAILED"); - break; - } + ESP_LOGCONFIG(TAG, " Scanner State: %s", this->scanner_state_to_string_(this->scanner_state_)); ESP_LOGCONFIG(TAG, " Connecting: %d, discovered: %d, searching: %d, disconnecting: %d", this->client_state_counts_.connecting, this->client_state_counts_.discovered, this->client_state_counts_.searching, this->client_state_counts_.disconnecting); @@ -796,17 +780,17 @@ void ESP32BLETracker::try_promote_discovered_clients_() { const char *ESP32BLETracker::scanner_state_to_string_(ScannerState state) const { switch (state) { case ScannerState::IDLE: - return "idle"; + return "IDLE"; case ScannerState::STARTING: - return "starting"; + return "STARTING"; case ScannerState::RUNNING: - return "running"; + return "RUNNING"; case ScannerState::STOPPING: - return "stopping"; + return "STOPPING"; case ScannerState::FAILED: - return "failed"; + return "FAILED"; default: - return "unknown"; + return "UNKNOWN"; } } From c1ace213ab8cc7ec4b72df1735660dae8c1d0ec2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 17:26:06 -1000 Subject: [PATCH 0371/1626] [bluetooth_proxy] Reduce flash usage by consolidating duplicate logging --- .../bluetooth_proxy/bluetooth_connection.cpp | 73 ++++++++++--------- .../bluetooth_proxy/bluetooth_connection.h | 4 + 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 01c2aa3d22..23b73127d9 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -185,8 +185,7 @@ void BluetoothConnection::send_service_for_discovery_() { service_result.start_handle, service_result.end_handle, 0, &total_char_count); if (char_count_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, - this->address_str().c_str(), char_count_status); + this->log_connection_error_("esp_ble_gattc_get_attr_count", char_count_status); this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -220,8 +219,7 @@ void BluetoothConnection::send_service_for_discovery_() { break; } if (char_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, - this->address_str().c_str(), char_status); + this->log_connection_error_("esp_ble_gattc_get_all_char", char_status); this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -244,8 +242,7 @@ void BluetoothConnection::send_service_for_discovery_() { this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); if (desc_count_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", - this->connection_index_, this->address_str().c_str(), char_result.char_handle, desc_count_status); + this->log_connection_error_("esp_ble_gattc_get_attr_count", desc_count_status); this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -266,8 +263,7 @@ void BluetoothConnection::send_service_for_discovery_() { break; } if (desc_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, - this->address_str().c_str(), desc_status); + this->log_connection_error_("esp_ble_gattc_get_all_descr", desc_status); this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -321,6 +317,25 @@ void BluetoothConnection::send_service_for_discovery_() { api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); } +void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) { + ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str().c_str(), operation, + status); +} + +void BluetoothConnection::log_connection_warning_(const char *operation, esp_err_t err) { + ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str().c_str(), operation, err); +} + +void BluetoothConnection::log_gatt_not_connected_(const char *action, const char *type) { + ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str().c_str(), + action, type); +} + +void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status) { + ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str().c_str(), + operation, handle, status); +} + bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { if (!BLEClientBase::gattc_event_handler(event, gattc_if, param)) @@ -361,8 +376,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga case ESP_GATTC_READ_DESCR_EVT: case ESP_GATTC_READ_CHAR_EVT: { if (param->read.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error reading char/descriptor at handle 0x%2X, status=%d", this->connection_index_, - this->address_str_.c_str(), param->read.handle, param->read.status); + this->log_gatt_operation_error_("reading char/descriptor", param->read.handle, param->read.status); this->proxy_->send_gatt_error(this->address_, param->read.handle, param->read.status); break; } @@ -376,8 +390,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga case ESP_GATTC_WRITE_CHAR_EVT: case ESP_GATTC_WRITE_DESCR_EVT: { if (param->write.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error writing char/descriptor at handle 0x%2X, status=%d", this->connection_index_, - this->address_str_.c_str(), param->write.handle, param->write.status); + this->log_gatt_operation_error_("writing char/descriptor", param->write.handle, param->write.status); this->proxy_->send_gatt_error(this->address_, param->write.handle, param->write.status); break; } @@ -389,9 +402,8 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga } case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { if (param->unreg_for_notify.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error unregistering notifications for handle 0x%2X, status=%d", - this->connection_index_, this->address_str_.c_str(), param->unreg_for_notify.handle, - param->unreg_for_notify.status); + this->log_gatt_operation_error_("unregistering notifications", param->unreg_for_notify.handle, + param->unreg_for_notify.status); this->proxy_->send_gatt_error(this->address_, param->unreg_for_notify.handle, param->unreg_for_notify.status); break; } @@ -403,8 +415,8 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { if (param->reg_for_notify.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error registering notifications for handle 0x%2X, status=%d", this->connection_index_, - this->address_str_.c_str(), param->reg_for_notify.handle, param->reg_for_notify.status); + this->log_gatt_operation_error_("registering notifications", param->reg_for_notify.handle, + param->reg_for_notify.status); this->proxy_->send_gatt_error(this->address_, param->reg_for_notify.handle, param->reg_for_notify.status); break; } @@ -450,8 +462,7 @@ void BluetoothConnection::gap_event_handler(esp_gap_ble_cb_event_t event, esp_bl esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { if (!this->connected()) { - ESP_LOGW(TAG, "[%d] [%s] Cannot read GATT characteristic, not connected.", this->connection_index_, - this->address_str_.c_str()); + this->log_gatt_not_connected_("read", "characteristic"); return ESP_GATT_NOT_CONNECTED; } @@ -469,8 +480,7 @@ esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std::string &data, bool response) { if (!this->connected()) { - ESP_LOGW(TAG, "[%d] [%s] Cannot write GATT characteristic, not connected.", this->connection_index_, - this->address_str_.c_str()); + this->log_gatt_not_connected_("write", "characteristic"); return ESP_GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_.c_str(), @@ -480,8 +490,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std:: esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (err != ERR_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_write_char error, err=%d", this->connection_index_, - this->address_str_.c_str(), err); + this->log_connection_warning_("esp_ble_gattc_write_char", err); return err; } return ESP_OK; @@ -489,8 +498,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std:: esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { if (!this->connected()) { - ESP_LOGW(TAG, "[%d] [%s] Cannot read GATT descriptor, not connected.", this->connection_index_, - this->address_str_.c_str()); + this->log_gatt_not_connected_("read", "descriptor"); return ESP_GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_.c_str(), @@ -498,8 +506,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); if (err != ERR_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_read_char_descr error, err=%d", this->connection_index_, - this->address_str_.c_str(), err); + this->log_connection_warning_("esp_ble_gattc_read_char_descr", err); return err; } return ESP_OK; @@ -507,8 +514,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::string &data, bool response) { if (!this->connected()) { - ESP_LOGW(TAG, "[%d] [%s] Cannot write GATT descriptor, not connected.", this->connection_index_, - this->address_str_.c_str()); + this->log_gatt_not_connected_("write", "descriptor"); return ESP_GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_.c_str(), @@ -527,8 +533,7 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::stri esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { if (!this->connected()) { - ESP_LOGW(TAG, "[%d] [%s] Cannot notify GATT characteristic, not connected.", this->connection_index_, - this->address_str_.c_str()); + this->log_gatt_not_connected_("notify", "characteristic"); return ESP_GATT_NOT_CONNECTED; } @@ -537,8 +542,7 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl this->address_str_.c_str(), handle); esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle); if (err != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_register_for_notify failed, err=%d", this->connection_index_, - this->address_str_.c_str(), err); + this->log_connection_warning_("esp_ble_gattc_register_for_notify", err); return err; } } else { @@ -546,8 +550,7 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl this->address_str_.c_str(), handle); esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); if (err != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_unregister_for_notify failed, err=%d", this->connection_index_, - this->address_str_.c_str(), err); + this->log_connection_warning_("esp_ble_gattc_unregister_for_notify", err); return err; } } diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 042868e7a4..92c9172e83 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -33,6 +33,10 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); void update_allocated_slot_(uint64_t find_value, uint64_t set_value); + void log_connection_error_(const char *operation, esp_gatt_status_t status); + void log_connection_warning_(const char *operation, esp_err_t err); + void log_gatt_not_connected_(const char *action, const char *type); + void log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status); // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) From 860a5ef5c04c47ca5c80b84efaa2802fb82396f3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 6 Aug 2025 00:28:09 -0400 Subject: [PATCH 0372/1626] [esp32_rmt_led_strip] Work around IDFGH-16195 (#10093) --- esphome/components/esp32_rmt_led_strip/led_strip.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index e22bb605e2..344ea35e81 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -42,9 +42,6 @@ static size_t IRAM_ATTR HOT encoder_callback(const void *data, size_t size, size symbols[i] = params->bit0; } } - if ((index + 1) >= size && params->reset.duration0 == 0 && params->reset.duration1 == 0) { - *done = true; - } return RMT_SYMBOLS_PER_BYTE; } @@ -110,7 +107,7 @@ void ESP32RMTLEDStripLightOutput::setup() { memset(&encoder, 0, sizeof(encoder)); encoder.callback = encoder_callback; encoder.arg = &this->params_; - encoder.min_chunk_size = 8; + encoder.min_chunk_size = RMT_SYMBOLS_PER_BYTE; if (rmt_new_simple_encoder(&encoder, &this->encoder_) != ESP_OK) { ESP_LOGE(TAG, "Encoder creation failed"); this->mark_failed(); From 99125c045f0f5e891feafc8b9bfd5e3ebdee6813 Mon Sep 17 00:00:00 2001 From: Pawelo <81100874+pgolawsk@users.noreply.github.com> Date: Wed, 6 Aug 2025 07:02:54 +0200 Subject: [PATCH 0373/1626] [bme680] Eliminate warnings due to unused functions (#9735) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/bme680/bme680.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index c5c4829985..16435ccfee 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -28,7 +28,7 @@ const float BME680_GAS_LOOKUP_TABLE_1[16] PROGMEM = {0.0, 0.0, 0.0, 0.0, 0.0, const float BME680_GAS_LOOKUP_TABLE_2[16] PROGMEM = {0.0, 0.0, 0.0, 0.0, 0.1, 0.7, 0.0, -0.8, -0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; -static const char *oversampling_to_str(BME680Oversampling oversampling) { +[[maybe_unused]] static const char *oversampling_to_str(BME680Oversampling oversampling) { switch (oversampling) { case BME680_OVERSAMPLING_NONE: return "None"; @@ -47,7 +47,7 @@ static const char *oversampling_to_str(BME680Oversampling oversampling) { } } -static const char *iir_filter_to_str(BME680IIRFilter filter) { +[[maybe_unused]] static const char *iir_filter_to_str(BME680IIRFilter filter) { switch (filter) { case BME680_IIR_FILTER_OFF: return "OFF"; From d872c8a999747d2738c8bce25de93e620ff1d841 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 6 Aug 2025 17:05:48 +1200 Subject: [PATCH 0374/1626] [light] Allow light effect schema to be a schema object already (#10091) --- esphome/components/light/effects.py | 40 ++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 6c8fd86225..15d9272d1a 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -29,6 +29,7 @@ from esphome.const import ( CONF_WHITE, CONF_WIDTH, ) +from esphome.cpp_generator import MockObjClass from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.util import Registry @@ -88,8 +89,15 @@ ADDRESSABLE_EFFECTS = [] EFFECTS_REGISTRY = Registry() -def register_effect(name, effect_type, default_name, schema, *extra_validators): - schema = cv.Schema(schema).extend( +def register_effect( + name: str, + effect_type: MockObjClass, + default_name: str, + schema: cv.Schema | dict, + *extra_validators, +): + schema = schema if isinstance(schema, cv.Schema) else cv.Schema(schema) + schema = schema.extend( { cv.Optional(CONF_NAME, default=default_name): cv.string_strict, } @@ -98,7 +106,13 @@ def register_effect(name, effect_type, default_name, schema, *extra_validators): return EFFECTS_REGISTRY.register(name, effect_type, validator) -def register_binary_effect(name, effect_type, default_name, schema, *extra_validators): +def register_binary_effect( + name: str, + effect_type: MockObjClass, + default_name: str, + schema: cv.Schema | dict, + *extra_validators, +): # binary effect can be used for all lights BINARY_EFFECTS.append(name) MONOCHROMATIC_EFFECTS.append(name) @@ -109,7 +123,11 @@ def register_binary_effect(name, effect_type, default_name, schema, *extra_valid def register_monochromatic_effect( - name, effect_type, default_name, schema, *extra_validators + name: str, + effect_type: MockObjClass, + default_name: str, + schema: cv.Schema | dict, + *extra_validators, ): # monochromatic effect can be used for all lights expect binary MONOCHROMATIC_EFFECTS.append(name) @@ -119,7 +137,13 @@ def register_monochromatic_effect( return register_effect(name, effect_type, default_name, schema, *extra_validators) -def register_rgb_effect(name, effect_type, default_name, schema, *extra_validators): +def register_rgb_effect( + name: str, + effect_type: MockObjClass, + default_name: str, + schema: cv.Schema | dict, + *extra_validators, +): # RGB effect can be used for RGB and addressable lights RGB_EFFECTS.append(name) ADDRESSABLE_EFFECTS.append(name) @@ -128,7 +152,11 @@ def register_rgb_effect(name, effect_type, default_name, schema, *extra_validato def register_addressable_effect( - name, effect_type, default_name, schema, *extra_validators + name: str, + effect_type: MockObjClass, + default_name: str, + schema: cv.Schema | dict, + *extra_validators, ): # addressable effect can be used only in addressable ADDRESSABLE_EFFECTS.append(name) From 8ceb1b9d60debe6b573aed8e3ae11b1a40f54a85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 19:49:20 -1000 Subject: [PATCH 0375/1626] [bluetooth_proxy] Reduce flash usage by consolidating duplicate logging (#10094) --- .../bluetooth_proxy/bluetooth_connection.cpp | 73 ++++++++++--------- .../bluetooth_proxy/bluetooth_connection.h | 4 + 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 01c2aa3d22..23b73127d9 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -185,8 +185,7 @@ void BluetoothConnection::send_service_for_discovery_() { service_result.start_handle, service_result.end_handle, 0, &total_char_count); if (char_count_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, - this->address_str().c_str(), char_count_status); + this->log_connection_error_("esp_ble_gattc_get_attr_count", char_count_status); this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -220,8 +219,7 @@ void BluetoothConnection::send_service_for_discovery_() { break; } if (char_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, - this->address_str().c_str(), char_status); + this->log_connection_error_("esp_ble_gattc_get_all_char", char_status); this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -244,8 +242,7 @@ void BluetoothConnection::send_service_for_discovery_() { this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); if (desc_count_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", - this->connection_index_, this->address_str().c_str(), char_result.char_handle, desc_count_status); + this->log_connection_error_("esp_ble_gattc_get_attr_count", desc_count_status); this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -266,8 +263,7 @@ void BluetoothConnection::send_service_for_discovery_() { break; } if (desc_status != ESP_GATT_OK) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, - this->address_str().c_str(), desc_status); + this->log_connection_error_("esp_ble_gattc_get_all_descr", desc_status); this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -321,6 +317,25 @@ void BluetoothConnection::send_service_for_discovery_() { api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); } +void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) { + ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str().c_str(), operation, + status); +} + +void BluetoothConnection::log_connection_warning_(const char *operation, esp_err_t err) { + ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str().c_str(), operation, err); +} + +void BluetoothConnection::log_gatt_not_connected_(const char *action, const char *type) { + ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str().c_str(), + action, type); +} + +void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status) { + ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str().c_str(), + operation, handle, status); +} + bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { if (!BLEClientBase::gattc_event_handler(event, gattc_if, param)) @@ -361,8 +376,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga case ESP_GATTC_READ_DESCR_EVT: case ESP_GATTC_READ_CHAR_EVT: { if (param->read.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error reading char/descriptor at handle 0x%2X, status=%d", this->connection_index_, - this->address_str_.c_str(), param->read.handle, param->read.status); + this->log_gatt_operation_error_("reading char/descriptor", param->read.handle, param->read.status); this->proxy_->send_gatt_error(this->address_, param->read.handle, param->read.status); break; } @@ -376,8 +390,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga case ESP_GATTC_WRITE_CHAR_EVT: case ESP_GATTC_WRITE_DESCR_EVT: { if (param->write.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error writing char/descriptor at handle 0x%2X, status=%d", this->connection_index_, - this->address_str_.c_str(), param->write.handle, param->write.status); + this->log_gatt_operation_error_("writing char/descriptor", param->write.handle, param->write.status); this->proxy_->send_gatt_error(this->address_, param->write.handle, param->write.status); break; } @@ -389,9 +402,8 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga } case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { if (param->unreg_for_notify.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error unregistering notifications for handle 0x%2X, status=%d", - this->connection_index_, this->address_str_.c_str(), param->unreg_for_notify.handle, - param->unreg_for_notify.status); + this->log_gatt_operation_error_("unregistering notifications", param->unreg_for_notify.handle, + param->unreg_for_notify.status); this->proxy_->send_gatt_error(this->address_, param->unreg_for_notify.handle, param->unreg_for_notify.status); break; } @@ -403,8 +415,8 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { if (param->reg_for_notify.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] Error registering notifications for handle 0x%2X, status=%d", this->connection_index_, - this->address_str_.c_str(), param->reg_for_notify.handle, param->reg_for_notify.status); + this->log_gatt_operation_error_("registering notifications", param->reg_for_notify.handle, + param->reg_for_notify.status); this->proxy_->send_gatt_error(this->address_, param->reg_for_notify.handle, param->reg_for_notify.status); break; } @@ -450,8 +462,7 @@ void BluetoothConnection::gap_event_handler(esp_gap_ble_cb_event_t event, esp_bl esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { if (!this->connected()) { - ESP_LOGW(TAG, "[%d] [%s] Cannot read GATT characteristic, not connected.", this->connection_index_, - this->address_str_.c_str()); + this->log_gatt_not_connected_("read", "characteristic"); return ESP_GATT_NOT_CONNECTED; } @@ -469,8 +480,7 @@ esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std::string &data, bool response) { if (!this->connected()) { - ESP_LOGW(TAG, "[%d] [%s] Cannot write GATT characteristic, not connected.", this->connection_index_, - this->address_str_.c_str()); + this->log_gatt_not_connected_("write", "characteristic"); return ESP_GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_.c_str(), @@ -480,8 +490,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std:: esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (err != ERR_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_write_char error, err=%d", this->connection_index_, - this->address_str_.c_str(), err); + this->log_connection_warning_("esp_ble_gattc_write_char", err); return err; } return ESP_OK; @@ -489,8 +498,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std:: esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { if (!this->connected()) { - ESP_LOGW(TAG, "[%d] [%s] Cannot read GATT descriptor, not connected.", this->connection_index_, - this->address_str_.c_str()); + this->log_gatt_not_connected_("read", "descriptor"); return ESP_GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_.c_str(), @@ -498,8 +506,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); if (err != ERR_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_read_char_descr error, err=%d", this->connection_index_, - this->address_str_.c_str(), err); + this->log_connection_warning_("esp_ble_gattc_read_char_descr", err); return err; } return ESP_OK; @@ -507,8 +514,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::string &data, bool response) { if (!this->connected()) { - ESP_LOGW(TAG, "[%d] [%s] Cannot write GATT descriptor, not connected.", this->connection_index_, - this->address_str_.c_str()); + this->log_gatt_not_connected_("write", "descriptor"); return ESP_GATT_NOT_CONNECTED; } ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_.c_str(), @@ -527,8 +533,7 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::stri esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { if (!this->connected()) { - ESP_LOGW(TAG, "[%d] [%s] Cannot notify GATT characteristic, not connected.", this->connection_index_, - this->address_str_.c_str()); + this->log_gatt_not_connected_("notify", "characteristic"); return ESP_GATT_NOT_CONNECTED; } @@ -537,8 +542,7 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl this->address_str_.c_str(), handle); esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle); if (err != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_register_for_notify failed, err=%d", this->connection_index_, - this->address_str_.c_str(), err); + this->log_connection_warning_("esp_ble_gattc_register_for_notify", err); return err; } } else { @@ -546,8 +550,7 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl this->address_str_.c_str(), handle); esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); if (err != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_unregister_for_notify failed, err=%d", this->connection_index_, - this->address_str_.c_str(), err); + this->log_connection_warning_("esp_ble_gattc_unregister_for_notify", err); return err; } } diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 042868e7a4..92c9172e83 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -33,6 +33,10 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); void update_allocated_slot_(uint64_t find_value, uint64_t set_value); + void log_connection_error_(const char *operation, esp_gatt_status_t status); + void log_connection_warning_(const char *operation, esp_err_t err); + void log_gatt_not_connected_(const char *action, const char *type); + void log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status); // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) From 1642d34d29b16d41f2bb02d0cf5fe3a44727b8a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 20:03:19 -1000 Subject: [PATCH 0376/1626] [esp32_ble_tracker] Simplify state machine guards with helper functions (#10092) --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 88 ++++++------------- .../esp32_ble_tracker/esp32_ble_tracker.h | 4 + 2 files changed, 32 insertions(+), 60 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 02f24e9286..460267a264 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -136,13 +136,7 @@ void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); void ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { - if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGE(TAG, "Scan is already stopped while trying to stop."); - } else if (this->scanner_state_ == ScannerState::STARTING) { - ESP_LOGE(TAG, "Scan is starting while trying to stop."); - } else if (this->scanner_state_ == ScannerState::STOPPING) { - ESP_LOGE(TAG, "Scan is already stopping while trying to stop."); - } + ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); return; } this->cancel_timeout("scan"); @@ -160,15 +154,7 @@ void ESP32BLETracker::start_scan_(bool first) { return; } if (this->scanner_state_ != ScannerState::IDLE) { - if (this->scanner_state_ == ScannerState::STARTING) { - ESP_LOGE(TAG, "Cannot start scan while already starting."); - } else if (this->scanner_state_ == ScannerState::RUNNING) { - ESP_LOGE(TAG, "Cannot start scan while already running."); - } else if (this->scanner_state_ == ScannerState::STOPPING) { - ESP_LOGE(TAG, "Cannot start scan while already stopping."); - } else if (this->scanner_state_ == ScannerState::FAILED) { - ESP_LOGE(TAG, "Cannot start scan while already failed."); - } + this->log_unexpected_state_("start scan", ScannerState::IDLE); return; } this->set_scanner_state_(ScannerState::STARTING); @@ -275,15 +261,7 @@ void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { } else if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { // Scan finished on its own if (this->scanner_state_ != ScannerState::RUNNING) { - if (this->scanner_state_ == ScannerState::STOPPING) { - ESP_LOGE(TAG, "Scan was not running when scan completed."); - } else if (this->scanner_state_ == ScannerState::STARTING) { - ESP_LOGE(TAG, "Scan was not started when scan completed."); - } else if (this->scanner_state_ == ScannerState::FAILED) { - ESP_LOGE(TAG, "Scan was in failed state when scan completed."); - } else if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGE(TAG, "Scan was idle when scan completed."); - } + this->log_unexpected_state_("scan complete", ScannerState::RUNNING); } // Scan completed naturally, perform cleanup and transition to IDLE this->cleanup_scan_state_(false); @@ -305,15 +283,7 @@ void ESP32BLETracker::gap_scan_start_complete_(const esp_ble_gap_cb_param_t::ble ESP_LOGV(TAG, "gap_scan_start_complete - status %d", param.status); this->scan_start_failed_ = param.status; if (this->scanner_state_ != ScannerState::STARTING) { - if (this->scanner_state_ == ScannerState::RUNNING) { - ESP_LOGE(TAG, "Scan was already running when start complete."); - } else if (this->scanner_state_ == ScannerState::STOPPING) { - ESP_LOGE(TAG, "Scan was stopping when start complete."); - } else if (this->scanner_state_ == ScannerState::FAILED) { - ESP_LOGE(TAG, "Scan was in failed state when start complete."); - } else if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGE(TAG, "Scan was idle when start complete."); - } + this->log_unexpected_state_("start complete", ScannerState::STARTING); } if (param.status == ESP_BT_STATUS_SUCCESS) { this->scan_start_fail_count_ = 0; @@ -331,15 +301,7 @@ void ESP32BLETracker::gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_ // This allows us to safely transition to IDLE state and perform cleanup without race conditions ESP_LOGV(TAG, "gap_scan_stop_complete - status %d", param.status); if (this->scanner_state_ != ScannerState::STOPPING) { - if (this->scanner_state_ == ScannerState::RUNNING) { - ESP_LOGE(TAG, "Scan was not running when stop complete."); - } else if (this->scanner_state_ == ScannerState::STARTING) { - ESP_LOGE(TAG, "Scan was not started when stop complete."); - } else if (this->scanner_state_ == ScannerState::FAILED) { - ESP_LOGE(TAG, "Scan was in failed state when stop complete."); - } else if (this->scanner_state_ == ScannerState::IDLE) { - ESP_LOGE(TAG, "Scan was idle when stop complete."); - } + this->log_unexpected_state_("stop complete", ScannerState::STOPPING); } // Perform cleanup and transition to IDLE @@ -620,23 +582,7 @@ void ESP32BLETracker::dump_config() { " Continuous Scanning: %s", this->scan_duration_, this->scan_interval_ * 0.625f, this->scan_window_ * 0.625f, this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); - switch (this->scanner_state_) { - case ScannerState::IDLE: - ESP_LOGCONFIG(TAG, " Scanner State: IDLE"); - break; - case ScannerState::STARTING: - ESP_LOGCONFIG(TAG, " Scanner State: STARTING"); - break; - case ScannerState::RUNNING: - ESP_LOGCONFIG(TAG, " Scanner State: RUNNING"); - break; - case ScannerState::STOPPING: - ESP_LOGCONFIG(TAG, " Scanner State: STOPPING"); - break; - case ScannerState::FAILED: - ESP_LOGCONFIG(TAG, " Scanner State: FAILED"); - break; - } + ESP_LOGCONFIG(TAG, " Scanner State: %s", this->scanner_state_to_string_(this->scanner_state_)); ESP_LOGCONFIG(TAG, " Connecting: %d, discovered: %d, searching: %d, disconnecting: %d", this->client_state_counts_.connecting, this->client_state_counts_.discovered, this->client_state_counts_.searching, this->client_state_counts_.disconnecting); @@ -831,6 +777,28 @@ void ESP32BLETracker::try_promote_discovered_clients_() { } } +const char *ESP32BLETracker::scanner_state_to_string_(ScannerState state) const { + switch (state) { + case ScannerState::IDLE: + return "IDLE"; + case ScannerState::STARTING: + return "STARTING"; + case ScannerState::RUNNING: + return "RUNNING"; + case ScannerState::STOPPING: + return "STOPPING"; + case ScannerState::FAILED: + return "FAILED"; + default: + return "UNKNOWN"; + } +} + +void ESP32BLETracker::log_unexpected_state_(const char *operation, ScannerState expected_state) const { + ESP_LOGE(TAG, "Unexpected state: %s on %s, expected: %s", this->scanner_state_to_string_(this->scanner_state_), + operation, this->scanner_state_to_string_(expected_state)); +} + #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE void ESP32BLETracker::update_coex_preference_(bool force_ble) { if (force_ble && !this->coex_prefer_ble_) { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 4d318b4cf6..d2423c43ba 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -297,6 +297,10 @@ class ESP32BLETracker : public Component, void handle_scanner_failure_(); /// Try to promote discovered clients to ready to connect void try_promote_discovered_clients_(); + /// Convert scanner state enum to string for logging + const char *scanner_state_to_string_(ScannerState state) const; + /// Log an unexpected scanner state + void log_unexpected_state_(const char *operation, ScannerState expected_state) const; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE /// Update BLE coexistence preference void update_coex_preference_(bool force_ble); From 5d93388a5fc551cfd9ef41cee36dbeb838cd1aef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 20:26:43 -1000 Subject: [PATCH 0377/1626] [bluetooth_proxy][esp32_ble_tracker][esp32_ble_client] Consolidate duplicate logging code to reduce flash usage --- .../bluetooth_proxy/bluetooth_connection.cpp | 6 +- .../bluetooth_proxy/bluetooth_proxy.cpp | 71 ++++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 4 + .../esp32_ble_client/ble_client_base.cpp | 98 ++++++------------- .../esp32_ble_client/ble_client_base.h | 3 + .../esp32_ble_tracker/esp32_ble_tracker.cpp | 25 +++++ .../esp32_ble_tracker/esp32_ble_tracker.h | 3 + 7 files changed, 100 insertions(+), 110 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 23b73127d9..c6edb4a9fb 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -471,8 +471,7 @@ esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { esp_err_t err = esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); if (err != ERR_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_read_char error, err=%d", this->connection_index_, - this->address_str_.c_str(), err); + this->log_connection_warning_("esp_ble_gattc_read_char", err); return err; } return ESP_OK; @@ -524,8 +523,7 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::stri this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (err != ERR_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_write_char_descr error, err=%d", this->connection_index_, - this->address_str_.c_str(), err); + this->log_connection_warning_("esp_ble_gattc_write_char_descr", err); return err; } return ESP_OK; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 97b0884dda..b921877615 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -53,6 +53,26 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta this->api_connection_->send_message(resp, api::BluetoothScannerStateResponse::MESSAGE_TYPE); } +void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) { + ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), + connection->address_str().c_str(), espbt::client_state_to_string(state)); +} + +void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { + ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str().c_str(), + message); +} + +void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { + ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); +} + +void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, + const char *type) { + this->log_not_connected_gatt_(action, type); + this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED); +} + #ifdef USE_ESP32_BLE_DEVICE bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { // This method should never be called since bluetooth_proxy always uses raw advertisements @@ -202,23 +222,10 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } if (connection->state() == espbt::ClientState::CONNECTED || connection->state() == espbt::ClientState::ESTABLISHED) { - ESP_LOGW(TAG, "[%d] [%s] Connection already established", connection->get_connection_index(), - connection->address_str().c_str()); + this->log_connection_request_ignored_(connection, connection->state()); this->send_device_connection(msg.address, true); this->send_connections_free(); return; - } else if (connection->state() == espbt::ClientState::SEARCHING) { - ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, already searching for device", - connection->get_connection_index(), connection->address_str().c_str()); - return; - } else if (connection->state() == espbt::ClientState::DISCOVERED) { - ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, device already discovered", - connection->get_connection_index(), connection->address_str().c_str()); - return; - } else if (connection->state() == espbt::ClientState::READY_TO_CONNECT) { - ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, waiting in line to connect", - connection->get_connection_index(), connection->address_str().c_str()); - return; } else if (connection->state() == espbt::ClientState::CONNECTING) { if (connection->disconnect_pending()) { ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", @@ -226,29 +233,21 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest connection->cancel_pending_disconnect(); return; } - ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, already connecting", connection->get_connection_index(), - connection->address_str().c_str()); - return; - } else if (connection->state() == espbt::ClientState::DISCONNECTING) { - ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, device is disconnecting", - connection->get_connection_index(), connection->address_str().c_str()); + this->log_connection_request_ignored_(connection, connection->state()); return; } else if (connection->state() != espbt::ClientState::INIT) { - ESP_LOGW(TAG, "[%d] [%s] Connection already in progress", connection->get_connection_index(), - connection->address_str().c_str()); + this->log_connection_request_ignored_(connection, connection->state()); return; } if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE) { connection->set_connection_type(espbt::ConnectionType::V3_WITH_CACHE); - ESP_LOGI(TAG, "[%d] [%s] Connecting v3 with cache", connection->get_connection_index(), - connection->address_str().c_str()); + this->log_connection_info_(connection, "v3 with cache"); } else if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE) { connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); - ESP_LOGI(TAG, "[%d] [%s] Connecting v3 without cache", connection->get_connection_index(), - connection->address_str().c_str()); + this->log_connection_info_(connection, "v3 without cache"); } else { connection->set_connection_type(espbt::ConnectionType::V1); - ESP_LOGI(TAG, "[%d] [%s] Connecting v1", connection->get_connection_index(), connection->address_str().c_str()); + this->log_connection_info_(connection, "v1"); } if (msg.has_address_type) { uint64_to_bd_addr(msg.address, connection->remote_bda_); @@ -316,8 +315,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - ESP_LOGW(TAG, "Cannot read GATT characteristic, not connected"); - this->send_gatt_error(msg.address, msg.handle, ESP_GATT_NOT_CONNECTED); + this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic"); return; } @@ -330,8 +328,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - ESP_LOGW(TAG, "Cannot write GATT characteristic, not connected"); - this->send_gatt_error(msg.address, msg.handle, ESP_GATT_NOT_CONNECTED); + this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic"); return; } @@ -344,8 +341,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - ESP_LOGW(TAG, "Cannot read GATT descriptor, not connected"); - this->send_gatt_error(msg.address, msg.handle, ESP_GATT_NOT_CONNECTED); + this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor"); return; } @@ -358,8 +354,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - ESP_LOGW(TAG, "Cannot write GATT descriptor, not connected"); - this->send_gatt_error(msg.address, msg.handle, ESP_GATT_NOT_CONNECTED); + this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor"); return; } @@ -372,8 +367,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr || !connection->connected()) { - ESP_LOGW(TAG, "Cannot get GATT services, not connected"); - this->send_gatt_error(msg.address, 0, ESP_GATT_NOT_CONNECTED); + this->handle_gatt_not_connected_(msg.address, 0, "get", "services"); return; } if (!connection->service_count_) { @@ -389,8 +383,7 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - ESP_LOGW(TAG, "Cannot notify GATT characteristic, not connected"); - this->send_gatt_error(msg.address, msg.handle, ESP_GATT_NOT_CONNECTED); + this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic"); return; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d367dad438..33817a212e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -136,6 +136,10 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); BluetoothConnection *get_connection_(uint64_t address, bool reserve); + void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); + void log_connection_info_(BluetoothConnection *connection, const char *message); + void log_not_connected_gatt_(const char *action, const char *type); + void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type); // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index f47642944b..202b074589 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -79,40 +79,7 @@ void BLEClientBase::dump_config() { " Address: %s\n" " Auto-Connect: %s", this->address_str().c_str(), TRUEFALSE(this->auto_connect_)); - std::string state_name; - switch (this->state()) { - case espbt::ClientState::INIT: - state_name = "INIT"; - break; - case espbt::ClientState::DISCONNECTING: - state_name = "DISCONNECTING"; - break; - case espbt::ClientState::IDLE: - state_name = "IDLE"; - break; - case espbt::ClientState::SEARCHING: - state_name = "SEARCHING"; - break; - case espbt::ClientState::DISCOVERED: - state_name = "DISCOVERED"; - break; - case espbt::ClientState::READY_TO_CONNECT: - state_name = "READY_TO_CONNECT"; - break; - case espbt::ClientState::CONNECTING: - state_name = "CONNECTING"; - break; - case espbt::ClientState::CONNECTED: - state_name = "CONNECTED"; - break; - case espbt::ClientState::ESTABLISHED: - state_name = "ESTABLISHED"; - break; - default: - state_name = "UNKNOWN_STATE"; - break; - } - ESP_LOGCONFIG(TAG, " State: %s", state_name.c_str()); + ESP_LOGCONFIG(TAG, " State: %s", espbt::client_state_to_string(this->state())); if (this->status_ == ESP_GATT_NO_RESOURCES) { ESP_LOGE(TAG, " Failed due to no resources. Try to reduce number of BLE clients in config."); } else if (this->status_ != ESP_GATT_OK) { @@ -177,8 +144,7 @@ void BLEClientBase::connect() { // Now open the connection auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); if (ret) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_open error, status=%d", this->connection_index_, this->address_str_.c_str(), - ret); + this->log_gattc_warning_("esp_ble_gattc_open", ret); this->set_state(espbt::ClientState::IDLE); } else { this->set_state(espbt::ClientState::CONNECTING); @@ -256,6 +222,19 @@ void BLEClientBase::log_event_(const char *name) { ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), name); } +void BLEClientBase::log_gattc_event_(const char *name) { + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_%s_EVT", this->connection_index_, this->address_str_.c_str(), name); +} + +void BLEClientBase::log_gattc_warning_(const char *operation, esp_gatt_status_t status) { + ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_.c_str(), operation, + status); +} + +void BLEClientBase::log_gattc_warning_(const char *operation, esp_err_t err) { + ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_.c_str(), operation, err); +} + void BLEClientBase::restore_medium_conn_params_() { // Restore to medium connection parameters after initial connection phase // This balances performance with bandwidth usage for normal operation @@ -296,30 +275,18 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_OPEN_EVT: { if (!this->check_addr(param->open.remote_bda)) return false; - this->log_event_("ESP_GATTC_OPEN_EVT"); + this->log_gattc_event_("OPEN"); // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; if (this->state_ != espbt::ClientState::CONNECTING) { // This should not happen but lets log it in case it does // because it means we have a bad assumption about how the // ESP BT stack works. - if (this->state_ == espbt::ClientState::CONNECTED) { - ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while already connected, status=%d", this->connection_index_, - this->address_str_.c_str(), param->open.status); - } else if (this->state_ == espbt::ClientState::ESTABLISHED) { - ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while already established, status=%d", - this->connection_index_, this->address_str_.c_str(), param->open.status); - } else if (this->state_ == espbt::ClientState::DISCONNECTING) { - ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while disconnecting, status=%d", this->connection_index_, - this->address_str_.c_str(), param->open.status); - } else { - ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while not in connecting state, status=%d", - this->connection_index_, this->address_str_.c_str(), param->open.status); - } + ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while in %s state, status=%d", this->connection_index_, + this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { - ESP_LOGW(TAG, "[%d] [%s] Connection failed, status=%d", this->connection_index_, this->address_str_.c_str(), - param->open.status); + this->log_gattc_warning_("Connection open", param->open.status); this->set_state(espbt::ClientState::IDLE); break; } @@ -351,7 +318,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_CONNECT_EVT: { if (!this->check_addr(param->connect.remote_bda)) return false; - this->log_event_("ESP_GATTC_CONNECT_EVT"); + this->log_gattc_event_("CONNECT"); this->conn_id_ = param->connect.conn_id; // Start MTU negotiation immediately as recommended by ESP-IDF examples // (gatt_client, ble_throughput) which call esp_ble_gattc_send_mtu_req in @@ -398,7 +365,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_CLOSE_EVT: { if (this->conn_id_ != param->close.conn_id) return false; - this->log_event_("ESP_GATTC_CLOSE_EVT"); + this->log_gattc_event_("CLOSE"); this->release_services(); this->set_state(espbt::ClientState::IDLE); this->conn_id_ = UNSET_CONN_ID; @@ -424,7 +391,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_SEARCH_CMPL_EVT: { if (this->conn_id_ != param->search_cmpl.conn_id) return false; - this->log_event_("ESP_GATTC_SEARCH_CMPL_EVT"); + this->log_gattc_event_("SEARCH_CMPL"); for (auto &svc : this->services_) { ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), svc->uuid.to_string().c_str()); @@ -446,35 +413,35 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_READ_DESCR_EVT: { if (this->conn_id_ != param->write.conn_id) return false; - this->log_event_("ESP_GATTC_READ_DESCR_EVT"); + this->log_gattc_event_("READ_DESCR"); break; } case ESP_GATTC_WRITE_DESCR_EVT: { if (this->conn_id_ != param->write.conn_id) return false; - this->log_event_("ESP_GATTC_WRITE_DESCR_EVT"); + this->log_gattc_event_("WRITE_DESCR"); break; } case ESP_GATTC_WRITE_CHAR_EVT: { if (this->conn_id_ != param->write.conn_id) return false; - this->log_event_("ESP_GATTC_WRITE_CHAR_EVT"); + this->log_gattc_event_("WRITE_CHAR"); break; } case ESP_GATTC_READ_CHAR_EVT: { if (this->conn_id_ != param->read.conn_id) return false; - this->log_event_("ESP_GATTC_READ_CHAR_EVT"); + this->log_gattc_event_("READ_CHAR"); break; } case ESP_GATTC_NOTIFY_EVT: { if (this->conn_id_ != param->notify.conn_id) return false; - this->log_event_("ESP_GATTC_NOTIFY_EVT"); + this->log_gattc_event_("NOTIFY"); break; } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { - this->log_event_("ESP_GATTC_REG_FOR_NOTIFY_EVT"); + this->log_gattc_event_("REG_FOR_NOTIFY"); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { // Client is responsible for flipping the descriptor value @@ -486,8 +453,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ esp_gatt_status_t descr_status = esp_ble_gattc_get_descr_by_char_handle( this->gattc_if_, this->conn_id_, param->reg_for_notify.handle, NOTIFY_DESC_UUID, &desc_result, &count); if (descr_status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_get_descr_by_char_handle error, status=%d", this->connection_index_, - this->address_str_.c_str(), descr_status); + this->log_gattc_warning_("esp_ble_gattc_get_descr_by_char_handle", descr_status); break; } esp_gattc_char_elem_t char_result; @@ -495,8 +461,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, param->reg_for_notify.handle, param->reg_for_notify.handle, &char_result, &count, 0); if (char_status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, - this->address_str_.c_str(), char_status); + this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status); break; } @@ -510,8 +475,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ (uint8_t *) ¬ify_en, ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE); ESP_LOGD(TAG, "Wrote notify descriptor %d, properties=%d", notify_en, char_result.properties); if (status) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_write_char_descr error, status=%d", this->connection_index_, - this->address_str_.c_str(), status); + this->log_gattc_warning_("esp_ble_gattc_write_char_descr", status); } break; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 93260b1c15..fa4463f685 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -127,7 +127,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { // 6 bytes used, 2 bytes padding void log_event_(const char *name); + void log_gattc_event_(const char *name); void restore_medium_conn_params_(); + void log_gattc_warning_(const char *operation, esp_gatt_status_t status); + void log_gattc_warning_(const char *operation, esp_err_t err); }; } // namespace esp32_ble_client diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 460267a264..5e97c81044 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -41,6 +41,31 @@ static const char *const TAG = "esp32_ble_tracker"; ESP32BLETracker *global_esp32_ble_tracker = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +const char *client_state_to_string(ClientState state) { + switch (state) { + case ClientState::INIT: + return "INIT"; + case ClientState::DISCONNECTING: + return "DISCONNECTING"; + case ClientState::IDLE: + return "IDLE"; + case ClientState::SEARCHING: + return "SEARCHING"; + case ClientState::DISCOVERED: + return "DISCOVERED"; + case ClientState::READY_TO_CONNECT: + return "READY_TO_CONNECT"; + case ClientState::CONNECTING: + return "CONNECTING"; + case ClientState::CONNECTED: + return "CONNECTED"; + case ClientState::ESTABLISHED: + return "ESTABLISHED"; + default: + return "UNKNOWN"; + } +} + float ESP32BLETracker::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } void ESP32BLETracker::setup() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index d2423c43ba..fba9dbd97e 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -184,6 +184,9 @@ enum class ScannerState { STOPPING, }; +// Helper function to convert ClientState to string +const char *client_state_to_string(ClientState state); + enum class ConnectionType : uint8_t { // The default connection type, we hold all the services in ram // for the duration of the connection. From 2b3e546203dacc16971006dd5461675a53571578 Mon Sep 17 00:00:00 2001 From: Craig Andrews Date: Wed, 6 Aug 2025 02:47:45 -0400 Subject: [PATCH 0378/1626] [deep_sleep] enable sleep pull up/down for wakeup pin (#9395) --- .../deep_sleep/deep_sleep_esp32.cpp | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 7965ab738a..c5c1fe0835 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -1,4 +1,5 @@ #ifdef USE_ESP32 +#include "driver/gpio.h" #include "deep_sleep_component.h" #include "esphome/core/log.h" @@ -74,11 +75,20 @@ void DeepSleepComponent::deep_sleep_() { if (this->sleep_duration_.has_value()) esp_sleep_enable_timer_wakeup(*this->sleep_duration_); if (this->wakeup_pin_ != nullptr) { + const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); + if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLUP) { + gpio_sleep_set_pull_mode(gpio_pin, GPIO_PULLUP_ONLY); + } else if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLDOWN) { + gpio_sleep_set_pull_mode(gpio_pin, GPIO_PULLDOWN_ONLY); + } + gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); + gpio_hold_en(gpio_pin); + gpio_deep_sleep_hold_en(); bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; } - esp_sleep_enable_ext0_wakeup(gpio_num_t(this->wakeup_pin_->get_pin()), level); + esp_sleep_enable_ext0_wakeup(gpio_pin, level); } if (this->ext1_wakeup_.has_value()) { esp_sleep_enable_ext1_wakeup(this->ext1_wakeup_->mask, this->ext1_wakeup_->wakeup_mode); @@ -102,6 +112,15 @@ void DeepSleepComponent::deep_sleep_() { if (this->sleep_duration_.has_value()) esp_sleep_enable_timer_wakeup(*this->sleep_duration_); if (this->wakeup_pin_ != nullptr) { + const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); + if (this->wakeup_pin_->get_flags() && gpio::FLAG_PULLUP) { + gpio_sleep_set_pull_mode(gpio_pin, GPIO_PULLUP_ONLY); + } else if (this->wakeup_pin_->get_flags() && gpio::FLAG_PULLDOWN) { + gpio_sleep_set_pull_mode(gpio_pin, GPIO_PULLDOWN_ONLY); + } + gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); + gpio_hold_en(gpio_pin); + gpio_deep_sleep_hold_en(); bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; From 3bfd77426a6adea46bd74a280f8972794ad096b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 20:49:35 -1000 Subject: [PATCH 0379/1626] [esp32_ble] Make BLE notification limit configurable to fix ESP_GATT_NO_RESOURCES errors --- esphome/components/esp32_ble/__init__.py | 15 +++++++++++++++ esphome/components/esp32_ble_tracker/__init__.py | 5 ----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index f208fda34c..bfe8bcf9be 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -118,6 +118,7 @@ CONF_IO_CAPABILITY = "io_capability" CONF_ADVERTISING_CYCLE_TIME = "advertising_cycle_time" CONF_DISABLE_BT_LOGS = "disable_bt_logs" CONF_CONNECTION_TIMEOUT = "connection_timeout" +CONF_MAX_NOTIFICATIONS = "max_notifications" NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] @@ -173,6 +174,11 @@ CONFIG_SCHEMA = cv.Schema( cv.positive_time_period_seconds, cv.Range(min=TimePeriod(seconds=10), max=TimePeriod(seconds=180)), ), + cv.SplitDefault(CONF_MAX_NOTIFICATIONS, esp32_idf=12): cv.All( + cv.only_with_esp_idf, + cv.positive_int, + cv.Range(min=1, max=64), + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -272,6 +278,15 @@ async def to_code(config): "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds ) + # Set the maximum number of notification registrations + # This controls how many BLE characteristics can have notifications enabled + # across all connections for a single GATT client interface + # https://github.com/esphome/issues/issues/6808 + if CONF_MAX_NOTIFICATIONS in config: + add_idf_sdkconfig_option( + "CONFIG_BT_GATTC_NOTIF_REG_MAX", config[CONF_MAX_NOTIFICATIONS] + ) + cg.add_define("USE_ESP32_BLE") diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index e1abdd8490..d03e968e2d 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -355,11 +355,6 @@ async def to_code(config): add_idf_sdkconfig_option( "CONFIG_BTDM_CTRL_BLE_MAX_CONN", config[CONF_MAX_CONNECTIONS] ) - # CONFIG_BT_GATTC_NOTIF_REG_MAX controls the number of - # max notifications in 5.x, setting CONFIG_BT_ACL_CONNECTIONS - # is enough in 4.x - # https://github.com/esphome/issues/issues/6808 - add_idf_sdkconfig_option("CONFIG_BT_GATTC_NOTIF_REG_MAX", 9) cg.add_define("USE_OTA_STATE_CALLBACK") # To be notified when an OTA update starts cg.add_define("USE_ESP32_BLE_CLIENT") From 722d76565cc801dea3617f7f6fc3f01ec9163655 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 21:30:07 -1000 Subject: [PATCH 0380/1626] [esp32_ble] Conditionally compile BLE advertising to reduce flash usage --- esphome/components/esp32_ble/__init__.py | 13 +++++++++++++ esphome/components/esp32_ble/ble.cpp | 6 ++++++ esphome/components/esp32_ble/ble.h | 12 ++++++++++-- esphome/components/esp32_ble/ble_advertising.cpp | 4 +++- esphome/components/esp32_ble/ble_advertising.h | 4 +++- esphome/components/esp32_ble_beacon/__init__.py | 2 ++ esphome/components/esp32_ble_server/__init__.py | 1 + esphome/core/defines.h | 1 + 8 files changed, 39 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index f208fda34c..a658a89b9e 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -12,6 +12,14 @@ import esphome.final_validate as fv DEPENDENCIES = ["esp32"] CODEOWNERS = ["@jesserockz", "@Rapsssito", "@bdraco"] +DOMAIN = "esp32_ble" + + +def FILTER_SOURCE_FILES() -> list[str]: + """Filter out ble_advertising.cpp when advertising is not enabled.""" + if "USE_ESP32_BLE_ADVERTISING" not in CORE.defines: + return ["ble_advertising.cpp"] + return [] class BTLoggers(Enum): @@ -115,6 +123,7 @@ def register_bt_logger(*loggers: BTLoggers) -> None: CONF_BLE_ID = "ble_id" CONF_IO_CAPABILITY = "io_capability" +CONF_ADVERTISING = "advertising" CONF_ADVERTISING_CYCLE_TIME = "advertising_cycle_time" CONF_DISABLE_BT_LOGS = "disable_bt_logs" CONF_CONNECTION_TIMEOUT = "connection_timeout" @@ -162,6 +171,7 @@ CONFIG_SCHEMA = cv.Schema( IO_CAPABILITY, lower=True ), cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + cv.Optional(CONF_ADVERTISING, default=False): cv.boolean, cv.Optional( CONF_ADVERTISING_CYCLE_TIME, default="10s" ): cv.positive_time_period_milliseconds, @@ -274,6 +284,9 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE") + if config[CONF_ADVERTISING]: + cg.add_define("USE_ESP32_BLE_ADVERTISING") + @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) async def ble_enabled_to_code(config, condition_id, template_arg, args): diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 33258552c7..4f4f4d08a5 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -53,6 +53,7 @@ void ESP32BLE::disable() { bool ESP32BLE::is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } +#ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_start() { this->advertising_init_(); if (!this->is_active()) @@ -88,6 +89,7 @@ void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) { this->advertising_->remove_service_uuid(uuid); this->advertising_start(); } +#endif bool ESP32BLE::ble_pre_setup_() { esp_err_t err = nvs_flash_init(); @@ -98,6 +100,7 @@ bool ESP32BLE::ble_pre_setup_() { return true; } +#ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_init_() { if (this->advertising_ != nullptr) return; @@ -107,6 +110,7 @@ void ESP32BLE::advertising_init_() { this->advertising_->set_min_preferred_interval(0x06); this->advertising_->set_appearance(this->appearance_); } +#endif bool ESP32BLE::ble_setup_() { esp_err_t err; @@ -394,9 +398,11 @@ void ESP32BLE::loop() { this->ble_event_pool_.release(ble_event); ble_event = this->ble_events_.pop(); } +#ifdef USE_ESP32_BLE_ADVERTISING if (this->advertising_ != nullptr) { this->advertising_->loop(); } +#endif // Log dropped events periodically uint16_t dropped = this->ble_events_.get_and_reset_dropped_count(); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 3f40c557f1..f4fe926da9 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -1,8 +1,10 @@ #pragma once -#include "ble_advertising.h" #include "ble_uuid.h" #include "ble_scan_result.h" +#ifdef USE_ESP32_BLE_ADVERTISING +#include "ble_advertising.h" +#endif #include @@ -106,6 +108,7 @@ class ESP32BLE : public Component { float get_setup_priority() const override; void set_name(const std::string &name) { this->name_ = name; } +#ifdef USE_ESP32_BLE_ADVERTISING void advertising_start(); void advertising_set_service_data(const std::vector &data); void advertising_set_manufacturer_data(const std::vector &data); @@ -113,6 +116,7 @@ class ESP32BLE : public Component { void advertising_add_service_uuid(ESPBTUUID uuid); void advertising_remove_service_uuid(ESPBTUUID uuid); void advertising_register_raw_advertisement_callback(std::function &&callback); +#endif void register_gap_event_handler(GAPEventHandler *handler) { this->gap_event_handlers_.push_back(handler); } void register_gap_scan_event_handler(GAPScanEventHandler *handler) { @@ -133,7 +137,9 @@ class ESP32BLE : public Component { bool ble_setup_(); bool ble_dismantle_(); bool ble_pre_setup_(); +#ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); +#endif private: template friend void enqueue_ble_event(Args... args); @@ -153,7 +159,9 @@ class ESP32BLE : public Component { optional name_; // 4-byte aligned members - BLEAdvertising *advertising_{}; // 4 bytes (pointer) +#ifdef USE_ESP32_BLE_ADVERTISING + BLEAdvertising *advertising_{}; // 4 bytes (pointer) +#endif esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum) uint32_t advertising_cycle_time_{}; // 4 bytes diff --git a/esphome/components/esp32_ble/ble_advertising.cpp b/esphome/components/esp32_ble/ble_advertising.cpp index 6a0d677aa7..d8b9b1cc36 100644 --- a/esphome/components/esp32_ble/ble_advertising.cpp +++ b/esphome/components/esp32_ble/ble_advertising.cpp @@ -1,6 +1,7 @@ #include "ble_advertising.h" #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_ADVERTISING #include #include @@ -161,4 +162,5 @@ void BLEAdvertising::register_raw_advertisement_callback(std::function #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_ADVERTISING #include #include @@ -56,4 +57,5 @@ class BLEAdvertising { } // namespace esphome::esp32_ble -#endif +#endif // USE_ESP32_BLE_ADVERTISING +#endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 6e0d103aa0..7ee0926eea 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -82,6 +82,8 @@ async def to_code(config): cg.add(var.set_measured_power(config[CONF_MEASURED_POWER])) cg.add(var.set_tx_power(config[CONF_TX_POWER])) + cg.add_define("USE_ESP32_BLE_ADVERTISING") + if CORE.using_esp_idf: add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 6f16d76a32..feeb055600 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -571,6 +571,7 @@ async def to_code(config): config[CONF_ON_DISCONNECT], ) cg.add_define("USE_ESP32_BLE_SERVER") + cg.add_define("USE_ESP32_BLE_ADVERTISING") if CORE.using_esp_idf: add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 56de0127a6..7631ff54f3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -153,6 +153,7 @@ #define USE_ESP32_BLE_CLIENT #define USE_ESP32_BLE_DEVICE #define USE_ESP32_BLE_SERVER +#define USE_ESP32_BLE_ADVERTISING #define USE_I2C #define USE_IMPROV #define USE_MICROPHONE From a11f32d6aa3090b9e684d2f3271c740178175127 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 21:54:44 -1000 Subject: [PATCH 0381/1626] dry --- .../bluetooth_proxy/bluetooth_connection.cpp | 51 +++++++------------ .../bluetooth_proxy/bluetooth_connection.h | 1 + 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index c6edb4a9fb..39c05c9d8b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -336,6 +336,14 @@ void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint1 operation, handle, status); } +esp_err_t BluetoothConnection::check_and_log_error_(const char *operation, esp_err_t err) { + if (err != ESP_OK) { + this->log_connection_warning_(operation, err); + return err; + } + return ESP_OK; +} + bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { if (!BLEClientBase::gattc_event_handler(event, gattc_if, param)) @@ -470,11 +478,7 @@ esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { handle); esp_err_t err = esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - if (err != ERR_OK) { - this->log_connection_warning_("esp_ble_gattc_read_char", err); - return err; - } - return ESP_OK; + return this->check_and_log_error_("esp_ble_gattc_read_char", err); } esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std::string &data, bool response) { @@ -488,11 +492,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std:: esp_err_t err = esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (err != ERR_OK) { - this->log_connection_warning_("esp_ble_gattc_write_char", err); - return err; - } - return ESP_OK; + return this->check_and_log_error_("esp_ble_gattc_write_char", err); } esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { @@ -504,11 +504,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { handle); esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - if (err != ERR_OK) { - this->log_connection_warning_("esp_ble_gattc_read_char_descr", err); - return err; - } - return ESP_OK; + return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); } esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::string &data, bool response) { @@ -522,11 +518,7 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::stri esp_err_t err = esp_ble_gattc_write_char_descr( this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (err != ERR_OK) { - this->log_connection_warning_("esp_ble_gattc_write_char_descr", err); - return err; - } - return ESP_OK; + return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err); } esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { @@ -539,20 +531,13 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl ESP_LOGV(TAG, "[%d] [%s] Registering for GATT characteristic notifications handle %d", this->connection_index_, this->address_str_.c_str(), handle); esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle); - if (err != ESP_OK) { - this->log_connection_warning_("esp_ble_gattc_register_for_notify", err); - return err; - } - } else { - ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_.c_str(), handle); - esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); - if (err != ESP_OK) { - this->log_connection_warning_("esp_ble_gattc_unregister_for_notify", err); - return err; - } + return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err); } - return ESP_OK; + + ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_, + this->address_str_.c_str(), handle); + esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); + return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); } esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisement_parser_type() { diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 92c9172e83..7feb3c80bc 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -37,6 +37,7 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { void log_connection_warning_(const char *operation, esp_err_t err); void log_gatt_not_connected_(const char *action, const char *type); void log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status); + esp_err_t check_and_log_error_(const char *operation, esp_err_t err); // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) From f55ab960bb11f2f3a53469a81a64197f17ac303b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 22:22:26 -1000 Subject: [PATCH 0382/1626] order --- esphome/components/esp32_ble/ble.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index f4fe926da9..712787fe53 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -1,5 +1,7 @@ #pragma once +#include "esphome/core/defines.h" // Must be included before conditional includes + #include "ble_uuid.h" #include "ble_scan_result.h" #ifdef USE_ESP32_BLE_ADVERTISING @@ -10,7 +12,6 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "ble_event.h" From 0aec58665a52d57c185aa22497acb78fa7912ebc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 07:09:05 -1000 Subject: [PATCH 0383/1626] remove filter, its too early --- esphome/components/esp32_ble/__init__.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index a658a89b9e..197b556929 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -15,13 +15,6 @@ CODEOWNERS = ["@jesserockz", "@Rapsssito", "@bdraco"] DOMAIN = "esp32_ble" -def FILTER_SOURCE_FILES() -> list[str]: - """Filter out ble_advertising.cpp when advertising is not enabled.""" - if "USE_ESP32_BLE_ADVERTISING" not in CORE.defines: - return ["ble_advertising.cpp"] - return [] - - class BTLoggers(Enum): """Bluetooth logger categories available in ESP-IDF. From 5021cc6d5ff94af41f10da0ba856b064c113fe03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 07:24:02 -1000 Subject: [PATCH 0384/1626] [esp32_ble] Make BLE notification limit configurable to fix ESP_GATT_NO_RESOURCES errors (#10098) --- esphome/components/esp32_ble/__init__.py | 15 +++++++++++++++ esphome/components/esp32_ble_tracker/__init__.py | 5 ----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index f208fda34c..bfe8bcf9be 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -118,6 +118,7 @@ CONF_IO_CAPABILITY = "io_capability" CONF_ADVERTISING_CYCLE_TIME = "advertising_cycle_time" CONF_DISABLE_BT_LOGS = "disable_bt_logs" CONF_CONNECTION_TIMEOUT = "connection_timeout" +CONF_MAX_NOTIFICATIONS = "max_notifications" NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] @@ -173,6 +174,11 @@ CONFIG_SCHEMA = cv.Schema( cv.positive_time_period_seconds, cv.Range(min=TimePeriod(seconds=10), max=TimePeriod(seconds=180)), ), + cv.SplitDefault(CONF_MAX_NOTIFICATIONS, esp32_idf=12): cv.All( + cv.only_with_esp_idf, + cv.positive_int, + cv.Range(min=1, max=64), + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -272,6 +278,15 @@ async def to_code(config): "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds ) + # Set the maximum number of notification registrations + # This controls how many BLE characteristics can have notifications enabled + # across all connections for a single GATT client interface + # https://github.com/esphome/issues/issues/6808 + if CONF_MAX_NOTIFICATIONS in config: + add_idf_sdkconfig_option( + "CONFIG_BT_GATTC_NOTIF_REG_MAX", config[CONF_MAX_NOTIFICATIONS] + ) + cg.add_define("USE_ESP32_BLE") diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index e1abdd8490..d03e968e2d 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -355,11 +355,6 @@ async def to_code(config): add_idf_sdkconfig_option( "CONFIG_BTDM_CTRL_BLE_MAX_CONN", config[CONF_MAX_CONNECTIONS] ) - # CONFIG_BT_GATTC_NOTIF_REG_MAX controls the number of - # max notifications in 5.x, setting CONFIG_BT_ACL_CONNECTIONS - # is enough in 4.x - # https://github.com/esphome/issues/issues/6808 - add_idf_sdkconfig_option("CONFIG_BT_GATTC_NOTIF_REG_MAX", 9) cg.add_define("USE_OTA_STATE_CALLBACK") # To be notified when an OTA update starts cg.add_define("USE_ESP32_BLE_CLIENT") From a10e7b2a54bafc9ad384e52f7d09a5dc2f0b8b9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 07:30:08 -1000 Subject: [PATCH 0385/1626] [bluetooth_proxy] Replace std::find with simple loop for small fixed array --- .../components/bluetooth_proxy/bluetooth_connection.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 23b73127d9..9bf78c866b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -80,9 +80,11 @@ void BluetoothConnection::dump_config() { void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) { auto &allocated = this->proxy_->connections_free_response_.allocated; - auto *it = std::find(allocated.begin(), allocated.end(), find_value); - if (it != allocated.end()) { - *it = set_value; + for (auto &slot : allocated) { + if (slot == find_value) { + slot = set_value; + return; + } } } From 2af29aab6fbd092b3b4bfba26dd39668f537d6ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 07:37:30 -1000 Subject: [PATCH 0386/1626] [bluetooth_proxy] Consolidate dump_config() log calls --- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 97b0884dda..e7393baa71 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -130,8 +130,8 @@ void BluetoothProxy::flush_pending_advertisements() { } void BluetoothProxy::dump_config() { - ESP_LOGCONFIG(TAG, "Bluetooth Proxy:"); ESP_LOGCONFIG(TAG, + "Bluetooth Proxy:\n" " Active: %s\n" " Connections: %d", YESNO(this->active_), this->connection_count_); From 0893d1d9580f09cc3d32e82969ea1f26bd11bd51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 07:47:14 -1000 Subject: [PATCH 0387/1626] [bluetooth_proxy] Remove unnecessary heap allocation for response object --- .../components/bluetooth_proxy/bluetooth_proxy.cpp | 11 ++++------- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 97b0884dda..982313e464 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -25,12 +25,9 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62 BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } void BluetoothProxy::setup() { - // Pre-allocate response object - this->response_ = std::make_unique(); - // Reserve capacity but start with size 0 // Reserve 50% since we'll grow naturally and flush at FLUSH_BATCH_SIZE - this->response_->advertisements.reserve(FLUSH_BATCH_SIZE / 2); + this->response_.advertisements.reserve(FLUSH_BATCH_SIZE / 2); // Don't pre-allocate pool - let it grow only if needed in busy environments // Many devices in quiet areas will never need the overflow pool @@ -65,7 +62,7 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) return false; - auto &advertisements = this->response_->advertisements; + auto &advertisements = this->response_.advertisements; for (size_t i = 0; i < count; i++) { auto &result = scan_results[i]; @@ -109,7 +106,7 @@ void BluetoothProxy::flush_pending_advertisements() { if (this->advertisement_count_ == 0 || !api::global_api_server->is_connected() || this->api_connection_ == nullptr) return; - auto &advertisements = this->response_->advertisements; + auto &advertisements = this->response_.advertisements; // Return any items beyond advertisement_count_ to the pool if (advertisements.size() > this->advertisement_count_) { @@ -123,7 +120,7 @@ void BluetoothProxy::flush_pending_advertisements() { } // Send the message - this->api_connection_->send_message(*this->response_, api::BluetoothLERawAdvertisementsResponse::MESSAGE_TYPE); + this->api_connection_->send_message(this->response_, api::BluetoothLERawAdvertisementsResponse::MESSAGE_TYPE); // Reset count - existing items will be overwritten in next batch this->advertisement_count_ = 0; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d367dad438..dc2fe03b72 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -146,7 +146,7 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // BLE advertisement batching std::vector advertisement_pool_; - std::unique_ptr response_; + api::BluetoothLERawAdvertisementsResponse response_; // Group 3: 4-byte types uint32_t last_advertisement_flush_time_{0}; From 4ceda31f320461cceea6e5ece1ddf6b0eeeac1e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 09:53:42 -1000 Subject: [PATCH 0388/1626] [bluetooth_proxy] Replace std::find with simple loop for small fixed array (#10102) --- .../components/bluetooth_proxy/bluetooth_connection.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 23b73127d9..9bf78c866b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -80,9 +80,11 @@ void BluetoothConnection::dump_config() { void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) { auto &allocated = this->proxy_->connections_free_response_.allocated; - auto *it = std::find(allocated.begin(), allocated.end(), find_value); - if (it != allocated.end()) { - *it = set_value; + for (auto &slot : allocated) { + if (slot == find_value) { + slot = set_value; + return; + } } } From 16a2677bcf27a6a907079a2b09345c9791689911 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 09:55:01 -1000 Subject: [PATCH 0389/1626] [bluetooth_proxy] Remove V1 connection support --- esphome/components/api/api.proto | 2 +- .../components/bluetooth_proxy/bluetooth_proxy.cpp | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 67e91cc8e3..9d77ecdfa8 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1442,7 +1442,7 @@ message BluetoothLERawAdvertisementsResponse { } enum BluetoothDeviceRequestType { - BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0; + BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0 [deprecated = true]; // V1 removed, use V3 variants BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1; BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR = 2; BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR = 3; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index b921877615..db29e4774f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -212,8 +212,7 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { switch (msg.request_type) { case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE: - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE: - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE: { auto *connection = this->get_connection_(msg.address, true); if (connection == nullptr) { ESP_LOGW(TAG, "No free connections available"); @@ -242,12 +241,9 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE) { connection->set_connection_type(espbt::ConnectionType::V3_WITH_CACHE); this->log_connection_info_(connection, "v3 with cache"); - } else if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE) { + } else { // BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); - } else { - connection->set_connection_type(espbt::ConnectionType::V1); - this->log_connection_info_(connection, "v1"); } if (msg.has_address_type) { uint64_to_bd_addr(msg.address, connection->remote_bda_); @@ -309,6 +305,11 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { + ESP_LOGE(TAG, "V1 connections removed"); + this->send_device_connection(msg.address, false); + break; + } } } From 083ac8ce8eaac973805c789b0bd7627604b35b37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 10:21:29 -1000 Subject: [PATCH 0390/1626] Bump aioesphomeapi from 37.2.5 to 38.0.0 (#10109) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 134dcde822..e882b8cf44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==37.2.5 +aioesphomeapi==38.0.0 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From ee98abe9f176b90030e5deee58231b4ad6109d7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 10:27:18 -1000 Subject: [PATCH 0391/1626] [esp32_ble_client] Fix V3_WITH_CACHE connections unnecessarily populating services vector --- .../esp32_ble_client/ble_client_base.cpp | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 202b074589..34f8e5fc91 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -375,9 +375,10 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->conn_id_ != param->search_res.conn_id) return false; this->service_count_++; - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || + this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { // V3 clients don't need services initialized since - // they only request by handle after receiving the services. + // as they use the ESP APIs to get services. break; } BLEService *ble_service = new BLEService(); // NOLINT(cppcoreguidelines-owning-memory) @@ -392,21 +393,20 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->conn_id_ != param->search_cmpl.conn_id) return false; this->log_gattc_event_("SEARCH_CMPL"); - for (auto &svc : this->services_) { - ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), - svc->uuid.to_string().c_str()); - ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, - this->address_str_.c_str(), svc->start_handle, svc->end_handle); - } - ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); - // For V3 connections, restore to medium connection parameters after service discovery // This balances performance with bandwidth usage after the critical discovery phase if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { this->restore_medium_conn_params_(); + } else { + for (auto &svc : this->services_) { + ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), + svc->uuid.to_string().c_str()); + ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, + this->address_str_.c_str(), svc->start_handle, svc->end_handle); + } } - + ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); this->state_ = espbt::ClientState::ESTABLISHED; break; } From 534681b9887de75181592d8295408f905a51ecbd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 10:27:18 -1000 Subject: [PATCH 0392/1626] [esp32_ble_client] Fix V3_WITH_CACHE connections unnecessarily populating services vector preen --- .../esp32_ble_client/ble_client_base.cpp | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 202b074589..0e302525a8 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -377,7 +377,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->service_count_++; if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { // V3 clients don't need services initialized since - // they only request by handle after receiving the services. + // as they use the ESP APIs to get services. break; } BLEService *ble_service = new BLEService(); // NOLINT(cppcoreguidelines-owning-memory) @@ -392,21 +392,20 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->conn_id_ != param->search_cmpl.conn_id) return false; this->log_gattc_event_("SEARCH_CMPL"); - for (auto &svc : this->services_) { - ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), - svc->uuid.to_string().c_str()); - ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, - this->address_str_.c_str(), svc->start_handle, svc->end_handle); - } - ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); - // For V3 connections, restore to medium connection parameters after service discovery // This balances performance with bandwidth usage after the critical discovery phase if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { this->restore_medium_conn_params_(); + } else { + for (auto &svc : this->services_) { + ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), + svc->uuid.to_string().c_str()); + ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, + this->address_str_.c_str(), svc->start_handle, svc->end_handle); + } } - + ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); this->state_ = espbt::ClientState::ESTABLISHED; break; } From cb4d3d37cf3996fc018280f9c0ced028f50f4195 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 10:55:56 -1000 Subject: [PATCH 0393/1626] [esp32_ble_client] Convert to C++17 nested namespace syntax --- esphome/components/esp32_ble_client/ble_characteristic.cpp | 6 ++---- esphome/components/esp32_ble_client/ble_characteristic.h | 6 ++---- esphome/components/esp32_ble_client/ble_client_base.cpp | 6 ++---- esphome/components/esp32_ble_client/ble_client_base.h | 6 ++---- esphome/components/esp32_ble_client/ble_descriptor.h | 6 ++---- esphome/components/esp32_ble_client/ble_service.cpp | 6 ++---- esphome/components/esp32_ble_client/ble_service.h | 6 ++---- 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_characteristic.cpp b/esphome/components/esp32_ble_client/ble_characteristic.cpp index 2fd7fe9871..8a3d313303 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_client/ble_characteristic.cpp @@ -6,8 +6,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { static const char *const TAG = "esp32_ble_client"; @@ -93,7 +92,6 @@ esp_err_t BLECharacteristic::write_value(uint8_t *new_val, int16_t new_val_size) return write_value(new_val, new_val_size, ESP_GATT_WRITE_TYPE_NO_RSP); } -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_characteristic.h b/esphome/components/esp32_ble_client/ble_characteristic.h index a014788e65..d55e69f47a 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.h +++ b/esphome/components/esp32_ble_client/ble_characteristic.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -33,7 +32,6 @@ class BLECharacteristic { BLEService *service; }; -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index f47642944b..5b433c2b97 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -8,8 +8,7 @@ #include #include -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { static const char *const TAG = "esp32_ble_client"; @@ -696,7 +695,6 @@ BLEDescriptor *BLEClientBase::get_descriptor(uint16_t handle) { return nullptr; } -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 93260b1c15..d6a196ff9f 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -16,8 +16,7 @@ #include #include -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -130,7 +129,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void restore_medium_conn_params_(); }; -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_descriptor.h b/esphome/components/esp32_ble_client/ble_descriptor.h index c05430144f..015a1243ed 100644 --- a/esphome/components/esp32_ble_client/ble_descriptor.h +++ b/esphome/components/esp32_ble_client/ble_descriptor.h @@ -4,8 +4,7 @@ #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -19,7 +18,6 @@ class BLEDescriptor { BLECharacteristic *characteristic; }; -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_service.cpp b/esphome/components/esp32_ble_client/ble_service.cpp index b22d2a1788..0defefd6ac 100644 --- a/esphome/components/esp32_ble_client/ble_service.cpp +++ b/esphome/components/esp32_ble_client/ble_service.cpp @@ -5,8 +5,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { static const char *const TAG = "esp32_ble_client"; @@ -71,7 +70,6 @@ void BLEService::parse_characteristics() { } } -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_service.h b/esphome/components/esp32_ble_client/ble_service.h index 41fc3e838b..1b8b5a36dc 100644 --- a/esphome/components/esp32_ble_client/ble_service.h +++ b/esphome/components/esp32_ble_client/ble_service.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -30,7 +29,6 @@ class BLEService { BLECharacteristic *get_characteristic(uint16_t uuid); }; -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 From 6071f4b02cec505a512abaae10455e551d47c10d Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 6 Aug 2025 17:26:11 -0500 Subject: [PATCH 0394/1626] [ld2410] Replace ``throttle`` with native filters (#10019) --- esphome/components/ld2410/__init__.py | 9 ++---- esphome/components/ld2410/binary_sensor.py | 4 +++ esphome/components/ld2410/ld2410.cpp | 13 ++------- esphome/components/ld2410/ld2410.h | 3 -- esphome/components/ld2410/sensor.py | 34 +++++++++++++++------- 5 files changed, 33 insertions(+), 30 deletions(-) diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index 4918190179..b492bbcd14 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -14,18 +14,16 @@ ld2410_ns = cg.esphome_ns.namespace("ld2410") LD2410Component = ld2410_ns.class_("LD2410Component", cg.Component, uart.UARTDevice) CONF_LD2410_ID = "ld2410_id" - CONF_MAX_MOVE_DISTANCE = "max_move_distance" CONF_MAX_STILL_DISTANCE = "max_still_distance" -CONF_STILL_THRESHOLDS = [f"g{x}_still_threshold" for x in range(9)] CONF_MOVE_THRESHOLDS = [f"g{x}_move_threshold" for x in range(9)] +CONF_STILL_THRESHOLDS = [f"g{x}_still_threshold" for x in range(9)] CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(LD2410Component), - cv.Optional(CONF_THROTTLE, default="1000ms"): cv.All( - cv.positive_time_period_milliseconds, - cv.Range(min=cv.TimePeriod(milliseconds=1)), + cv.Optional(CONF_THROTTLE): cv.invalid( + f"{CONF_THROTTLE} has been removed; use per-sensor filters, instead" ), cv.Optional(CONF_MAX_MOVE_DISTANCE): cv.invalid( f"The '{CONF_MAX_MOVE_DISTANCE}' option has been moved to the '{CONF_MAX_MOVE_DISTANCE}'" @@ -75,7 +73,6 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - cg.add(var.set_throttle(config[CONF_THROTTLE])) CALIBRATION_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/ld2410/binary_sensor.py b/esphome/components/ld2410/binary_sensor.py index d2938754e9..4e35f67fbe 100644 --- a/esphome/components/ld2410/binary_sensor.py +++ b/esphome/components/ld2410/binary_sensor.py @@ -22,19 +22,23 @@ CONFIG_SCHEMA = { cv.GenerateID(CONF_LD2410_ID): cv.use_id(LD2410Component), cv.Optional(CONF_HAS_TARGET): binary_sensor.binary_sensor_schema( device_class=DEVICE_CLASS_OCCUPANCY, + filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], icon=ICON_ACCOUNT, ), cv.Optional(CONF_HAS_MOVING_TARGET): binary_sensor.binary_sensor_schema( device_class=DEVICE_CLASS_MOTION, + filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], icon=ICON_MOTION_SENSOR, ), cv.Optional(CONF_HAS_STILL_TARGET): binary_sensor.binary_sensor_schema( device_class=DEVICE_CLASS_OCCUPANCY, + filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], icon=ICON_MOTION_SENSOR, ), cv.Optional(CONF_OUT_PIN_PRESENCE_STATUS): binary_sensor.binary_sensor_schema( device_class=DEVICE_CLASS_PRESENCE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], icon=ICON_ACCOUNT, ), } diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index e0287465f8..5c3af54ad8 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -188,9 +188,8 @@ void LD2410Component::dump_config() { ESP_LOGCONFIG(TAG, "LD2410:\n" " Firmware version: %s\n" - " MAC address: %s\n" - " Throttle: %u ms", - version.c_str(), mac_str.c_str(), this->throttle_); + " MAC address: %s", + version.c_str(), mac_str.c_str()); #ifdef USE_BINARY_SENSOR ESP_LOGCONFIG(TAG, "Binary Sensors:"); LOG_BINARY_SENSOR(" ", "Target", this->target_binary_sensor_); @@ -306,11 +305,6 @@ void LD2410Component::send_command_(uint8_t command, const uint8_t *command_valu } void LD2410Component::handle_periodic_data_() { - // Reduce data update rate to reduce home assistant database growth - // Check this first to prevent unnecessary processing done in later checks/parsing - if (App.get_loop_component_start_time() - this->last_periodic_millis_ < this->throttle_) { - return; - } // 4 frame header bytes + 2 length bytes + 1 data end byte + 1 crc byte + 4 frame footer bytes // data header=0xAA, data footer=0x55, crc=0x00 if (this->buffer_pos_ < 12 || !ld2410::validate_header_footer(DATA_FRAME_HEADER, this->buffer_data_) || @@ -318,9 +312,6 @@ void LD2410Component::handle_periodic_data_() { this->buffer_data_[this->buffer_pos_ - 5] != CHECK) { return; } - // Save the timestamp after validating the frame so, if invalid, we'll take the next frame immediately - this->last_periodic_millis_ = App.get_loop_component_start_time(); - /* Data Type: 7th 0x01: Engineering mode diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index e9225ccfe4..54fe1ce14d 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -93,7 +93,6 @@ class LD2410Component : public Component, public uart::UARTDevice { void set_gate_move_sensor(uint8_t gate, sensor::Sensor *s); void set_gate_still_sensor(uint8_t gate, sensor::Sensor *s); #endif - void set_throttle(uint16_t value) { this->throttle_ = value; }; void set_bluetooth_password(const std::string &password); void set_engineering_mode(bool enable); void read_all_info(); @@ -116,8 +115,6 @@ class LD2410Component : public Component, public uart::UARTDevice { void query_light_control_(); void restart_(); - uint32_t last_periodic_millis_ = 0; - uint16_t throttle_ = 0; uint8_t light_function_ = 0; uint8_t light_threshold_ = 0; uint8_t out_pin_level_ = 0; diff --git a/esphome/components/ld2410/sensor.py b/esphome/components/ld2410/sensor.py index 92245ea9a6..fca2b2ceca 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -18,42 +18,50 @@ from esphome.const import ( from . import CONF_LD2410_ID, LD2410Component DEPENDENCIES = ["ld2410"] -CONF_STILL_DISTANCE = "still_distance" -CONF_MOVING_ENERGY = "moving_energy" -CONF_STILL_ENERGY = "still_energy" + CONF_DETECTION_DISTANCE = "detection_distance" CONF_MOVE_ENERGY = "move_energy" +CONF_MOVING_ENERGY = "moving_energy" +CONF_STILL_DISTANCE = "still_distance" +CONF_STILL_ENERGY = "still_energy" + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_LD2410_ID): cv.use_id(LD2410Component), cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - unit_of_measurement=UNIT_CENTIMETER, + filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], icon=ICON_SIGNAL, + unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - unit_of_measurement=UNIT_CENTIMETER, + filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], icon=ICON_SIGNAL, + unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema( - unit_of_measurement=UNIT_PERCENT, + filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], icon=ICON_MOTION_SENSOR, + unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( - unit_of_measurement=UNIT_PERCENT, + filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], icon=ICON_FLASH, + unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_LIGHT): sensor.sensor_schema( device_class=DEVICE_CLASS_ILLUMINANCE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], icon=ICON_LIGHTBULB, ), cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - unit_of_measurement=UNIT_CENTIMETER, + filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], icon=ICON_SIGNAL, + unit_of_measurement=UNIT_CENTIMETER, ), } ) @@ -63,14 +71,20 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( cv.Optional(f"g{x}"): cv.Schema( { cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema( - unit_of_measurement=UNIT_PERCENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + filters=[ + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)} + ], icon=ICON_MOTION_SENSOR, + unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( - unit_of_measurement=UNIT_PERCENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + filters=[ + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)} + ], icon=ICON_FLASH, + unit_of_measurement=UNIT_PERCENT, ), } ) From 325ec0a0ae6d5459db063d4cbabb426736d1fd2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 14:18:03 -1000 Subject: [PATCH 0395/1626] [esp32_ble_client] Convert to C++17 nested namespace syntax (#10111) --- esphome/components/esp32_ble_client/ble_characteristic.cpp | 6 ++---- esphome/components/esp32_ble_client/ble_characteristic.h | 6 ++---- esphome/components/esp32_ble_client/ble_client_base.cpp | 6 ++---- esphome/components/esp32_ble_client/ble_client_base.h | 6 ++---- esphome/components/esp32_ble_client/ble_descriptor.h | 6 ++---- esphome/components/esp32_ble_client/ble_service.cpp | 6 ++---- esphome/components/esp32_ble_client/ble_service.h | 6 ++---- 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_characteristic.cpp b/esphome/components/esp32_ble_client/ble_characteristic.cpp index 2fd7fe9871..8a3d313303 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_client/ble_characteristic.cpp @@ -6,8 +6,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { static const char *const TAG = "esp32_ble_client"; @@ -93,7 +92,6 @@ esp_err_t BLECharacteristic::write_value(uint8_t *new_val, int16_t new_val_size) return write_value(new_val, new_val_size, ESP_GATT_WRITE_TYPE_NO_RSP); } -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_characteristic.h b/esphome/components/esp32_ble_client/ble_characteristic.h index a014788e65..d55e69f47a 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.h +++ b/esphome/components/esp32_ble_client/ble_characteristic.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -33,7 +32,6 @@ class BLECharacteristic { BLEService *service; }; -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index f47642944b..5b433c2b97 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -8,8 +8,7 @@ #include #include -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { static const char *const TAG = "esp32_ble_client"; @@ -696,7 +695,6 @@ BLEDescriptor *BLEClientBase::get_descriptor(uint16_t handle) { return nullptr; } -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 93260b1c15..d6a196ff9f 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -16,8 +16,7 @@ #include #include -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -130,7 +129,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void restore_medium_conn_params_(); }; -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_descriptor.h b/esphome/components/esp32_ble_client/ble_descriptor.h index c05430144f..015a1243ed 100644 --- a/esphome/components/esp32_ble_client/ble_descriptor.h +++ b/esphome/components/esp32_ble_client/ble_descriptor.h @@ -4,8 +4,7 @@ #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -19,7 +18,6 @@ class BLEDescriptor { BLECharacteristic *characteristic; }; -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_service.cpp b/esphome/components/esp32_ble_client/ble_service.cpp index b22d2a1788..0defefd6ac 100644 --- a/esphome/components/esp32_ble_client/ble_service.cpp +++ b/esphome/components/esp32_ble_client/ble_service.cpp @@ -5,8 +5,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { static const char *const TAG = "esp32_ble_client"; @@ -71,7 +70,6 @@ void BLEService::parse_characteristics() { } } -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_service.h b/esphome/components/esp32_ble_client/ble_service.h index 41fc3e838b..1b8b5a36dc 100644 --- a/esphome/components/esp32_ble_client/ble_service.h +++ b/esphome/components/esp32_ble_client/ble_service.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace esp32_ble_client { +namespace esphome::esp32_ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -30,7 +29,6 @@ class BLEService { BLECharacteristic *get_characteristic(uint16_t uuid); }; -} // namespace esp32_ble_client -} // namespace esphome +} // namespace esphome::esp32_ble_client #endif // USE_ESP32 From ee1d4f27efd9e41ec66d408be6be40245fbfc692 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 14:29:24 -1000 Subject: [PATCH 0396/1626] [esp32_ble] Conditionally compile BLE advertising to reduce flash usage (#10099) --- esphome/components/esp32_ble/__init__.py | 6 ++++++ esphome/components/esp32_ble/ble.cpp | 10 ++++++++-- esphome/components/esp32_ble/ble.h | 15 ++++++++++++--- esphome/components/esp32_ble/ble_advertising.cpp | 4 +++- esphome/components/esp32_ble/ble_advertising.h | 6 +++++- esphome/components/esp32_ble_beacon/__init__.py | 2 ++ esphome/components/esp32_ble_server/__init__.py | 1 + esphome/core/defines.h | 1 + 8 files changed, 38 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index bfe8bcf9be..c758b3ef8f 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -12,6 +12,7 @@ import esphome.final_validate as fv DEPENDENCIES = ["esp32"] CODEOWNERS = ["@jesserockz", "@Rapsssito", "@bdraco"] +DOMAIN = "esp32_ble" class BTLoggers(Enum): @@ -115,6 +116,7 @@ def register_bt_logger(*loggers: BTLoggers) -> None: CONF_BLE_ID = "ble_id" CONF_IO_CAPABILITY = "io_capability" +CONF_ADVERTISING = "advertising" CONF_ADVERTISING_CYCLE_TIME = "advertising_cycle_time" CONF_DISABLE_BT_LOGS = "disable_bt_logs" CONF_CONNECTION_TIMEOUT = "connection_timeout" @@ -163,6 +165,7 @@ CONFIG_SCHEMA = cv.Schema( IO_CAPABILITY, lower=True ), cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + cv.Optional(CONF_ADVERTISING, default=False): cv.boolean, cv.Optional( CONF_ADVERTISING_CYCLE_TIME, default="10s" ): cv.positive_time_period_milliseconds, @@ -289,6 +292,9 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE") + if config[CONF_ADVERTISING]: + cg.add_define("USE_ESP32_BLE_ADVERTISING") + @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) async def ble_enabled_to_code(config, condition_id, template_arg, args): diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 33258552c7..d1ee7af4ea 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -1,7 +1,7 @@ -#ifdef USE_ESP32 - #include "ble.h" +#ifdef USE_ESP32 + #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -53,6 +53,7 @@ void ESP32BLE::disable() { bool ESP32BLE::is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } +#ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_start() { this->advertising_init_(); if (!this->is_active()) @@ -88,6 +89,7 @@ void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) { this->advertising_->remove_service_uuid(uuid); this->advertising_start(); } +#endif bool ESP32BLE::ble_pre_setup_() { esp_err_t err = nvs_flash_init(); @@ -98,6 +100,7 @@ bool ESP32BLE::ble_pre_setup_() { return true; } +#ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_init_() { if (this->advertising_ != nullptr) return; @@ -107,6 +110,7 @@ void ESP32BLE::advertising_init_() { this->advertising_->set_min_preferred_interval(0x06); this->advertising_->set_appearance(this->appearance_); } +#endif bool ESP32BLE::ble_setup_() { esp_err_t err; @@ -394,9 +398,11 @@ void ESP32BLE::loop() { this->ble_event_pool_.release(ble_event); ble_event = this->ble_events_.pop(); } +#ifdef USE_ESP32_BLE_ADVERTISING if (this->advertising_ != nullptr) { this->advertising_->loop(); } +#endif // Log dropped events periodically uint16_t dropped = this->ble_events_.get_and_reset_dropped_count(); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 3f40c557f1..712787fe53 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -1,14 +1,17 @@ #pragma once -#include "ble_advertising.h" +#include "esphome/core/defines.h" // Must be included before conditional includes + #include "ble_uuid.h" #include "ble_scan_result.h" +#ifdef USE_ESP32_BLE_ADVERTISING +#include "ble_advertising.h" +#endif #include #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "ble_event.h" @@ -106,6 +109,7 @@ class ESP32BLE : public Component { float get_setup_priority() const override; void set_name(const std::string &name) { this->name_ = name; } +#ifdef USE_ESP32_BLE_ADVERTISING void advertising_start(); void advertising_set_service_data(const std::vector &data); void advertising_set_manufacturer_data(const std::vector &data); @@ -113,6 +117,7 @@ class ESP32BLE : public Component { void advertising_add_service_uuid(ESPBTUUID uuid); void advertising_remove_service_uuid(ESPBTUUID uuid); void advertising_register_raw_advertisement_callback(std::function &&callback); +#endif void register_gap_event_handler(GAPEventHandler *handler) { this->gap_event_handlers_.push_back(handler); } void register_gap_scan_event_handler(GAPScanEventHandler *handler) { @@ -133,7 +138,9 @@ class ESP32BLE : public Component { bool ble_setup_(); bool ble_dismantle_(); bool ble_pre_setup_(); +#ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); +#endif private: template friend void enqueue_ble_event(Args... args); @@ -153,7 +160,9 @@ class ESP32BLE : public Component { optional name_; // 4-byte aligned members - BLEAdvertising *advertising_{}; // 4 bytes (pointer) +#ifdef USE_ESP32_BLE_ADVERTISING + BLEAdvertising *advertising_{}; // 4 bytes (pointer) +#endif esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum) uint32_t advertising_cycle_time_{}; // 4 bytes diff --git a/esphome/components/esp32_ble/ble_advertising.cpp b/esphome/components/esp32_ble/ble_advertising.cpp index 6a0d677aa7..d8b9b1cc36 100644 --- a/esphome/components/esp32_ble/ble_advertising.cpp +++ b/esphome/components/esp32_ble/ble_advertising.cpp @@ -1,6 +1,7 @@ #include "ble_advertising.h" #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_ADVERTISING #include #include @@ -161,4 +162,5 @@ void BLEAdvertising::register_raw_advertisement_callback(std::function #include #include #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_ADVERTISING #include #include @@ -56,4 +59,5 @@ class BLEAdvertising { } // namespace esphome::esp32_ble -#endif +#endif // USE_ESP32_BLE_ADVERTISING +#endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 6e0d103aa0..7ee0926eea 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -82,6 +82,8 @@ async def to_code(config): cg.add(var.set_measured_power(config[CONF_MEASURED_POWER])) cg.add(var.set_tx_power(config[CONF_TX_POWER])) + cg.add_define("USE_ESP32_BLE_ADVERTISING") + if CORE.using_esp_idf: add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 6f16d76a32..feeb055600 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -571,6 +571,7 @@ async def to_code(config): config[CONF_ON_DISCONNECT], ) cg.add_define("USE_ESP32_BLE_SERVER") + cg.add_define("USE_ESP32_BLE_ADVERTISING") if CORE.using_esp_idf: add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 56de0127a6..7631ff54f3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -153,6 +153,7 @@ #define USE_ESP32_BLE_CLIENT #define USE_ESP32_BLE_DEVICE #define USE_ESP32_BLE_SERVER +#define USE_ESP32_BLE_ADVERTISING #define USE_I2C #define USE_IMPROV #define USE_MICROPHONE From fc180251be9dae04b473c5eeae155a79dceb04dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 14:43:59 -1000 Subject: [PATCH 0397/1626] [bluetooth_proxy] Consolidate dump_config() log calls (#10103) --- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 97b0884dda..e7393baa71 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -130,8 +130,8 @@ void BluetoothProxy::flush_pending_advertisements() { } void BluetoothProxy::dump_config() { - ESP_LOGCONFIG(TAG, "Bluetooth Proxy:"); ESP_LOGCONFIG(TAG, + "Bluetooth Proxy:\n" " Active: %s\n" " Connections: %d", YESNO(this->active_), this->connection_count_); From 6d66ddd68ded8fc5c40ce79122a0a87411b7d945 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 15:41:03 -1000 Subject: [PATCH 0398/1626] [bluetooth_proxy][esp32_ble_tracker][esp32_ble_client] Consolidate duplicate logging code to reduce flash usage (#10097) --- .../bluetooth_proxy/bluetooth_connection.cpp | 53 ++++------ .../bluetooth_proxy/bluetooth_connection.h | 1 + .../bluetooth_proxy/bluetooth_proxy.cpp | 71 ++++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 4 + .../esp32_ble_client/ble_client_base.cpp | 98 ++++++------------- .../esp32_ble_client/ble_client_base.h | 3 + .../esp32_ble_tracker/esp32_ble_tracker.cpp | 25 +++++ .../esp32_ble_tracker/esp32_ble_tracker.h | 3 + 8 files changed, 117 insertions(+), 141 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 9bf78c866b..fd77f9bd5b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -338,6 +338,14 @@ void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint1 operation, handle, status); } +esp_err_t BluetoothConnection::check_and_log_error_(const char *operation, esp_err_t err) { + if (err != ESP_OK) { + this->log_connection_warning_(operation, err); + return err; + } + return ESP_OK; +} + bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { if (!BLEClientBase::gattc_event_handler(event, gattc_if, param)) @@ -472,12 +480,7 @@ esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { handle); esp_err_t err = esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - if (err != ERR_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_read_char error, err=%d", this->connection_index_, - this->address_str_.c_str(), err); - return err; - } - return ESP_OK; + return this->check_and_log_error_("esp_ble_gattc_read_char", err); } esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std::string &data, bool response) { @@ -491,11 +494,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std:: esp_err_t err = esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (err != ERR_OK) { - this->log_connection_warning_("esp_ble_gattc_write_char", err); - return err; - } - return ESP_OK; + return this->check_and_log_error_("esp_ble_gattc_write_char", err); } esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { @@ -507,11 +506,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { handle); esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - if (err != ERR_OK) { - this->log_connection_warning_("esp_ble_gattc_read_char_descr", err); - return err; - } - return ESP_OK; + return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); } esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::string &data, bool response) { @@ -525,12 +520,7 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::stri esp_err_t err = esp_ble_gattc_write_char_descr( this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (err != ERR_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_write_char_descr error, err=%d", this->connection_index_, - this->address_str_.c_str(), err); - return err; - } - return ESP_OK; + return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err); } esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { @@ -543,20 +533,13 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl ESP_LOGV(TAG, "[%d] [%s] Registering for GATT characteristic notifications handle %d", this->connection_index_, this->address_str_.c_str(), handle); esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle); - if (err != ESP_OK) { - this->log_connection_warning_("esp_ble_gattc_register_for_notify", err); - return err; - } - } else { - ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_.c_str(), handle); - esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); - if (err != ESP_OK) { - this->log_connection_warning_("esp_ble_gattc_unregister_for_notify", err); - return err; - } + return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err); } - return ESP_OK; + + ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_, + this->address_str_.c_str(), handle); + esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); + return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); } esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisement_parser_type() { diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 92c9172e83..7feb3c80bc 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -37,6 +37,7 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { void log_connection_warning_(const char *operation, esp_err_t err); void log_gatt_not_connected_(const char *action, const char *type); void log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status); + esp_err_t check_and_log_error_(const char *operation, esp_err_t err); // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index e7393baa71..d37472e866 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -53,6 +53,26 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta this->api_connection_->send_message(resp, api::BluetoothScannerStateResponse::MESSAGE_TYPE); } +void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) { + ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), + connection->address_str().c_str(), espbt::client_state_to_string(state)); +} + +void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { + ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str().c_str(), + message); +} + +void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { + ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); +} + +void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, + const char *type) { + this->log_not_connected_gatt_(action, type); + this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED); +} + #ifdef USE_ESP32_BLE_DEVICE bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { // This method should never be called since bluetooth_proxy always uses raw advertisements @@ -202,23 +222,10 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } if (connection->state() == espbt::ClientState::CONNECTED || connection->state() == espbt::ClientState::ESTABLISHED) { - ESP_LOGW(TAG, "[%d] [%s] Connection already established", connection->get_connection_index(), - connection->address_str().c_str()); + this->log_connection_request_ignored_(connection, connection->state()); this->send_device_connection(msg.address, true); this->send_connections_free(); return; - } else if (connection->state() == espbt::ClientState::SEARCHING) { - ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, already searching for device", - connection->get_connection_index(), connection->address_str().c_str()); - return; - } else if (connection->state() == espbt::ClientState::DISCOVERED) { - ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, device already discovered", - connection->get_connection_index(), connection->address_str().c_str()); - return; - } else if (connection->state() == espbt::ClientState::READY_TO_CONNECT) { - ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, waiting in line to connect", - connection->get_connection_index(), connection->address_str().c_str()); - return; } else if (connection->state() == espbt::ClientState::CONNECTING) { if (connection->disconnect_pending()) { ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", @@ -226,29 +233,21 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest connection->cancel_pending_disconnect(); return; } - ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, already connecting", connection->get_connection_index(), - connection->address_str().c_str()); - return; - } else if (connection->state() == espbt::ClientState::DISCONNECTING) { - ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, device is disconnecting", - connection->get_connection_index(), connection->address_str().c_str()); + this->log_connection_request_ignored_(connection, connection->state()); return; } else if (connection->state() != espbt::ClientState::INIT) { - ESP_LOGW(TAG, "[%d] [%s] Connection already in progress", connection->get_connection_index(), - connection->address_str().c_str()); + this->log_connection_request_ignored_(connection, connection->state()); return; } if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE) { connection->set_connection_type(espbt::ConnectionType::V3_WITH_CACHE); - ESP_LOGI(TAG, "[%d] [%s] Connecting v3 with cache", connection->get_connection_index(), - connection->address_str().c_str()); + this->log_connection_info_(connection, "v3 with cache"); } else if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE) { connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); - ESP_LOGI(TAG, "[%d] [%s] Connecting v3 without cache", connection->get_connection_index(), - connection->address_str().c_str()); + this->log_connection_info_(connection, "v3 without cache"); } else { connection->set_connection_type(espbt::ConnectionType::V1); - ESP_LOGI(TAG, "[%d] [%s] Connecting v1", connection->get_connection_index(), connection->address_str().c_str()); + this->log_connection_info_(connection, "v1"); } if (msg.has_address_type) { uint64_to_bd_addr(msg.address, connection->remote_bda_); @@ -316,8 +315,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - ESP_LOGW(TAG, "Cannot read GATT characteristic, not connected"); - this->send_gatt_error(msg.address, msg.handle, ESP_GATT_NOT_CONNECTED); + this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic"); return; } @@ -330,8 +328,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - ESP_LOGW(TAG, "Cannot write GATT characteristic, not connected"); - this->send_gatt_error(msg.address, msg.handle, ESP_GATT_NOT_CONNECTED); + this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic"); return; } @@ -344,8 +341,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - ESP_LOGW(TAG, "Cannot read GATT descriptor, not connected"); - this->send_gatt_error(msg.address, msg.handle, ESP_GATT_NOT_CONNECTED); + this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor"); return; } @@ -358,8 +354,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - ESP_LOGW(TAG, "Cannot write GATT descriptor, not connected"); - this->send_gatt_error(msg.address, msg.handle, ESP_GATT_NOT_CONNECTED); + this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor"); return; } @@ -372,8 +367,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr || !connection->connected()) { - ESP_LOGW(TAG, "Cannot get GATT services, not connected"); - this->send_gatt_error(msg.address, 0, ESP_GATT_NOT_CONNECTED); + this->handle_gatt_not_connected_(msg.address, 0, "get", "services"); return; } if (!connection->service_count_) { @@ -389,8 +383,7 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - ESP_LOGW(TAG, "Cannot notify GATT characteristic, not connected"); - this->send_gatt_error(msg.address, msg.handle, ESP_GATT_NOT_CONNECTED); + this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic"); return; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d367dad438..33817a212e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -136,6 +136,10 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); BluetoothConnection *get_connection_(uint64_t address, bool reserve); + void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); + void log_connection_info_(BluetoothConnection *connection, const char *message); + void log_not_connected_gatt_(const char *action, const char *type); + void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type); // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 5b433c2b97..2f78a479a3 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -78,40 +78,7 @@ void BLEClientBase::dump_config() { " Address: %s\n" " Auto-Connect: %s", this->address_str().c_str(), TRUEFALSE(this->auto_connect_)); - std::string state_name; - switch (this->state()) { - case espbt::ClientState::INIT: - state_name = "INIT"; - break; - case espbt::ClientState::DISCONNECTING: - state_name = "DISCONNECTING"; - break; - case espbt::ClientState::IDLE: - state_name = "IDLE"; - break; - case espbt::ClientState::SEARCHING: - state_name = "SEARCHING"; - break; - case espbt::ClientState::DISCOVERED: - state_name = "DISCOVERED"; - break; - case espbt::ClientState::READY_TO_CONNECT: - state_name = "READY_TO_CONNECT"; - break; - case espbt::ClientState::CONNECTING: - state_name = "CONNECTING"; - break; - case espbt::ClientState::CONNECTED: - state_name = "CONNECTED"; - break; - case espbt::ClientState::ESTABLISHED: - state_name = "ESTABLISHED"; - break; - default: - state_name = "UNKNOWN_STATE"; - break; - } - ESP_LOGCONFIG(TAG, " State: %s", state_name.c_str()); + ESP_LOGCONFIG(TAG, " State: %s", espbt::client_state_to_string(this->state())); if (this->status_ == ESP_GATT_NO_RESOURCES) { ESP_LOGE(TAG, " Failed due to no resources. Try to reduce number of BLE clients in config."); } else if (this->status_ != ESP_GATT_OK) { @@ -176,8 +143,7 @@ void BLEClientBase::connect() { // Now open the connection auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); if (ret) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_open error, status=%d", this->connection_index_, this->address_str_.c_str(), - ret); + this->log_gattc_warning_("esp_ble_gattc_open", ret); this->set_state(espbt::ClientState::IDLE); } else { this->set_state(espbt::ClientState::CONNECTING); @@ -255,6 +221,19 @@ void BLEClientBase::log_event_(const char *name) { ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), name); } +void BLEClientBase::log_gattc_event_(const char *name) { + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_%s_EVT", this->connection_index_, this->address_str_.c_str(), name); +} + +void BLEClientBase::log_gattc_warning_(const char *operation, esp_gatt_status_t status) { + ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_.c_str(), operation, + status); +} + +void BLEClientBase::log_gattc_warning_(const char *operation, esp_err_t err) { + ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_.c_str(), operation, err); +} + void BLEClientBase::restore_medium_conn_params_() { // Restore to medium connection parameters after initial connection phase // This balances performance with bandwidth usage for normal operation @@ -295,30 +274,18 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_OPEN_EVT: { if (!this->check_addr(param->open.remote_bda)) return false; - this->log_event_("ESP_GATTC_OPEN_EVT"); + this->log_gattc_event_("OPEN"); // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; if (this->state_ != espbt::ClientState::CONNECTING) { // This should not happen but lets log it in case it does // because it means we have a bad assumption about how the // ESP BT stack works. - if (this->state_ == espbt::ClientState::CONNECTED) { - ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while already connected, status=%d", this->connection_index_, - this->address_str_.c_str(), param->open.status); - } else if (this->state_ == espbt::ClientState::ESTABLISHED) { - ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while already established, status=%d", - this->connection_index_, this->address_str_.c_str(), param->open.status); - } else if (this->state_ == espbt::ClientState::DISCONNECTING) { - ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while disconnecting, status=%d", this->connection_index_, - this->address_str_.c_str(), param->open.status); - } else { - ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while not in connecting state, status=%d", - this->connection_index_, this->address_str_.c_str(), param->open.status); - } + ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while in %s state, status=%d", this->connection_index_, + this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { - ESP_LOGW(TAG, "[%d] [%s] Connection failed, status=%d", this->connection_index_, this->address_str_.c_str(), - param->open.status); + this->log_gattc_warning_("Connection open", param->open.status); this->set_state(espbt::ClientState::IDLE); break; } @@ -350,7 +317,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_CONNECT_EVT: { if (!this->check_addr(param->connect.remote_bda)) return false; - this->log_event_("ESP_GATTC_CONNECT_EVT"); + this->log_gattc_event_("CONNECT"); this->conn_id_ = param->connect.conn_id; // Start MTU negotiation immediately as recommended by ESP-IDF examples // (gatt_client, ble_throughput) which call esp_ble_gattc_send_mtu_req in @@ -397,7 +364,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_CLOSE_EVT: { if (this->conn_id_ != param->close.conn_id) return false; - this->log_event_("ESP_GATTC_CLOSE_EVT"); + this->log_gattc_event_("CLOSE"); this->release_services(); this->set_state(espbt::ClientState::IDLE); this->conn_id_ = UNSET_CONN_ID; @@ -423,7 +390,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_SEARCH_CMPL_EVT: { if (this->conn_id_ != param->search_cmpl.conn_id) return false; - this->log_event_("ESP_GATTC_SEARCH_CMPL_EVT"); + this->log_gattc_event_("SEARCH_CMPL"); for (auto &svc : this->services_) { ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), svc->uuid.to_string().c_str()); @@ -445,35 +412,35 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ case ESP_GATTC_READ_DESCR_EVT: { if (this->conn_id_ != param->write.conn_id) return false; - this->log_event_("ESP_GATTC_READ_DESCR_EVT"); + this->log_gattc_event_("READ_DESCR"); break; } case ESP_GATTC_WRITE_DESCR_EVT: { if (this->conn_id_ != param->write.conn_id) return false; - this->log_event_("ESP_GATTC_WRITE_DESCR_EVT"); + this->log_gattc_event_("WRITE_DESCR"); break; } case ESP_GATTC_WRITE_CHAR_EVT: { if (this->conn_id_ != param->write.conn_id) return false; - this->log_event_("ESP_GATTC_WRITE_CHAR_EVT"); + this->log_gattc_event_("WRITE_CHAR"); break; } case ESP_GATTC_READ_CHAR_EVT: { if (this->conn_id_ != param->read.conn_id) return false; - this->log_event_("ESP_GATTC_READ_CHAR_EVT"); + this->log_gattc_event_("READ_CHAR"); break; } case ESP_GATTC_NOTIFY_EVT: { if (this->conn_id_ != param->notify.conn_id) return false; - this->log_event_("ESP_GATTC_NOTIFY_EVT"); + this->log_gattc_event_("NOTIFY"); break; } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { - this->log_event_("ESP_GATTC_REG_FOR_NOTIFY_EVT"); + this->log_gattc_event_("REG_FOR_NOTIFY"); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { // Client is responsible for flipping the descriptor value @@ -485,8 +452,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ esp_gatt_status_t descr_status = esp_ble_gattc_get_descr_by_char_handle( this->gattc_if_, this->conn_id_, param->reg_for_notify.handle, NOTIFY_DESC_UUID, &desc_result, &count); if (descr_status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_get_descr_by_char_handle error, status=%d", this->connection_index_, - this->address_str_.c_str(), descr_status); + this->log_gattc_warning_("esp_ble_gattc_get_descr_by_char_handle", descr_status); break; } esp_gattc_char_elem_t char_result; @@ -494,8 +460,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, param->reg_for_notify.handle, param->reg_for_notify.handle, &char_result, &count, 0); if (char_status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, - this->address_str_.c_str(), char_status); + this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status); break; } @@ -509,8 +474,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ (uint8_t *) ¬ify_en, ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE); ESP_LOGD(TAG, "Wrote notify descriptor %d, properties=%d", notify_en, char_result.properties); if (status) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_write_char_descr error, status=%d", this->connection_index_, - this->address_str_.c_str(), status); + this->log_gattc_warning_("esp_ble_gattc_write_char_descr", status); } break; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index d6a196ff9f..6bdf84e18f 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -126,7 +126,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { // 6 bytes used, 2 bytes padding void log_event_(const char *name); + void log_gattc_event_(const char *name); void restore_medium_conn_params_(); + void log_gattc_warning_(const char *operation, esp_gatt_status_t status); + void log_gattc_warning_(const char *operation, esp_err_t err); }; } // namespace esphome::esp32_ble_client diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 460267a264..5e97c81044 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -41,6 +41,31 @@ static const char *const TAG = "esp32_ble_tracker"; ESP32BLETracker *global_esp32_ble_tracker = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +const char *client_state_to_string(ClientState state) { + switch (state) { + case ClientState::INIT: + return "INIT"; + case ClientState::DISCONNECTING: + return "DISCONNECTING"; + case ClientState::IDLE: + return "IDLE"; + case ClientState::SEARCHING: + return "SEARCHING"; + case ClientState::DISCOVERED: + return "DISCOVERED"; + case ClientState::READY_TO_CONNECT: + return "READY_TO_CONNECT"; + case ClientState::CONNECTING: + return "CONNECTING"; + case ClientState::CONNECTED: + return "CONNECTED"; + case ClientState::ESTABLISHED: + return "ESTABLISHED"; + default: + return "UNKNOWN"; + } +} + float ESP32BLETracker::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } void ESP32BLETracker::setup() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index d2423c43ba..fba9dbd97e 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -184,6 +184,9 @@ enum class ScannerState { STOPPING, }; +// Helper function to convert ClientState to string +const char *client_state_to_string(ClientState state); + enum class ConnectionType : uint8_t { // The default connection type, we hold all the services in ram // for the duration of the connection. From 61008bc8a959e163d20f95bf2e700b4d9fabdfdc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 15:42:04 -1000 Subject: [PATCH 0399/1626] [bluetooth_proxy] Remove unnecessary heap allocation for response object (#10104) --- .../components/bluetooth_proxy/bluetooth_proxy.cpp | 11 ++++------- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index d37472e866..6944fed851 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -25,12 +25,9 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62 BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } void BluetoothProxy::setup() { - // Pre-allocate response object - this->response_ = std::make_unique(); - // Reserve capacity but start with size 0 // Reserve 50% since we'll grow naturally and flush at FLUSH_BATCH_SIZE - this->response_->advertisements.reserve(FLUSH_BATCH_SIZE / 2); + this->response_.advertisements.reserve(FLUSH_BATCH_SIZE / 2); // Don't pre-allocate pool - let it grow only if needed in busy environments // Many devices in quiet areas will never need the overflow pool @@ -85,7 +82,7 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) return false; - auto &advertisements = this->response_->advertisements; + auto &advertisements = this->response_.advertisements; for (size_t i = 0; i < count; i++) { auto &result = scan_results[i]; @@ -129,7 +126,7 @@ void BluetoothProxy::flush_pending_advertisements() { if (this->advertisement_count_ == 0 || !api::global_api_server->is_connected() || this->api_connection_ == nullptr) return; - auto &advertisements = this->response_->advertisements; + auto &advertisements = this->response_.advertisements; // Return any items beyond advertisement_count_ to the pool if (advertisements.size() > this->advertisement_count_) { @@ -143,7 +140,7 @@ void BluetoothProxy::flush_pending_advertisements() { } // Send the message - this->api_connection_->send_message(*this->response_, api::BluetoothLERawAdvertisementsResponse::MESSAGE_TYPE); + this->api_connection_->send_message(this->response_, api::BluetoothLERawAdvertisementsResponse::MESSAGE_TYPE); // Reset count - existing items will be overwritten in next batch this->advertisement_count_ = 0; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 33817a212e..4f22a179d6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -150,7 +150,7 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // BLE advertisement batching std::vector advertisement_pool_; - std::unique_ptr response_; + api::BluetoothLERawAdvertisementsResponse response_; // Group 3: 4-byte types uint32_t last_advertisement_flush_time_{0}; From 81f907e994138f559ee9a13bd969676836517b5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 13:47:03 +1200 Subject: [PATCH 0400/1626] Bump actions/download-artifact from 4.3.0 to 5.0.0 (#10106) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d955df85f..82cf605342 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,7 +171,7 @@ jobs: - uses: actions/checkout@v4.2.2 - name: Download digests - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: pattern: digests-* path: /tmp/digests From 1415e02e40026a8a5e646b0e11edaa419bef8f2c Mon Sep 17 00:00:00 2001 From: mbo18 Date: Thu, 7 Aug 2025 03:48:26 +0200 Subject: [PATCH 0401/1626] Add device class absolute_humidity to the absolute humidity component (#10100) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/absolute_humidity/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/absolute_humidity/sensor.py b/esphome/components/absolute_humidity/sensor.py index 62a2c8ab7c..caaa546e25 100644 --- a/esphome/components/absolute_humidity/sensor.py +++ b/esphome/components/absolute_humidity/sensor.py @@ -5,7 +5,7 @@ from esphome.const import ( CONF_EQUATION, CONF_HUMIDITY, CONF_TEMPERATURE, - ICON_WATER, + DEVICE_CLASS_ABSOLUTE_HUMIDITY, STATE_CLASS_MEASUREMENT, UNIT_GRAMS_PER_CUBIC_METER, ) @@ -27,8 +27,8 @@ EQUATION = { CONFIG_SCHEMA = ( sensor.sensor_schema( unit_of_measurement=UNIT_GRAMS_PER_CUBIC_METER, - icon=ICON_WATER, accuracy_decimals=2, + device_class=DEVICE_CLASS_ABSOLUTE_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ) .extend( From bfb14e1cf92b78537788bc368c57bc61a6573b1e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 16:21:32 -1000 Subject: [PATCH 0402/1626] [esp32_touch] Restore get_value() for ESP32-S2/S3 variants (#10112) --- esphome/components/esp32_touch/esp32_touch.h | 15 +++++++++------ .../esp32_touch/esp32_touch_common.cpp | 2 ++ .../components/esp32_touch/esp32_touch_v2.cpp | 16 ++++++++++------ .../esp32_touch/common-get-value.yaml | 18 ++++++++++++++++++ .../components/esp32_touch/test.esp32-idf.yaml | 1 + .../esp32_touch/test.esp32-s2-idf.yaml | 1 + .../esp32_touch/test.esp32-s3-idf.yaml | 1 + 7 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 tests/components/esp32_touch/common-get-value.yaml diff --git a/esphome/components/esp32_touch/esp32_touch.h b/esphome/components/esp32_touch/esp32_touch.h index 5a91b1c750..fb1973e26f 100644 --- a/esphome/components/esp32_touch/esp32_touch.h +++ b/esphome/components/esp32_touch/esp32_touch.h @@ -171,8 +171,8 @@ class ESP32TouchComponent : public Component { // based on the filter configuration uint32_t read_touch_value(touch_pad_t pad) const; - // Helper to update touch state with a known state - void update_touch_state_(ESP32TouchBinarySensor *child, bool is_touched); + // Helper to update touch state with a known state and value + void update_touch_state_(ESP32TouchBinarySensor *child, bool is_touched, uint32_t value); // Helper to read touch value and update state for a given child bool check_and_update_touch_state_(ESP32TouchBinarySensor *child); @@ -234,9 +234,13 @@ class ESP32TouchBinarySensor : public binary_sensor::BinarySensor { touch_pad_t get_touch_pad() const { return this->touch_pad_; } uint32_t get_threshold() const { return this->threshold_; } void set_threshold(uint32_t threshold) { this->threshold_ = threshold; } -#ifdef USE_ESP32_VARIANT_ESP32 + + /// Get the raw touch measurement value. + /// @note Although this method may appear unused within the component, it is a public API + /// used by lambdas in user configurations for custom touch value processing. + /// @return The current raw touch sensor reading uint32_t get_value() const { return this->value_; } -#endif + uint32_t get_wakeup_threshold() const { return this->wakeup_threshold_; } protected: @@ -245,9 +249,8 @@ class ESP32TouchBinarySensor : public binary_sensor::BinarySensor { touch_pad_t touch_pad_{TOUCH_PAD_MAX}; uint32_t threshold_{0}; uint32_t benchmark_{}; -#ifdef USE_ESP32_VARIANT_ESP32 + /// Stores the last raw touch measurement value. uint32_t value_{0}; -#endif bool last_state_{false}; const uint32_t wakeup_threshold_{0}; diff --git a/esphome/components/esp32_touch/esp32_touch_common.cpp b/esphome/components/esp32_touch/esp32_touch_common.cpp index 2d93de077e..a0b1df38c1 100644 --- a/esphome/components/esp32_touch/esp32_touch_common.cpp +++ b/esphome/components/esp32_touch/esp32_touch_common.cpp @@ -100,6 +100,8 @@ void ESP32TouchComponent::process_setup_mode_logging_(uint32_t now) { #else // Read the value being used for touch detection uint32_t value = this->read_touch_value(child->get_touch_pad()); + // Store the value for get_value() access in lambdas + child->value_ = value; ESP_LOGD(TAG, "Touch Pad '%s' (T%d): %d", child->get_name().c_str(), child->get_touch_pad(), value); #endif } diff --git a/esphome/components/esp32_touch/esp32_touch_v2.cpp b/esphome/components/esp32_touch/esp32_touch_v2.cpp index afd2655fd7..9662b009f6 100644 --- a/esphome/components/esp32_touch/esp32_touch_v2.cpp +++ b/esphome/components/esp32_touch/esp32_touch_v2.cpp @@ -10,8 +10,11 @@ namespace esp32_touch { static const char *const TAG = "esp32_touch"; -// Helper to update touch state with a known state -void ESP32TouchComponent::update_touch_state_(ESP32TouchBinarySensor *child, bool is_touched) { +// Helper to update touch state with a known state and value +void ESP32TouchComponent::update_touch_state_(ESP32TouchBinarySensor *child, bool is_touched, uint32_t value) { + // Store the value for get_value() access in lambdas + child->value_ = value; + // Always update timer when touched if (is_touched) { child->last_touch_time_ = App.get_loop_component_start_time(); @@ -21,9 +24,8 @@ void ESP32TouchComponent::update_touch_state_(ESP32TouchBinarySensor *child, boo child->last_state_ = is_touched; child->publish_state(is_touched); if (is_touched) { - // ESP32-S2/S3 v2: touched when value > threshold ESP_LOGV(TAG, "Touch Pad '%s' state: ON (value: %" PRIu32 " > threshold: %" PRIu32 ")", child->get_name().c_str(), - this->read_touch_value(child->touch_pad_), child->threshold_ + child->benchmark_); + value, child->threshold_ + child->benchmark_); } else { ESP_LOGV(TAG, "Touch Pad '%s' state: OFF", child->get_name().c_str()); } @@ -41,7 +43,7 @@ bool ESP32TouchComponent::check_and_update_touch_state_(ESP32TouchBinarySensor * child->get_name().c_str(), child->touch_pad_, value, child->threshold_, child->benchmark_); bool is_touched = value > child->benchmark_ + child->threshold_; - this->update_touch_state_(child, is_touched); + this->update_touch_state_(child, is_touched, value); return is_touched; } @@ -296,7 +298,9 @@ void ESP32TouchComponent::loop() { this->check_and_update_touch_state_(child); } else if (event.intr_mask & TOUCH_PAD_INTR_MASK_ACTIVE) { // We only get ACTIVE interrupts now, releases are detected by timeout - this->update_touch_state_(child, true); // Always touched for ACTIVE interrupts + // Read the current value + uint32_t value = this->read_touch_value(child->touch_pad_); + this->update_touch_state_(child, true, value); // Always touched for ACTIVE interrupts } break; } diff --git a/tests/components/esp32_touch/common-get-value.yaml b/tests/components/esp32_touch/common-get-value.yaml new file mode 100644 index 0000000000..4066303797 --- /dev/null +++ b/tests/components/esp32_touch/common-get-value.yaml @@ -0,0 +1,18 @@ +binary_sensor: + - platform: esp32_touch + name: ESP32 Touch Pad Get Value + pin: ${pin} + threshold: 1000 + id: esp32_touch_pad_get_value + on_press: + then: + - lambda: |- + // Test that get_value() compiles and works + uint32_t value = id(esp32_touch_pad_get_value).get_value(); + ESP_LOGD("test", "Touch value on press: %u", value); + on_release: + then: + - lambda: |- + // Test get_value() on release + uint32_t value = id(esp32_touch_pad_get_value).get_value(); + ESP_LOGD("test", "Touch value on release: %u", value); diff --git a/tests/components/esp32_touch/test.esp32-idf.yaml b/tests/components/esp32_touch/test.esp32-idf.yaml index 25316b8646..5158613eb1 100644 --- a/tests/components/esp32_touch/test.esp32-idf.yaml +++ b/tests/components/esp32_touch/test.esp32-idf.yaml @@ -2,3 +2,4 @@ substitutions: pin: GPIO27 <<: !include common.yaml +<<: !include common-get-value.yaml diff --git a/tests/components/esp32_touch/test.esp32-s2-idf.yaml b/tests/components/esp32_touch/test.esp32-s2-idf.yaml index 575d758fae..b9f5671969 100644 --- a/tests/components/esp32_touch/test.esp32-s2-idf.yaml +++ b/tests/components/esp32_touch/test.esp32-s2-idf.yaml @@ -2,3 +2,4 @@ substitutions: pin: GPIO12 <<: !include common-variants.yaml +<<: !include common-get-value.yaml diff --git a/tests/components/esp32_touch/test.esp32-s3-idf.yaml b/tests/components/esp32_touch/test.esp32-s3-idf.yaml index 575d758fae..b9f5671969 100644 --- a/tests/components/esp32_touch/test.esp32-s3-idf.yaml +++ b/tests/components/esp32_touch/test.esp32-s3-idf.yaml @@ -2,3 +2,4 @@ substitutions: pin: GPIO12 <<: !include common-variants.yaml +<<: !include common-get-value.yaml From cf1b24145b2038c45c0fb4448db4db541b83852d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 17:30:48 -1000 Subject: [PATCH 0403/1626] [esp32_ble_tracker] Optimize member variable ordering to reduce memory padding --- .../esp32_ble_tracker/esp32_ble_tracker.h | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index fba9dbd97e..4b09d521b6 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -333,33 +333,37 @@ class ESP32BLETracker : public Component, return counts; } - uint8_t app_id_{0}; - + // Group 1: Large objects (12+ bytes) - vectors and callback manager + std::vector listeners_; + std::vector clients_; + CallbackManager scanner_state_callbacks_; #ifdef USE_ESP32_BLE_DEVICE /// Vector of addresses that have already been printed in print_bt_device_info std::vector already_discovered_; #endif - std::vector listeners_; - /// Client parameters. - std::vector clients_; + + // Group 2: Structs (aligned to 4 bytes) /// A structure holding the ESP BLE scan parameters. esp_ble_scan_params_t scan_params_; + ClientStateCounts client_state_counts_; + + // Group 3: 4-byte types /// The interval in seconds to perform scans. uint32_t scan_duration_; uint32_t scan_interval_; uint32_t scan_window_; + esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; + esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; + + // Group 4: 1-byte types (enums, uint8_t, bool) + uint8_t app_id_{0}; uint8_t scan_start_fail_count_{0}; + ScannerState scanner_state_{ScannerState::IDLE}; bool scan_continuous_; bool scan_active_; - ScannerState scanner_state_{ScannerState::IDLE}; - CallbackManager scanner_state_callbacks_; bool ble_was_disabled_{true}; bool raw_advertisements_{false}; bool parse_advertisements_{false}; - - esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; - esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; - ClientStateCounts client_state_counts_; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE bool coex_prefer_ble_{false}; #endif From c0a62c0be116e8afc3c399b89f3ce8e6e6eb11dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 17:40:12 -1000 Subject: [PATCH 0404/1626] [esp32_ble_client] Avoid iterating empty services vector for bluetooth_proxy connections (#10110) --- .../esp32_ble_client/ble_client_base.cpp | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 2f78a479a3..a7c2ced397 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -376,7 +376,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->service_count_++; if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { // V3 clients don't need services initialized since - // they only request by handle after receiving the services. + // as they use the ESP APIs to get services. break; } BLEService *ble_service = new BLEService(); // NOLINT(cppcoreguidelines-owning-memory) @@ -391,21 +391,20 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->conn_id_ != param->search_cmpl.conn_id) return false; this->log_gattc_event_("SEARCH_CMPL"); - for (auto &svc : this->services_) { - ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), - svc->uuid.to_string().c_str()); - ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, - this->address_str_.c_str(), svc->start_handle, svc->end_handle); - } - ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); - // For V3 connections, restore to medium connection parameters after service discovery // This balances performance with bandwidth usage after the critical discovery phase if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { this->restore_medium_conn_params_(); + } else { + for (auto &svc : this->services_) { + ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), + svc->uuid.to_string().c_str()); + ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, + this->address_str_.c_str(), svc->start_handle, svc->end_handle); + } } - + ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); this->state_ = espbt::ClientState::ESTABLISHED; break; } From c5065f21b80b74f2a248c0a3d0e2189966395596 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 19:53:35 -1000 Subject: [PATCH 0405/1626] condtional --- .../components/esp32_ble_client/ble_characteristic.cpp | 2 ++ esphome/components/esp32_ble_client/ble_characteristic.h | 4 ++++ esphome/components/esp32_ble_client/ble_client_base.cpp | 8 ++++++++ esphome/components/esp32_ble_client/ble_client_base.h | 6 ++++++ esphome/components/esp32_ble_client/ble_descriptor.h | 4 ++++ esphome/components/esp32_ble_client/ble_service.cpp | 2 ++ esphome/components/esp32_ble_client/ble_service.h | 4 ++++ 7 files changed, 30 insertions(+) diff --git a/esphome/components/esp32_ble_client/ble_characteristic.cpp b/esphome/components/esp32_ble_client/ble_characteristic.cpp index 8a3d313303..36229c23c3 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_client/ble_characteristic.cpp @@ -5,6 +5,7 @@ #include "esphome/core/log.h" #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_DEVICE namespace esphome::esp32_ble_client { @@ -94,4 +95,5 @@ esp_err_t BLECharacteristic::write_value(uint8_t *new_val, int16_t new_val_size) } // namespace esphome::esp32_ble_client +#endif // USE_ESP32_BLE_DEVICE #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_characteristic.h b/esphome/components/esp32_ble_client/ble_characteristic.h index d55e69f47a..1428b42739 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.h +++ b/esphome/components/esp32_ble_client/ble_characteristic.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" + #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_DEVICE #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" @@ -34,4 +37,5 @@ class BLECharacteristic { } // namespace esphome::esp32_ble_client +#endif // USE_ESP32_BLE_DEVICE #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index a7c2ced397..37b41326f7 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -209,9 +209,11 @@ void BLEClientBase::unconditional_disconnect() { } void BLEClientBase::release_services() { +#ifdef USE_ESP32_BLE_DEVICE for (auto &svc : this->services_) delete svc; // NOLINT(cppcoreguidelines-owning-memory) this->services_.clear(); +#endif #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH esp_ble_gattc_cache_clean(this->remote_bda_); #endif @@ -379,12 +381,14 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // as they use the ESP APIs to get services. break; } +#ifdef USE_ESP32_BLE_DEVICE BLEService *ble_service = new BLEService(); // NOLINT(cppcoreguidelines-owning-memory) ble_service->uuid = espbt::ESPBTUUID::from_uuid(param->search_res.srvc_id.uuid); ble_service->start_handle = param->search_res.start_handle; ble_service->end_handle = param->search_res.end_handle; ble_service->client = this; this->services_.push_back(ble_service); +#endif break; } case ESP_GATTC_SEARCH_CMPL_EVT: { @@ -397,12 +401,14 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { this->restore_medium_conn_params_(); } else { +#ifdef USE_ESP32_BLE_DEVICE for (auto &svc : this->services_) { ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), svc->uuid.to_string().c_str()); ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, this->address_str_.c_str(), svc->start_handle, svc->end_handle); } +#endif } ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); this->state_ = espbt::ClientState::ESTABLISHED; @@ -581,6 +587,7 @@ float BLEClientBase::parse_char_value(uint8_t *value, uint16_t length) { return NAN; } +#ifdef USE_ESP32_BLE_DEVICE BLEService *BLEClientBase::get_service(espbt::ESPBTUUID uuid) { for (auto *svc : this->services_) { if (svc->uuid == uuid) @@ -657,6 +664,7 @@ BLEDescriptor *BLEClientBase::get_descriptor(uint16_t handle) { } return nullptr; } +#endif // USE_ESP32_BLE_DEVICE } // namespace esphome::esp32_ble_client diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 6bdf84e18f..093d04640b 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -5,7 +5,9 @@ #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "esphome/core/component.h" +#ifdef USE_ESP32_BLE_DEVICE #include "ble_service.h" +#endif #include #include @@ -67,6 +69,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { } const std::string &address_str() const { return this->address_str_; } +#ifdef USE_ESP32_BLE_DEVICE BLEService *get_service(espbt::ESPBTUUID uuid); BLEService *get_service(uint16_t uuid); BLECharacteristic *get_characteristic(espbt::ESPBTUUID service, espbt::ESPBTUUID chr); @@ -77,6 +80,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { BLEDescriptor *get_descriptor(uint16_t handle); // Get the configuration descriptor for the given characteristic handle. BLEDescriptor *get_config_descriptor(uint16_t handle); +#endif float parse_char_value(uint8_t *value, uint16_t length); @@ -103,7 +107,9 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { // Group 2: Container types (grouped for memory optimization) std::string address_str_{}; +#ifdef USE_ESP32_BLE_DEVICE std::vector services_; +#endif // Group 3: 4-byte types int gattc_if_; diff --git a/esphome/components/esp32_ble_client/ble_descriptor.h b/esphome/components/esp32_ble_client/ble_descriptor.h index 015a1243ed..fb2b78a7b1 100644 --- a/esphome/components/esp32_ble_client/ble_descriptor.h +++ b/esphome/components/esp32_ble_client/ble_descriptor.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" + #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_DEVICE #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" @@ -20,4 +23,5 @@ class BLEDescriptor { } // namespace esphome::esp32_ble_client +#endif // USE_ESP32_BLE_DEVICE #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_service.cpp b/esphome/components/esp32_ble_client/ble_service.cpp index 0defefd6ac..accaad15e1 100644 --- a/esphome/components/esp32_ble_client/ble_service.cpp +++ b/esphome/components/esp32_ble_client/ble_service.cpp @@ -4,6 +4,7 @@ #include "esphome/core/log.h" #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_DEVICE namespace esphome::esp32_ble_client { @@ -72,4 +73,5 @@ void BLEService::parse_characteristics() { } // namespace esphome::esp32_ble_client +#endif // USE_ESP32_BLE_DEVICE #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_service.h b/esphome/components/esp32_ble_client/ble_service.h index 1b8b5a36dc..00ecc777e7 100644 --- a/esphome/components/esp32_ble_client/ble_service.h +++ b/esphome/components/esp32_ble_client/ble_service.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" + #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_DEVICE #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" @@ -31,4 +34,5 @@ class BLEService { } // namespace esphome::esp32_ble_client +#endif // USE_ESP32_BLE_DEVICE #endif // USE_ESP32 From 37a9ad6a0d40e46f131813672b477a0e1622adc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 22:34:46 -1000 Subject: [PATCH 0406/1626] [esp32_ble_tracker] Optimize member variable ordering to reduce memory padding (#10113) --- .../esp32_ble_tracker/esp32_ble_tracker.h | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index fba9dbd97e..4b09d521b6 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -333,33 +333,37 @@ class ESP32BLETracker : public Component, return counts; } - uint8_t app_id_{0}; - + // Group 1: Large objects (12+ bytes) - vectors and callback manager + std::vector listeners_; + std::vector clients_; + CallbackManager scanner_state_callbacks_; #ifdef USE_ESP32_BLE_DEVICE /// Vector of addresses that have already been printed in print_bt_device_info std::vector already_discovered_; #endif - std::vector listeners_; - /// Client parameters. - std::vector clients_; + + // Group 2: Structs (aligned to 4 bytes) /// A structure holding the ESP BLE scan parameters. esp_ble_scan_params_t scan_params_; + ClientStateCounts client_state_counts_; + + // Group 3: 4-byte types /// The interval in seconds to perform scans. uint32_t scan_duration_; uint32_t scan_interval_; uint32_t scan_window_; + esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; + esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; + + // Group 4: 1-byte types (enums, uint8_t, bool) + uint8_t app_id_{0}; uint8_t scan_start_fail_count_{0}; + ScannerState scanner_state_{ScannerState::IDLE}; bool scan_continuous_; bool scan_active_; - ScannerState scanner_state_{ScannerState::IDLE}; - CallbackManager scanner_state_callbacks_; bool ble_was_disabled_{true}; bool raw_advertisements_{false}; bool parse_advertisements_{false}; - - esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; - esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; - ClientStateCounts client_state_counts_; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE bool coex_prefer_ble_{false}; #endif From 481bbeb6b578417b312829733b91fe95f88b1c34 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 22:41:50 -1000 Subject: [PATCH 0407/1626] [esp32_ble_client] Reduce flash usage by optimizing logging strings --- .../esp32_ble_client/ble_client_base.cpp | 30 ++++++++----------- .../esp32_ble_client/ble_client_base.h | 1 + 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index a7c2ced397..413a1c3ce8 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -107,7 +107,7 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { #endif void BLEClientBase::connect() { - ESP_LOGI(TAG, "[%d] [%s] 0x%02x Attempting BLE connection", this->connection_index_, this->address_str_.c_str(), + ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_.c_str(), this->remote_addr_type_); this->paired_ = false; @@ -137,7 +137,7 @@ void BLEClientBase::connect() { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, this->address_str_.c_str(), param_ret); } else { - ESP_LOGD(TAG, "[%d] [%s] Set %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); + this->log_connection_params_(param_type); } // Now open the connection @@ -153,14 +153,9 @@ void BLEClientBase::connect() { esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); } void BLEClientBase::disconnect() { - if (this->state_ == espbt::ClientState::IDLE) { - ESP_LOGI(TAG, "[%d] [%s] Disconnect requested, but already idle.", this->connection_index_, - this->address_str_.c_str()); - return; - } - if (this->state_ == espbt::ClientState::DISCONNECTING) { - ESP_LOGI(TAG, "[%d] [%s] Disconnect requested, but already disconnecting.", this->connection_index_, - this->address_str_.c_str()); + if (this->state_ == espbt::ClientState::IDLE || this->state_ == espbt::ClientState::DISCONNECTING) { + ESP_LOGI(TAG, "[%d] [%s] Disconnect requested, but already %s", this->connection_index_, this->address_str_.c_str(), + espbt::client_state_to_string(this->state_)); return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { @@ -195,8 +190,7 @@ void BLEClientBase::unconditional_disconnect() { // In the future we might consider App.reboot() here since // the BLE stack is in an indeterminate state. // - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_close error, err=%d", this->connection_index_, this->address_str_.c_str(), - err); + this->log_gattc_warning_("esp_ble_gattc_close", err); } if (this->state_ == espbt::ClientState::SEARCHING || this->state_ == espbt::ClientState::READY_TO_CONNECT || @@ -234,6 +228,10 @@ void BLEClientBase::log_gattc_warning_(const char *operation, esp_err_t err) { ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_.c_str(), operation, err); } +void BLEClientBase::log_connection_params_(const char *param_type) { + ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); +} + void BLEClientBase::restore_medium_conn_params_() { // Restore to medium connection parameters after initial connection phase // This balances performance with bandwidth usage for normal operation @@ -243,7 +241,7 @@ void BLEClientBase::restore_medium_conn_params_() { conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; conn_params.latency = 0; conn_params.timeout = MEDIUM_CONN_TIMEOUT; - ESP_LOGD(TAG, "[%d] [%s] Restoring medium conn params", this->connection_index_, this->address_str_.c_str()); + this->log_connection_params_("medium"); esp_ble_gap_update_conn_params(&conn_params); } @@ -301,11 +299,8 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->set_state(espbt::ClientState::CONNECTED); ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - ESP_LOGI(TAG, "[%d] [%s] Using cached services", this->connection_index_, this->address_str_.c_str()); - // Restore to medium connection parameters for cached connections too this->restore_medium_conn_params_(); - // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; @@ -325,8 +320,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // This saves ~3ms in the connection process. auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id); if (ret) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_send_mtu_req failed, status=%x", this->connection_index_, - this->address_str_.c_str(), ret); + this->log_gattc_warning_("esp_ble_gattc_send_mtu_req", ret); } break; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 6bdf84e18f..dc4b9103c6 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -130,6 +130,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void restore_medium_conn_params_(); void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); + void log_connection_params_(const char *param_type); }; } // namespace esphome::esp32_ble_client From 1ba76f5f2e30f3b77f64a73b3c40a037078366dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 22:46:34 -1000 Subject: [PATCH 0408/1626] [esp32_ble_client] Conditionally compile BLE service classes to reduce flash usage (#10114) --- .../components/esp32_ble_client/ble_characteristic.cpp | 2 ++ esphome/components/esp32_ble_client/ble_characteristic.h | 4 ++++ esphome/components/esp32_ble_client/ble_client_base.cpp | 8 ++++++++ esphome/components/esp32_ble_client/ble_client_base.h | 6 ++++++ esphome/components/esp32_ble_client/ble_descriptor.h | 4 ++++ esphome/components/esp32_ble_client/ble_service.cpp | 2 ++ esphome/components/esp32_ble_client/ble_service.h | 4 ++++ 7 files changed, 30 insertions(+) diff --git a/esphome/components/esp32_ble_client/ble_characteristic.cpp b/esphome/components/esp32_ble_client/ble_characteristic.cpp index 8a3d313303..36229c23c3 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_client/ble_characteristic.cpp @@ -5,6 +5,7 @@ #include "esphome/core/log.h" #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_DEVICE namespace esphome::esp32_ble_client { @@ -94,4 +95,5 @@ esp_err_t BLECharacteristic::write_value(uint8_t *new_val, int16_t new_val_size) } // namespace esphome::esp32_ble_client +#endif // USE_ESP32_BLE_DEVICE #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_characteristic.h b/esphome/components/esp32_ble_client/ble_characteristic.h index d55e69f47a..1428b42739 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.h +++ b/esphome/components/esp32_ble_client/ble_characteristic.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" + #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_DEVICE #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" @@ -34,4 +37,5 @@ class BLECharacteristic { } // namespace esphome::esp32_ble_client +#endif // USE_ESP32_BLE_DEVICE #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index a7c2ced397..37b41326f7 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -209,9 +209,11 @@ void BLEClientBase::unconditional_disconnect() { } void BLEClientBase::release_services() { +#ifdef USE_ESP32_BLE_DEVICE for (auto &svc : this->services_) delete svc; // NOLINT(cppcoreguidelines-owning-memory) this->services_.clear(); +#endif #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH esp_ble_gattc_cache_clean(this->remote_bda_); #endif @@ -379,12 +381,14 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // as they use the ESP APIs to get services. break; } +#ifdef USE_ESP32_BLE_DEVICE BLEService *ble_service = new BLEService(); // NOLINT(cppcoreguidelines-owning-memory) ble_service->uuid = espbt::ESPBTUUID::from_uuid(param->search_res.srvc_id.uuid); ble_service->start_handle = param->search_res.start_handle; ble_service->end_handle = param->search_res.end_handle; ble_service->client = this; this->services_.push_back(ble_service); +#endif break; } case ESP_GATTC_SEARCH_CMPL_EVT: { @@ -397,12 +401,14 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { this->restore_medium_conn_params_(); } else { +#ifdef USE_ESP32_BLE_DEVICE for (auto &svc : this->services_) { ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), svc->uuid.to_string().c_str()); ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, this->address_str_.c_str(), svc->start_handle, svc->end_handle); } +#endif } ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); this->state_ = espbt::ClientState::ESTABLISHED; @@ -581,6 +587,7 @@ float BLEClientBase::parse_char_value(uint8_t *value, uint16_t length) { return NAN; } +#ifdef USE_ESP32_BLE_DEVICE BLEService *BLEClientBase::get_service(espbt::ESPBTUUID uuid) { for (auto *svc : this->services_) { if (svc->uuid == uuid) @@ -657,6 +664,7 @@ BLEDescriptor *BLEClientBase::get_descriptor(uint16_t handle) { } return nullptr; } +#endif // USE_ESP32_BLE_DEVICE } // namespace esphome::esp32_ble_client diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 6bdf84e18f..093d04640b 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -5,7 +5,9 @@ #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "esphome/core/component.h" +#ifdef USE_ESP32_BLE_DEVICE #include "ble_service.h" +#endif #include #include @@ -67,6 +69,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { } const std::string &address_str() const { return this->address_str_; } +#ifdef USE_ESP32_BLE_DEVICE BLEService *get_service(espbt::ESPBTUUID uuid); BLEService *get_service(uint16_t uuid); BLECharacteristic *get_characteristic(espbt::ESPBTUUID service, espbt::ESPBTUUID chr); @@ -77,6 +80,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { BLEDescriptor *get_descriptor(uint16_t handle); // Get the configuration descriptor for the given characteristic handle. BLEDescriptor *get_config_descriptor(uint16_t handle); +#endif float parse_char_value(uint8_t *value, uint16_t length); @@ -103,7 +107,9 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { // Group 2: Container types (grouped for memory optimization) std::string address_str_{}; +#ifdef USE_ESP32_BLE_DEVICE std::vector services_; +#endif // Group 3: 4-byte types int gattc_if_; diff --git a/esphome/components/esp32_ble_client/ble_descriptor.h b/esphome/components/esp32_ble_client/ble_descriptor.h index 015a1243ed..fb2b78a7b1 100644 --- a/esphome/components/esp32_ble_client/ble_descriptor.h +++ b/esphome/components/esp32_ble_client/ble_descriptor.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" + #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_DEVICE #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" @@ -20,4 +23,5 @@ class BLEDescriptor { } // namespace esphome::esp32_ble_client +#endif // USE_ESP32_BLE_DEVICE #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_service.cpp b/esphome/components/esp32_ble_client/ble_service.cpp index 0defefd6ac..accaad15e1 100644 --- a/esphome/components/esp32_ble_client/ble_service.cpp +++ b/esphome/components/esp32_ble_client/ble_service.cpp @@ -4,6 +4,7 @@ #include "esphome/core/log.h" #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_DEVICE namespace esphome::esp32_ble_client { @@ -72,4 +73,5 @@ void BLEService::parse_characteristics() { } // namespace esphome::esp32_ble_client +#endif // USE_ESP32_BLE_DEVICE #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_client/ble_service.h b/esphome/components/esp32_ble_client/ble_service.h index 1b8b5a36dc..00ecc777e7 100644 --- a/esphome/components/esp32_ble_client/ble_service.h +++ b/esphome/components/esp32_ble_client/ble_service.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" + #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_DEVICE #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" @@ -31,4 +34,5 @@ class BLEService { } // namespace esphome::esp32_ble_client +#endif // USE_ESP32_BLE_DEVICE #endif // USE_ESP32 From a7a119f5761ad1dc36dd3fcd0ff228a6f4c1208a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 22:52:46 -1000 Subject: [PATCH 0409/1626] [bluetooth_proxy] Remove V1 connection support (#10107) --- esphome/components/api/api.proto | 2 +- .../components/bluetooth_proxy/bluetooth_proxy.cpp | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 67e91cc8e3..9d77ecdfa8 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1442,7 +1442,7 @@ message BluetoothLERawAdvertisementsResponse { } enum BluetoothDeviceRequestType { - BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0; + BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0 [deprecated = true]; // V1 removed, use V3 variants BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1; BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR = 2; BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR = 3; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 6944fed851..1986ea90d5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -209,8 +209,7 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { switch (msg.request_type) { case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE: - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE: - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE: { auto *connection = this->get_connection_(msg.address, true); if (connection == nullptr) { ESP_LOGW(TAG, "No free connections available"); @@ -239,12 +238,9 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE) { connection->set_connection_type(espbt::ConnectionType::V3_WITH_CACHE); this->log_connection_info_(connection, "v3 with cache"); - } else if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE) { + } else { // BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); - } else { - connection->set_connection_type(espbt::ConnectionType::V1); - this->log_connection_info_(connection, "v1"); } if (msg.has_address_type) { uint64_to_bd_addr(msg.address, connection->remote_bda_); @@ -306,6 +302,11 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { + ESP_LOGE(TAG, "V1 connections removed"); + this->send_device_connection(msg.address, false); + break; + } } } From 73b2db8af5489b871914ff53b52975e9790455ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 09:16:58 -1000 Subject: [PATCH 0410/1626] Bump actions/cache from 4.2.3 to 4.2.4 in /.github/actions/restore-python (#10125) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 3a7b301b60..9fb80e6a9d 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv # yamllint disable-line rule:line-length From c5c71bd85ef9fc6dbb811b310a0789bccb1b1022 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Aug 2025 09:43:24 -1000 Subject: [PATCH 0411/1626] [wifi] Reduce flash usage by optimizing logging --- esphome/components/wifi/wifi_component.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f815ab73c2..987e276e0c 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -457,9 +457,11 @@ void WiFiComponent::print_connect_params_() { " Signal strength: %d dB %s", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5], App.get_name().c_str(), rssi, LOG_STR_ARG(get_signal_bars(rssi))); +#ifdef ESPHOME_LOG_HAS_VERBOSE if (this->selected_ap_.get_bssid().has_value()) { ESP_LOGV(TAG, " Priority: %.1f", this->get_sta_priority(*this->selected_ap_.get_bssid())); } +#endif ESP_LOGCONFIG(TAG, " Channel: %" PRId32 "\n" " Subnet: %s\n" @@ -594,8 +596,10 @@ void WiFiComponent::check_scanning_finished() { if (res.get_matches()) { ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), res.get_is_hidden() ? "(HIDDEN) " : "", bssid_s, LOG_STR_ARG(get_signal_bars(res.get_rssi()))); - ESP_LOGD(TAG, " Channel: %u", res.get_channel()); - ESP_LOGD(TAG, " RSSI: %d dB", res.get_rssi()); + ESP_LOGD(TAG, + " Channel: %u\n" + " RSSI: %d dB", + res.get_channel(), res.get_rssi()); } else { ESP_LOGD(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), bssid_s, LOG_STR_ARG(get_signal_bars(res.get_rssi()))); From 887d43d76c5ed5f4b89bf38af711a89a94264275 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Aug 2025 09:56:47 -1000 Subject: [PATCH 0412/1626] [mdns] Conditionally compile extra services to reduce flash usage --- esphome/components/mdns/__init__.py | 3 +++ esphome/components/mdns/mdns_component.cpp | 2 ++ esphome/components/mdns/mdns_component.h | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index e32d39cede..469fe8ada6 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -93,6 +93,9 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + if config[CONF_SERVICES]: + cg.add_define("USE_MDNS_EXTRA_SERVICES") + for service in config[CONF_SERVICES]: txt = [ cg.StructInitializer( diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 06ca99b402..640750720d 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -104,7 +104,9 @@ void MDNSComponent::compile_records_() { } #endif +#ifdef USE_MDNS_EXTRA_SERVICES this->services_.insert(this->services_.end(), this->services_extra_.begin(), this->services_extra_.end()); +#endif if (this->services_.empty()) { // Publish "http" service if not using native API diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 93a16f40d2..f87ef08bcd 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -35,14 +35,18 @@ class MDNSComponent : public Component { #endif float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } +#ifdef USE_MDNS_EXTRA_SERVICES void add_extra_service(MDNSService service) { services_extra_.push_back(std::move(service)); } +#endif std::vector get_services(); void on_shutdown() override; protected: +#ifdef USE_MDNS_EXTRA_SERVICES std::vector services_extra_{}; +#endif std::vector services_{}; std::string hostname_; void compile_records_(); From a1461c5293ef215e76be9c96e2803521b09582e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:09:53 -1000 Subject: [PATCH 0413/1626] Bump actions/cache from 4.2.3 to 4.2.4 (#10128) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 992918a035..e503bf556e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: venv # yamllint disable-line rule:line-length @@ -161,7 +161,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@v4.2.3 + uses: actions/cache/save@v4.2.4 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -222,7 +222,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -300,14 +300,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} From 14bc83342fd546769e2995fd61054d64a8d5b7b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 20:15:14 +0000 Subject: [PATCH 0414/1626] Bump ruff from 0.12.7 to 0.12.8 (#10126) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3e2da47446..1830e7881c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.7 + rev: v0.12.8 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 188d2ff22c..5b0d10d136 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.12.7 # also change in .pre-commit-config.yaml when updating +ruff==0.12.8 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From ac05ab6de180cfe272d252ebd4c1e61064712f89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Aug 2025 10:28:07 -1000 Subject: [PATCH 0415/1626] [cover] Reduce flash usage by optimizing validation messages --- esphome/components/cover/cover.cpp | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index d139bab8ee..68dfab111b 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -99,43 +99,39 @@ const optional &CoverCall::get_tilt() const { return this->tilt_; } const optional &CoverCall::get_toggle() const { return this->toggle_; } void CoverCall::validate_() { auto traits = this->parent_->get_traits(); + const char *name = this->parent_->get_name().c_str(); + if (this->position_.has_value()) { auto pos = *this->position_; if (!traits.get_supports_position() && pos != COVER_OPEN && pos != COVER_CLOSED) { - ESP_LOGW(TAG, "'%s' - This cover device does not support setting position!", this->parent_->get_name().c_str()); + ESP_LOGW(TAG, "'%s': position unsupported", name); this->position_.reset(); } else if (pos < 0.0f || pos > 1.0f) { - ESP_LOGW(TAG, "'%s' - Position %.2f is out of range [0.0 - 1.0]", this->parent_->get_name().c_str(), pos); + ESP_LOGW(TAG, "'%s': position %.2f out of range", name, pos); this->position_ = clamp(pos, 0.0f, 1.0f); } } if (this->tilt_.has_value()) { auto tilt = *this->tilt_; if (!traits.get_supports_tilt()) { - ESP_LOGW(TAG, "'%s' - This cover device does not support tilt!", this->parent_->get_name().c_str()); + ESP_LOGW(TAG, "'%s': tilt unsupported", name); this->tilt_.reset(); } else if (tilt < 0.0f || tilt > 1.0f) { - ESP_LOGW(TAG, "'%s' - Tilt %.2f is out of range [0.0 - 1.0]", this->parent_->get_name().c_str(), tilt); + ESP_LOGW(TAG, "'%s': tilt %.2f out of range", name, tilt); this->tilt_ = clamp(tilt, 0.0f, 1.0f); } } if (this->toggle_.has_value()) { if (!traits.get_supports_toggle()) { - ESP_LOGW(TAG, "'%s' - This cover device does not support toggle!", this->parent_->get_name().c_str()); + ESP_LOGW(TAG, "'%s': toggle unsupported", name); this->toggle_.reset(); } } if (this->stop_) { - if (this->position_.has_value()) { - ESP_LOGW(TAG, "Cannot set position when stopping a cover!"); + if (this->position_.has_value() || this->tilt_.has_value() || this->toggle_.has_value()) { + ESP_LOGW(TAG, "'%s': cannot position/tilt/toggle when stopping", name); this->position_.reset(); - } - if (this->tilt_.has_value()) { - ESP_LOGW(TAG, "Cannot set tilt when stopping a cover!"); this->tilt_.reset(); - } - if (this->toggle_.has_value()) { - ESP_LOGW(TAG, "Cannot set toggle when stopping a cover!"); this->toggle_.reset(); } } From 543e5099a4d6df3f116c0558a91a2576569599ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Aug 2025 12:38:15 -1000 Subject: [PATCH 0416/1626] [bluetooth_proxy] Optimize connection loop to reduce CPU usage --- .../bluetooth_proxy/bluetooth_connection.cpp | 22 ++++++++++++++----- .../bluetooth_proxy/bluetooth_connection.h | 2 +- .../bluetooth_proxy/bluetooth_proxy.cpp | 5 ++--- .../bluetooth_proxy/bluetooth_proxy.h | 1 + 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index fd77f9bd5b..a21a859c9e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -107,13 +107,25 @@ void BluetoothConnection::set_address(uint64_t address) { void BluetoothConnection::loop() { BLEClientBase::loop(); - // Early return if no active connection or not in service discovery phase - if (this->address_ == 0 || this->send_service_ < 0 || this->send_service_ > this->service_count_) { + // Early return if no active connection + if (this->address_ == 0) { return; } - // Handle service discovery - this->send_service_for_discovery_(); + // Handle service discovery if in valid range + if (this->send_service_ >= 0 && this->send_service_ <= this->service_count_) { + this->send_service_for_discovery_(); + } + + // Check if we should disable the loop + // - For V3_WITH_CACHE: Services are never sent, disable immediately once connected + // - For other connections: Disable only after service discovery is complete + // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) + if ((this->state() == espbt::ClientState::ESTABLISHED || this->state() == espbt::ClientState::CONNECTED) && + (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || + this->send_service_ == DONE_SENDING_SERVICES)) { + this->disable_loop(); + } } void BluetoothConnection::reset_connection_(esp_err_t reason) { @@ -127,7 +139,7 @@ void BluetoothConnection::reset_connection_(esp_err_t reason) { // to detect incomplete service discovery rather than relying on us to // tell them about a partial list. this->set_address(0); - this->send_service_ = DONE_SENDING_SERVICES; + this->send_service_ = INIT_SENDING_SERVICES; this->proxy_->send_connections_free(); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 7feb3c80bc..a975d25d91 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -44,7 +44,7 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { BluetoothProxy *proxy_; // Group 2: 2-byte types - int16_t send_service_{-2}; // Needs to handle negative values and service count + int16_t send_service_{-3}; // -3 = INIT_SENDING_SERVICES, -2 = DONE_SENDING_SERVICES, >=0 = service index // Group 3: 1-byte types bool seen_mtu_or_services_{false}; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 1986ea90d5..04b85fc3f0 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -192,7 +192,7 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; if (connection->get_address() == 0) { - connection->send_service_ = DONE_SENDING_SERVICES; + connection->send_service_ = INIT_SENDING_SERVICES; connection->set_address(address); // All connections must start at INIT // We only set the state if we allocate the connection @@ -373,8 +373,7 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer this->send_gatt_services_done(msg.address); return; } - if (connection->send_service_ == - DONE_SENDING_SERVICES) // Only start sending services if we're not already sending them + if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet connection->send_service_ = 0; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 4f22a179d6..21695d9819 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -23,6 +23,7 @@ namespace esphome::bluetooth_proxy { static const esp_err_t ESP_GATT_NOT_CONNECTED = -1; static const int DONE_SENDING_SERVICES = -2; +static const int INIT_SENDING_SERVICES = -3; using namespace esp32_ble_client; From 1de0a73a635abeb5121b53261b207724ed84d99f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Aug 2025 12:55:31 -1000 Subject: [PATCH 0417/1626] preen --- .../components/bluetooth_proxy/bluetooth_connection.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index a21a859c9e..b16b894188 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -118,12 +118,11 @@ void BluetoothConnection::loop() { } // Check if we should disable the loop - // - For V3_WITH_CACHE: Services are never sent, disable immediately once connected + // - For V3_WITH_CACHE: Services are never sent, disable after INIT state // - For other connections: Disable only after service discovery is complete // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) - if ((this->state() == espbt::ClientState::ESTABLISHED || this->state() == espbt::ClientState::CONNECTED) && - (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { + if (this->state_ != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || + this->send_service_ == DONE_SENDING_SERVICES)) { this->disable_loop(); } } From c4d1b1317adcabac1c82ede8fd61f40c29fb7a4c Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 8 Aug 2025 00:55:54 +0200 Subject: [PATCH 0418/1626] [switch] Add `switch.control` automation action (#10105) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/switch/__init__.py | 19 +++++++++++++++++++ esphome/components/switch/automation.h | 21 +++++++++++++++++++++ tests/components/switch/common.yaml | 8 ++++++++ 3 files changed, 48 insertions(+) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index a595d43445..4668a1458c 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_ON_TURN_OFF, CONF_ON_TURN_ON, CONF_RESTORE_MODE, + CONF_STATE, CONF_TRIGGER_ID, CONF_WEB_SERVER, DEVICE_CLASS_EMPTY, @@ -48,6 +49,7 @@ RESTORE_MODES = { } +ControlAction = switch_ns.class_("ControlAction", automation.Action) ToggleAction = switch_ns.class_("ToggleAction", automation.Action) TurnOffAction = switch_ns.class_("TurnOffAction", automation.Action) TurnOnAction = switch_ns.class_("TurnOnAction", automation.Action) @@ -177,6 +179,23 @@ SWITCH_ACTION_SCHEMA = maybe_simple_id( cv.Required(CONF_ID): cv.use_id(Switch), } ) +SWITCH_CONTROL_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(Switch), + cv.Required(CONF_STATE): cv.templatable(cv.boolean), + } +) + + +@automation.register_action( + "switch.control", ControlAction, SWITCH_CONTROL_ACTION_SCHEMA +) +async def switch_control_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + template_ = await cg.templatable(config[CONF_STATE], args, bool) + cg.add(var.set_state(template_)) + return var @automation.register_action("switch.toggle", ToggleAction, SWITCH_ACTION_SCHEMA) diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index 579daf4d24..51ac5d7a3a 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -37,6 +37,27 @@ template class ToggleAction : public Action { Switch *switch_; }; +template class ControlAction : public Action { + public: + explicit ControlAction(Switch *a_switch) : switch_(a_switch) {} + + TEMPLATABLE_VALUE(bool, state) + + void play(Ts... x) override { + auto state = this->state_.optional_value(x...); + if (state.has_value()) { + if (*state) { + this->switch_->turn_on(); + } else { + this->switch_->turn_off(); + } + } + } + + protected: + Switch *switch_; +}; + template class SwitchCondition : public Condition { public: SwitchCondition(Switch *parent, bool state) : parent_(parent), state_(state) {} diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index 8d6972f91b..b69e36a1c0 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -9,3 +9,11 @@ switch: name: "Template Switch" id: the_switch optimistic: true + +esphome: + on_boot: + - switch.turn_on: the_switch + - switch.turn_off: the_switch + - switch.control: + id: the_switch + state: !lambda return (1 > 2); From 76fd104fb6d41cfbde810aa9433c49ee9c84665f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Aug 2025 14:32:35 -1000 Subject: [PATCH 0419/1626] [mdns] Conditionally compile extra services to reduce flash usage (#10129) --- esphome/components/mdns/__init__.py | 3 +++ esphome/components/mdns/mdns_component.cpp | 2 ++ esphome/components/mdns/mdns_component.h | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index e32d39cede..469fe8ada6 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -93,6 +93,9 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + if config[CONF_SERVICES]: + cg.add_define("USE_MDNS_EXTRA_SERVICES") + for service in config[CONF_SERVICES]: txt = [ cg.StructInitializer( diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 06ca99b402..640750720d 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -104,7 +104,9 @@ void MDNSComponent::compile_records_() { } #endif +#ifdef USE_MDNS_EXTRA_SERVICES this->services_.insert(this->services_.end(), this->services_extra_.begin(), this->services_extra_.end()); +#endif if (this->services_.empty()) { // Publish "http" service if not using native API diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 93a16f40d2..f87ef08bcd 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -35,14 +35,18 @@ class MDNSComponent : public Component { #endif float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } +#ifdef USE_MDNS_EXTRA_SERVICES void add_extra_service(MDNSService service) { services_extra_.push_back(std::move(service)); } +#endif std::vector get_services(); void on_shutdown() override; protected: +#ifdef USE_MDNS_EXTRA_SERVICES std::vector services_extra_{}; +#endif std::vector services_{}; std::string hostname_; void compile_records_(); From d4d1a96f9b259902d068d346a47273c4054376bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Aug 2025 14:42:03 -1000 Subject: [PATCH 0420/1626] [esp32_ble_client] Reduce flash usage by optimizing logging strings (#10119) --- .../esp32_ble_client/ble_client_base.cpp | 30 ++++++++----------- .../esp32_ble_client/ble_client_base.h | 1 + 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 37b41326f7..2d84436d84 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -107,7 +107,7 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { #endif void BLEClientBase::connect() { - ESP_LOGI(TAG, "[%d] [%s] 0x%02x Attempting BLE connection", this->connection_index_, this->address_str_.c_str(), + ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_.c_str(), this->remote_addr_type_); this->paired_ = false; @@ -137,7 +137,7 @@ void BLEClientBase::connect() { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, this->address_str_.c_str(), param_ret); } else { - ESP_LOGD(TAG, "[%d] [%s] Set %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); + this->log_connection_params_(param_type); } // Now open the connection @@ -153,14 +153,9 @@ void BLEClientBase::connect() { esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); } void BLEClientBase::disconnect() { - if (this->state_ == espbt::ClientState::IDLE) { - ESP_LOGI(TAG, "[%d] [%s] Disconnect requested, but already idle.", this->connection_index_, - this->address_str_.c_str()); - return; - } - if (this->state_ == espbt::ClientState::DISCONNECTING) { - ESP_LOGI(TAG, "[%d] [%s] Disconnect requested, but already disconnecting.", this->connection_index_, - this->address_str_.c_str()); + if (this->state_ == espbt::ClientState::IDLE || this->state_ == espbt::ClientState::DISCONNECTING) { + ESP_LOGI(TAG, "[%d] [%s] Disconnect requested, but already %s", this->connection_index_, this->address_str_.c_str(), + espbt::client_state_to_string(this->state_)); return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { @@ -195,8 +190,7 @@ void BLEClientBase::unconditional_disconnect() { // In the future we might consider App.reboot() here since // the BLE stack is in an indeterminate state. // - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_close error, err=%d", this->connection_index_, this->address_str_.c_str(), - err); + this->log_gattc_warning_("esp_ble_gattc_close", err); } if (this->state_ == espbt::ClientState::SEARCHING || this->state_ == espbt::ClientState::READY_TO_CONNECT || @@ -236,6 +230,10 @@ void BLEClientBase::log_gattc_warning_(const char *operation, esp_err_t err) { ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_.c_str(), operation, err); } +void BLEClientBase::log_connection_params_(const char *param_type) { + ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); +} + void BLEClientBase::restore_medium_conn_params_() { // Restore to medium connection parameters after initial connection phase // This balances performance with bandwidth usage for normal operation @@ -245,7 +243,7 @@ void BLEClientBase::restore_medium_conn_params_() { conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; conn_params.latency = 0; conn_params.timeout = MEDIUM_CONN_TIMEOUT; - ESP_LOGD(TAG, "[%d] [%s] Restoring medium conn params", this->connection_index_, this->address_str_.c_str()); + this->log_connection_params_("medium"); esp_ble_gap_update_conn_params(&conn_params); } @@ -303,11 +301,8 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->set_state(espbt::ClientState::CONNECTED); ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - ESP_LOGI(TAG, "[%d] [%s] Using cached services", this->connection_index_, this->address_str_.c_str()); - // Restore to medium connection parameters for cached connections too this->restore_medium_conn_params_(); - // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; @@ -327,8 +322,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // This saves ~3ms in the connection process. auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id); if (ret) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_send_mtu_req failed, status=%x", this->connection_index_, - this->address_str_.c_str(), ret); + this->log_gattc_warning_("esp_ble_gattc_send_mtu_req", ret); } break; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 093d04640b..2d00688dbd 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -136,6 +136,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void restore_medium_conn_params_(); void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); + void log_connection_params_(const char *param_type); }; } // namespace esphome::esp32_ble_client From 83b69519dd14cdc93cce0fe1745fc939a50892dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Aug 2025 14:43:13 -1000 Subject: [PATCH 0421/1626] [wifi] Reduce flash usage by optimizing logging (#10127) --- esphome/components/wifi/wifi_component.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f815ab73c2..987e276e0c 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -457,9 +457,11 @@ void WiFiComponent::print_connect_params_() { " Signal strength: %d dB %s", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5], App.get_name().c_str(), rssi, LOG_STR_ARG(get_signal_bars(rssi))); +#ifdef ESPHOME_LOG_HAS_VERBOSE if (this->selected_ap_.get_bssid().has_value()) { ESP_LOGV(TAG, " Priority: %.1f", this->get_sta_priority(*this->selected_ap_.get_bssid())); } +#endif ESP_LOGCONFIG(TAG, " Channel: %" PRId32 "\n" " Subnet: %s\n" @@ -594,8 +596,10 @@ void WiFiComponent::check_scanning_finished() { if (res.get_matches()) { ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), res.get_is_hidden() ? "(HIDDEN) " : "", bssid_s, LOG_STR_ARG(get_signal_bars(res.get_rssi()))); - ESP_LOGD(TAG, " Channel: %u", res.get_channel()); - ESP_LOGD(TAG, " RSSI: %d dB", res.get_rssi()); + ESP_LOGD(TAG, + " Channel: %u\n" + " RSSI: %d dB", + res.get_channel(), res.get_rssi()); } else { ESP_LOGD(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), bssid_s, LOG_STR_ARG(get_signal_bars(res.get_rssi()))); From 58504662d84559e575a9e3f3d92e5252ad0f8307 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Aug 2025 14:44:47 -1000 Subject: [PATCH 0422/1626] [cover] Reduce flash usage by optimizing validation messages (#10130) --- esphome/components/cover/cover.cpp | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index d139bab8ee..68dfab111b 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -99,43 +99,39 @@ const optional &CoverCall::get_tilt() const { return this->tilt_; } const optional &CoverCall::get_toggle() const { return this->toggle_; } void CoverCall::validate_() { auto traits = this->parent_->get_traits(); + const char *name = this->parent_->get_name().c_str(); + if (this->position_.has_value()) { auto pos = *this->position_; if (!traits.get_supports_position() && pos != COVER_OPEN && pos != COVER_CLOSED) { - ESP_LOGW(TAG, "'%s' - This cover device does not support setting position!", this->parent_->get_name().c_str()); + ESP_LOGW(TAG, "'%s': position unsupported", name); this->position_.reset(); } else if (pos < 0.0f || pos > 1.0f) { - ESP_LOGW(TAG, "'%s' - Position %.2f is out of range [0.0 - 1.0]", this->parent_->get_name().c_str(), pos); + ESP_LOGW(TAG, "'%s': position %.2f out of range", name, pos); this->position_ = clamp(pos, 0.0f, 1.0f); } } if (this->tilt_.has_value()) { auto tilt = *this->tilt_; if (!traits.get_supports_tilt()) { - ESP_LOGW(TAG, "'%s' - This cover device does not support tilt!", this->parent_->get_name().c_str()); + ESP_LOGW(TAG, "'%s': tilt unsupported", name); this->tilt_.reset(); } else if (tilt < 0.0f || tilt > 1.0f) { - ESP_LOGW(TAG, "'%s' - Tilt %.2f is out of range [0.0 - 1.0]", this->parent_->get_name().c_str(), tilt); + ESP_LOGW(TAG, "'%s': tilt %.2f out of range", name, tilt); this->tilt_ = clamp(tilt, 0.0f, 1.0f); } } if (this->toggle_.has_value()) { if (!traits.get_supports_toggle()) { - ESP_LOGW(TAG, "'%s' - This cover device does not support toggle!", this->parent_->get_name().c_str()); + ESP_LOGW(TAG, "'%s': toggle unsupported", name); this->toggle_.reset(); } } if (this->stop_) { - if (this->position_.has_value()) { - ESP_LOGW(TAG, "Cannot set position when stopping a cover!"); + if (this->position_.has_value() || this->tilt_.has_value() || this->toggle_.has_value()) { + ESP_LOGW(TAG, "'%s': cannot position/tilt/toggle when stopping", name); this->position_.reset(); - } - if (this->tilt_.has_value()) { - ESP_LOGW(TAG, "Cannot set tilt when stopping a cover!"); this->tilt_.reset(); - } - if (this->toggle_.has_value()) { - ESP_LOGW(TAG, "Cannot set toggle when stopping a cover!"); this->toggle_.reset(); } } From 7e4d09dbd89fb07522825f315f62584299cc82de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Aug 2025 16:24:26 -1000 Subject: [PATCH 0423/1626] [bluetooth_proxy] Optimize connection loop to reduce CPU usage (#10133) --- .../bluetooth_proxy/bluetooth_connection.cpp | 21 ++++++++++++++----- .../bluetooth_proxy/bluetooth_connection.h | 2 +- .../bluetooth_proxy/bluetooth_proxy.cpp | 5 ++--- .../bluetooth_proxy/bluetooth_proxy.h | 1 + 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index fd77f9bd5b..b16b894188 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -107,13 +107,24 @@ void BluetoothConnection::set_address(uint64_t address) { void BluetoothConnection::loop() { BLEClientBase::loop(); - // Early return if no active connection or not in service discovery phase - if (this->address_ == 0 || this->send_service_ < 0 || this->send_service_ > this->service_count_) { + // Early return if no active connection + if (this->address_ == 0) { return; } - // Handle service discovery - this->send_service_for_discovery_(); + // Handle service discovery if in valid range + if (this->send_service_ >= 0 && this->send_service_ <= this->service_count_) { + this->send_service_for_discovery_(); + } + + // Check if we should disable the loop + // - For V3_WITH_CACHE: Services are never sent, disable after INIT state + // - For other connections: Disable only after service discovery is complete + // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) + if (this->state_ != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || + this->send_service_ == DONE_SENDING_SERVICES)) { + this->disable_loop(); + } } void BluetoothConnection::reset_connection_(esp_err_t reason) { @@ -127,7 +138,7 @@ void BluetoothConnection::reset_connection_(esp_err_t reason) { // to detect incomplete service discovery rather than relying on us to // tell them about a partial list. this->set_address(0); - this->send_service_ = DONE_SENDING_SERVICES; + this->send_service_ = INIT_SENDING_SERVICES; this->proxy_->send_connections_free(); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 7feb3c80bc..a975d25d91 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -44,7 +44,7 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { BluetoothProxy *proxy_; // Group 2: 2-byte types - int16_t send_service_{-2}; // Needs to handle negative values and service count + int16_t send_service_{-3}; // -3 = INIT_SENDING_SERVICES, -2 = DONE_SENDING_SERVICES, >=0 = service index // Group 3: 1-byte types bool seen_mtu_or_services_{false}; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 1986ea90d5..04b85fc3f0 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -192,7 +192,7 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; if (connection->get_address() == 0) { - connection->send_service_ = DONE_SENDING_SERVICES; + connection->send_service_ = INIT_SENDING_SERVICES; connection->set_address(address); // All connections must start at INIT // We only set the state if we allocate the connection @@ -373,8 +373,7 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer this->send_gatt_services_done(msg.address); return; } - if (connection->send_service_ == - DONE_SENDING_SERVICES) // Only start sending services if we're not already sending them + if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet connection->send_service_ = 0; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 4f22a179d6..21695d9819 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -23,6 +23,7 @@ namespace esphome::bluetooth_proxy { static const esp_err_t ESP_GATT_NOT_CONNECTED = -1; static const int DONE_SENDING_SERVICES = -2; +static const int INIT_SENDING_SERVICES = -3; using namespace esp32_ble_client; From cdcf5fd74c306ea0c4cd84c8372ed79c2317409b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Aug 2025 18:47:54 -1000 Subject: [PATCH 0424/1626] [dashboard] Fix port fallback regression when device is offline --- esphome/dashboard/web_server.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 46f09336bb..9db389c39a 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -324,14 +324,13 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): configuration = json_message["configuration"] config_file = settings.rel_path(configuration) port = json_message["port"] - addresses: list[str] = [port] + addresses: list[str] = [] if ( port == "OTA" # pylint: disable=too-many-boolean-expressions and (entry := entries.get(config_file)) and entry.loaded_integrations and "api" in entry.loaded_integrations ): - addresses = [] # First priority: entry.address AKA use_address if ( (use_address := entry.address) @@ -359,6 +358,13 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): # since MQTT logging will not work otherwise addresses.extend(sort_ip_addresses(new_addresses)) + if not addresses: + # If no address was found, use the port directly + # as otherwise they will get the chooser which + # does not work with the dashboard as there is no + # interactive way to get keyboard input + addresses = [port] + device_args: list[str] = [ arg for address in addresses for arg in ("--device", address) ] From 676c51ffa05a2853defb867565284bf7d0818ecc Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 8 Aug 2025 07:51:19 +0200 Subject: [PATCH 0425/1626] [switch] Add `control()` method to API (#10118) --- esphome/components/switch/automation.h | 6 +----- esphome/components/switch/switch.cpp | 8 ++++++++ esphome/components/switch/switch.h | 8 ++++++++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index 51ac5d7a3a..66818a80be 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -46,11 +46,7 @@ template class ControlAction : public Action { void play(Ts... x) override { auto state = this->state_.optional_value(x...); if (state.has_value()) { - if (*state) { - this->switch_->turn_on(); - } else { - this->switch_->turn_off(); - } + this->switch_->control(*state); } } diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index c204895755..13c12c1213 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -8,6 +8,14 @@ static const char *const TAG = "switch"; Switch::Switch() : state(false) {} +void Switch::control(bool target_state) { + ESP_LOGV(TAG, "'%s' Control: %s", this->get_name().c_str(), ONOFF(target_state)); + if (target_state) { + this->turn_on(); + } else { + this->turn_off(); + } +} void Switch::turn_on() { ESP_LOGD(TAG, "'%s' Turning ON.", this->get_name().c_str()); this->write_state(!this->inverted_); diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index b999296564..6371e35292 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -55,6 +55,14 @@ class Switch : public EntityBase, public EntityBase_DeviceClass { /// The current reported state of the binary sensor. bool state; + /** Control this switch using a boolean state value. + * + * This method provides a unified interface for setting the switch state based on a boolean parameter. + * It automatically calls turn_on() when state is true or turn_off() when state is false. + * + * @param target_state The desired state: true to turn the switch ON, false to turn it OFF. + */ + void control(bool target_state); /** Turn this switch on. This is called by the front-end. * * For implementing switches, please override write_state. From ff9ddb9d685081bfef946195bf531ff8fc6245cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:03:13 -0500 Subject: [PATCH 0426/1626] Bump tornado from 6.5.1 to 6.5.2 (#10142) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e882b8cf44..5e2773a71b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.2 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.1 +tornado==6.5.2 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 From 3ded96bb263ad2d4a116c50205e2638bbf0699ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Aug 2025 16:18:04 -0500 Subject: [PATCH 0427/1626] Optimize subprocess performance with close_fds=False --- docker/build.py | 2 +- esphome/components/sdl/display.py | 4 +++- esphome/dashboard/web_server.py | 1 + esphome/git.py | 4 +++- esphome/helpers.py | 4 +++- esphome/platformio_api.py | 2 +- esphome/util.py | 7 ++++++- script/clang-format | 6 +++++- script/clang-tidy | 12 +++++++++--- script/platformio_install_deps.py | 4 +++- script/run-in-env.py | 4 ++-- tests/integration/conftest.py | 3 +++ 12 files changed, 40 insertions(+), 13 deletions(-) diff --git a/docker/build.py b/docker/build.py index 921adac7ab..4d093cf88d 100755 --- a/docker/build.py +++ b/docker/build.py @@ -90,7 +90,7 @@ def main(): def run_command(*cmd, ignore_error: bool = False): print(f"$ {shlex.join(list(cmd))}") if not args.dry_run: - rc = subprocess.call(list(cmd)) + rc = subprocess.call(list(cmd), close_fds=False) if rc != 0 and not ignore_error: print("Command failed") sys.exit(1) diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index ae8b0fd43a..78c180aa65 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -36,7 +36,9 @@ def get_sdl_options(value): if value != "": return value try: - return subprocess.check_output(["sdl2-config", "--cflags", "--libs"]).decode() + return subprocess.check_output( + ["sdl2-config", "--cflags", "--libs"], close_fds=False + ).decode() except Exception as e: raise cv.Invalid("Unable to run sdl2-config - have you installed sdl2?") from e diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 46f09336bb..4d691b34b6 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -229,6 +229,7 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + close_fds=False, ) stdout_thread = threading.Thread(target=self._stdout_thread) stdout_thread.daemon = True diff --git a/esphome/git.py b/esphome/git.py index 005bcae702..56aedd1519 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -17,7 +17,9 @@ _LOGGER = logging.getLogger(__name__) def run_git_command(cmd, cwd=None) -> str: _LOGGER.debug("Running git command: %s", " ".join(cmd)) try: - ret = subprocess.run(cmd, cwd=cwd, capture_output=True, check=False) + ret = subprocess.run( + cmd, cwd=cwd, capture_output=True, check=False, close_fds=False + ) except FileNotFoundError as err: raise cv.Invalid( "git is not installed but required for external_components.\n" diff --git a/esphome/helpers.py b/esphome/helpers.py index f722dc3f7c..377a4e1717 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -114,7 +114,9 @@ def cpp_string_escape(string, encoding="utf-8"): def run_system_command(*args): import subprocess - with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as p: + with subprocess.Popen( + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=False + ) as p: stdout, stderr = p.communicate() rc = p.returncode return rc, stdout, stderr diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 21124fc859..267277ebe1 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -211,7 +211,7 @@ def _decode_pc(config, addr): return command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] try: - translation = subprocess.check_output(command).decode().strip() + translation = subprocess.check_output(command, close_fds=False).decode().strip() except Exception: # pylint: disable=broad-except _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return diff --git a/esphome/util.py b/esphome/util.py index ed9ab4a446..6362260fde 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -239,7 +239,12 @@ def run_external_process(*cmd: str, **kwargs: Any) -> int | str: try: proc = subprocess.run( - cmd, stdout=sub_stdout, stderr=sub_stderr, encoding="utf-8", check=False + cmd, + stdout=sub_stdout, + stderr=sub_stderr, + encoding="utf-8", + check=False, + close_fds=False, ) return proc.stdout if capture_stdout else proc.returncode except KeyboardInterrupt: # pylint: disable=try-except-raise diff --git a/script/clang-format b/script/clang-format index d62a5b59c7..028d752c55 100755 --- a/script/clang-format +++ b/script/clang-format @@ -31,7 +31,11 @@ def run_format(executable, args, queue, lock, failed_files): invocation.append(path) proc = subprocess.run( - invocation, capture_output=True, encoding="utf-8", check=False + invocation, + capture_output=True, + encoding="utf-8", + check=False, + close_fds=False, ) if proc.returncode != 0: with lock: diff --git a/script/clang-tidy b/script/clang-tidy index 2c4a2e36ac..142b616119 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -158,7 +158,11 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): invocation.extend(options) proc = subprocess.run( - invocation, capture_output=True, encoding="utf-8", check=False + invocation, + capture_output=True, + encoding="utf-8", + check=False, + close_fds=False, ) if proc.returncode != 0: with lock: @@ -320,9 +324,11 @@ def main(): print("Applying fixes ...") try: try: - subprocess.call(["clang-apply-replacements-18", tmpdir]) + subprocess.call( + ["clang-apply-replacements-18", tmpdir], close_fds=False + ) except FileNotFoundError: - subprocess.call(["clang-apply-replacements", tmpdir]) + subprocess.call(["clang-apply-replacements", tmpdir], close_fds=False) except FileNotFoundError: print( "Error please install clang-apply-replacements-18 or clang-apply-replacements.\n", diff --git a/script/platformio_install_deps.py b/script/platformio_install_deps.py index ed133ecb47..8f7261efc3 100755 --- a/script/platformio_install_deps.py +++ b/script/platformio_install_deps.py @@ -55,4 +55,6 @@ for section in config.sections(): tools.append("-t") tools.append(tool) -subprocess.check_call(["platformio", "pkg", "install", "-g", *libs, *platforms, *tools]) +subprocess.check_call( + ["platformio", "pkg", "install", "-g", *libs, *platforms, *tools], close_fds=False +) diff --git a/script/run-in-env.py b/script/run-in-env.py index d9bd01a62f..886e65db27 100755 --- a/script/run-in-env.py +++ b/script/run-in-env.py @@ -13,7 +13,7 @@ def find_and_activate_virtualenv(): try: # Get the top-level directory of the git repository my_path = subprocess.check_output( - ["git", "rev-parse", "--show-toplevel"], text=True + ["git", "rev-parse", "--show-toplevel"], text=True, close_fds=False ).strip() except subprocess.CalledProcessError: print( @@ -44,7 +44,7 @@ def find_and_activate_virtualenv(): def run_command(): # Execute the remaining arguments in the new environment if len(sys.argv) > 1: - subprocess.run(sys.argv[1:], check=False) + subprocess.run(sys.argv[1:], check=False, close_fds=False) else: print( "No command provided to run in the virtual environment.", diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 55bf0b97a7..0530752551 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -105,6 +105,7 @@ logger: check=True, cwd=init_dir, env=env, + close_fds=False, ) # Lock is held until here, ensuring cache is fully populated before any test proceeds @@ -245,6 +246,7 @@ async def compile_esphome( # Start in a new process group to isolate signal handling start_new_session=True, env=env, + close_fds=False, ) await proc.wait() @@ -477,6 +479,7 @@ async def run_binary_and_wait_for_port( # Start in a new process group to isolate signal handling start_new_session=True, pass_fds=(device_fd,), + close_fds=False, ) # Close the device end in the parent process From 2b9e1ce315bc3bcdf865ce0f5097b0b97708e690 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Sat, 9 Aug 2025 13:09:40 +0200 Subject: [PATCH 0428/1626] [switch] Add trigger ``on_state`` (#10108) --- esphome/components/switch/__init__.py | 12 ++++++++++++ esphome/components/switch/automation.h | 7 +++++++ tests/components/switch/common.yaml | 12 ++++++++++++ 3 files changed, 31 insertions(+) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 4668a1458c..f495dbc0b4 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_ID, CONF_INVERTED, CONF_MQTT_ID, + CONF_ON_STATE, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, CONF_RESTORE_MODE, @@ -56,6 +57,9 @@ TurnOnAction = switch_ns.class_("TurnOnAction", automation.Action) SwitchPublishAction = switch_ns.class_("SwitchPublishAction", automation.Action) SwitchCondition = switch_ns.class_("SwitchCondition", Condition) +SwitchStateTrigger = switch_ns.class_( + "SwitchStateTrigger", automation.Trigger.template(bool) +) SwitchTurnOnTrigger = switch_ns.class_( "SwitchTurnOnTrigger", automation.Trigger.template() ) @@ -77,6 +81,11 @@ _SWITCH_SCHEMA = ( cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum( RESTORE_MODES, upper=True, space="_" ), + cv.Optional(CONF_ON_STATE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SwitchStateTrigger), + } + ), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SwitchTurnOnTrigger), @@ -140,6 +149,9 @@ async def setup_switch_core_(var, config): if (inverted := config.get(CONF_INVERTED)) is not None: cg.add(var.set_inverted(inverted)) + for conf in config.get(CONF_ON_STATE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(bool, "x")], conf) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index 66818a80be..b8cbc9b976 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -64,6 +64,13 @@ template class SwitchCondition : public Condition { bool state_; }; +class SwitchStateTrigger : public Trigger { + public: + SwitchStateTrigger(Switch *a_switch) { + a_switch->add_on_state_callback([this](bool state) { this->trigger(state); }); + } +}; + class SwitchTurnOnTrigger : public Trigger<> { public: SwitchTurnOnTrigger(Switch *a_switch) { diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index b69e36a1c0..afdf26c150 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -9,6 +9,18 @@ switch: name: "Template Switch" id: the_switch optimistic: true + on_state: + - if: + condition: + - lambda: return x; + then: + - logger.log: "Switch turned ON" + else: + - logger.log: "Switch turned OFF" + on_turn_on: + - logger.log: "Switch is now ON" + on_turn_off: + - logger.log: "Switch is now OFF" esphome: on_boot: From ea74a9ec8f42e76059ff52df819e0d14466fc40b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Aug 2025 14:01:49 -0500 Subject: [PATCH 0429/1626] [esphome] Fix OTA watchdog reset when port scanning --- .../components/esphome/ota/ota_esphome.cpp | 98 ++++++++++++------- esphome/components/esphome/ota/ota_esphome.h | 9 +- 2 files changed, 67 insertions(+), 40 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 4cc82b9094..58cfbfbcc3 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -20,6 +20,7 @@ namespace esphome { static const char *const TAG = "esphome.ota"; static constexpr u_int16_t OTA_BLOCK_SIZE = 8192; +static constexpr uint16_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 1000; // milliseconds for initial handshake void ESPHomeOTAComponent::setup() { #ifdef USE_OTA_STATE_CALLBACK @@ -28,19 +29,19 @@ void ESPHomeOTAComponent::setup() { this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0); // monitored for incoming connections if (this->server_ == nullptr) { - ESP_LOGW(TAG, "Could not create socket"); + this->log_socket_error_("creation"); this->mark_failed(); return; } int enable = 1; int err = this->server_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { - ESP_LOGW(TAG, "Socket unable to set reuseaddr: errno %d", err); + this->log_socket_error_("reuseaddr"); // we can still continue } err = this->server_->setblocking(false); if (err != 0) { - ESP_LOGW(TAG, "Socket unable to set nonblocking mode: errno %d", err); + this->log_socket_error_("non-blocking"); this->mark_failed(); return; } @@ -49,21 +50,21 @@ void ESPHomeOTAComponent::setup() { socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); if (sl == 0) { - ESP_LOGW(TAG, "Socket unable to set sockaddr: errno %d", errno); + this->log_socket_error_("set sockaddr"); this->mark_failed(); return; } err = this->server_->bind((struct sockaddr *) &server, sizeof(server)); if (err != 0) { - ESP_LOGW(TAG, "Socket unable to bind: errno %d", errno); + this->log_socket_error_("bind"); this->mark_failed(); return; } err = this->server_->listen(4); if (err != 0) { - ESP_LOGW(TAG, "Socket unable to listen: errno %d", errno); + this->log_socket_error_("listen"); this->mark_failed(); return; } @@ -120,26 +121,26 @@ void ESPHomeOTAComponent::handle_() { int enable = 1; int err = this->client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); if (err != 0) { - ESP_LOGW(TAG, "Socket could not enable TCP nodelay, errno %d", errno); - this->client_->close(); - this->client_ = nullptr; + this->log_socket_error_("nodelay"); + this->cleanup_connection_(); + return; + } + err = this->client_->setblocking(false); + if (err != 0) { + this->log_socket_error_("non-blocking"); + this->cleanup_connection_(); return; } - ESP_LOGD(TAG, "Starting update from %s", this->client_->getpeername().c_str()); - this->status_set_warning(); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_STARTED, 0.0f, 0); -#endif + this->log_start_("handshake"); - if (!this->readall_(buf, 5)) { - ESP_LOGW(TAG, "Reading magic bytes failed"); + if (!this->readall_(buf, 5, OTA_SOCKET_TIMEOUT_HANDSHAKE)) { + ESP_LOGW(TAG, "Read magic bytes failed"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } // 0x6C, 0x26, 0xF7, 0x5C, 0x45 if (buf[0] != 0x6C || buf[1] != 0x26 || buf[2] != 0xF7 || buf[3] != 0x5C || buf[4] != 0x45) { - ESP_LOGW(TAG, "Magic bytes do not match! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", buf[0], buf[1], buf[2], buf[3], - buf[4]); + ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", buf[0], buf[1], buf[2], buf[3], buf[4]); error_code = ota::OTA_RESPONSE_ERROR_MAGIC; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -153,7 +154,7 @@ void ESPHomeOTAComponent::handle_() { // Read features - 1 byte if (!this->readall_(buf, 1)) { - ESP_LOGW(TAG, "Reading features failed"); + ESP_LOGW(TAG, "Read features failed"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_features = buf[0]; // NOLINT @@ -232,7 +233,7 @@ void ESPHomeOTAComponent::handle_() { // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { - ESP_LOGW(TAG, "Reading size failed"); + ESP_LOGW(TAG, "Read size failed"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_size = 0; @@ -242,6 +243,17 @@ void ESPHomeOTAComponent::handle_() { } ESP_LOGV(TAG, "Size is %u bytes", ota_size); + // Now that we've passed authentication and are actually + // starting the update, set the warning status and notify + // listeners. This ensures that port scanners do not + // accidentally trigger the update process. + this->log_start_("update"); + this->status_set_warning(); +#ifdef USE_OTA_STATE_CALLBACK + this->state_callback_.call(ota::OTA_STARTED, 0.0f, 0); +#endif + + // This will block for a few seconds as it locks flash error_code = backend->begin(ota_size); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) @@ -253,7 +265,7 @@ void ESPHomeOTAComponent::handle_() { // Read binary MD5, 32 bytes if (!this->readall_(buf, 32)) { - ESP_LOGW(TAG, "Reading binary MD5 checksum failed"); + ESP_LOGW(TAG, "Read MD5 checksum failed"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } sbuf[32] = '\0'; @@ -274,19 +286,19 @@ void ESPHomeOTAComponent::handle_() { delay(1); continue; } - ESP_LOGW(TAG, "Error receiving data for update, errno %d", errno); + ESP_LOGW(TAG, "Read error, errno %d", errno); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } else if (read == 0) { // $ man recv // "When a stream socket peer has performed an orderly shutdown, the return value will // be 0 (the traditional "end-of-file" return)." - ESP_LOGW(TAG, "Remote end closed connection"); + ESP_LOGW(TAG, "Remote closed connection"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } error_code = backend->write(buf, read); if (error_code != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Error writing binary data to flash!, error_code: %d", error_code); + ESP_LOGW(TAG, "Flash write error, code: %d", error_code); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } total += read; @@ -318,7 +330,7 @@ void ESPHomeOTAComponent::handle_() { error_code = backend->end(); if (error_code != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Error ending update! error_code: %d", error_code); + ESP_LOGW(TAG, "Error ending update! code: %d", error_code); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -328,12 +340,11 @@ void ESPHomeOTAComponent::handle_() { // Read ACK if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Reading back acknowledgement failed"); + ESP_LOGW(TAG, "Read ack failed"); // do not go to error, this is not fatal } - this->client_->close(); - this->client_ = nullptr; + this->cleanup_connection_(); delay(10); ESP_LOGI(TAG, "Update complete"); this->status_clear_warning(); @@ -346,8 +357,7 @@ void ESPHomeOTAComponent::handle_() { error: buf[0] = static_cast(error_code); this->writeall_(buf, 1); - this->client_->close(); - this->client_ = nullptr; + this->cleanup_connection_(); if (backend != nullptr && update_started) { backend->abort(); @@ -359,13 +369,13 @@ error: #endif } -bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { +bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len, uint16_t timeout) { uint32_t start = millis(); uint32_t at = 0; while (len - at > 0) { uint32_t now = millis(); - if (now - start > 1000) { - ESP_LOGW(TAG, "Timed out reading %d bytes of data", len); + if (now - start > timeout) { + ESP_LOGW(TAG, "Timeout reading %d bytes", len); return false; } @@ -376,7 +386,7 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { delay(1); continue; } - ESP_LOGW(TAG, "Failed to read %d bytes of data, errno %d", len, errno); + ESP_LOGW(TAG, "Error reading %d bytes, errno %d", len, errno); return false; } else if (read == 0) { ESP_LOGW(TAG, "Remote closed connection"); @@ -390,13 +400,13 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { return true; } -bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { +bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len, uint16_t timeout) { uint32_t start = millis(); uint32_t at = 0; while (len - at > 0) { uint32_t now = millis(); - if (now - start > 1000) { - ESP_LOGW(TAG, "Timed out writing %d bytes of data", len); + if (now - start > timeout) { + ESP_LOGW(TAG, "Timeout writing %d bytes", len); return false; } @@ -407,7 +417,7 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { delay(1); continue; } - ESP_LOGW(TAG, "Failed to write %d bytes of data, errno %d", len, errno); + ESP_LOGW(TAG, "Error writing %d bytes, errno %d", len, errno); return false; } else { at += written; @@ -421,5 +431,17 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { float ESPHomeOTAComponent::get_setup_priority() const { return setup_priority::AFTER_WIFI; } uint16_t ESPHomeOTAComponent::get_port() const { return this->port_; } void ESPHomeOTAComponent::set_port(uint16_t port) { this->port_ = port; } + +void ESPHomeOTAComponent::log_socket_error_(const char *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", msg, errno); } + +void ESPHomeOTAComponent::log_start_(const char *phase) { + ESP_LOGD(TAG, "Starting %s from %s", phase, this->client_->getpeername().c_str()); +} + +void ESPHomeOTAComponent::cleanup_connection_() { + this->client_->close(); + this->client_ = nullptr; +} + } // namespace esphome #endif diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index e0d09ff37e..5d58eefd2f 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -9,6 +9,8 @@ namespace esphome { +static constexpr uint16_t OTA_SOCKET_TIMEOUT_DATA = 2500; // milliseconds for data transfer + /// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA. class ESPHomeOTAComponent : public ota::OTAComponent { public: @@ -28,8 +30,11 @@ class ESPHomeOTAComponent : public ota::OTAComponent { protected: void handle_(); - bool readall_(uint8_t *buf, size_t len); - bool writeall_(const uint8_t *buf, size_t len); + bool readall_(uint8_t *buf, size_t len, uint16_t timeout = OTA_SOCKET_TIMEOUT_DATA); + bool writeall_(const uint8_t *buf, size_t len, uint16_t timeout = OTA_SOCKET_TIMEOUT_DATA); + void log_socket_error_(const char *msg); + void log_start_(const char *phase); + void cleanup_connection_(); #ifdef USE_OTA_PASSWORD std::string password_; From 8faac0c1847c496fb1a8a7f01d7cb3e2485f23ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Aug 2025 20:41:44 -0500 Subject: [PATCH 0430/1626] [web_server] Reduce flash usage by consolidating parameter parsing --- esphome/components/web_server/web_server.cpp | 208 +++++-------------- esphome/components/web_server/web_server.h | 59 ++++++ 2 files changed, 108 insertions(+), 159 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index a8d94d80da..5719665ed4 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -635,15 +635,8 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc } else if (match.method_equals("turn_on") || match.method_equals("turn_off")) { auto call = match.method_equals("turn_on") ? obj->turn_on() : obj->turn_off(); - if (request->hasParam("speed_level")) { - auto speed_level = request->getParam("speed_level")->value(); - auto val = parse_number(speed_level.c_str()); - if (!val.has_value()) { - ESP_LOGW(TAG, "Can't convert '%s' to number!", speed_level.c_str()); - return; - } - call.set_speed(*val); - } + parse_int_param(request, "speed_level", call, &decltype(call)::set_speed); + if (request->hasParam("oscillation")) { auto speed = request->getParam("oscillation")->value(); auto val = parse_on_off(speed.c_str()); @@ -715,69 +708,26 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa request->send(200); } else if (match.method_equals("turn_on")) { auto call = obj->turn_on(); - if (request->hasParam("brightness")) { - auto brightness = parse_number(request->getParam("brightness")->value().c_str()); - if (brightness.has_value()) { - call.set_brightness(*brightness / 255.0f); - } - } - if (request->hasParam("r")) { - auto r = parse_number(request->getParam("r")->value().c_str()); - if (r.has_value()) { - call.set_red(*r / 255.0f); - } - } - if (request->hasParam("g")) { - auto g = parse_number(request->getParam("g")->value().c_str()); - if (g.has_value()) { - call.set_green(*g / 255.0f); - } - } - if (request->hasParam("b")) { - auto b = parse_number(request->getParam("b")->value().c_str()); - if (b.has_value()) { - call.set_blue(*b / 255.0f); - } - } - if (request->hasParam("white_value")) { - auto white_value = parse_number(request->getParam("white_value")->value().c_str()); - if (white_value.has_value()) { - call.set_white(*white_value / 255.0f); - } - } - if (request->hasParam("color_temp")) { - auto color_temp = parse_number(request->getParam("color_temp")->value().c_str()); - if (color_temp.has_value()) { - call.set_color_temperature(*color_temp); - } - } - if (request->hasParam("flash")) { - auto flash = parse_number(request->getParam("flash")->value().c_str()); - if (flash.has_value()) { - call.set_flash_length(*flash * 1000); - } - } - if (request->hasParam("transition")) { - auto transition = parse_number(request->getParam("transition")->value().c_str()); - if (transition.has_value()) { - call.set_transition_length(*transition * 1000); - } - } - if (request->hasParam("effect")) { - const char *effect = request->getParam("effect")->value().c_str(); - call.set_effect(effect); - } + + // Parse color parameters + parse_light_param(request, "brightness", call, &decltype(call)::set_brightness, 255.0f); + parse_light_param(request, "r", call, &decltype(call)::set_red, 255.0f); + parse_light_param(request, "g", call, &decltype(call)::set_green, 255.0f); + parse_light_param(request, "b", call, &decltype(call)::set_blue, 255.0f); + parse_light_param(request, "white_value", call, &decltype(call)::set_white, 255.0f); + parse_light_param(request, "color_temp", call, &decltype(call)::set_color_temperature); + + // Parse timing parameters + parse_light_param_uint(request, "flash", call, &decltype(call)::set_flash_length, 1000); + parse_light_param_uint(request, "transition", call, &decltype(call)::set_transition_length, 1000); + + parse_string_param(request, "effect", call, &decltype(call)::set_effect); this->defer([call]() mutable { call.perform(); }); request->send(200); } else if (match.method_equals("turn_off")) { auto call = obj->turn_off(); - if (request->hasParam("transition")) { - auto transition = parse_number(request->getParam("transition")->value().c_str()); - if (transition.has_value()) { - call.set_transition_length(*transition * 1000); - } - } + parse_light_param_uint(request, "transition", call, &decltype(call)::set_transition_length, 1000); this->defer([call]() mutable { call.perform(); }); request->send(200); } else { @@ -850,18 +800,8 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa return; } - if (request->hasParam("position")) { - auto position = parse_number(request->getParam("position")->value().c_str()); - if (position.has_value()) { - call.set_position(*position); - } - } - if (request->hasParam("tilt")) { - auto tilt = parse_number(request->getParam("tilt")->value().c_str()); - if (tilt.has_value()) { - call.set_tilt(*tilt); - } - } + parse_float_param(request, "position", call, &decltype(call)::set_position); + parse_float_param(request, "tilt", call, &decltype(call)::set_tilt); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -915,11 +855,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM } auto call = obj->make_call(); - if (request->hasParam("value")) { - auto value = parse_number(request->getParam("value")->value().c_str()); - if (value.has_value()) - call.set_value(*value); - } + parse_float_param(request, "value", call, &decltype(call)::set_value); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -991,10 +927,7 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat return; } - if (request->hasParam("value")) { - std::string value = request->getParam("value")->value().c_str(); // NOLINT - call.set_date(value); - } + parse_string_param(request, "value", call, &decltype(call)::set_date); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1050,10 +983,7 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat return; } - if (request->hasParam("value")) { - std::string value = request->getParam("value")->value().c_str(); // NOLINT - call.set_time(value); - } + parse_string_param(request, "value", call, &decltype(call)::set_time); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1108,10 +1038,7 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur return; } - if (request->hasParam("value")) { - std::string value = request->getParam("value")->value().c_str(); // NOLINT - call.set_datetime(value); - } + parse_string_param(request, "value", call, &decltype(call)::set_datetime); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1162,10 +1089,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat } auto call = obj->make_call(); - if (request->hasParam("value")) { - String value = request->getParam("value")->value(); - call.set_value(value.c_str()); // NOLINT - } + parse_string_param(request, "value", call, &decltype(call)::set_value); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1224,11 +1148,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM } auto call = obj->make_call(); - - if (request->hasParam("option")) { - auto option = request->getParam("option")->value(); - call.set_option(option.c_str()); // NOLINT - } + parse_string_param(request, "option", call, &decltype(call)::set_option); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1284,38 +1204,15 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url auto call = obj->make_call(); - if (request->hasParam("mode")) { - auto mode = request->getParam("mode")->value(); - call.set_mode(mode.c_str()); // NOLINT - } + // Parse string mode parameters + parse_string_param(request, "mode", call, &decltype(call)::set_mode); + parse_string_param(request, "fan_mode", call, &decltype(call)::set_fan_mode); + parse_string_param(request, "swing_mode", call, &decltype(call)::set_swing_mode); - if (request->hasParam("fan_mode")) { - auto mode = request->getParam("fan_mode")->value(); - call.set_fan_mode(mode.c_str()); // NOLINT - } - - if (request->hasParam("swing_mode")) { - auto mode = request->getParam("swing_mode")->value(); - call.set_swing_mode(mode.c_str()); // NOLINT - } - - if (request->hasParam("target_temperature_high")) { - auto target_temperature_high = parse_number(request->getParam("target_temperature_high")->value().c_str()); - if (target_temperature_high.has_value()) - call.set_target_temperature_high(*target_temperature_high); - } - - if (request->hasParam("target_temperature_low")) { - auto target_temperature_low = parse_number(request->getParam("target_temperature_low")->value().c_str()); - if (target_temperature_low.has_value()) - call.set_target_temperature_low(*target_temperature_low); - } - - if (request->hasParam("target_temperature")) { - auto target_temperature = parse_number(request->getParam("target_temperature")->value().c_str()); - if (target_temperature.has_value()) - call.set_target_temperature(*target_temperature); - } + // Parse temperature parameters + parse_float_param(request, "target_temperature_high", call, &decltype(call)::set_target_temperature_high); + parse_float_param(request, "target_temperature_low", call, &decltype(call)::set_target_temperature_low); + parse_float_param(request, "target_temperature", call, &decltype(call)::set_target_temperature); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1506,12 +1403,7 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa return; } - if (request->hasParam("position")) { - auto position = parse_number(request->getParam("position")->value().c_str()); - if (position.has_value()) { - call.set_position(*position); - } - } + parse_float_param(request, "position", call, &decltype(call)::set_position); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1559,9 +1451,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques } auto call = obj->make_call(); - if (request->hasParam("code")) { - call.set_code(request->getParam("code")->value().c_str()); // NOLINT - } + parse_string_param(request, "code", call, &decltype(call)::set_code); if (match.method_equals("disarm")) { call.disarm(); @@ -1659,6 +1549,19 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty #endif #ifdef USE_UPDATE +static const char *update_state_to_string(update::UpdateState state) { + switch (state) { + case update::UPDATE_STATE_NO_UPDATE: + return "NO UPDATE"; + case update::UPDATE_STATE_AVAILABLE: + return "UPDATE AVAILABLE"; + case update::UPDATE_STATE_INSTALLING: + return "INSTALLING"; + default: + return "UNKNOWN"; + } +} + void WebServer::on_update(update::UpdateEntity *obj) { if (this->events_.empty()) return; @@ -1698,20 +1601,7 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c return json::build_json([this, obj, start_config](JsonObject root) { set_json_id(root, obj, "update-" + obj->get_object_id(), start_config); root["value"] = obj->update_info.latest_version; - switch (obj->state) { - case update::UPDATE_STATE_NO_UPDATE: - root["state"] = "NO UPDATE"; - break; - case update::UPDATE_STATE_AVAILABLE: - root["state"] = "UPDATE AVAILABLE"; - break; - case update::UPDATE_STATE_INSTALLING: - root["state"] = "INSTALLING"; - break; - default: - root["state"] = "UNKNOWN"; - break; - } + root["state"] = update_state_to_string(obj->state); if (start_config == DETAIL_ALL) { root["current_version"] = obj->update_info.current_version; root["title"] = obj->update_info.title; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index ef1b03a73b..94c75935cd 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -498,6 +498,65 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { protected: void add_sorting_info_(JsonObject &root, EntityBase *entity); + +#ifdef USE_LIGHT + // Helper to parse and apply a float parameter with optional scaling + template + void parse_light_param(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(float), + float scale = 1.0f) { + if (request->hasParam(param_name)) { + auto value = parse_number(request->getParam(param_name)->value().c_str()); + if (value.has_value()) { + (call.*setter)(*value / scale); + } + } + } + + // Helper to parse and apply a uint32_t parameter with optional scaling + template + void parse_light_param_uint(AsyncWebServerRequest *request, const char *param_name, T &call, + Ret (T::*setter)(uint32_t), uint32_t scale = 1) { + if (request->hasParam(param_name)) { + auto value = parse_number(request->getParam(param_name)->value().c_str()); + if (value.has_value()) { + (call.*setter)(*value * scale); + } + } + } +#endif + + // Generic helper to parse and apply a float parameter + template + void parse_float_param(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(float)) { + if (request->hasParam(param_name)) { + auto value = parse_number(request->getParam(param_name)->value().c_str()); + if (value.has_value()) { + (call.*setter)(*value); + } + } + } + + // Generic helper to parse and apply an int parameter + template + void parse_int_param(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(int)) { + if (request->hasParam(param_name)) { + auto value = parse_number(request->getParam(param_name)->value().c_str()); + if (value.has_value()) { + (call.*setter)(*value); + } + } + } + + // Generic helper to parse and apply a string parameter + template + void parse_string_param(AsyncWebServerRequest *request, const char *param_name, T &call, + Ret (T::*setter)(const std::string &)) { + if (request->hasParam(param_name)) { + std::string value = request->getParam(param_name)->value().c_str(); + (call.*setter)(value); + } + } + web_server_base::WebServerBase *base_; #ifdef USE_ARDUINO DeferredUpdateEventSourceList events_; From 4e07c504900ba54653dfcdf538d1a86243898c37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Aug 2025 20:52:28 -0500 Subject: [PATCH 0431/1626] tweak --- esphome/components/web_server/web_server.cpp | 54 ++++++++++---------- esphome/components/web_server/web_server.h | 16 +++--- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 5719665ed4..92c5961f87 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -635,7 +635,7 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc } else if (match.method_equals("turn_on") || match.method_equals("turn_off")) { auto call = match.method_equals("turn_on") ? obj->turn_on() : obj->turn_off(); - parse_int_param(request, "speed_level", call, &decltype(call)::set_speed); + parse_int_param_(request, "speed_level", call, &decltype(call)::set_speed); if (request->hasParam("oscillation")) { auto speed = request->getParam("oscillation")->value(); @@ -710,24 +710,24 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa auto call = obj->turn_on(); // Parse color parameters - parse_light_param(request, "brightness", call, &decltype(call)::set_brightness, 255.0f); - parse_light_param(request, "r", call, &decltype(call)::set_red, 255.0f); - parse_light_param(request, "g", call, &decltype(call)::set_green, 255.0f); - parse_light_param(request, "b", call, &decltype(call)::set_blue, 255.0f); - parse_light_param(request, "white_value", call, &decltype(call)::set_white, 255.0f); - parse_light_param(request, "color_temp", call, &decltype(call)::set_color_temperature); + parse_light_param_(request, "brightness", call, &decltype(call)::set_brightness, 255.0f); + parse_light_param_(request, "r", call, &decltype(call)::set_red, 255.0f); + parse_light_param_(request, "g", call, &decltype(call)::set_green, 255.0f); + parse_light_param_(request, "b", call, &decltype(call)::set_blue, 255.0f); + parse_light_param_(request, "white_value", call, &decltype(call)::set_white, 255.0f); + parse_light_param_(request, "color_temp", call, &decltype(call)::set_color_temperature); // Parse timing parameters - parse_light_param_uint(request, "flash", call, &decltype(call)::set_flash_length, 1000); - parse_light_param_uint(request, "transition", call, &decltype(call)::set_transition_length, 1000); + parse_light_param_uint_(request, "flash", call, &decltype(call)::set_flash_length, 1000); + parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000); - parse_string_param(request, "effect", call, &decltype(call)::set_effect); + parse_string_param_(request, "effect", call, &decltype(call)::set_effect); this->defer([call]() mutable { call.perform(); }); request->send(200); } else if (match.method_equals("turn_off")) { auto call = obj->turn_off(); - parse_light_param_uint(request, "transition", call, &decltype(call)::set_transition_length, 1000); + parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000); this->defer([call]() mutable { call.perform(); }); request->send(200); } else { @@ -800,8 +800,8 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa return; } - parse_float_param(request, "position", call, &decltype(call)::set_position); - parse_float_param(request, "tilt", call, &decltype(call)::set_tilt); + parse_float_param_(request, "position", call, &decltype(call)::set_position); + parse_float_param_(request, "tilt", call, &decltype(call)::set_tilt); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -855,7 +855,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM } auto call = obj->make_call(); - parse_float_param(request, "value", call, &decltype(call)::set_value); + parse_float_param_(request, "value", call, &decltype(call)::set_value); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -927,7 +927,7 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat return; } - parse_string_param(request, "value", call, &decltype(call)::set_date); + parse_string_param_(request, "value", call, &decltype(call)::set_date); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -983,7 +983,7 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat return; } - parse_string_param(request, "value", call, &decltype(call)::set_time); + parse_string_param_(request, "value", call, &decltype(call)::set_time); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1038,7 +1038,7 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur return; } - parse_string_param(request, "value", call, &decltype(call)::set_datetime); + parse_string_param_(request, "value", call, &decltype(call)::set_datetime); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1089,7 +1089,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat } auto call = obj->make_call(); - parse_string_param(request, "value", call, &decltype(call)::set_value); + parse_string_param_(request, "value", call, &decltype(call)::set_value); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1148,7 +1148,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM } auto call = obj->make_call(); - parse_string_param(request, "option", call, &decltype(call)::set_option); + parse_string_param_(request, "option", call, &decltype(call)::set_option); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1205,14 +1205,14 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url auto call = obj->make_call(); // Parse string mode parameters - parse_string_param(request, "mode", call, &decltype(call)::set_mode); - parse_string_param(request, "fan_mode", call, &decltype(call)::set_fan_mode); - parse_string_param(request, "swing_mode", call, &decltype(call)::set_swing_mode); + parse_string_param_(request, "mode", call, &decltype(call)::set_mode); + parse_string_param_(request, "fan_mode", call, &decltype(call)::set_fan_mode); + parse_string_param_(request, "swing_mode", call, &decltype(call)::set_swing_mode); // Parse temperature parameters - parse_float_param(request, "target_temperature_high", call, &decltype(call)::set_target_temperature_high); - parse_float_param(request, "target_temperature_low", call, &decltype(call)::set_target_temperature_low); - parse_float_param(request, "target_temperature", call, &decltype(call)::set_target_temperature); + parse_float_param_(request, "target_temperature_high", call, &decltype(call)::set_target_temperature_high); + parse_float_param_(request, "target_temperature_low", call, &decltype(call)::set_target_temperature_low); + parse_float_param_(request, "target_temperature", call, &decltype(call)::set_target_temperature); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1403,7 +1403,7 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa return; } - parse_float_param(request, "position", call, &decltype(call)::set_position); + parse_float_param_(request, "position", call, &decltype(call)::set_position); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1451,7 +1451,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques } auto call = obj->make_call(); - parse_string_param(request, "code", call, &decltype(call)::set_code); + parse_string_param_(request, "code", call, &decltype(call)::set_code); if (match.method_equals("disarm")) { call.disarm(); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 94c75935cd..536e7d5fa5 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -502,8 +502,8 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { #ifdef USE_LIGHT // Helper to parse and apply a float parameter with optional scaling template - void parse_light_param(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(float), - float scale = 1.0f) { + void parse_light_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(float), + float scale = 1.0f) { if (request->hasParam(param_name)) { auto value = parse_number(request->getParam(param_name)->value().c_str()); if (value.has_value()) { @@ -514,8 +514,8 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { // Helper to parse and apply a uint32_t parameter with optional scaling template - void parse_light_param_uint(AsyncWebServerRequest *request, const char *param_name, T &call, - Ret (T::*setter)(uint32_t), uint32_t scale = 1) { + void parse_light_param_uint_(AsyncWebServerRequest *request, const char *param_name, T &call, + Ret (T::*setter)(uint32_t), uint32_t scale = 1) { if (request->hasParam(param_name)) { auto value = parse_number(request->getParam(param_name)->value().c_str()); if (value.has_value()) { @@ -527,7 +527,7 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { // Generic helper to parse and apply a float parameter template - void parse_float_param(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(float)) { + void parse_float_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(float)) { if (request->hasParam(param_name)) { auto value = parse_number(request->getParam(param_name)->value().c_str()); if (value.has_value()) { @@ -538,7 +538,7 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { // Generic helper to parse and apply an int parameter template - void parse_int_param(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(int)) { + void parse_int_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(int)) { if (request->hasParam(param_name)) { auto value = parse_number(request->getParam(param_name)->value().c_str()); if (value.has_value()) { @@ -549,8 +549,8 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { // Generic helper to parse and apply a string parameter template - void parse_string_param(AsyncWebServerRequest *request, const char *param_name, T &call, - Ret (T::*setter)(const std::string &)) { + void parse_string_param_(AsyncWebServerRequest *request, const char *param_name, T &call, + Ret (T::*setter)(const std::string &)) { if (request->hasParam(param_name)) { std::string value = request->getParam(param_name)->value().c_str(); (call.*setter)(value); From e64ecca771b58cecbacbf44077018c7bfce05e2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Aug 2025 21:02:02 -0500 Subject: [PATCH 0432/1626] tidy --- esphome/components/web_server/web_server.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 536e7d5fa5..450cdd4337 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -552,7 +552,7 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { void parse_string_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(const std::string &)) { if (request->hasParam(param_name)) { - std::string value = request->getParam(param_name)->value().c_str(); + std::string value = request->getParam(param_name)->value(); (call.*setter)(value); } } From cef39e7c597dd1ee3398108931a9a4d5b97278c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 04:44:23 -0500 Subject: [PATCH 0433/1626] [esp32_ble_tracker] Fix false reboots when event loop is blocked (#10144) --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 47 ++++++++++++++++--- .../esp32_ble_tracker/esp32_ble_tracker.h | 8 ++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 5e97c81044..0455d136df 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -101,6 +101,38 @@ void ESP32BLETracker::loop() { this->start_scan(); } } + + // Check for scan timeout - moved here from scheduler to avoid false reboots + // when the loop is blocked + if (this->scanner_state_ == ScannerState::RUNNING) { + switch (this->scan_timeout_state_) { + case ScanTimeoutState::MONITORING: { + uint32_t now = App.get_loop_component_start_time(); + uint32_t timeout_ms = this->scan_duration_ * 2000; + // Robust time comparison that handles rollover correctly + // This works because unsigned arithmetic wraps around predictably + if ((now - this->scan_start_time_) > timeout_ms) { + // First time we've seen the timeout exceeded - wait one more loop iteration + // This ensures all components have had a chance to process pending events + // This is because esp32_ble may not have run yet and called + // gap_scan_event_handler yet when the loop unblocks + ESP_LOGW(TAG, "Scan timeout exceeded"); + this->scan_timeout_state_ = ScanTimeoutState::EXCEEDED_WAIT; + } + break; + } + case ScanTimeoutState::EXCEEDED_WAIT: + // We've waited at least one full loop iteration, and scan is still running + ESP_LOGE(TAG, "Scan never terminated, rebooting"); + App.reboot(); + break; + + case ScanTimeoutState::INACTIVE: + // This case should be unreachable - scanner and timeout states are always synchronized + break; + } + } + ClientStateCounts counts = this->count_client_states_(); if (counts != this->client_state_counts_) { this->client_state_counts_ = counts; @@ -164,7 +196,8 @@ void ESP32BLETracker::stop_scan_() { ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); return; } - this->cancel_timeout("scan"); + // Reset timeout state machine when stopping scan + this->scan_timeout_state_ = ScanTimeoutState::INACTIVE; this->set_scanner_state_(ScannerState::STOPPING); esp_err_t err = esp_ble_gap_stop_scanning(); if (err != ESP_OK) { @@ -197,11 +230,10 @@ void ESP32BLETracker::start_scan_(bool first) { this->scan_params_.scan_interval = this->scan_interval_; this->scan_params_.scan_window = this->scan_window_; - // Start timeout before scan is started. Otherwise scan never starts if any error. - this->set_timeout("scan", this->scan_duration_ * 2000, []() { - ESP_LOGE(TAG, "Scan never terminated, rebooting to restore stack (IDF)"); - App.reboot(); - }); + // Start timeout monitoring in loop() instead of using scheduler + // This prevents false reboots when the loop is blocked + this->scan_start_time_ = App.get_loop_component_start_time(); + this->scan_timeout_state_ = ScanTimeoutState::MONITORING; esp_err_t err = esp_ble_gap_set_scan_params(&this->scan_params_); if (err != ESP_OK) { @@ -752,7 +784,8 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { #ifdef USE_ESP32_BLE_DEVICE this->already_discovered_.clear(); #endif - this->cancel_timeout("scan"); + // Reset timeout state machine instead of cancelling scheduler timeout + this->scan_timeout_state_ = ScanTimeoutState::INACTIVE; for (auto *listener : this->listeners_) listener->on_scan_end(); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 4b09d521b6..bf99026810 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -367,6 +367,14 @@ class ESP32BLETracker : public Component, #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE bool coex_prefer_ble_{false}; #endif + // Scan timeout state machine + enum class ScanTimeoutState : uint8_t { + INACTIVE, // No timeout monitoring + MONITORING, // Actively monitoring for timeout + EXCEEDED_WAIT, // Timeout exceeded, waiting one loop before reboot + }; + uint32_t scan_start_time_{0}; + ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE}; }; // NOLINTNEXTLINE From 6d0e86cf25d252d9ac247b399e11442e77c38bf6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 10 Aug 2025 09:00:03 -0400 Subject: [PATCH 0434/1626] Add log_level option to idf framework --- esphome/components/esp32/__init__.py | 17 +++++++++++++++++ esphome/const.py | 1 + 2 files changed, 18 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index c43cafc100..c219b8851a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( CONF_FRAMEWORK, CONF_IGNORE_EFUSE_CUSTOM_MAC, CONF_IGNORE_EFUSE_MAC_CRC, + CONF_LOG_LEVEL, CONF_NAME, CONF_PATH, CONF_PLATFORM_VERSION, @@ -79,6 +80,15 @@ CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_RELEASE = "release" +LOG_LEVELS_IDF = [ + "NONE", + "ERROR", + "WARN", + "INFO", + "DEBUG", + "VERBOSE", +] + ASSERTION_LEVELS = { "DISABLE": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE", "ENABLE": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE", @@ -623,6 +633,9 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All( cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { cv.string_strict: cv.string_strict }, + cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of( + *LOG_LEVELS_IDF, upper=True + ), cv.Optional(CONF_ADVANCED, default={}): cv.Schema( { cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( @@ -937,6 +950,10 @@ async def to_code(config): ), ) + add_idf_sdkconfig_option( + f"CONFIG_LOG_DEFAULT_LEVEL_{conf[CONF_LOG_LEVEL]}", True + ) + for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) diff --git a/esphome/const.py b/esphome/const.py index 7d373ff26c..56e0edab9b 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -523,6 +523,7 @@ CONF_LOADED_INTEGRATIONS = "loaded_integrations" CONF_LOCAL = "local" CONF_LOCK_ACTION = "lock_action" CONF_LOG = "log" +CONF_LOG_LEVEL = "log_level" CONF_LOG_TOPIC = "log_topic" CONF_LOGGER = "logger" CONF_LOGS = "logs" From d052dec11bf6c80d1a74724f0bab48199a1a6763 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 10 Aug 2025 09:11:17 -0400 Subject: [PATCH 0435/1626] Use CONF_LOG_LEVEL from const --- esphome/components/lvgl/__init__.py | 9 +++++---- esphome/components/lvgl/defines.py | 1 - 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index a37f4570f3..5af61300da 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_GROUP, CONF_ID, CONF_LAMBDA, + CONF_LOG_LEVEL, CONF_ON_BOOT, CONF_ON_IDLE, CONF_PAGES, @@ -186,7 +187,7 @@ def multi_conf_validate(configs: list[dict]): base_config = configs[0] for config in configs[1:]: for item in ( - df.CONF_LOG_LEVEL, + CONF_LOG_LEVEL, CONF_COLOR_DEPTH, df.CONF_BYTE_ORDER, df.CONF_TRANSPARENCY_KEY, @@ -269,11 +270,11 @@ async def to_code(configs): add_define( "LV_LOG_LEVEL", - f"LV_LOG_LEVEL_{df.LV_LOG_LEVELS[config_0[df.CONF_LOG_LEVEL]]}", + f"LV_LOG_LEVEL_{df.LV_LOG_LEVELS[config_0[CONF_LOG_LEVEL]]}", ) cg.add_define( "LVGL_LOG_LEVEL", - cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[df.CONF_LOG_LEVEL]}"), + cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[CONF_LOG_LEVEL]}"), ) add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) for font in helpers.lv_fonts_used: @@ -423,7 +424,7 @@ LVGL_SCHEMA = cv.All( cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, - cv.Optional(df.CONF_LOG_LEVEL, default="WARN"): cv.one_of( + cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), cv.Optional(df.CONF_BYTE_ORDER, default="big_endian"): cv.one_of( diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 206a3d1622..8f09a3a6d0 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -456,7 +456,6 @@ CONF_KEYPADS = "keypads" CONF_LAYOUT = "layout" CONF_LEFT_BUTTON = "left_button" CONF_LINE_WIDTH = "line_width" -CONF_LOG_LEVEL = "log_level" CONF_LONG_PRESS_TIME = "long_press_time" CONF_LONG_PRESS_REPEAT_TIME = "long_press_repeat_time" CONF_LVGL_ID = "lvgl_id" From d5c9c10b3bd6eb5576affa9c21cd7ae2f72d5a68 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 10 Aug 2025 13:27:08 -0400 Subject: [PATCH 0436/1626] [esp32] Add IDF log_level option (#10134) --- esphome/components/esp32/__init__.py | 17 +++++++++++++++++ esphome/components/lvgl/__init__.py | 9 +++++---- esphome/components/lvgl/defines.py | 1 - esphome/const.py | 1 + 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index c43cafc100..c219b8851a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( CONF_FRAMEWORK, CONF_IGNORE_EFUSE_CUSTOM_MAC, CONF_IGNORE_EFUSE_MAC_CRC, + CONF_LOG_LEVEL, CONF_NAME, CONF_PATH, CONF_PLATFORM_VERSION, @@ -79,6 +80,15 @@ CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_RELEASE = "release" +LOG_LEVELS_IDF = [ + "NONE", + "ERROR", + "WARN", + "INFO", + "DEBUG", + "VERBOSE", +] + ASSERTION_LEVELS = { "DISABLE": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE", "ENABLE": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE", @@ -623,6 +633,9 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All( cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { cv.string_strict: cv.string_strict }, + cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of( + *LOG_LEVELS_IDF, upper=True + ), cv.Optional(CONF_ADVANCED, default={}): cv.Schema( { cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( @@ -937,6 +950,10 @@ async def to_code(config): ), ) + add_idf_sdkconfig_option( + f"CONFIG_LOG_DEFAULT_LEVEL_{conf[CONF_LOG_LEVEL]}", True + ) + for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index a37f4570f3..5af61300da 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_GROUP, CONF_ID, CONF_LAMBDA, + CONF_LOG_LEVEL, CONF_ON_BOOT, CONF_ON_IDLE, CONF_PAGES, @@ -186,7 +187,7 @@ def multi_conf_validate(configs: list[dict]): base_config = configs[0] for config in configs[1:]: for item in ( - df.CONF_LOG_LEVEL, + CONF_LOG_LEVEL, CONF_COLOR_DEPTH, df.CONF_BYTE_ORDER, df.CONF_TRANSPARENCY_KEY, @@ -269,11 +270,11 @@ async def to_code(configs): add_define( "LV_LOG_LEVEL", - f"LV_LOG_LEVEL_{df.LV_LOG_LEVELS[config_0[df.CONF_LOG_LEVEL]]}", + f"LV_LOG_LEVEL_{df.LV_LOG_LEVELS[config_0[CONF_LOG_LEVEL]]}", ) cg.add_define( "LVGL_LOG_LEVEL", - cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[df.CONF_LOG_LEVEL]}"), + cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[CONF_LOG_LEVEL]}"), ) add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) for font in helpers.lv_fonts_used: @@ -423,7 +424,7 @@ LVGL_SCHEMA = cv.All( cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, - cv.Optional(df.CONF_LOG_LEVEL, default="WARN"): cv.one_of( + cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), cv.Optional(df.CONF_BYTE_ORDER, default="big_endian"): cv.one_of( diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 206a3d1622..8f09a3a6d0 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -456,7 +456,6 @@ CONF_KEYPADS = "keypads" CONF_LAYOUT = "layout" CONF_LEFT_BUTTON = "left_button" CONF_LINE_WIDTH = "line_width" -CONF_LOG_LEVEL = "log_level" CONF_LONG_PRESS_TIME = "long_press_time" CONF_LONG_PRESS_REPEAT_TIME = "long_press_repeat_time" CONF_LVGL_ID = "lvgl_id" diff --git a/esphome/const.py b/esphome/const.py index 7d373ff26c..56e0edab9b 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -523,6 +523,7 @@ CONF_LOADED_INTEGRATIONS = "loaded_integrations" CONF_LOCAL = "local" CONF_LOCK_ACTION = "lock_action" CONF_LOG = "log" +CONF_LOG_LEVEL = "log_level" CONF_LOG_TOPIC = "log_topic" CONF_LOGGER = "logger" CONF_LOGS = "logs" From 0c230fcd104f712ac784b8c83237c648d26c717e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 12:39:44 -0500 Subject: [PATCH 0437/1626] increase to 30s --- esphome/components/esphome/ota/ota_esphome.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 5d58eefd2f..8c119fcc59 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -9,7 +9,7 @@ namespace esphome { -static constexpr uint16_t OTA_SOCKET_TIMEOUT_DATA = 2500; // milliseconds for data transfer +static constexpr uint16_t OTA_SOCKET_TIMEOUT_DATA = 30000; // milliseconds for data transfer /// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA. class ESPHomeOTAComponent : public ota::OTAComponent { From a1371aea37d64796449bc2fcd598c04111146d2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 16:04:40 -0500 Subject: [PATCH 0438/1626] [dashboard] Fix port fallback regression when device is offline (#10135) --- esphome/dashboard/web_server.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 46f09336bb..9db389c39a 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -324,14 +324,13 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): configuration = json_message["configuration"] config_file = settings.rel_path(configuration) port = json_message["port"] - addresses: list[str] = [port] + addresses: list[str] = [] if ( port == "OTA" # pylint: disable=too-many-boolean-expressions and (entry := entries.get(config_file)) and entry.loaded_integrations and "api" in entry.loaded_integrations ): - addresses = [] # First priority: entry.address AKA use_address if ( (use_address := entry.address) @@ -359,6 +358,13 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): # since MQTT logging will not work otherwise addresses.extend(sort_ip_addresses(new_addresses)) + if not addresses: + # If no address was found, use the port directly + # as otherwise they will get the chooser which + # does not work with the dashboard as there is no + # interactive way to get keyboard input + addresses = [port] + device_args: list[str] = [ arg for address in addresses for arg in ("--device", address) ] From 6bfe281d189c0b075396b350e781966395063784 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 16:09:31 -0500 Subject: [PATCH 0439/1626] [web_server] Reduce flash usage by consolidating parameter parsing (#10154) --- esphome/components/web_server/web_server.cpp | 208 +++++-------------- esphome/components/web_server/web_server.h | 60 ++++++ 2 files changed, 109 insertions(+), 159 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index a8d94d80da..92c5961f87 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -635,15 +635,8 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc } else if (match.method_equals("turn_on") || match.method_equals("turn_off")) { auto call = match.method_equals("turn_on") ? obj->turn_on() : obj->turn_off(); - if (request->hasParam("speed_level")) { - auto speed_level = request->getParam("speed_level")->value(); - auto val = parse_number(speed_level.c_str()); - if (!val.has_value()) { - ESP_LOGW(TAG, "Can't convert '%s' to number!", speed_level.c_str()); - return; - } - call.set_speed(*val); - } + parse_int_param_(request, "speed_level", call, &decltype(call)::set_speed); + if (request->hasParam("oscillation")) { auto speed = request->getParam("oscillation")->value(); auto val = parse_on_off(speed.c_str()); @@ -715,69 +708,26 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa request->send(200); } else if (match.method_equals("turn_on")) { auto call = obj->turn_on(); - if (request->hasParam("brightness")) { - auto brightness = parse_number(request->getParam("brightness")->value().c_str()); - if (brightness.has_value()) { - call.set_brightness(*brightness / 255.0f); - } - } - if (request->hasParam("r")) { - auto r = parse_number(request->getParam("r")->value().c_str()); - if (r.has_value()) { - call.set_red(*r / 255.0f); - } - } - if (request->hasParam("g")) { - auto g = parse_number(request->getParam("g")->value().c_str()); - if (g.has_value()) { - call.set_green(*g / 255.0f); - } - } - if (request->hasParam("b")) { - auto b = parse_number(request->getParam("b")->value().c_str()); - if (b.has_value()) { - call.set_blue(*b / 255.0f); - } - } - if (request->hasParam("white_value")) { - auto white_value = parse_number(request->getParam("white_value")->value().c_str()); - if (white_value.has_value()) { - call.set_white(*white_value / 255.0f); - } - } - if (request->hasParam("color_temp")) { - auto color_temp = parse_number(request->getParam("color_temp")->value().c_str()); - if (color_temp.has_value()) { - call.set_color_temperature(*color_temp); - } - } - if (request->hasParam("flash")) { - auto flash = parse_number(request->getParam("flash")->value().c_str()); - if (flash.has_value()) { - call.set_flash_length(*flash * 1000); - } - } - if (request->hasParam("transition")) { - auto transition = parse_number(request->getParam("transition")->value().c_str()); - if (transition.has_value()) { - call.set_transition_length(*transition * 1000); - } - } - if (request->hasParam("effect")) { - const char *effect = request->getParam("effect")->value().c_str(); - call.set_effect(effect); - } + + // Parse color parameters + parse_light_param_(request, "brightness", call, &decltype(call)::set_brightness, 255.0f); + parse_light_param_(request, "r", call, &decltype(call)::set_red, 255.0f); + parse_light_param_(request, "g", call, &decltype(call)::set_green, 255.0f); + parse_light_param_(request, "b", call, &decltype(call)::set_blue, 255.0f); + parse_light_param_(request, "white_value", call, &decltype(call)::set_white, 255.0f); + parse_light_param_(request, "color_temp", call, &decltype(call)::set_color_temperature); + + // Parse timing parameters + parse_light_param_uint_(request, "flash", call, &decltype(call)::set_flash_length, 1000); + parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000); + + parse_string_param_(request, "effect", call, &decltype(call)::set_effect); this->defer([call]() mutable { call.perform(); }); request->send(200); } else if (match.method_equals("turn_off")) { auto call = obj->turn_off(); - if (request->hasParam("transition")) { - auto transition = parse_number(request->getParam("transition")->value().c_str()); - if (transition.has_value()) { - call.set_transition_length(*transition * 1000); - } - } + parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000); this->defer([call]() mutable { call.perform(); }); request->send(200); } else { @@ -850,18 +800,8 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa return; } - if (request->hasParam("position")) { - auto position = parse_number(request->getParam("position")->value().c_str()); - if (position.has_value()) { - call.set_position(*position); - } - } - if (request->hasParam("tilt")) { - auto tilt = parse_number(request->getParam("tilt")->value().c_str()); - if (tilt.has_value()) { - call.set_tilt(*tilt); - } - } + parse_float_param_(request, "position", call, &decltype(call)::set_position); + parse_float_param_(request, "tilt", call, &decltype(call)::set_tilt); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -915,11 +855,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM } auto call = obj->make_call(); - if (request->hasParam("value")) { - auto value = parse_number(request->getParam("value")->value().c_str()); - if (value.has_value()) - call.set_value(*value); - } + parse_float_param_(request, "value", call, &decltype(call)::set_value); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -991,10 +927,7 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat return; } - if (request->hasParam("value")) { - std::string value = request->getParam("value")->value().c_str(); // NOLINT - call.set_date(value); - } + parse_string_param_(request, "value", call, &decltype(call)::set_date); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1050,10 +983,7 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat return; } - if (request->hasParam("value")) { - std::string value = request->getParam("value")->value().c_str(); // NOLINT - call.set_time(value); - } + parse_string_param_(request, "value", call, &decltype(call)::set_time); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1108,10 +1038,7 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur return; } - if (request->hasParam("value")) { - std::string value = request->getParam("value")->value().c_str(); // NOLINT - call.set_datetime(value); - } + parse_string_param_(request, "value", call, &decltype(call)::set_datetime); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1162,10 +1089,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat } auto call = obj->make_call(); - if (request->hasParam("value")) { - String value = request->getParam("value")->value(); - call.set_value(value.c_str()); // NOLINT - } + parse_string_param_(request, "value", call, &decltype(call)::set_value); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1224,11 +1148,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM } auto call = obj->make_call(); - - if (request->hasParam("option")) { - auto option = request->getParam("option")->value(); - call.set_option(option.c_str()); // NOLINT - } + parse_string_param_(request, "option", call, &decltype(call)::set_option); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1284,38 +1204,15 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url auto call = obj->make_call(); - if (request->hasParam("mode")) { - auto mode = request->getParam("mode")->value(); - call.set_mode(mode.c_str()); // NOLINT - } + // Parse string mode parameters + parse_string_param_(request, "mode", call, &decltype(call)::set_mode); + parse_string_param_(request, "fan_mode", call, &decltype(call)::set_fan_mode); + parse_string_param_(request, "swing_mode", call, &decltype(call)::set_swing_mode); - if (request->hasParam("fan_mode")) { - auto mode = request->getParam("fan_mode")->value(); - call.set_fan_mode(mode.c_str()); // NOLINT - } - - if (request->hasParam("swing_mode")) { - auto mode = request->getParam("swing_mode")->value(); - call.set_swing_mode(mode.c_str()); // NOLINT - } - - if (request->hasParam("target_temperature_high")) { - auto target_temperature_high = parse_number(request->getParam("target_temperature_high")->value().c_str()); - if (target_temperature_high.has_value()) - call.set_target_temperature_high(*target_temperature_high); - } - - if (request->hasParam("target_temperature_low")) { - auto target_temperature_low = parse_number(request->getParam("target_temperature_low")->value().c_str()); - if (target_temperature_low.has_value()) - call.set_target_temperature_low(*target_temperature_low); - } - - if (request->hasParam("target_temperature")) { - auto target_temperature = parse_number(request->getParam("target_temperature")->value().c_str()); - if (target_temperature.has_value()) - call.set_target_temperature(*target_temperature); - } + // Parse temperature parameters + parse_float_param_(request, "target_temperature_high", call, &decltype(call)::set_target_temperature_high); + parse_float_param_(request, "target_temperature_low", call, &decltype(call)::set_target_temperature_low); + parse_float_param_(request, "target_temperature", call, &decltype(call)::set_target_temperature); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1506,12 +1403,7 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa return; } - if (request->hasParam("position")) { - auto position = parse_number(request->getParam("position")->value().c_str()); - if (position.has_value()) { - call.set_position(*position); - } - } + parse_float_param_(request, "position", call, &decltype(call)::set_position); this->defer([call]() mutable { call.perform(); }); request->send(200); @@ -1559,9 +1451,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques } auto call = obj->make_call(); - if (request->hasParam("code")) { - call.set_code(request->getParam("code")->value().c_str()); // NOLINT - } + parse_string_param_(request, "code", call, &decltype(call)::set_code); if (match.method_equals("disarm")) { call.disarm(); @@ -1659,6 +1549,19 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty #endif #ifdef USE_UPDATE +static const char *update_state_to_string(update::UpdateState state) { + switch (state) { + case update::UPDATE_STATE_NO_UPDATE: + return "NO UPDATE"; + case update::UPDATE_STATE_AVAILABLE: + return "UPDATE AVAILABLE"; + case update::UPDATE_STATE_INSTALLING: + return "INSTALLING"; + default: + return "UNKNOWN"; + } +} + void WebServer::on_update(update::UpdateEntity *obj) { if (this->events_.empty()) return; @@ -1698,20 +1601,7 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c return json::build_json([this, obj, start_config](JsonObject root) { set_json_id(root, obj, "update-" + obj->get_object_id(), start_config); root["value"] = obj->update_info.latest_version; - switch (obj->state) { - case update::UPDATE_STATE_NO_UPDATE: - root["state"] = "NO UPDATE"; - break; - case update::UPDATE_STATE_AVAILABLE: - root["state"] = "UPDATE AVAILABLE"; - break; - case update::UPDATE_STATE_INSTALLING: - root["state"] = "INSTALLING"; - break; - default: - root["state"] = "UNKNOWN"; - break; - } + root["state"] = update_state_to_string(obj->state); if (start_config == DETAIL_ALL) { root["current_version"] = obj->update_info.current_version; root["title"] = obj->update_info.title; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index ef1b03a73b..6bece732fc 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -498,6 +498,66 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { protected: void add_sorting_info_(JsonObject &root, EntityBase *entity); + +#ifdef USE_LIGHT + // Helper to parse and apply a float parameter with optional scaling + template + void parse_light_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(float), + float scale = 1.0f) { + if (request->hasParam(param_name)) { + auto value = parse_number(request->getParam(param_name)->value().c_str()); + if (value.has_value()) { + (call.*setter)(*value / scale); + } + } + } + + // Helper to parse and apply a uint32_t parameter with optional scaling + template + void parse_light_param_uint_(AsyncWebServerRequest *request, const char *param_name, T &call, + Ret (T::*setter)(uint32_t), uint32_t scale = 1) { + if (request->hasParam(param_name)) { + auto value = parse_number(request->getParam(param_name)->value().c_str()); + if (value.has_value()) { + (call.*setter)(*value * scale); + } + } + } +#endif + + // Generic helper to parse and apply a float parameter + template + void parse_float_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(float)) { + if (request->hasParam(param_name)) { + auto value = parse_number(request->getParam(param_name)->value().c_str()); + if (value.has_value()) { + (call.*setter)(*value); + } + } + } + + // Generic helper to parse and apply an int parameter + template + void parse_int_param_(AsyncWebServerRequest *request, const char *param_name, T &call, Ret (T::*setter)(int)) { + if (request->hasParam(param_name)) { + auto value = parse_number(request->getParam(param_name)->value().c_str()); + if (value.has_value()) { + (call.*setter)(*value); + } + } + } + + // Generic helper to parse and apply a string parameter + template + void parse_string_param_(AsyncWebServerRequest *request, const char *param_name, T &call, + Ret (T::*setter)(const std::string &)) { + if (request->hasParam(param_name)) { + // .c_str() is required for Arduino framework where value() returns Arduino String instead of std::string + std::string value = request->getParam(param_name)->value().c_str(); // NOLINT(readability-redundant-string-cstr) + (call.*setter)(value); + } + } + web_server_base::WebServerBase *base_; #ifdef USE_ARDUINO DeferredUpdateEventSourceList events_; From 279f56141ecd587aae369ef32e875a36553e8f2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 16:12:36 -0500 Subject: [PATCH 0440/1626] [ade7880] Fix duplicate sensor name validation error (#10155) --- esphome/components/ade7880/sensor.py | 124 ++++++++++++++++----------- tests/components/ade7880/common.yaml | 38 ++++---- 2 files changed, 94 insertions(+), 68 deletions(-) diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py index 3ef5e6bfff..39dbeb225f 100644 --- a/esphome/components/ade7880/sensor.py +++ b/esphome/components/ade7880/sensor.py @@ -36,6 +36,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -51,6 +52,20 @@ CONF_POWER_GAIN = "power_gain" CONF_NEUTRAL = "neutral" +# Tuple of power channel phases +POWER_PHASES = (CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C) + +# Tuple of sensor types that can be configured for power channels +POWER_SENSOR_TYPES = ( + CONF_CURRENT, + CONF_VOLTAGE, + CONF_ACTIVE_POWER, + CONF_APPARENT_POWER, + CONF_POWER_FACTOR, + CONF_FORWARD_ACTIVE_ENERGY, + CONF_REVERSE_ACTIVE_ENERGY, +) + NEUTRAL_CHANNEL_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(NeutralChannel), @@ -150,7 +165,64 @@ POWER_CHANNEL_SCHEMA = cv.Schema( } ) -CONFIG_SCHEMA = ( + +def prefix_sensor_name( + sensor_conf: ConfigType, + channel_name: str, + channel_config: ConfigType, + sensor_type: str, +) -> None: + """Helper to prefix sensor name with channel name. + + Args: + sensor_conf: The sensor configuration (dict or string) + channel_name: The channel name to prefix with + channel_config: The channel configuration to update + sensor_type: The sensor type key in the channel config + """ + if isinstance(sensor_conf, dict) and CONF_NAME in sensor_conf: + sensor_name = sensor_conf[CONF_NAME] + if sensor_name and not sensor_name.startswith(channel_name): + sensor_conf[CONF_NAME] = f"{channel_name} {sensor_name}" + elif isinstance(sensor_conf, str): + # Simple value case - convert to dict with prefixed name + channel_config[sensor_type] = {CONF_NAME: f"{channel_name} {sensor_conf}"} + + +def process_channel_sensors( + config: ConfigType, channel_key: str, sensor_types: tuple +) -> None: + """Process sensors for a channel and prefix their names. + + Args: + config: The main configuration + channel_key: The channel key (e.g., CONF_PHASE_A, CONF_NEUTRAL) + sensor_types: Tuple of sensor types to process for this channel + """ + if not (channel_config := config.get(channel_key)) or not ( + channel_name := channel_config.get(CONF_NAME) + ): + return + + for sensor_type in sensor_types: + if sensor_conf := channel_config.get(sensor_type): + prefix_sensor_name(sensor_conf, channel_name, channel_config, sensor_type) + + +def preprocess_channels(config: ConfigType) -> ConfigType: + """Preprocess channel configurations to add channel name prefix to sensor names.""" + # Process power channels + for channel in POWER_PHASES: + process_channel_sensors(config, channel, POWER_SENSOR_TYPES) + + # Process neutral channel + process_channel_sensors(config, CONF_NEUTRAL, (CONF_CURRENT,)) + + return config + + +CONFIG_SCHEMA = cv.All( + preprocess_channels, cv.Schema( { cv.GenerateID(): cv.declare_id(ADE7880), @@ -167,7 +239,7 @@ CONFIG_SCHEMA = ( } ) .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x38)) + .extend(i2c.i2c_device_schema(0x38)), ) @@ -188,15 +260,7 @@ async def neutral_channel(config): async def power_channel(config): var = cg.new_Pvariable(config[CONF_ID]) - for sensor_type in [ - CONF_CURRENT, - CONF_VOLTAGE, - CONF_ACTIVE_POWER, - CONF_APPARENT_POWER, - CONF_POWER_FACTOR, - CONF_FORWARD_ACTIVE_ENERGY, - CONF_REVERSE_ACTIVE_ENERGY, - ]: + for sensor_type in POWER_SENSOR_TYPES: if conf := config.get(sensor_type): sens = await sensor.new_sensor(conf) cg.add(getattr(var, f"set_{sensor_type}")(sens)) @@ -216,44 +280,6 @@ async def power_channel(config): return var -def final_validate(config): - for channel in [CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C]: - if channel := config.get(channel): - channel_name = channel.get(CONF_NAME) - - for sensor_type in [ - CONF_CURRENT, - CONF_VOLTAGE, - CONF_ACTIVE_POWER, - CONF_APPARENT_POWER, - CONF_POWER_FACTOR, - CONF_FORWARD_ACTIVE_ENERGY, - CONF_REVERSE_ACTIVE_ENERGY, - ]: - if conf := channel.get(sensor_type): - sensor_name = conf.get(CONF_NAME) - if ( - sensor_name - and channel_name - and not sensor_name.startswith(channel_name) - ): - conf[CONF_NAME] = f"{channel_name} {sensor_name}" - - if channel := config.get(CONF_NEUTRAL): - channel_name = channel.get(CONF_NAME) - if conf := channel.get(CONF_CURRENT): - sensor_name = conf.get(CONF_NAME) - if ( - sensor_name - and channel_name - and not sensor_name.startswith(channel_name) - ): - conf[CONF_NAME] = f"{channel_name} {sensor_name}" - - -FINAL_VALIDATE_SCHEMA = final_validate - - async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/tests/components/ade7880/common.yaml b/tests/components/ade7880/common.yaml index 48c22c8485..0aa388a325 100644 --- a/tests/components/ade7880/common.yaml +++ b/tests/components/ade7880/common.yaml @@ -12,12 +12,12 @@ sensor: frequency: 60Hz phase_a: name: Channel A - voltage: Channel A Voltage - current: Channel A Current - active_power: Channel A Active Power - power_factor: Channel A Power Factor - forward_active_energy: Channel A Forward Active Energy - reverse_active_energy: Channel A Reverse Active Energy + voltage: Voltage + current: Current + active_power: Active Power + power_factor: Power Factor + forward_active_energy: Forward Active Energy + reverse_active_energy: Reverse Active Energy calibration: current_gain: 3116628 voltage_gain: -757178 @@ -25,12 +25,12 @@ sensor: phase_angle: 188 phase_b: name: Channel B - voltage: Channel B Voltage - current: Channel B Current - active_power: Channel B Active Power - power_factor: Channel B Power Factor - forward_active_energy: Channel B Forward Active Energy - reverse_active_energy: Channel B Reverse Active Energy + voltage: Voltage + current: Current + active_power: Active Power + power_factor: Power Factor + forward_active_energy: Forward Active Energy + reverse_active_energy: Reverse Active Energy calibration: current_gain: 3133655 voltage_gain: -755235 @@ -38,12 +38,12 @@ sensor: phase_angle: 188 phase_c: name: Channel C - voltage: Channel C Voltage - current: Channel C Current - active_power: Channel C Active Power - power_factor: Channel C Power Factor - forward_active_energy: Channel C Forward Active Energy - reverse_active_energy: Channel C Reverse Active Energy + voltage: Voltage + current: Current + active_power: Active Power + power_factor: Power Factor + forward_active_energy: Forward Active Energy + reverse_active_energy: Reverse Active Energy calibration: current_gain: 3111158 voltage_gain: -743813 @@ -51,6 +51,6 @@ sensor: phase_angle: 180 neutral: name: Neutral - current: Neutral Current + current: Current calibration: current_gain: 3189 From 5a8f722316146d4509a07eddc3b62fb7ee89378b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 16:14:13 -0500 Subject: [PATCH 0441/1626] Optimize subprocess performance with close_fds=False (#10145) --- docker/build.py | 2 +- esphome/components/sdl/display.py | 4 +++- esphome/dashboard/web_server.py | 1 + esphome/git.py | 4 +++- esphome/helpers.py | 4 +++- esphome/platformio_api.py | 2 +- esphome/util.py | 7 ++++++- script/clang-format | 6 +++++- script/clang-tidy | 12 +++++++++--- script/platformio_install_deps.py | 4 +++- script/run-in-env.py | 4 ++-- tests/integration/conftest.py | 3 +++ 12 files changed, 40 insertions(+), 13 deletions(-) diff --git a/docker/build.py b/docker/build.py index 921adac7ab..4d093cf88d 100755 --- a/docker/build.py +++ b/docker/build.py @@ -90,7 +90,7 @@ def main(): def run_command(*cmd, ignore_error: bool = False): print(f"$ {shlex.join(list(cmd))}") if not args.dry_run: - rc = subprocess.call(list(cmd)) + rc = subprocess.call(list(cmd), close_fds=False) if rc != 0 and not ignore_error: print("Command failed") sys.exit(1) diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index ae8b0fd43a..78c180aa65 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -36,7 +36,9 @@ def get_sdl_options(value): if value != "": return value try: - return subprocess.check_output(["sdl2-config", "--cflags", "--libs"]).decode() + return subprocess.check_output( + ["sdl2-config", "--cflags", "--libs"], close_fds=False + ).decode() except Exception as e: raise cv.Invalid("Unable to run sdl2-config - have you installed sdl2?") from e diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 9db389c39a..fd16667d8a 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -229,6 +229,7 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + close_fds=False, ) stdout_thread = threading.Thread(target=self._stdout_thread) stdout_thread.daemon = True diff --git a/esphome/git.py b/esphome/git.py index 005bcae702..56aedd1519 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -17,7 +17,9 @@ _LOGGER = logging.getLogger(__name__) def run_git_command(cmd, cwd=None) -> str: _LOGGER.debug("Running git command: %s", " ".join(cmd)) try: - ret = subprocess.run(cmd, cwd=cwd, capture_output=True, check=False) + ret = subprocess.run( + cmd, cwd=cwd, capture_output=True, check=False, close_fds=False + ) except FileNotFoundError as err: raise cv.Invalid( "git is not installed but required for external_components.\n" diff --git a/esphome/helpers.py b/esphome/helpers.py index f722dc3f7c..377a4e1717 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -114,7 +114,9 @@ def cpp_string_escape(string, encoding="utf-8"): def run_system_command(*args): import subprocess - with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as p: + with subprocess.Popen( + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=False + ) as p: stdout, stderr = p.communicate() rc = p.returncode return rc, stdout, stderr diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 21124fc859..267277ebe1 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -211,7 +211,7 @@ def _decode_pc(config, addr): return command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] try: - translation = subprocess.check_output(command).decode().strip() + translation = subprocess.check_output(command, close_fds=False).decode().strip() except Exception: # pylint: disable=broad-except _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return diff --git a/esphome/util.py b/esphome/util.py index ed9ab4a446..6362260fde 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -239,7 +239,12 @@ def run_external_process(*cmd: str, **kwargs: Any) -> int | str: try: proc = subprocess.run( - cmd, stdout=sub_stdout, stderr=sub_stderr, encoding="utf-8", check=False + cmd, + stdout=sub_stdout, + stderr=sub_stderr, + encoding="utf-8", + check=False, + close_fds=False, ) return proc.stdout if capture_stdout else proc.returncode except KeyboardInterrupt: # pylint: disable=try-except-raise diff --git a/script/clang-format b/script/clang-format index d62a5b59c7..028d752c55 100755 --- a/script/clang-format +++ b/script/clang-format @@ -31,7 +31,11 @@ def run_format(executable, args, queue, lock, failed_files): invocation.append(path) proc = subprocess.run( - invocation, capture_output=True, encoding="utf-8", check=False + invocation, + capture_output=True, + encoding="utf-8", + check=False, + close_fds=False, ) if proc.returncode != 0: with lock: diff --git a/script/clang-tidy b/script/clang-tidy index 2c4a2e36ac..142b616119 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -158,7 +158,11 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): invocation.extend(options) proc = subprocess.run( - invocation, capture_output=True, encoding="utf-8", check=False + invocation, + capture_output=True, + encoding="utf-8", + check=False, + close_fds=False, ) if proc.returncode != 0: with lock: @@ -320,9 +324,11 @@ def main(): print("Applying fixes ...") try: try: - subprocess.call(["clang-apply-replacements-18", tmpdir]) + subprocess.call( + ["clang-apply-replacements-18", tmpdir], close_fds=False + ) except FileNotFoundError: - subprocess.call(["clang-apply-replacements", tmpdir]) + subprocess.call(["clang-apply-replacements", tmpdir], close_fds=False) except FileNotFoundError: print( "Error please install clang-apply-replacements-18 or clang-apply-replacements.\n", diff --git a/script/platformio_install_deps.py b/script/platformio_install_deps.py index ed133ecb47..8f7261efc3 100755 --- a/script/platformio_install_deps.py +++ b/script/platformio_install_deps.py @@ -55,4 +55,6 @@ for section in config.sections(): tools.append("-t") tools.append(tool) -subprocess.check_call(["platformio", "pkg", "install", "-g", *libs, *platforms, *tools]) +subprocess.check_call( + ["platformio", "pkg", "install", "-g", *libs, *platforms, *tools], close_fds=False +) diff --git a/script/run-in-env.py b/script/run-in-env.py index d9bd01a62f..886e65db27 100755 --- a/script/run-in-env.py +++ b/script/run-in-env.py @@ -13,7 +13,7 @@ def find_and_activate_virtualenv(): try: # Get the top-level directory of the git repository my_path = subprocess.check_output( - ["git", "rev-parse", "--show-toplevel"], text=True + ["git", "rev-parse", "--show-toplevel"], text=True, close_fds=False ).strip() except subprocess.CalledProcessError: print( @@ -44,7 +44,7 @@ def find_and_activate_virtualenv(): def run_command(): # Execute the remaining arguments in the new environment if len(sys.argv) > 1: - subprocess.run(sys.argv[1:], check=False) + subprocess.run(sys.argv[1:], check=False, close_fds=False) else: print( "No command provided to run in the virtual environment.", diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 55bf0b97a7..0530752551 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -105,6 +105,7 @@ logger: check=True, cwd=init_dir, env=env, + close_fds=False, ) # Lock is held until here, ensuring cache is fully populated before any test proceeds @@ -245,6 +246,7 @@ async def compile_esphome( # Start in a new process group to isolate signal handling start_new_session=True, env=env, + close_fds=False, ) await proc.wait() @@ -477,6 +479,7 @@ async def run_binary_and_wait_for_port( # Start in a new process group to isolate signal handling start_new_session=True, pass_fds=(device_fd,), + close_fds=False, ) # Close the device end in the parent process From 2fc0a11596955c07dcb6dba5e8edd8a7ef2b0388 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 11 Aug 2025 09:53:40 +1200 Subject: [PATCH 0442/1626] [CI] Print more info for when consts are duplicated (#10166) --- script/ci-custom.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/ci-custom.py b/script/ci-custom.py index 6f3c513f42..61081608d5 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -500,7 +500,8 @@ def lint_constants_usage(): continue errs.append( f"Constant {highlight(constant)} is defined in {len(uses)} files. Please move all definitions of the " - f"constant to const.py (Uses: {', '.join(uses)})" + f"constant to const.py (Uses: {', '.join(uses)}) in a separate PR. " + "See https://developers.esphome.io/contributing/code/#python" ) return errs From da02f970d442ac69fd8650bb2d8bad6a1deeec95 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 10 Aug 2025 18:24:12 -0400 Subject: [PATCH 0443/1626] [neopixelbus] Fix neopixelbus on esp32 (#10123) --- esphome/components/neopixelbus/light.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/neopixelbus/light.py b/esphome/components/neopixelbus/light.py index c63790e60b..0c9604e932 100644 --- a/esphome/components/neopixelbus/light.py +++ b/esphome/components/neopixelbus/light.py @@ -225,6 +225,9 @@ async def to_code(config): # https://github.com/Makuna/NeoPixelBus/blob/master/library.json # Version Listed Here: https://registry.platformio.org/libraries/makuna/NeoPixelBus/versions if CORE.is_esp32: + # disable built in rgb support as it uses the new RMT drivers and will + # conflict with NeoPixelBus which uses the legacy drivers + cg.add_build_flag("-DESP32_ARDUINO_NO_RGB_BUILTIN") cg.add_library("makuna/NeoPixelBus", "2.8.0") else: cg.add_library("makuna/NeoPixelBus", "2.7.3") From 581b4ef5a1279fe56f702eaec921fac3a001dcc8 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 11 Aug 2025 08:27:54 +1000 Subject: [PATCH 0444/1626] [lvgl] Various validation fixes (#10141) --- esphome/components/lvgl/lv_validation.py | 8 +++++-- esphome/components/lvgl/types.py | 2 +- esphome/components/lvgl/widgets/__init__.py | 2 +- esphome/components/lvgl/widgets/arc.py | 25 ++++++++++++--------- esphome/components/lvgl/widgets/qrcode.py | 14 ++++++------ esphome/components/lvgl/widgets/spinner.py | 12 +++++----- esphome/components/lvgl/widgets/tabview.py | 6 ++--- tests/components/lvgl/lvgl-package.yaml | 14 ++++++++++++ 8 files changed, 52 insertions(+), 31 deletions(-) diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 5a1b99cf7c..d345ac70f3 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -287,10 +287,14 @@ def angle(value): :param value: The input in the range 0..360 :return: An angle in 1/10 degree units. """ - return int(cv.float_range(0.0, 360.0)(cv.angle(value)) * 10) + return cv.float_range(0.0, 360.0)(cv.angle(value)) -lv_angle = LValidator(angle, uint32) +# Validator for angles in LVGL expressed in 1/10 degree units. +lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10)) + +# Validator for angles in LVGL expressed in whole degrees +lv_angle_degrees = LValidator(angle, uint32, retmapper=int) @schema_extractor("one_of") diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 10b6f63528..c19c89401a 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -161,7 +161,7 @@ class WidgetType: """ return [] - def obj_creator(self, parent: MockObjClass, config: dict): + async def obj_creator(self, parent: MockObjClass, config: dict): """ Create an instance of the widget type :param parent: The parent to which it should be attached diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index d12464fe71..bb6155234c 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -439,7 +439,7 @@ async def widget_to_code(w_cnfig, w_type: WidgetType, parent): :return: """ spec: WidgetType = WIDGET_TYPES[w_type] - creator = spec.obj_creator(parent, w_cnfig) + creator = await spec.obj_creator(parent, w_cnfig) add_lv_use(spec.name) add_lv_use(*spec.get_uses()) wid = w_cnfig[CONF_ID] diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index 65f0e785b6..ef4da0d815 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -20,7 +20,7 @@ from ..defines import ( CONF_START_ANGLE, literal, ) -from ..lv_validation import angle, get_start_value, lv_float +from ..lv_validation import get_start_value, lv_angle_degrees, lv_float, lv_int from ..lvcode import lv, lv_expr, lv_obj from ..types import LvNumber, NumberType from . import Widget @@ -29,11 +29,11 @@ CONF_ARC = "arc" ARC_SCHEMA = cv.Schema( { cv.Optional(CONF_VALUE): lv_float, - cv.Optional(CONF_MIN_VALUE, default=0): cv.int_, - cv.Optional(CONF_MAX_VALUE, default=100): cv.int_, - cv.Optional(CONF_START_ANGLE, default=135): angle, - cv.Optional(CONF_END_ANGLE, default=45): angle, - cv.Optional(CONF_ROTATION, default=0.0): angle, + cv.Optional(CONF_MIN_VALUE, default=0): lv_int, + cv.Optional(CONF_MAX_VALUE, default=100): lv_int, + cv.Optional(CONF_START_ANGLE, default=135): lv_angle_degrees, + cv.Optional(CONF_END_ANGLE, default=45): lv_angle_degrees, + cv.Optional(CONF_ROTATION, default=0.0): lv_angle_degrees, cv.Optional(CONF_ADJUSTABLE, default=False): bool, cv.Optional(CONF_MODE, default="NORMAL"): ARC_MODES.one_of, cv.Optional(CONF_CHANGE_RATE, default=720): cv.uint16_t, @@ -59,11 +59,14 @@ class ArcType(NumberType): async def to_code(self, w: Widget, config): if CONF_MIN_VALUE in config: - lv.arc_set_range(w.obj, config[CONF_MIN_VALUE], config[CONF_MAX_VALUE]) - lv.arc_set_bg_angles( - w.obj, config[CONF_START_ANGLE] // 10, config[CONF_END_ANGLE] // 10 - ) - lv.arc_set_rotation(w.obj, config[CONF_ROTATION] // 10) + max_value = await lv_int.process(config[CONF_MAX_VALUE]) + min_value = await lv_int.process(config[CONF_MIN_VALUE]) + lv.arc_set_range(w.obj, min_value, max_value) + start = await lv_angle_degrees.process(config[CONF_START_ANGLE]) + end = await lv_angle_degrees.process(config[CONF_END_ANGLE]) + rotation = await lv_angle_degrees.process(config[CONF_ROTATION]) + lv.arc_set_bg_angles(w.obj, start, end) + lv.arc_set_rotation(w.obj, rotation) lv.arc_set_mode(w.obj, literal(config[CONF_MODE])) lv.arc_set_change_rate(w.obj, config[CONF_CHANGE_RATE]) diff --git a/esphome/components/lvgl/widgets/qrcode.py b/esphome/components/lvgl/widgets/qrcode.py index 7d8d13d8c4..028a81b449 100644 --- a/esphome/components/lvgl/widgets/qrcode.py +++ b/esphome/components/lvgl/widgets/qrcode.py @@ -4,7 +4,7 @@ from esphome.const import CONF_SIZE, CONF_TEXT from esphome.cpp_generator import MockObjClass from ..defines import CONF_MAIN -from ..lv_validation import color, color_retmapper, lv_text +from ..lv_validation import lv_color, lv_text from ..lvcode import LocalVariable, lv, lv_expr from ..schemas import TEXT_SCHEMA from ..types import WidgetType, lv_obj_t @@ -16,8 +16,8 @@ CONF_LIGHT_COLOR = "light_color" QRCODE_SCHEMA = TEXT_SCHEMA.extend( { - cv.Optional(CONF_DARK_COLOR, default="black"): color, - cv.Optional(CONF_LIGHT_COLOR, default="white"): color, + cv.Optional(CONF_DARK_COLOR, default="black"): lv_color, + cv.Optional(CONF_LIGHT_COLOR, default="white"): lv_color, cv.Required(CONF_SIZE): cv.int_, } ) @@ -34,11 +34,11 @@ class QrCodeType(WidgetType): ) def get_uses(self): - return ("canvas", "img", "label") + return "canvas", "img", "label" - def obj_creator(self, parent: MockObjClass, config: dict): - dark_color = color_retmapper(config[CONF_DARK_COLOR]) - light_color = color_retmapper(config[CONF_LIGHT_COLOR]) + async def obj_creator(self, parent: MockObjClass, config: dict): + dark_color = await lv_color.process(config[CONF_DARK_COLOR]) + light_color = await lv_color.process(config[CONF_LIGHT_COLOR]) size = config[CONF_SIZE] return lv_expr.call("qrcode_create", parent, size, dark_color, light_color) diff --git a/esphome/components/lvgl/widgets/spinner.py b/esphome/components/lvgl/widgets/spinner.py index 2940feb594..83aac25a59 100644 --- a/esphome/components/lvgl/widgets/spinner.py +++ b/esphome/components/lvgl/widgets/spinner.py @@ -2,7 +2,7 @@ import esphome.config_validation as cv from esphome.cpp_generator import MockObjClass from ..defines import CONF_ARC_LENGTH, CONF_INDICATOR, CONF_MAIN, CONF_SPIN_TIME -from ..lv_validation import angle +from ..lv_validation import lv_angle_degrees, lv_milliseconds from ..lvcode import lv_expr from ..types import LvType from . import Widget, WidgetType @@ -12,8 +12,8 @@ CONF_SPINNER = "spinner" SPINNER_SCHEMA = cv.Schema( { - cv.Required(CONF_ARC_LENGTH): angle, - cv.Required(CONF_SPIN_TIME): cv.positive_time_period_milliseconds, + cv.Required(CONF_ARC_LENGTH): lv_angle_degrees, + cv.Required(CONF_SPIN_TIME): lv_milliseconds, } ) @@ -34,9 +34,9 @@ class SpinnerType(WidgetType): def get_uses(self): return (CONF_ARC,) - def obj_creator(self, parent: MockObjClass, config: dict): - spin_time = config[CONF_SPIN_TIME].total_milliseconds - arc_length = config[CONF_ARC_LENGTH] // 10 + async def obj_creator(self, parent: MockObjClass, config: dict): + spin_time = await lv_milliseconds.process(config[CONF_SPIN_TIME]) + arc_length = await lv_angle_degrees.process(config[CONF_ARC_LENGTH]) return lv_expr.call("spinner_create", parent, spin_time, arc_length) diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 42cf486e1c..e8931bab7c 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -87,12 +87,12 @@ class TabviewType(WidgetType): ) as content_obj: await set_obj_properties(Widget(content_obj, obj_spec), content_style) - def obj_creator(self, parent: MockObjClass, config: dict): + async def obj_creator(self, parent: MockObjClass, config: dict): return lv_expr.call( "tabview_create", parent, - literal(config[CONF_POSITION]), - literal(config[CONF_SIZE]), + await DIRECTIONS.process(config[CONF_POSITION]), + await size.process(config[CONF_SIZE]), ) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 7cd2e2b93e..feee96672c 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -723,6 +723,20 @@ lvgl: arc_color: 0xFFFF00 focused: arc_color: 0x808080 + - arc: + align: center + id: lv_arc_1 + value: !lambda return 75; + min_value: !lambda return 50; + max_value: !lambda return 60; + arc_color: 0xFF0000 + indicator: + arc_width: !lambda return 20; + arc_color: 0xF000FF + pressed: + arc_color: 0xFFFF00 + focused: + arc_color: 0x808080 - bar: id: bar_id align: top_mid From 8b67d6dfec1b5ea9473ed3a2997f18f8d7db1b84 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 11 Aug 2025 08:32:01 +1000 Subject: [PATCH 0445/1626] [lvgl] fix allocation of reduced size buffer with rotation (#10147) --- esphome/components/lvgl/lvgl_esphome.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 32930ddec4..7a32691b53 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -451,7 +451,8 @@ void LvglComponent::setup() { if (buffer == nullptr && this->buffer_frac_ == 0) { frac = MIN_BUFFER_FRAC; buffer_pixels /= MIN_BUFFER_FRAC; - buffer = lv_custom_mem_alloc(buf_bytes / MIN_BUFFER_FRAC); // NOLINT + buf_bytes /= MIN_BUFFER_FRAC; + buffer = lv_custom_mem_alloc(buf_bytes); // NOLINT } if (buffer == nullptr) { this->status_set_error("Memory allocation failure"); From 17cdf9c8d6db2c7fee7aa128919d0f0188d8058d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 17:48:47 -0500 Subject: [PATCH 0446/1626] do not block until we get first magic byte --- .../components/esphome/ota/ota_esphome.cpp | 103 ++++++++++++------ esphome/components/esphome/ota/ota_esphome.h | 7 +- 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 58cfbfbcc3..c8156b8df2 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -20,7 +20,6 @@ namespace esphome { static const char *const TAG = "esphome.ota"; static constexpr u_int16_t OTA_BLOCK_SIZE = 8192; -static constexpr uint16_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 1000; // milliseconds for initial handshake void ESPHomeOTAComponent::setup() { #ifdef USE_OTA_STATE_CALLBACK @@ -88,13 +87,76 @@ void ESPHomeOTAComponent::loop() { // This optimization reduces idle loop overhead when OTA is not active // Note: No need to check server_ for null as the component is marked failed in setup() if server_ creation fails if (this->client_ != nullptr || this->server_->ready()) { - this->handle_(); + this->handle_handshake_(); } } static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; -void ESPHomeOTAComponent::handle_() { +void ESPHomeOTAComponent::handle_handshake_() { + // This method does the initial handshake with the client + // and will not block the loop until we receive the first byte + // of the magic bytes + + if (this->client_ == nullptr) { + // We already checked server_->ready() in loop(), so we can accept directly + struct sockaddr_storage source_addr; + socklen_t addr_len = sizeof(source_addr); + int enable = 1; + + this->client_ = this->server_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); + if (this->client_ == nullptr) + return; + int err = this->client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); + if (err != 0) { + this->log_socket_error_("nodelay"); + this->cleanup_connection_(); + return; + } + err = this->client_->setblocking(false); + if (err != 0) { + this->log_socket_error_("non-blocking"); + this->cleanup_connection_(); + return; + } + this->log_start_("handshake"); + this->client_connect_time_ = App.get_loop_component_start_time(); + } + + // Check for handshake timeout + uint32_t now = App.get_loop_component_start_time(); + if (now - this->client_connect_time_ > OTA_SOCKET_TIMEOUT_HANDSHAKE) { + ESP_LOGW(TAG, "Handshake timeout"); + this->cleanup_connection_(); + return; + } + + // Try to read first byte of magic bytes + uint8_t first_byte; + ssize_t read = this->client_->read(&first_byte, 1); + if (read == 1) { + // Got the first byte, check if it's the magic byte + if (first_byte != 0x6C) { + ESP_LOGW(TAG, "Invalid initial byte: 0x%02X", first_byte); + this->cleanup_connection_(); + return; + } + // First byte is valid, continue with data handling + this->handle_data_(); + } else if (read == -1) { + if (errno != EAGAIN && errno != EWOULDBLOCK) { + ESP_LOGW(TAG, "Error reading first byte, errno %d", errno); + this->cleanup_connection_(); + } + // For EAGAIN/EWOULDBLOCK, just return and try again next loop + } else if (read == 0) { + ESP_LOGW(TAG, "Remote closed connection during handshake"); + this->cleanup_connection_(); + } +} + +void ESPHomeOTAComponent::handle_data_() { + // This method blocks the main loop until the OTA update is complete ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; bool update_started = false; size_t total = 0; @@ -109,38 +171,14 @@ void ESPHomeOTAComponent::handle_() { size_t size_acknowledged = 0; #endif - if (this->client_ == nullptr) { - // We already checked server_->ready() in loop(), so we can accept directly - struct sockaddr_storage source_addr; - socklen_t addr_len = sizeof(source_addr); - this->client_ = this->server_->accept((struct sockaddr *) &source_addr, &addr_len); - if (this->client_ == nullptr) - return; - } - - int enable = 1; - int err = this->client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); - if (err != 0) { - this->log_socket_error_("nodelay"); - this->cleanup_connection_(); - return; - } - err = this->client_->setblocking(false); - if (err != 0) { - this->log_socket_error_("non-blocking"); - this->cleanup_connection_(); - return; - } - - this->log_start_("handshake"); - - if (!this->readall_(buf, 5, OTA_SOCKET_TIMEOUT_HANDSHAKE)) { + // Read remaining 4 bytes of magic (we already read the first byte 0x6C in handle_handshake_) + if (!this->readall_(buf, 4, OTA_SOCKET_TIMEOUT_DATA)) { ESP_LOGW(TAG, "Read magic bytes failed"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - // 0x6C, 0x26, 0xF7, 0x5C, 0x45 - if (buf[0] != 0x6C || buf[1] != 0x26 || buf[2] != 0xF7 || buf[3] != 0x5C || buf[4] != 0x45) { - ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", buf[0], buf[1], buf[2], buf[3], buf[4]); + // Check remaining magic bytes: 0x26, 0xF7, 0x5C, 0x45 + if (buf[0] != 0x26 || buf[1] != 0xF7 || buf[2] != 0x5C || buf[3] != 0x45) { + ESP_LOGW(TAG, "Magic bytes mismatch! 0x6C-0x%02X-0x%02X-0x%02X-0x%02X", buf[0], buf[1], buf[2], buf[3]); error_code = ota::OTA_RESPONSE_ERROR_MAGIC; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -441,6 +479,7 @@ void ESPHomeOTAComponent::log_start_(const char *phase) { void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; + this->client_connect_time_ = 0; } } // namespace esphome diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 8c119fcc59..ac16e3b21d 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -9,7 +9,8 @@ namespace esphome { -static constexpr uint16_t OTA_SOCKET_TIMEOUT_DATA = 30000; // milliseconds for data transfer +static constexpr uint16_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake +static constexpr uint16_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer /// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA. class ESPHomeOTAComponent : public ota::OTAComponent { @@ -29,7 +30,8 @@ class ESPHomeOTAComponent : public ota::OTAComponent { uint16_t get_port() const; protected: - void handle_(); + void handle_handshake_(); + void handle_data_(); bool readall_(uint8_t *buf, size_t len, uint16_t timeout = OTA_SOCKET_TIMEOUT_DATA); bool writeall_(const uint8_t *buf, size_t len, uint16_t timeout = OTA_SOCKET_TIMEOUT_DATA); void log_socket_error_(const char *msg); @@ -44,6 +46,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { std::unique_ptr server_; std::unique_ptr client_; + uint32_t client_connect_time_{0}; }; } // namespace esphome From f0c97c299f6fbcae0e8ea70623a2b7d71ab37dc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 17:53:02 -0500 Subject: [PATCH 0447/1626] uint32_t --- esphome/components/esphome/ota/ota_esphome.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index ac16e3b21d..d20d25d8c6 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -9,8 +9,8 @@ namespace esphome { -static constexpr uint16_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake -static constexpr uint16_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer +static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake +static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer /// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA. class ESPHomeOTAComponent : public ota::OTAComponent { @@ -32,8 +32,8 @@ class ESPHomeOTAComponent : public ota::OTAComponent { protected: void handle_handshake_(); void handle_data_(); - bool readall_(uint8_t *buf, size_t len, uint16_t timeout = OTA_SOCKET_TIMEOUT_DATA); - bool writeall_(const uint8_t *buf, size_t len, uint16_t timeout = OTA_SOCKET_TIMEOUT_DATA); + bool readall_(uint8_t *buf, size_t len, uint32_t timeout = OTA_SOCKET_TIMEOUT_DATA); + bool writeall_(const uint8_t *buf, size_t len, uint32_t timeout = OTA_SOCKET_TIMEOUT_DATA); void log_socket_error_(const char *msg); void log_start_(const char *phase); void cleanup_connection_(); From 2bdf335127e641c65f31fe3bac29eb9b3899a85c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 17:53:24 -0500 Subject: [PATCH 0448/1626] uint32_t --- esphome/components/esphome/ota/ota_esphome.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index c8156b8df2..60a9ae3cf5 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -172,7 +172,7 @@ void ESPHomeOTAComponent::handle_data_() { #endif // Read remaining 4 bytes of magic (we already read the first byte 0x6C in handle_handshake_) - if (!this->readall_(buf, 4, OTA_SOCKET_TIMEOUT_DATA)) { + if (!this->readall_(buf, 4)) { ESP_LOGW(TAG, "Read magic bytes failed"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -407,7 +407,7 @@ error: #endif } -bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len, uint16_t timeout) { +bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len, uint32_t timeout) { uint32_t start = millis(); uint32_t at = 0; while (len - at > 0) { @@ -438,7 +438,7 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len, uint16_t timeout) { return true; } -bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len, uint16_t timeout) { +bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len, uint32_t timeout) { uint32_t start = millis(); uint32_t at = 0; while (len - at > 0) { From e48a223eac9ddcfc3650978b2ea154b162dafcec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 18:03:56 -0500 Subject: [PATCH 0449/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 60a9ae3cf5..938bf88930 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -145,7 +145,7 @@ void ESPHomeOTAComponent::handle_handshake_() { this->handle_data_(); } else if (read == -1) { if (errno != EAGAIN && errno != EWOULDBLOCK) { - ESP_LOGW(TAG, "Error reading first byte, errno %d", errno); + this->log_socket_error_("reading first byte"); this->cleanup_connection_(); } // For EAGAIN/EWOULDBLOCK, just return and try again next loop From f5790bff7328501c6a58cad6737ca579c76d13ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 18:19:52 -0500 Subject: [PATCH 0450/1626] adjust --- esphome/components/esphome/ota/ota_esphome.cpp | 10 ++++++---- esphome/components/esphome/ota/ota_esphome.h | 9 +++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 938bf88930..ff429df5a5 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -20,6 +20,8 @@ namespace esphome { static const char *const TAG = "esphome.ota"; static constexpr u_int16_t OTA_BLOCK_SIZE = 8192; +static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake +static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer void ESPHomeOTAComponent::setup() { #ifdef USE_OTA_STATE_CALLBACK @@ -407,12 +409,12 @@ error: #endif } -bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len, uint32_t timeout) { +bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { uint32_t start = millis(); uint32_t at = 0; while (len - at > 0) { uint32_t now = millis(); - if (now - start > timeout) { + if (now - start > OTA_SOCKET_TIMEOUT_DATA) { ESP_LOGW(TAG, "Timeout reading %d bytes", len); return false; } @@ -438,12 +440,12 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len, uint32_t timeout) { return true; } -bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len, uint32_t timeout) { +bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { uint32_t start = millis(); uint32_t at = 0; while (len - at > 0) { uint32_t now = millis(); - if (now - start > timeout) { + if (now - start > OTA_SOCKET_TIMEOUT_DATA) { ESP_LOGW(TAG, "Timeout writing %d bytes", len); return false; } diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index d20d25d8c6..833d4b49d0 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -9,9 +9,6 @@ namespace esphome { -static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake -static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer - /// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA. class ESPHomeOTAComponent : public ota::OTAComponent { public: @@ -32,8 +29,8 @@ class ESPHomeOTAComponent : public ota::OTAComponent { protected: void handle_handshake_(); void handle_data_(); - bool readall_(uint8_t *buf, size_t len, uint32_t timeout = OTA_SOCKET_TIMEOUT_DATA); - bool writeall_(const uint8_t *buf, size_t len, uint32_t timeout = OTA_SOCKET_TIMEOUT_DATA); + bool readall_(uint8_t *buf, size_t len); + bool writeall_(const uint8_t *buf, size_t len); void log_socket_error_(const char *msg); void log_start_(const char *phase); void cleanup_connection_(); @@ -43,10 +40,10 @@ class ESPHomeOTAComponent : public ota::OTAComponent { #endif // USE_OTA_PASSWORD uint16_t port_; + uint32_t client_connect_time_{0}; std::unique_ptr server_; std::unique_ptr client_; - uint32_t client_connect_time_{0}; }; } // namespace esphome From ffa89eb2d37a35e042889883c1176e6d84c8598d Mon Sep 17 00:00:00 2001 From: tomaszduda23 <35012788+tomaszduda23@users.noreply.github.com> Date: Mon, 11 Aug 2025 02:20:45 +0300 Subject: [PATCH 0451/1626] [nrf52, zephyr_debug] add zephyr debug component (#8319) --- esphome/components/debug/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index 9dffe00bf4..6b4205a545 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -48,6 +48,15 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): if CORE.using_zephyr: zephyr_add_prj_conf("HWINFO", True) + # gdb thread support + zephyr_add_prj_conf("DEBUG_THREAD_INFO", True) + # RTT + zephyr_add_prj_conf("USE_SEGGER_RTT", True) + zephyr_add_prj_conf("RTT_CONSOLE", True) + zephyr_add_prj_conf("LOG", True) + zephyr_add_prj_conf("LOG_BLOCK_IN_THREAD", True) + zephyr_add_prj_conf("LOG_BUFFER_SIZE", 4096) + zephyr_add_prj_conf("SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL", True) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) From 4a8369ef939f1dd8e8d57d72880cf2d45e98da9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 18:24:41 -0500 Subject: [PATCH 0452/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ff429df5a5..1e61a1df23 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -152,7 +152,7 @@ void ESPHomeOTAComponent::handle_handshake_() { } // For EAGAIN/EWOULDBLOCK, just return and try again next loop } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed connection during handshake"); + ESP_LOGW(TAG, "Remote closed during handshake"); this->cleanup_connection_(); } } From 856e13986ace04e99f8140a56aa282dbc3af4d0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 18:25:51 -0500 Subject: [PATCH 0453/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 1e61a1df23..cb8cdb4a42 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -85,9 +85,10 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Skip handle_() call if no client connected and no incoming connections + // Skip handle_handshake_() call if no client connected and no incoming connections // This optimization reduces idle loop overhead when OTA is not active - // Note: No need to check server_ for null as the component is marked failed in setup() if server_ creation fails + // Note: No need to check server_ for null as the component is marked failed in setup() + // if server_ creation fails if (this->client_ != nullptr || this->server_->ready()) { this->handle_handshake_(); } From 4bdf44bb78405835bf34ba99d34f1368e12e54bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 18:28:27 -0500 Subject: [PATCH 0454/1626] preen --- .../components/esphome/ota/ota_esphome.cpp | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index cb8cdb4a42..fb862b3925 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -137,25 +137,31 @@ void ESPHomeOTAComponent::handle_handshake_() { // Try to read first byte of magic bytes uint8_t first_byte; ssize_t read = this->client_->read(&first_byte, 1); - if (read == 1) { - // Got the first byte, check if it's the magic byte - if (first_byte != 0x6C) { - ESP_LOGW(TAG, "Invalid initial byte: 0x%02X", first_byte); - this->cleanup_connection_(); - return; - } - // First byte is valid, continue with data handling - this->handle_data_(); - } else if (read == -1) { - if (errno != EAGAIN && errno != EWOULDBLOCK) { - this->log_socket_error_("reading first byte"); - this->cleanup_connection_(); - } - // For EAGAIN/EWOULDBLOCK, just return and try again next loop - } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed during handshake"); - this->cleanup_connection_(); + + if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + return; // No data yet, try again next loop } + + if (read <= 0) { + // Error or connection closed + if (read == -1) { + this->log_socket_error_("reading first byte"); + } else { + ESP_LOGW(TAG, "Remote closed during handshake"); + } + this->cleanup_connection_(); + return; + } + + // Got first byte, check if it's the magic byte + if (first_byte != 0x6C) { + ESP_LOGW(TAG, "Invalid initial byte: 0x%02X", first_byte); + this->cleanup_connection_(); + return; + } + + // First byte is valid, continue with data handling + this->handle_data_(); } void ESPHomeOTAComponent::handle_data_() { From 4faa9231a986558e85460de3c21c29f3769cc6b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 18:29:00 -0500 Subject: [PATCH 0455/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index fb862b3925..3226567c5a 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -182,7 +182,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read remaining 4 bytes of magic (we already read the first byte 0x6C in handle_handshake_) if (!this->readall_(buf, 4)) { - ESP_LOGW(TAG, "Read magic bytes failed"); + this->log_socket_error_("reading magic bytes"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } // Check remaining magic bytes: 0x26, 0xF7, 0x5C, 0x45 From ef676a5a77ddd4490e3fac9b6c3108a18ff21baf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 18:30:28 -0500 Subject: [PATCH 0456/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 3226567c5a..53fbdb2fb4 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -201,7 +201,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read features - 1 byte if (!this->readall_(buf, 1)) { - ESP_LOGW(TAG, "Read features failed"); + this->log_socket_error_("reading features"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_features = buf[0]; // NOLINT From 2f9d1e6dac55147de9fd33817aa6f05438c783ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 18:33:30 -0500 Subject: [PATCH 0457/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 12 +++++++----- esphome/components/esphome/ota/ota_esphome.h | 1 + 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 53fbdb2fb4..c1d50790a2 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -182,7 +182,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read remaining 4 bytes of magic (we already read the first byte 0x6C in handle_handshake_) if (!this->readall_(buf, 4)) { - this->log_socket_error_("reading magic bytes"); + this->log_read_error_("magic bytes"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } // Check remaining magic bytes: 0x26, 0xF7, 0x5C, 0x45 @@ -201,7 +201,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read features - 1 byte if (!this->readall_(buf, 1)) { - this->log_socket_error_("reading features"); + this->log_read_error_("features"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_features = buf[0]; // NOLINT @@ -280,7 +280,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { - ESP_LOGW(TAG, "Read size failed"); + this->log_read_error_("size"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_size = 0; @@ -312,7 +312,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read binary MD5, 32 bytes if (!this->readall_(buf, 32)) { - ESP_LOGW(TAG, "Read MD5 checksum failed"); + this->log_read_error_("MD5 checksum"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } sbuf[32] = '\0'; @@ -387,7 +387,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read ACK if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Read ack failed"); + this->log_read_error_("ack"); // do not go to error, this is not fatal } @@ -481,6 +481,8 @@ void ESPHomeOTAComponent::set_port(uint16_t port) { this->port_ = port; } void ESPHomeOTAComponent::log_socket_error_(const char *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", msg, errno); } +void ESPHomeOTAComponent::log_read_error_(const char *what) { ESP_LOGW(TAG, "Read %s failed", what); } + void ESPHomeOTAComponent::log_start_(const char *phase) { ESP_LOGD(TAG, "Starting %s from %s", phase, this->client_->getpeername().c_str()); } diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 833d4b49d0..0059dfd0d9 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -32,6 +32,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); void log_socket_error_(const char *msg); + void log_read_error_(const char *what); void log_start_(const char *phase); void cleanup_connection_(); From b70983ed09140c1188ec7b1ec5af775a49edecb2 Mon Sep 17 00:00:00 2001 From: Chad Matsalla <48810546+ChadMatsalla@users.noreply.github.com> Date: Sun, 10 Aug 2025 17:41:37 -0600 Subject: [PATCH 0458/1626] [display] Disallow ``show_test_card: true`` and ``update_interval: never`` (#9927) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/display/__init__.py | 16 +++++++++++++++- esphome/const.py | 4 ++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 81f2536e96..8021a8f9b1 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -12,6 +12,8 @@ from esphome.const import ( CONF_ROTATION, CONF_TO, CONF_TRIGGER_ID, + CONF_UPDATE_INTERVAL, + SCHEDULER_DONT_RUN, ) from esphome.core import coroutine_with_priority @@ -67,6 +69,18 @@ BASIC_DISPLAY_SCHEMA = cv.Schema( } ).extend(cv.polling_component_schema("1s")) + +def _validate_test_card(config): + if ( + config.get(CONF_SHOW_TEST_CARD, False) + and config.get(CONF_UPDATE_INTERVAL, False) == SCHEDULER_DONT_RUN + ): + raise cv.Invalid( + f"`{CONF_SHOW_TEST_CARD}: True` cannot be used with `{CONF_UPDATE_INTERVAL}: never` because this combination will not show a test_card." + ) + return config + + FULL_DISPLAY_SCHEMA = BASIC_DISPLAY_SCHEMA.extend( { cv.Optional(CONF_ROTATION): validate_rotation, @@ -94,6 +108,7 @@ FULL_DISPLAY_SCHEMA = BASIC_DISPLAY_SCHEMA.extend( cv.Optional(CONF_SHOW_TEST_CARD): cv.boolean, } ) +FULL_DISPLAY_SCHEMA.add_extra(_validate_test_card) async def setup_display_core_(var, config): @@ -200,7 +215,6 @@ async def display_is_displaying_page_to_code(config, condition_id, template_arg, page = await cg.get_variable(config[CONF_PAGE_ID]) var = cg.new_Pvariable(condition_id, template_arg, paren) cg.add(var.set_page(page)) - return var diff --git a/esphome/const.py b/esphome/const.py index 56e0edab9b..983850746d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1331,3 +1331,7 @@ ENTITY_CATEGORY_CONFIG = "config" # The entity category for read only diagnostic values, for example RSSI, uptime or MAC Address ENTITY_CATEGORY_DIAGNOSTIC = "diagnostic" + +# The corresponding constant exists in c++ +# when update_interval is set to never, it becomes SCHEDULER_DONT_RUN milliseconds +SCHEDULER_DONT_RUN = 4294967295 From 9ce75d2f0f75e0b4b65bea6cb8e8af3aa4b358e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 18:45:44 -0500 Subject: [PATCH 0459/1626] preen --- .../components/esphome/ota/ota_esphome.cpp | 23 +++++++++---------- esphome/components/esphome/ota/ota_esphome.h | 1 + 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index c1d50790a2..894cee9aea 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -329,8 +329,7 @@ void ESPHomeOTAComponent::handle_data_() { ssize_t read = this->client_->read(buf, requested); if (read == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { - App.feed_wdt(); - delay(1); + this->yield_and_feed_watchdog_(); continue; } ESP_LOGW(TAG, "Read error, errno %d", errno); @@ -366,8 +365,7 @@ void ESPHomeOTAComponent::handle_data_() { this->state_callback_.call(ota::OTA_IN_PROGRESS, percentage, 0); #endif // feed watchdog and give other tasks a chance to run - App.feed_wdt(); - yield(); + this->yield_and_feed_watchdog_(); } } @@ -429,8 +427,7 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { ssize_t read = this->client_->read(buf + at, len - at); if (read == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { - App.feed_wdt(); - delay(1); + this->yield_and_feed_watchdog_(); continue; } ESP_LOGW(TAG, "Error reading %d bytes, errno %d", len, errno); @@ -441,8 +438,7 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { } else { at += read; } - App.feed_wdt(); - delay(1); + this->yield_and_feed_watchdog_(); } return true; @@ -460,8 +456,7 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { ssize_t written = this->client_->write(buf + at, len - at); if (written == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { - App.feed_wdt(); - delay(1); + this->yield_and_feed_watchdog_(); continue; } ESP_LOGW(TAG, "Error writing %d bytes, errno %d", len, errno); @@ -469,8 +464,7 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { } else { at += written; } - App.feed_wdt(); - delay(1); + this->yield_and_feed_watchdog_(); } return true; } @@ -493,5 +487,10 @@ void ESPHomeOTAComponent::cleanup_connection_() { this->client_connect_time_ = 0; } +void ESPHomeOTAComponent::yield_and_feed_watchdog_() { + App.feed_wdt(); + delay(1); +} + } // namespace esphome #endif diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 0059dfd0d9..8397b86528 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -35,6 +35,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { void log_read_error_(const char *what); void log_start_(const char *phase); void cleanup_connection_(); + void yield_and_feed_watchdog_(); #ifdef USE_OTA_PASSWORD std::string password_; From d337da3d3cf1544a5782b46b0d67360dba58d283 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 18:53:48 -0500 Subject: [PATCH 0460/1626] cleanp --- esphome/components/esphome/ota/ota_esphome.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 894cee9aea..ef1e811c4f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -97,9 +97,11 @@ void ESPHomeOTAComponent::loop() { static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; void ESPHomeOTAComponent::handle_handshake_() { - // This method does the initial handshake with the client - // and will not block the loop until we receive the first byte - // of the magic bytes + /// Handle the initial OTA handshake. + /// + /// This method is non-blocking and will return immediately if no data is available. + /// It waits for the first magic byte (0x6C) before proceeding to handle_data_(). + /// A 10-second timeout is enforced from initial connection. if (this->client_ == nullptr) { // We already checked server_->ready() in loop(), so we can accept directly @@ -165,7 +167,11 @@ void ESPHomeOTAComponent::handle_handshake_() { } void ESPHomeOTAComponent::handle_data_() { - // This method blocks the main loop until the OTA update is complete + /// Handle the OTA data transfer and update process. + /// + /// This method is blocking and will not return until the OTA update completes, + /// fails, or times out. It handles authentication, receives the firmware data, + /// writes it to flash, and reboots on success. ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; bool update_started = false; size_t total = 0; From 9021de9c1cc696aca688d139a1e174055c4659c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 19:04:24 -0500 Subject: [PATCH 0461/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ef1e811c4f..7807f1bd0b 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -19,7 +19,7 @@ namespace esphome { static const char *const TAG = "esphome.ota"; -static constexpr u_int16_t OTA_BLOCK_SIZE = 8192; +static constexpr uint16_t OTA_BLOCK_SIZE = 8192; static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer From 8f8d6734db1f3e7d93b5f18ce15bb69451545e63 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 19:26:12 -0500 Subject: [PATCH 0462/1626] dry --- esphome/components/esphome/ota/ota_esphome.cpp | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 7807f1bd0b..5217e9c61f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -432,12 +432,10 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { ssize_t read = this->client_->read(buf + at, len - at); if (read == -1) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - this->yield_and_feed_watchdog_(); - continue; + if (errno != EAGAIN && errno != EWOULDBLOCK) { + ESP_LOGW(TAG, "Error reading %d bytes, errno %d", len, errno); + return false; } - ESP_LOGW(TAG, "Error reading %d bytes, errno %d", len, errno); - return false; } else if (read == 0) { ESP_LOGW(TAG, "Remote closed connection"); return false; @@ -461,12 +459,10 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { ssize_t written = this->client_->write(buf + at, len - at); if (written == -1) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - this->yield_and_feed_watchdog_(); - continue; + if (errno != EAGAIN && errno != EWOULDBLOCK) { + ESP_LOGW(TAG, "Error writing %d bytes, errno %d", len, errno); + return false; } - ESP_LOGW(TAG, "Error writing %d bytes, errno %d", len, errno); - return false; } else { at += written; } From 35a51280d40f4d215e953f2e7e68dee038321d24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 23:29:46 -0500 Subject: [PATCH 0463/1626] fixed ble adv --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 8 +- esphome/components/api/api_pb2.h | 5 +- esphome/components/api/api_pb2_dump.cpp | 4 +- .../components/bluetooth_proxy/__init__.py | 6 ++ .../bluetooth_proxy/bluetooth_proxy.cpp | 62 ++++---------- .../bluetooth_proxy/bluetooth_proxy.h | 4 +- esphome/core/defines.h | 1 + script/api_protobuf/api_protobuf.py | 84 +++++++++++++++++++ 10 files changed, 118 insertions(+), 59 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 9d77ecdfa8..6b19f2026a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1438,7 +1438,7 @@ message BluetoothLERawAdvertisementsResponse { option (ifdef) = "USE_BLUETOOTH_PROXY"; option (no_delay) = true; - repeated BluetoothLERawAdvertisement advertisements = 1; + repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"]; } enum BluetoothDeviceRequestType { diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index ed0e0d7455..50c43b96fd 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -30,6 +30,7 @@ extend google.protobuf.FieldOptions { optional bool no_zero_copy = 50008 [default=false]; optional bool fixed_array_skip_zero = 50009 [default=false]; optional string fixed_array_size_define = 50010; + optional string fixed_array_with_length_define = 50011; // container_pointer: Zero-copy optimization for repeated fields. // diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 5dddc79b49..476e3c88d0 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1843,12 +1843,14 @@ void BluetoothLERawAdvertisement::calculate_size(ProtoSize &size) const { size.add_length(1, this->data_len); } void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer buffer) const { - for (auto &it : this->advertisements) { - buffer.encode_message(1, it, true); + for (uint16_t i = 0; i < this->advertisements_len; i++) { + buffer.encode_message(1, this->advertisements[i], true); } } void BluetoothLERawAdvertisementsResponse::calculate_size(ProtoSize &size) const { - size.add_repeated_message(1, this->advertisements); + for (uint16_t i = 0; i < this->advertisements_len; i++) { + size.add_message_object_force(1, this->advertisements[i]); + } } bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d43d3c61b7..edf839be55 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1788,11 +1788,12 @@ class BluetoothLERawAdvertisement : public ProtoMessage { class BluetoothLERawAdvertisementsResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 93; - static constexpr uint8_t ESTIMATED_SIZE = 34; + static constexpr uint8_t ESTIMATED_SIZE = 136; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_le_raw_advertisements_response"; } #endif - std::vector advertisements{}; + std::array advertisements{}; + uint16_t advertisements_len{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index b212353ad8..7af322f96d 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1534,9 +1534,9 @@ void BluetoothLERawAdvertisement::dump_to(std::string &out) const { } void BluetoothLERawAdvertisementsResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothLERawAdvertisementsResponse"); - for (const auto &it : this->advertisements) { + for (uint16_t i = 0; i < this->advertisements_len; i++) { out.append(" advertisements: "); - it.dump_to(out); + this->advertisements[i].dump_to(out); out.append("\n"); } } diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index fb7f7a37c0..112faa27e5 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -118,6 +118,12 @@ async def to_code(config): connection_count = len(config.get(CONF_CONNECTIONS, [])) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) + # Define batch size for BLE advertisements + # Each advertisement is up to 80 bytes when packaged (including protocol overhead) + # 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload + # This achieves ~97% WiFi MTU utilization while staying under the limit + cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + for connection_conf in config.get(CONF_CONNECTIONS, []): connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) await cg.register_component(connection_var, connection_conf) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 04b85fc3f0..6bd7af2eeb 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -11,12 +11,8 @@ namespace esphome::bluetooth_proxy { static const char *const TAG = "bluetooth_proxy"; -// Batch size for BLE advertisements to maximize WiFi efficiency -// Each advertisement is up to 80 bytes when packaged (including protocol overhead) -// Most advertisements are 20-30 bytes, allowing even more to fit per packet -// 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload -// This achieves ~97% WiFi MTU utilization while staying under the limit -static constexpr size_t FLUSH_BATCH_SIZE = 16; +// BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE is defined during code generation +// It sets the batch size for BLE advertisements to maximize WiFi efficiency // Verify BLE advertisement data array size matches the BLE specification (31 bytes adv + 31 bytes scan response) static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62, @@ -25,13 +21,6 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62 BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } void BluetoothProxy::setup() { - // Reserve capacity but start with size 0 - // Reserve 50% since we'll grow naturally and flush at FLUSH_BATCH_SIZE - this->response_.advertisements.reserve(FLUSH_BATCH_SIZE / 2); - - // Don't pre-allocate pool - let it grow only if needed in busy environments - // Many devices in quiet areas will never need the overflow pool - this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; @@ -82,68 +71,45 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) return false; - auto &advertisements = this->response_.advertisements; - for (size_t i = 0; i < count; i++) { auto &result = scan_results[i]; uint8_t length = result.adv_data_len + result.scan_rsp_len; - // Check if we need to expand the vector - if (this->advertisement_count_ >= advertisements.size()) { - if (this->advertisement_pool_.empty()) { - // No room in pool, need to allocate - advertisements.emplace_back(); - } else { - // Pull from pool - advertisements.push_back(std::move(this->advertisement_pool_.back())); - this->advertisement_pool_.pop_back(); - } + // Check if we're at capacity + if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { + // Flush the batch before adding more + this->flush_pending_advertisements(); } // Fill in the data directly at current position - auto &adv = advertisements[this->advertisement_count_]; + auto &adv = this->response_.advertisements[this->response_.advertisements_len]; adv.address = esp32_ble::ble_addr_to_uint64(result.bda); adv.rssi = result.rssi; adv.address_type = result.ble_addr_type; adv.data_len = length; std::memcpy(adv.data, result.ble_adv, length); - this->advertisement_count_++; + this->response_.advertisements_len++; ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", result.bda[0], result.bda[1], result.bda[2], result.bda[3], result.bda[4], result.bda[5], length, result.rssi); - - // Flush if we have reached FLUSH_BATCH_SIZE - if (this->advertisement_count_ >= FLUSH_BATCH_SIZE) { - this->flush_pending_advertisements(); - } } return true; } void BluetoothProxy::flush_pending_advertisements() { - if (this->advertisement_count_ == 0 || !api::global_api_server->is_connected() || this->api_connection_ == nullptr) + if (this->response_.advertisements_len == 0 || !api::global_api_server->is_connected() || + this->api_connection_ == nullptr) return; - auto &advertisements = this->response_.advertisements; - - // Return any items beyond advertisement_count_ to the pool - if (advertisements.size() > this->advertisement_count_) { - // Move unused items back to pool - this->advertisement_pool_.insert(this->advertisement_pool_.end(), - std::make_move_iterator(advertisements.begin() + this->advertisement_count_), - std::make_move_iterator(advertisements.end())); - - // Resize to actual count - advertisements.resize(this->advertisement_count_); - } - // Send the message this->api_connection_->send_message(this->response_, api::BluetoothLERawAdvertisementsResponse::MESSAGE_TYPE); - // Reset count - existing items will be overwritten in next batch - this->advertisement_count_ = 0; + ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); + + // Reset the length for the next batch + this->response_.advertisements_len = 0; } void BluetoothProxy::dump_config() { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 21695d9819..bc8d3ed762 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -150,7 +150,6 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com std::array connections_{}; // BLE advertisement batching - std::vector advertisement_pool_; api::BluetoothLERawAdvertisementsResponse response_; // Group 3: 4-byte types @@ -161,9 +160,8 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 4: 1-byte types grouped together bool active_; - uint8_t advertisement_count_{0}; uint8_t connection_count_{0}; - // 3 bytes used, 1 byte padding + // 2 bytes used, 2 bytes padding }; extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7631ff54f3..01f6811e05 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -148,6 +148,7 @@ #define USE_BLUETOOTH_PROXY #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 #define USE_CAPTIVE_PORTAL #define USE_ESP32_BLE #define USE_ESP32_BLE_CLIENT diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index fa2f87d98d..6b59488ad0 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -339,6 +339,11 @@ def create_field_type_info( ) -> TypeInfo: """Create the appropriate TypeInfo instance for a field, handling repeated fields and custom options.""" if field.label == 3: # repeated + # Check if this repeated field has fixed_array_with_length_define option + if ( + fixed_size := get_field_opt(field, pb.fixed_array_with_length_define) + ) is not None: + return FixedArrayWithLengthRepeatedType(field, fixed_size) # Check if this repeated field has fixed_array_size option if (fixed_size := get_field_opt(field, pb.fixed_array_size)) is not None: return FixedArrayRepeatedType(field, fixed_size) @@ -1230,6 +1235,72 @@ class FixedArrayRepeatedType(TypeInfo): return underlying_size * self.array_size +class FixedArrayWithLengthRepeatedType(FixedArrayRepeatedType): + """Special type for fixed-size repeated fields with variable length tracking. + + Similar to FixedArrayRepeatedType but generates an additional length field + to track how many elements are actually in use. Only encodes/sends elements + up to the current length. + + Fixed arrays with length are only supported for encoding (SOURCE_SERVER) since + we cannot control how many items we receive when decoding. + """ + + @property + def public_content(self) -> list[str]: + # Return both the array and the length field + return [ + f"{self.cpp_type} {self.field_name}{{}};", + f"uint16_t {self.field_name}_len{{0}};", + ] + + @property + def encode_content(self) -> str: + # Helper to generate encode statement for a single element + def encode_element(element: str) -> str: + if isinstance(self._ti, EnumType): + return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" + return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" + + # Always use a loop up to the current length + o = f"for (uint16_t i = 0; i < this->{self.field_name}_len; i++) {{\n" + o += f" {encode_element(f'this->{self.field_name}[i]')}\n" + o += "}" + return o + + @property + def dump_content(self) -> str: + # Dump only the active elements + o = f"for (uint16_t i = 0; i < this->{self.field_name}_len; i++) {{\n" + # Check if underlying type can use dump_field + if type(self._ti).can_use_dump_field(): + o += f' dump_field(out, "{self.name}", {self._ti.dump_field_value(f"this->{self.field_name}[i]")}, 4);\n' + else: + o += f' out.append(" {self.name}: ");\n' + o += indent(self._ti.dump(f"this->{self.field_name}[i]")) + "\n" + o += ' out.append("\\n");\n' + o += "}" + return o + + def get_size_calculation(self, name: str, force: bool = False) -> str: + # Calculate size only for active elements + o = f"for (uint16_t i = 0; i < {name}_len; i++) {{\n" + o += f" {self._ti.get_size_calculation(f'{name}[i]', True)}\n" + o += "}" + return o + + def get_estimated_size(self) -> int: + # For fixed arrays with length, estimate based on typical usage + # Assume on average half the array is used + underlying_size = self._ti.get_estimated_size() + if self.is_define: + # When using a define, estimate 8 elements as typical + return underlying_size * 8 + return underlying_size * ( + self.array_size // 2 if self.array_size > 2 else self.array_size + ) + + class RepeatedTypeInfo(TypeInfo): def __init__(self, field: descriptor.FieldDescriptorProto) -> None: super().__init__(field) @@ -1711,6 +1782,19 @@ def build_message_type( f"since we cannot trust or control the number of items received from clients." ) + # Validate that fixed_array_with_length_define is only used in encode-only messages + if ( + needs_decode + and field.label == 3 + and get_field_opt(field, pb.fixed_array_with_length_define) is not None + ): + raise ValueError( + f"Message '{desc.name}' uses fixed_array_with_length_define on field '{field.name}' " + f"but has source={SOURCE_NAMES[source]}. " + f"Fixed arrays with length are only supported for SOURCE_SERVER (encode-only) messages " + f"since we cannot trust or control the number of items received from clients." + ) + ti = create_field_type_info(field, needs_decode, needs_encode) # Skip field declarations for fields that are in the base class From 07db443207c7aa39a22e277a53c63c123b6eb5f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 23:31:40 -0500 Subject: [PATCH 0464/1626] fixed ble adv --- script/api_protobuf/api_protobuf.py | 34 ++++++++++++----------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 6b59488ad0..fff0ab56ca 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1089,6 +1089,12 @@ class FixedArrayRepeatedType(TypeInfo): validate_field_type(field.type, field.name) self._ti: TypeInfo = TYPE_INFO[field.type](field) + def _encode_element(self, element: str) -> str: + """Helper to generate encode statement for a single element.""" + if isinstance(self._ti, EnumType): + return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" + return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" + @property def cpp_type(self) -> str: return f"std::array<{self._ti.cpp_type}, {self.array_size}>" @@ -1116,19 +1122,13 @@ class FixedArrayRepeatedType(TypeInfo): @property def encode_content(self) -> str: - # Helper to generate encode statement for a single element - def encode_element(element: str) -> str: - if isinstance(self._ti, EnumType): - return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" - return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" - # If skip_zero is enabled, wrap encoding in a zero check if self.skip_zero: if self.is_define: # When using a define, we need to use a loop-based approach o = f"for (const auto &it : this->{self.field_name}) {{\n" o += " if (it != 0) {\n" - o += f" {encode_element('it')}\n" + o += f" {self._encode_element('it')}\n" o += " }\n" o += "}" return o @@ -1137,7 +1137,7 @@ class FixedArrayRepeatedType(TypeInfo): [f"this->{self.field_name}[{i}] != 0" for i in range(self.array_size)] ) encode_lines = [ - f" {encode_element(f'this->{self.field_name}[{i}]')}" + f" {self._encode_element(f'this->{self.field_name}[{i}]')}" for i in range(self.array_size) ] return f"if ({non_zero_checks}) {{\n" + "\n".join(encode_lines) + "\n}" @@ -1145,23 +1145,23 @@ class FixedArrayRepeatedType(TypeInfo): # When using a define, always use loop-based approach if self.is_define: o = f"for (const auto &it : this->{self.field_name}) {{\n" - o += f" {encode_element('it')}\n" + o += f" {self._encode_element('it')}\n" o += "}" return o # Unroll small arrays for efficiency if self.array_size == 1: - return encode_element(f"this->{self.field_name}[0]") + return self._encode_element(f"this->{self.field_name}[0]") if self.array_size == 2: return ( - encode_element(f"this->{self.field_name}[0]") + self._encode_element(f"this->{self.field_name}[0]") + "\n " - + encode_element(f"this->{self.field_name}[1]") + + self._encode_element(f"this->{self.field_name}[1]") ) # Use loops for larger arrays o = f"for (const auto &it : this->{self.field_name}) {{\n" - o += f" {encode_element('it')}\n" + o += f" {self._encode_element('it')}\n" o += "}" return o @@ -1256,15 +1256,9 @@ class FixedArrayWithLengthRepeatedType(FixedArrayRepeatedType): @property def encode_content(self) -> str: - # Helper to generate encode statement for a single element - def encode_element(element: str) -> str: - if isinstance(self._ti, EnumType): - return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" - return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" - # Always use a loop up to the current length o = f"for (uint16_t i = 0; i < this->{self.field_name}_len; i++) {{\n" - o += f" {encode_element(f'this->{self.field_name}[i]')}\n" + o += f" {self._encode_element(f'this->{self.field_name}[i]')}\n" o += "}" return o From 5cf0e4d9dd4df13cfa04ab13d2a33edd1c83ced2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 05:11:22 +0000 Subject: [PATCH 0465/1626] Bump aioesphomeapi from 38.0.0 to 38.1.0 (#10176) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5e2773a71b..009a6850e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==38.0.0 +aioesphomeapi==38.1.0 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From af9c008ccbab757c65c55dcada1735f4cfd0f3e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Aug 2025 00:40:28 -0500 Subject: [PATCH 0466/1626] fix off by 1 --- .../bluetooth_proxy/bluetooth_proxy.cpp | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 6bd7af2eeb..723466a5ff 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -71,18 +71,14 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) return false; + auto &advertisements = this->response_.advertisements; + for (size_t i = 0; i < count; i++) { auto &result = scan_results[i]; uint8_t length = result.adv_data_len + result.scan_rsp_len; - // Check if we're at capacity - if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { - // Flush the batch before adding more - this->flush_pending_advertisements(); - } - // Fill in the data directly at current position - auto &adv = this->response_.advertisements[this->response_.advertisements_len]; + auto &adv = advertisements[this->response_.advertisements_len]; adv.address = esp32_ble::ble_addr_to_uint64(result.bda); adv.rssi = result.rssi; adv.address_type = result.ble_addr_type; @@ -93,6 +89,11 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", result.bda[0], result.bda[1], result.bda[2], result.bda[3], result.bda[4], result.bda[5], length, result.rssi); + + // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE + if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { + this->flush_pending_advertisements(); + } } return true; From 3d821f122377fb8ab0ce01b297495099785cea2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Aug 2025 00:42:42 -0500 Subject: [PATCH 0467/1626] preen --- script/api_protobuf/api_protobuf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index fff0ab56ca..63548557e7 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1267,7 +1267,7 @@ class FixedArrayWithLengthRepeatedType(FixedArrayRepeatedType): # Dump only the active elements o = f"for (uint16_t i = 0; i < this->{self.field_name}_len; i++) {{\n" # Check if underlying type can use dump_field - if type(self._ti).can_use_dump_field(): + if self._ti.can_use_dump_field(): o += f' dump_field(out, "{self.name}", {self._ti.dump_field_value(f"this->{self.field_name}[i]")}, 4);\n' else: o += f' out.append(" {self.name}: ");\n' From a847aab65ee03a2aa01acd5ec9d3011de7b36e97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Aug 2025 00:42:58 -0500 Subject: [PATCH 0468/1626] preen --- script/api_protobuf/api_protobuf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 63548557e7..3396e5ad05 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1057,7 +1057,7 @@ def _generate_array_dump_content( """ o = f"for (const auto {'' if is_bool else '&'}it : {field_name}) {{\n" # Check if underlying type can use dump_field - if type(ti).can_use_dump_field(): + if ti.can_use_dump_field(): # For types that have dump_field overloads, use them with extra indent o += f' dump_field(out, "{name}", {ti.dump_field_value("it")}, 4);\n' else: From 8f5396149675f51921eb726eadeee92e49e584ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 01:05:14 -0500 Subject: [PATCH 0469/1626] Bump pylint from 3.3.7 to 3.3.8 (#10177) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5b0d10d136..9ad4591a04 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==3.3.7 +pylint==3.3.8 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.12.8 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating From 45b88f2da9e56ec0a5cd7b5bc45adbf3654f2f75 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 11 Aug 2025 10:36:44 -0500 Subject: [PATCH 0470/1626] [sensor] Extend timeout filter with option to return last value received (#10115) --- esphome/components/sensor/__init__.py | 15 ++++++++++----- esphome/components/sensor/filter.cpp | 11 ++++++++--- esphome/components/sensor/filter.h | 5 +++-- tests/components/template/common.yaml | 3 +++ 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 2275027004..277718e46c 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -596,7 +596,7 @@ async def throttle_filter_to_code(config, filter_id): return cg.new_Pvariable(filter_id, config) -TIMEOUT_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( +THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( { cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds, cv.Optional(CONF_VALUE, default="nan"): cv.Any( @@ -610,7 +610,7 @@ TIMEOUT_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( @FILTER_REGISTRY.register( "throttle_with_priority", ThrottleWithPriorityFilter, - TIMEOUT_WITH_PRIORITY_SCHEMA, + THROTTLE_WITH_PRIORITY_SCHEMA, ) async def throttle_with_priority_filter_to_code(config, filter_id): if not isinstance(config[CONF_VALUE], list): @@ -631,7 +631,9 @@ async def heartbeat_filter_to_code(config, filter_id): TIMEOUT_SCHEMA = cv.maybe_simple_value( { cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds, - cv.Optional(CONF_VALUE, default="nan"): cv.templatable(cv.float_), + cv.Optional(CONF_VALUE, default="nan"): cv.Any( + "last", cv.templatable(cv.float_) + ), }, key=CONF_TIMEOUT, ) @@ -639,8 +641,11 @@ TIMEOUT_SCHEMA = cv.maybe_simple_value( @FILTER_REGISTRY.register("timeout", TimeoutFilter, TIMEOUT_SCHEMA) async def timeout_filter_to_code(config, filter_id): - template_ = await cg.templatable(config[CONF_VALUE], [], float) - var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) + if config[CONF_VALUE] == "last": + var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) + else: + template_ = await cg.templatable(config[CONF_VALUE], [], float) + var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) await cg.register_component(var, {}) return var diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index f077ad2416..3241ae28af 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -417,12 +417,17 @@ void OrFilter::initialize(Sensor *parent, Filter *next) { // TimeoutFilter optional TimeoutFilter::new_value(float value) { - this->set_timeout("timeout", this->time_period_, [this]() { this->output(this->value_.value()); }); + if (this->value_.has_value()) { + this->set_timeout("timeout", this->time_period_, [this]() { this->output(this->value_.value().value()); }); + } else { + this->set_timeout("timeout", this->time_period_, [this, value]() { this->output(value); }); + } return value; } -TimeoutFilter::TimeoutFilter(uint32_t time_period, TemplatableValue new_value) - : time_period_(time_period), value_(std::move(new_value)) {} +TimeoutFilter::TimeoutFilter(uint32_t time_period) : time_period_(time_period) {} +TimeoutFilter::TimeoutFilter(uint32_t time_period, const TemplatableValue &new_value) + : time_period_(time_period), value_(new_value) {} float TimeoutFilter::get_setup_priority() const { return setup_priority::HARDWARE; } // DebounceFilter diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 5765c9a081..49d83e5b4b 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -330,7 +330,8 @@ class ThrottleWithPriorityFilter : public Filter { class TimeoutFilter : public Filter, public Component { public: - explicit TimeoutFilter(uint32_t time_period, TemplatableValue new_value); + explicit TimeoutFilter(uint32_t time_period); + explicit TimeoutFilter(uint32_t time_period, const TemplatableValue &new_value); optional new_value(float value) override; @@ -338,7 +339,7 @@ class TimeoutFilter : public Filter, public Component { protected: uint32_t time_period_; - TemplatableValue value_; + optional> value_; }; class DebounceFilter : public Filter, public Component { diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index 6b7c7ddea1..ae7dc98e57 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -153,6 +153,9 @@ sensor: - timeout: timeout: 1h value: 20.0 + - timeout: + timeout: 1min + value: last - timeout: timeout: 1d - to_ntc_resistance: From 753ee1badcbf58b72b9061d1c90150843f5b137d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Aug 2025 11:32:23 -0500 Subject: [PATCH 0471/1626] [core] Improve entity duplicate validation error messages --- esphome/config.py | 4 +- esphome/core/__init__.py | 22 +++++-- esphome/core/entity_helpers.py | 37 ++++++++++- esphome/types.py | 12 ++++ tests/unit_tests/core/test_entity_helpers.py | 66 +++++++++++++++++++- 5 files changed, 131 insertions(+), 10 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index cf7a232d8e..ecd0cbb048 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -627,13 +627,15 @@ class SchemaValidationStep(ConfigValidationStep): def __init__( self, domain: str, path: ConfigPath, conf: ConfigType, comp: ComponentManifest ): + self.domain = domain self.path = path self.conf = conf self.comp = comp def run(self, result: Config) -> None: token = path_context.set(self.path) - with result.catch_error(self.path): + # The domain already contains the full component path (e.g., "sensor.template", "sensor.uptime") + with CORE.component_context(self.domain), result.catch_error(self.path): if self.comp.is_platform: # Remove 'platform' key for validation input_conf = OrderedDict(self.conf) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 39c6c3def1..9df5da1c78 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -1,4 +1,5 @@ from collections import defaultdict +from contextlib import contextmanager import logging import math import os @@ -38,7 +39,7 @@ from esphome.util import OrderedDict if TYPE_CHECKING: from ..cpp_generator import MockObj, MockObjClass, Statement - from ..types import ConfigType + from ..types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) @@ -571,14 +572,16 @@ class EsphomeCore: # Key: platform name (e.g. "sensor", "binary_sensor"), Value: count self.platform_counts: defaultdict[str, int] = defaultdict(int) # Track entity unique IDs to handle duplicates - # Set of (device_id, platform, sanitized_name) tuples - self.unique_ids: set[tuple[str, str, str]] = set() + # Dict mapping (device_id, platform, sanitized_name) -> entity metadata + self.unique_ids: dict[tuple[str, str, str], EntityMetadata] = {} # Whether ESPHome was started in verbose mode self.verbose = False # Whether ESPHome was started in quiet mode self.quiet = False # A list of all known ID classes self.id_classes = {} + # The current component being processed during validation + self.current_component: str | None = None def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -604,9 +607,20 @@ class EsphomeCore: self.loaded_integrations = set() self.component_ids = set() self.platform_counts = defaultdict(int) - self.unique_ids = set() + self.unique_ids = {} + self.current_component = None PIN_SCHEMA_REGISTRY.reset() + @contextmanager + def component_context(self, component: str): + """Context manager to set the current component being processed.""" + old_component = self.current_component + self.current_component = component + try: + yield + finally: + self.current_component = old_component + @property def address(self) -> str | None: if self.config is None: diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index cc388ffb4c..107b9fd739 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -16,7 +16,7 @@ from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, add, get_variable import esphome.final_validate as fv from esphome.helpers import sanitize, snake_case -from esphome.types import ConfigType +from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) @@ -214,14 +214,45 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Check for duplicates unique_key = (device_id, platform, name_key) if unique_key in CORE.unique_ids: + # Get the existing entity metadata + existing = CORE.unique_ids[unique_key] + existing_name = existing.get("name", entity_name) + existing_device = existing.get("device_id", "") + existing_id = existing.get("entity_id", "unknown") + + # Build detailed error message device_prefix = f" on device '{device_id}'" if device_id else "" + existing_device_prefix = ( + f" on device '{existing_device}'" if existing_device else "" + ) + existing_component = existing.get("component", "unknown") + + # Provide more context about where the duplicate was found + conflict_msg = ( + f"Conflicts with entity '{existing_name}'{existing_device_prefix}" + ) + if existing_id != "unknown": + conflict_msg += f" (id: {existing_id})" + if existing_component != "unknown": + conflict_msg += f" from component '{existing_component}'" + raise cv.Invalid( f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " + f"{conflict_msg}. " f"Each entity on a device must have a unique name within its platform." ) - # Add to tracking set - CORE.unique_ids.add(unique_key) + # Store metadata about this entity + entity_metadata: EntityMetadata = { + "name": entity_name, + "device_id": device_id, + "platform": platform, + "entity_id": str(config.get(CONF_ID, "unknown")), + "component": CORE.current_component or "unknown", + } + + # Add to tracking dict + CORE.unique_ids[unique_key] = entity_metadata return config return validator diff --git a/esphome/types.py b/esphome/types.py index f68f503993..62499a953c 100644 --- a/esphome/types.py +++ b/esphome/types.py @@ -1,5 +1,7 @@ """This helper module tracks commonly used types in the esphome python codebase.""" +from typing import TypedDict + from esphome.core import ID, EsphomeCore, Lambda ConfigFragmentType = ( @@ -16,3 +18,13 @@ ConfigFragmentType = ( ConfigType = dict[str, ConfigFragmentType] CoreType = EsphomeCore ConfigPathType = str | int + + +class EntityMetadata(TypedDict): + """Metadata stored for each entity to help with duplicate detection.""" + + name: str + device_id: str + platform: str + entity_id: str + component: str diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index c639ad94b2..2157bc20a9 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ICON, + CONF_ID, CONF_INTERNAL, CONF_NAME, ) @@ -511,12 +512,18 @@ def test_entity_duplicate_validator() -> None: validated1 = validator(config1) assert validated1 == config1 assert ("", "sensor", "temperature") in CORE.unique_ids + # Check metadata was stored + metadata = CORE.unique_ids[("", "sensor", "temperature")] + assert metadata["name"] == "Temperature" + assert metadata["platform"] == "sensor" # Second entity with different name should pass config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 assert ("", "sensor", "humidity") in CORE.unique_ids + metadata2 = CORE.unique_ids[("", "sensor", "humidity")] + assert metadata2["name"] == "Humidity" # Duplicate entity should fail config3 = {CONF_NAME: "Temperature"} @@ -540,11 +547,15 @@ def test_entity_duplicate_validator_with_devices() -> None: validated1 = validator(config1) assert validated1 == config1 assert ("device1", "sensor", "temperature") in CORE.unique_ids + metadata1 = CORE.unique_ids[("device1", "sensor", "temperature")] + assert metadata1["device_id"] == "device1" config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} validated2 = validator(config2) assert validated2 == config2 assert ("device2", "sensor", "temperature") in CORE.unique_ids + metadata2 = CORE.unique_ids[("device2", "sensor", "temperature")] + assert metadata2["device_id"] == "device2" # Duplicate on same device should fail config3 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} @@ -595,6 +606,54 @@ def test_entity_different_platforms_yaml_validation( assert result is not None +def test_entity_duplicate_validator_error_message() -> None: + """Test that duplicate entity error messages include helpful metadata.""" + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # Set current component to simulate validation context for uptime sensor + CORE.current_component = "sensor.uptime" + + # First entity should pass + config1 = {CONF_NAME: "Battery", CONF_ID: ID("battery_1")} + validated1 = validator(config1) + assert validated1 == config1 + + # Reset component to simulate template sensor + CORE.current_component = "sensor.template" + + # Duplicate entity should fail with detailed error + config2 = {CONF_NAME: "Battery", CONF_ID: ID("battery_2")} + with pytest.raises( + Invalid, + match=r"Duplicate sensor entity with name 'Battery' found.*" + r"Conflicts with entity 'Battery' \(id: battery_1\) from component 'sensor\.uptime'", + ): + validator(config2) + + # Clean up + CORE.current_component = None + + +def test_entity_conflict_between_components_yaml( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that conflicts between different components show helpful error messages.""" + result = load_config_from_fixture( + yaml_file, "entity_conflict_components.yaml", FIXTURES_DIR + ) + assert result is None + + # Check for the enhanced error message + captured = capsys.readouterr() + # The error should mention both the conflict and which component created it + assert "Duplicate sensor entity with name 'Battery' found" in captured.out + # Should mention it conflicts with an entity from a specific sensor platform + assert "from component 'sensor." in captured.out + # Should show it's a conflict between wifi_signal and template + assert "sensor.wifi_signal" in captured.out or "sensor.template" in captured.out + + def test_entity_duplicate_validator_internal_entities() -> None: """Test that internal entities are excluded from duplicate name validation.""" # Create validator for sensor platform @@ -612,14 +671,17 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated2 = validator(config2) assert validated2 == config2 # Internal entity should not be added to unique_ids - assert len([k for k in CORE.unique_ids if k == ("", "sensor", "temperature")]) == 1 + # Count how many times the key appears (should still be 1) + count = sum(1 for k in CORE.unique_ids if k == ("", "sensor", "temperature")) + assert count == 1 # Another internal entity with same name should also pass config3 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - assert len([k for k in CORE.unique_ids if k == ("", "sensor", "temperature")]) == 1 + count = sum(1 for k in CORE.unique_ids if k == ("", "sensor", "temperature")) + assert count == 1 # Non-internal entity with same name should fail config4 = {CONF_NAME: "Temperature"} From d04422e27d97aa1413fef5d5df3e7a19310d3f0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Aug 2025 11:36:15 -0500 Subject: [PATCH 0472/1626] add missing file --- .../entity_conflict_components.yaml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/unit_tests/fixtures/core/entity_helpers/entity_conflict_components.yaml diff --git a/tests/unit_tests/fixtures/core/entity_helpers/entity_conflict_components.yaml b/tests/unit_tests/fixtures/core/entity_helpers/entity_conflict_components.yaml new file mode 100644 index 0000000000..6a1df0f7b4 --- /dev/null +++ b/tests/unit_tests/fixtures/core/entity_helpers/entity_conflict_components.yaml @@ -0,0 +1,20 @@ +esphome: + name: test-device + +esp32: + board: esp32dev + +# Uptime sensor +sensor: + - platform: uptime + name: "Battery" + id: uptime_battery + +# Template sensor also named "Battery" - this should conflict + - platform: template + name: "Battery" + id: template_battery + lambda: |- + return 95.0; + unit_of_measurement: "%" + update_interval: 60s From 1bd4098cea357ca379604b82e8dbabda27f6ec3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Aug 2025 14:36:31 -0500 Subject: [PATCH 0473/1626] [api] Add constexpr optimizations to protobuf encoding --- esphome/components/api/proto.h | 76 ++++++++++++++++------------------ 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 5c174b679c..e7c9ce01b5 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -15,6 +15,23 @@ namespace esphome::api { +// Helper functions for ZigZag encoding/decoding +static constexpr uint32_t encode_zigzag32(int32_t value) { + return (static_cast(value) << 1) ^ (static_cast(value >> 31)); +} + +static constexpr uint64_t encode_zigzag64(int64_t value) { + return (static_cast(value) << 1) ^ (static_cast(value >> 63)); +} + +static constexpr int32_t decode_zigzag32(uint32_t value) { + return (value & 1) ? static_cast(~(value >> 1)) : static_cast(value >> 1); +} + +static constexpr int64_t decode_zigzag64(uint64_t value) { + return (value & 1) ? static_cast(~(value >> 1)) : static_cast(value >> 1); +} + /* * StringRef Ownership Model for API Protocol Messages * =================================================== @@ -87,33 +104,25 @@ class ProtoVarInt { return {}; // Incomplete or invalid varint } - uint16_t as_uint16() const { return this->value_; } - uint32_t as_uint32() const { return this->value_; } - uint64_t as_uint64() const { return this->value_; } - bool as_bool() const { return this->value_; } - int32_t as_int32() const { + constexpr uint16_t as_uint16() const { return this->value_; } + constexpr uint32_t as_uint32() const { return this->value_; } + constexpr uint64_t as_uint64() const { return this->value_; } + constexpr bool as_bool() const { return this->value_; } + constexpr int32_t as_int32() const { // Not ZigZag encoded return static_cast(this->as_int64()); } - int64_t as_int64() const { + constexpr int64_t as_int64() const { // Not ZigZag encoded return static_cast(this->value_); } - int32_t as_sint32() const { + constexpr int32_t as_sint32() const { // with ZigZag encoding - if (this->value_ & 1) { - return static_cast(~(this->value_ >> 1)); - } else { - return static_cast(this->value_ >> 1); - } + return decode_zigzag32(static_cast(this->value_)); } - int64_t as_sint64() const { + constexpr int64_t as_sint64() const { // with ZigZag encoding - if (this->value_ & 1) { - return static_cast(~(this->value_ >> 1)); - } else { - return static_cast(this->value_ >> 1); - } + return decode_zigzag64(this->value_); } /** * Encode the varint value to a pre-allocated buffer without bounds checking. @@ -309,22 +318,10 @@ class ProtoWriteBuffer { this->encode_uint64(field_id, static_cast(value), force); } void encode_sint32(uint32_t field_id, int32_t value, bool force = false) { - uint32_t uvalue; - if (value < 0) { - uvalue = ~(value << 1); - } else { - uvalue = value << 1; - } - this->encode_uint32(field_id, uvalue, force); + this->encode_uint32(field_id, encode_zigzag32(value), force); } void encode_sint64(uint32_t field_id, int64_t value, bool force = false) { - uint64_t uvalue; - if (value < 0) { - uvalue = ~(value << 1); - } else { - uvalue = value << 1; - } - this->encode_uint64(field_id, uvalue, force); + this->encode_uint64(field_id, encode_zigzag64(value), force); } void encode_message(uint32_t field_id, const ProtoMessage &value, bool force = false); std::vector *get_buffer() const { return buffer_; } @@ -395,7 +392,7 @@ class ProtoSize { * @param value The uint32_t value to calculate size for * @return The number of bytes needed to encode the value */ - static inline uint32_t varint(uint32_t value) { + static constexpr uint32_t varint(uint32_t value) { // Optimized varint size calculation using leading zeros // Each 7 bits requires one byte in the varint encoding if (value < 128) @@ -419,7 +416,7 @@ class ProtoSize { * @param value The uint64_t value to calculate size for * @return The number of bytes needed to encode the value */ - static inline uint32_t varint(uint64_t value) { + static constexpr uint32_t varint(uint64_t value) { // Handle common case of values fitting in uint32_t (vast majority of use cases) if (value <= UINT32_MAX) { return varint(static_cast(value)); @@ -450,7 +447,7 @@ class ProtoSize { * @param value The int32_t value to calculate size for * @return The number of bytes needed to encode the value */ - static inline uint32_t varint(int32_t value) { + static constexpr uint32_t varint(int32_t value) { // Negative values are sign-extended to 64 bits in protocol buffers, // which always results in a 10-byte varint for negative int32 if (value < 0) { @@ -466,7 +463,7 @@ class ProtoSize { * @param value The int64_t value to calculate size for * @return The number of bytes needed to encode the value */ - static inline uint32_t varint(int64_t value) { + static constexpr uint32_t varint(int64_t value) { // For int64_t, we convert to uint64_t and calculate the size // This works because the bit pattern determines the encoding size, // and we've handled negative int32 values as a special case above @@ -480,7 +477,7 @@ class ProtoSize { * @param type The wire type value (from the WireType enum in the protobuf spec) * @return The number of bytes needed to encode the field ID and wire type */ - static inline uint32_t field(uint32_t field_id, uint32_t type) { + static constexpr uint32_t field(uint32_t field_id, uint32_t type) { uint32_t tag = (field_id << 3) | (type & 0b111); return varint(tag); } @@ -607,9 +604,8 @@ class ProtoSize { */ inline void add_sint32_force(uint32_t field_id_size, int32_t value) { // Always calculate size when force is true - // ZigZag encoding for sint32: (n << 1) ^ (n >> 31) - uint32_t zigzag = (static_cast(value) << 1) ^ (static_cast(value >> 31)); - total_size_ += field_id_size + varint(zigzag); + // ZigZag encoding for sint32 + total_size_ += field_id_size + varint(encode_zigzag32(value)); } /** From 04415211e6f55819381bd480fabb62463698d2c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Aug 2025 14:52:41 -0500 Subject: [PATCH 0474/1626] [api] Optimize single vector writes to use write() instead of writev() --- esphome/components/api/api_frame_helper.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 6ca38e80ed..dee3af2ac3 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -156,7 +156,9 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ } // Try to send directly if no buffered data - ssize_t sent = this->socket_->writev(iov, iovcnt); + // Optimize for single iovec case (common for plaintext API) + ssize_t sent = + (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); if (sent == -1) { APIError err = this->handle_socket_write_error_(); From 4c2874a32b08001e27feb1d2b1970bdaca4a58ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Aug 2025 15:37:01 -0500 Subject: [PATCH 0475/1626] [esphome] Fix OTA watchdog resets during port scanning and network delays (#10152) --- .../components/esphome/ota/ota_esphome.cpp | 227 ++++++++++++------ esphome/components/esphome/ota/ota_esphome.h | 9 +- 2 files changed, 158 insertions(+), 78 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 4cc82b9094..5217e9c61f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -19,7 +19,9 @@ namespace esphome { static const char *const TAG = "esphome.ota"; -static constexpr u_int16_t OTA_BLOCK_SIZE = 8192; +static constexpr uint16_t OTA_BLOCK_SIZE = 8192; +static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake +static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer void ESPHomeOTAComponent::setup() { #ifdef USE_OTA_STATE_CALLBACK @@ -28,19 +30,19 @@ void ESPHomeOTAComponent::setup() { this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0); // monitored for incoming connections if (this->server_ == nullptr) { - ESP_LOGW(TAG, "Could not create socket"); + this->log_socket_error_("creation"); this->mark_failed(); return; } int enable = 1; int err = this->server_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { - ESP_LOGW(TAG, "Socket unable to set reuseaddr: errno %d", err); + this->log_socket_error_("reuseaddr"); // we can still continue } err = this->server_->setblocking(false); if (err != 0) { - ESP_LOGW(TAG, "Socket unable to set nonblocking mode: errno %d", err); + this->log_socket_error_("non-blocking"); this->mark_failed(); return; } @@ -49,21 +51,21 @@ void ESPHomeOTAComponent::setup() { socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); if (sl == 0) { - ESP_LOGW(TAG, "Socket unable to set sockaddr: errno %d", errno); + this->log_socket_error_("set sockaddr"); this->mark_failed(); return; } err = this->server_->bind((struct sockaddr *) &server, sizeof(server)); if (err != 0) { - ESP_LOGW(TAG, "Socket unable to bind: errno %d", errno); + this->log_socket_error_("bind"); this->mark_failed(); return; } err = this->server_->listen(4); if (err != 0) { - ESP_LOGW(TAG, "Socket unable to listen: errno %d", errno); + this->log_socket_error_("listen"); this->mark_failed(); return; } @@ -83,17 +85,93 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Skip handle_() call if no client connected and no incoming connections + // Skip handle_handshake_() call if no client connected and no incoming connections // This optimization reduces idle loop overhead when OTA is not active - // Note: No need to check server_ for null as the component is marked failed in setup() if server_ creation fails + // Note: No need to check server_ for null as the component is marked failed in setup() + // if server_ creation fails if (this->client_ != nullptr || this->server_->ready()) { - this->handle_(); + this->handle_handshake_(); } } static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; -void ESPHomeOTAComponent::handle_() { +void ESPHomeOTAComponent::handle_handshake_() { + /// Handle the initial OTA handshake. + /// + /// This method is non-blocking and will return immediately if no data is available. + /// It waits for the first magic byte (0x6C) before proceeding to handle_data_(). + /// A 10-second timeout is enforced from initial connection. + + if (this->client_ == nullptr) { + // We already checked server_->ready() in loop(), so we can accept directly + struct sockaddr_storage source_addr; + socklen_t addr_len = sizeof(source_addr); + int enable = 1; + + this->client_ = this->server_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); + if (this->client_ == nullptr) + return; + int err = this->client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); + if (err != 0) { + this->log_socket_error_("nodelay"); + this->cleanup_connection_(); + return; + } + err = this->client_->setblocking(false); + if (err != 0) { + this->log_socket_error_("non-blocking"); + this->cleanup_connection_(); + return; + } + this->log_start_("handshake"); + this->client_connect_time_ = App.get_loop_component_start_time(); + } + + // Check for handshake timeout + uint32_t now = App.get_loop_component_start_time(); + if (now - this->client_connect_time_ > OTA_SOCKET_TIMEOUT_HANDSHAKE) { + ESP_LOGW(TAG, "Handshake timeout"); + this->cleanup_connection_(); + return; + } + + // Try to read first byte of magic bytes + uint8_t first_byte; + ssize_t read = this->client_->read(&first_byte, 1); + + if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + return; // No data yet, try again next loop + } + + if (read <= 0) { + // Error or connection closed + if (read == -1) { + this->log_socket_error_("reading first byte"); + } else { + ESP_LOGW(TAG, "Remote closed during handshake"); + } + this->cleanup_connection_(); + return; + } + + // Got first byte, check if it's the magic byte + if (first_byte != 0x6C) { + ESP_LOGW(TAG, "Invalid initial byte: 0x%02X", first_byte); + this->cleanup_connection_(); + return; + } + + // First byte is valid, continue with data handling + this->handle_data_(); +} + +void ESPHomeOTAComponent::handle_data_() { + /// Handle the OTA data transfer and update process. + /// + /// This method is blocking and will not return until the OTA update completes, + /// fails, or times out. It handles authentication, receives the firmware data, + /// writes it to flash, and reboots on success. ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; bool update_started = false; size_t total = 0; @@ -108,38 +186,14 @@ void ESPHomeOTAComponent::handle_() { size_t size_acknowledged = 0; #endif - if (this->client_ == nullptr) { - // We already checked server_->ready() in loop(), so we can accept directly - struct sockaddr_storage source_addr; - socklen_t addr_len = sizeof(source_addr); - this->client_ = this->server_->accept((struct sockaddr *) &source_addr, &addr_len); - if (this->client_ == nullptr) - return; - } - - int enable = 1; - int err = this->client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); - if (err != 0) { - ESP_LOGW(TAG, "Socket could not enable TCP nodelay, errno %d", errno); - this->client_->close(); - this->client_ = nullptr; - return; - } - - ESP_LOGD(TAG, "Starting update from %s", this->client_->getpeername().c_str()); - this->status_set_warning(); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_STARTED, 0.0f, 0); -#endif - - if (!this->readall_(buf, 5)) { - ESP_LOGW(TAG, "Reading magic bytes failed"); + // Read remaining 4 bytes of magic (we already read the first byte 0x6C in handle_handshake_) + if (!this->readall_(buf, 4)) { + this->log_read_error_("magic bytes"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - // 0x6C, 0x26, 0xF7, 0x5C, 0x45 - if (buf[0] != 0x6C || buf[1] != 0x26 || buf[2] != 0xF7 || buf[3] != 0x5C || buf[4] != 0x45) { - ESP_LOGW(TAG, "Magic bytes do not match! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", buf[0], buf[1], buf[2], buf[3], - buf[4]); + // Check remaining magic bytes: 0x26, 0xF7, 0x5C, 0x45 + if (buf[0] != 0x26 || buf[1] != 0xF7 || buf[2] != 0x5C || buf[3] != 0x45) { + ESP_LOGW(TAG, "Magic bytes mismatch! 0x6C-0x%02X-0x%02X-0x%02X-0x%02X", buf[0], buf[1], buf[2], buf[3]); error_code = ota::OTA_RESPONSE_ERROR_MAGIC; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -153,7 +207,7 @@ void ESPHomeOTAComponent::handle_() { // Read features - 1 byte if (!this->readall_(buf, 1)) { - ESP_LOGW(TAG, "Reading features failed"); + this->log_read_error_("features"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_features = buf[0]; // NOLINT @@ -232,7 +286,7 @@ void ESPHomeOTAComponent::handle_() { // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { - ESP_LOGW(TAG, "Reading size failed"); + this->log_read_error_("size"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_size = 0; @@ -242,6 +296,17 @@ void ESPHomeOTAComponent::handle_() { } ESP_LOGV(TAG, "Size is %u bytes", ota_size); + // Now that we've passed authentication and are actually + // starting the update, set the warning status and notify + // listeners. This ensures that port scanners do not + // accidentally trigger the update process. + this->log_start_("update"); + this->status_set_warning(); +#ifdef USE_OTA_STATE_CALLBACK + this->state_callback_.call(ota::OTA_STARTED, 0.0f, 0); +#endif + + // This will block for a few seconds as it locks flash error_code = backend->begin(ota_size); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) @@ -253,7 +318,7 @@ void ESPHomeOTAComponent::handle_() { // Read binary MD5, 32 bytes if (!this->readall_(buf, 32)) { - ESP_LOGW(TAG, "Reading binary MD5 checksum failed"); + this->log_read_error_("MD5 checksum"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } sbuf[32] = '\0'; @@ -270,23 +335,22 @@ void ESPHomeOTAComponent::handle_() { ssize_t read = this->client_->read(buf, requested); if (read == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { - App.feed_wdt(); - delay(1); + this->yield_and_feed_watchdog_(); continue; } - ESP_LOGW(TAG, "Error receiving data for update, errno %d", errno); + ESP_LOGW(TAG, "Read error, errno %d", errno); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } else if (read == 0) { // $ man recv // "When a stream socket peer has performed an orderly shutdown, the return value will // be 0 (the traditional "end-of-file" return)." - ESP_LOGW(TAG, "Remote end closed connection"); + ESP_LOGW(TAG, "Remote closed connection"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } error_code = backend->write(buf, read); if (error_code != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Error writing binary data to flash!, error_code: %d", error_code); + ESP_LOGW(TAG, "Flash write error, code: %d", error_code); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } total += read; @@ -307,8 +371,7 @@ void ESPHomeOTAComponent::handle_() { this->state_callback_.call(ota::OTA_IN_PROGRESS, percentage, 0); #endif // feed watchdog and give other tasks a chance to run - App.feed_wdt(); - yield(); + this->yield_and_feed_watchdog_(); } } @@ -318,7 +381,7 @@ void ESPHomeOTAComponent::handle_() { error_code = backend->end(); if (error_code != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Error ending update! error_code: %d", error_code); + ESP_LOGW(TAG, "Error ending update! code: %d", error_code); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -328,12 +391,11 @@ void ESPHomeOTAComponent::handle_() { // Read ACK if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Reading back acknowledgement failed"); + this->log_read_error_("ack"); // do not go to error, this is not fatal } - this->client_->close(); - this->client_ = nullptr; + this->cleanup_connection_(); delay(10); ESP_LOGI(TAG, "Update complete"); this->status_clear_warning(); @@ -346,8 +408,7 @@ void ESPHomeOTAComponent::handle_() { error: buf[0] = static_cast(error_code); this->writeall_(buf, 1); - this->client_->close(); - this->client_ = nullptr; + this->cleanup_connection_(); if (backend != nullptr && update_started) { backend->abort(); @@ -364,28 +425,24 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { uint32_t at = 0; while (len - at > 0) { uint32_t now = millis(); - if (now - start > 1000) { - ESP_LOGW(TAG, "Timed out reading %d bytes of data", len); + if (now - start > OTA_SOCKET_TIMEOUT_DATA) { + ESP_LOGW(TAG, "Timeout reading %d bytes", len); return false; } ssize_t read = this->client_->read(buf + at, len - at); if (read == -1) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - App.feed_wdt(); - delay(1); - continue; + if (errno != EAGAIN && errno != EWOULDBLOCK) { + ESP_LOGW(TAG, "Error reading %d bytes, errno %d", len, errno); + return false; } - ESP_LOGW(TAG, "Failed to read %d bytes of data, errno %d", len, errno); - return false; } else if (read == 0) { ESP_LOGW(TAG, "Remote closed connection"); return false; } else { at += read; } - App.feed_wdt(); - delay(1); + this->yield_and_feed_watchdog_(); } return true; @@ -395,25 +452,21 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { uint32_t at = 0; while (len - at > 0) { uint32_t now = millis(); - if (now - start > 1000) { - ESP_LOGW(TAG, "Timed out writing %d bytes of data", len); + if (now - start > OTA_SOCKET_TIMEOUT_DATA) { + ESP_LOGW(TAG, "Timeout writing %d bytes", len); return false; } ssize_t written = this->client_->write(buf + at, len - at); if (written == -1) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - App.feed_wdt(); - delay(1); - continue; + if (errno != EAGAIN && errno != EWOULDBLOCK) { + ESP_LOGW(TAG, "Error writing %d bytes, errno %d", len, errno); + return false; } - ESP_LOGW(TAG, "Failed to write %d bytes of data, errno %d", len, errno); - return false; } else { at += written; } - App.feed_wdt(); - delay(1); + this->yield_and_feed_watchdog_(); } return true; } @@ -421,5 +474,25 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { float ESPHomeOTAComponent::get_setup_priority() const { return setup_priority::AFTER_WIFI; } uint16_t ESPHomeOTAComponent::get_port() const { return this->port_; } void ESPHomeOTAComponent::set_port(uint16_t port) { this->port_ = port; } + +void ESPHomeOTAComponent::log_socket_error_(const char *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", msg, errno); } + +void ESPHomeOTAComponent::log_read_error_(const char *what) { ESP_LOGW(TAG, "Read %s failed", what); } + +void ESPHomeOTAComponent::log_start_(const char *phase) { + ESP_LOGD(TAG, "Starting %s from %s", phase, this->client_->getpeername().c_str()); +} + +void ESPHomeOTAComponent::cleanup_connection_() { + this->client_->close(); + this->client_ = nullptr; + this->client_connect_time_ = 0; +} + +void ESPHomeOTAComponent::yield_and_feed_watchdog_() { + App.feed_wdt(); + delay(1); +} + } // namespace esphome #endif diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index e0d09ff37e..8397b86528 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -27,15 +27,22 @@ class ESPHomeOTAComponent : public ota::OTAComponent { uint16_t get_port() const; protected: - void handle_(); + void handle_handshake_(); + void handle_data_(); bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); + void log_socket_error_(const char *msg); + void log_read_error_(const char *what); + void log_start_(const char *phase); + void cleanup_connection_(); + void yield_and_feed_watchdog_(); #ifdef USE_OTA_PASSWORD std::string password_; #endif // USE_OTA_PASSWORD uint16_t port_; + uint32_t client_connect_time_{0}; std::unique_ptr server_; std::unique_ptr client_; From 9aa21956c8af931f529d51bfb972d49292daa375 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Aug 2025 15:41:08 -0500 Subject: [PATCH 0476/1626] [api] Optimize single vector writes to use write() instead of writev() (#10193) --- esphome/components/api/api_frame_helper.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 6ca38e80ed..dee3af2ac3 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -156,7 +156,9 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ } // Try to send directly if no buffered data - ssize_t sent = this->socket_->writev(iov, iovcnt); + // Optimize for single iovec case (common for plaintext API) + ssize_t sent = + (iovcnt == 1) ? this->socket_->write(iov[0].iov_base, iov[0].iov_len) : this->socket_->writev(iov, iovcnt); if (sent == -1) { APIError err = this->handle_socket_write_error_(); From 42aee53dde435611cf152f5fc88d53bf60ca9d48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Aug 2025 15:47:46 -0500 Subject: [PATCH 0477/1626] [bluetooth_proxy] Replace dynamic vector with fixed array for BLE advertisements (#10174) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 8 +- esphome/components/api/api_pb2.h | 5 +- esphome/components/api/api_pb2_dump.cpp | 4 +- .../components/bluetooth_proxy/__init__.py | 6 + .../bluetooth_proxy/bluetooth_proxy.cpp | 57 ++-------- .../bluetooth_proxy/bluetooth_proxy.h | 4 +- esphome/core/defines.h | 1 + script/api_protobuf/api_protobuf.py | 106 +++++++++++++++--- 10 files changed, 124 insertions(+), 70 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 9d77ecdfa8..6b19f2026a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1438,7 +1438,7 @@ message BluetoothLERawAdvertisementsResponse { option (ifdef) = "USE_BLUETOOTH_PROXY"; option (no_delay) = true; - repeated BluetoothLERawAdvertisement advertisements = 1; + repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"]; } enum BluetoothDeviceRequestType { diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index ed0e0d7455..50c43b96fd 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -30,6 +30,7 @@ extend google.protobuf.FieldOptions { optional bool no_zero_copy = 50008 [default=false]; optional bool fixed_array_skip_zero = 50009 [default=false]; optional string fixed_array_size_define = 50010; + optional string fixed_array_with_length_define = 50011; // container_pointer: Zero-copy optimization for repeated fields. // diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 5dddc79b49..476e3c88d0 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1843,12 +1843,14 @@ void BluetoothLERawAdvertisement::calculate_size(ProtoSize &size) const { size.add_length(1, this->data_len); } void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer buffer) const { - for (auto &it : this->advertisements) { - buffer.encode_message(1, it, true); + for (uint16_t i = 0; i < this->advertisements_len; i++) { + buffer.encode_message(1, this->advertisements[i], true); } } void BluetoothLERawAdvertisementsResponse::calculate_size(ProtoSize &size) const { - size.add_repeated_message(1, this->advertisements); + for (uint16_t i = 0; i < this->advertisements_len; i++) { + size.add_message_object_force(1, this->advertisements[i]); + } } bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d43d3c61b7..edf839be55 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1788,11 +1788,12 @@ class BluetoothLERawAdvertisement : public ProtoMessage { class BluetoothLERawAdvertisementsResponse : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 93; - static constexpr uint8_t ESTIMATED_SIZE = 34; + static constexpr uint8_t ESTIMATED_SIZE = 136; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_le_raw_advertisements_response"; } #endif - std::vector advertisements{}; + std::array advertisements{}; + uint16_t advertisements_len{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index b212353ad8..7af322f96d 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1534,9 +1534,9 @@ void BluetoothLERawAdvertisement::dump_to(std::string &out) const { } void BluetoothLERawAdvertisementsResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothLERawAdvertisementsResponse"); - for (const auto &it : this->advertisements) { + for (uint16_t i = 0; i < this->advertisements_len; i++) { out.append(" advertisements: "); - it.dump_to(out); + this->advertisements[i].dump_to(out); out.append("\n"); } } diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index fb7f7a37c0..112faa27e5 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -118,6 +118,12 @@ async def to_code(config): connection_count = len(config.get(CONF_CONNECTIONS, [])) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) + # Define batch size for BLE advertisements + # Each advertisement is up to 80 bytes when packaged (including protocol overhead) + # 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload + # This achieves ~97% WiFi MTU utilization while staying under the limit + cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + for connection_conf in config.get(CONF_CONNECTIONS, []): connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) await cg.register_component(connection_var, connection_conf) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 04b85fc3f0..723466a5ff 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -11,12 +11,8 @@ namespace esphome::bluetooth_proxy { static const char *const TAG = "bluetooth_proxy"; -// Batch size for BLE advertisements to maximize WiFi efficiency -// Each advertisement is up to 80 bytes when packaged (including protocol overhead) -// Most advertisements are 20-30 bytes, allowing even more to fit per packet -// 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload -// This achieves ~97% WiFi MTU utilization while staying under the limit -static constexpr size_t FLUSH_BATCH_SIZE = 16; +// BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE is defined during code generation +// It sets the batch size for BLE advertisements to maximize WiFi efficiency // Verify BLE advertisement data array size matches the BLE specification (31 bytes adv + 31 bytes scan response) static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62, @@ -25,13 +21,6 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62 BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } void BluetoothProxy::setup() { - // Reserve capacity but start with size 0 - // Reserve 50% since we'll grow naturally and flush at FLUSH_BATCH_SIZE - this->response_.advertisements.reserve(FLUSH_BATCH_SIZE / 2); - - // Don't pre-allocate pool - let it grow only if needed in busy environments - // Many devices in quiet areas will never need the overflow pool - this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; @@ -88,33 +77,21 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, auto &result = scan_results[i]; uint8_t length = result.adv_data_len + result.scan_rsp_len; - // Check if we need to expand the vector - if (this->advertisement_count_ >= advertisements.size()) { - if (this->advertisement_pool_.empty()) { - // No room in pool, need to allocate - advertisements.emplace_back(); - } else { - // Pull from pool - advertisements.push_back(std::move(this->advertisement_pool_.back())); - this->advertisement_pool_.pop_back(); - } - } - // Fill in the data directly at current position - auto &adv = advertisements[this->advertisement_count_]; + auto &adv = advertisements[this->response_.advertisements_len]; adv.address = esp32_ble::ble_addr_to_uint64(result.bda); adv.rssi = result.rssi; adv.address_type = result.ble_addr_type; adv.data_len = length; std::memcpy(adv.data, result.ble_adv, length); - this->advertisement_count_++; + this->response_.advertisements_len++; ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", result.bda[0], result.bda[1], result.bda[2], result.bda[3], result.bda[4], result.bda[5], length, result.rssi); - // Flush if we have reached FLUSH_BATCH_SIZE - if (this->advertisement_count_ >= FLUSH_BATCH_SIZE) { + // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE + if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { this->flush_pending_advertisements(); } } @@ -123,27 +100,17 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, } void BluetoothProxy::flush_pending_advertisements() { - if (this->advertisement_count_ == 0 || !api::global_api_server->is_connected() || this->api_connection_ == nullptr) + if (this->response_.advertisements_len == 0 || !api::global_api_server->is_connected() || + this->api_connection_ == nullptr) return; - auto &advertisements = this->response_.advertisements; - - // Return any items beyond advertisement_count_ to the pool - if (advertisements.size() > this->advertisement_count_) { - // Move unused items back to pool - this->advertisement_pool_.insert(this->advertisement_pool_.end(), - std::make_move_iterator(advertisements.begin() + this->advertisement_count_), - std::make_move_iterator(advertisements.end())); - - // Resize to actual count - advertisements.resize(this->advertisement_count_); - } - // Send the message this->api_connection_->send_message(this->response_, api::BluetoothLERawAdvertisementsResponse::MESSAGE_TYPE); - // Reset count - existing items will be overwritten in next batch - this->advertisement_count_ = 0; + ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); + + // Reset the length for the next batch + this->response_.advertisements_len = 0; } void BluetoothProxy::dump_config() { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 21695d9819..bc8d3ed762 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -150,7 +150,6 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com std::array connections_{}; // BLE advertisement batching - std::vector advertisement_pool_; api::BluetoothLERawAdvertisementsResponse response_; // Group 3: 4-byte types @@ -161,9 +160,8 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com // Group 4: 1-byte types grouped together bool active_; - uint8_t advertisement_count_{0}; uint8_t connection_count_{0}; - // 3 bytes used, 1 byte padding + // 2 bytes used, 2 bytes padding }; extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7631ff54f3..01f6811e05 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -148,6 +148,7 @@ #define USE_BLUETOOTH_PROXY #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 #define USE_CAPTIVE_PORTAL #define USE_ESP32_BLE #define USE_ESP32_BLE_CLIENT diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index fa2f87d98d..3396e5ad05 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -339,6 +339,11 @@ def create_field_type_info( ) -> TypeInfo: """Create the appropriate TypeInfo instance for a field, handling repeated fields and custom options.""" if field.label == 3: # repeated + # Check if this repeated field has fixed_array_with_length_define option + if ( + fixed_size := get_field_opt(field, pb.fixed_array_with_length_define) + ) is not None: + return FixedArrayWithLengthRepeatedType(field, fixed_size) # Check if this repeated field has fixed_array_size option if (fixed_size := get_field_opt(field, pb.fixed_array_size)) is not None: return FixedArrayRepeatedType(field, fixed_size) @@ -1052,7 +1057,7 @@ def _generate_array_dump_content( """ o = f"for (const auto {'' if is_bool else '&'}it : {field_name}) {{\n" # Check if underlying type can use dump_field - if type(ti).can_use_dump_field(): + if ti.can_use_dump_field(): # For types that have dump_field overloads, use them with extra indent o += f' dump_field(out, "{name}", {ti.dump_field_value("it")}, 4);\n' else: @@ -1084,6 +1089,12 @@ class FixedArrayRepeatedType(TypeInfo): validate_field_type(field.type, field.name) self._ti: TypeInfo = TYPE_INFO[field.type](field) + def _encode_element(self, element: str) -> str: + """Helper to generate encode statement for a single element.""" + if isinstance(self._ti, EnumType): + return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" + return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" + @property def cpp_type(self) -> str: return f"std::array<{self._ti.cpp_type}, {self.array_size}>" @@ -1111,19 +1122,13 @@ class FixedArrayRepeatedType(TypeInfo): @property def encode_content(self) -> str: - # Helper to generate encode statement for a single element - def encode_element(element: str) -> str: - if isinstance(self._ti, EnumType): - return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" - return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" - # If skip_zero is enabled, wrap encoding in a zero check if self.skip_zero: if self.is_define: # When using a define, we need to use a loop-based approach o = f"for (const auto &it : this->{self.field_name}) {{\n" o += " if (it != 0) {\n" - o += f" {encode_element('it')}\n" + o += f" {self._encode_element('it')}\n" o += " }\n" o += "}" return o @@ -1132,7 +1137,7 @@ class FixedArrayRepeatedType(TypeInfo): [f"this->{self.field_name}[{i}] != 0" for i in range(self.array_size)] ) encode_lines = [ - f" {encode_element(f'this->{self.field_name}[{i}]')}" + f" {self._encode_element(f'this->{self.field_name}[{i}]')}" for i in range(self.array_size) ] return f"if ({non_zero_checks}) {{\n" + "\n".join(encode_lines) + "\n}" @@ -1140,23 +1145,23 @@ class FixedArrayRepeatedType(TypeInfo): # When using a define, always use loop-based approach if self.is_define: o = f"for (const auto &it : this->{self.field_name}) {{\n" - o += f" {encode_element('it')}\n" + o += f" {self._encode_element('it')}\n" o += "}" return o # Unroll small arrays for efficiency if self.array_size == 1: - return encode_element(f"this->{self.field_name}[0]") + return self._encode_element(f"this->{self.field_name}[0]") if self.array_size == 2: return ( - encode_element(f"this->{self.field_name}[0]") + self._encode_element(f"this->{self.field_name}[0]") + "\n " - + encode_element(f"this->{self.field_name}[1]") + + self._encode_element(f"this->{self.field_name}[1]") ) # Use loops for larger arrays o = f"for (const auto &it : this->{self.field_name}) {{\n" - o += f" {encode_element('it')}\n" + o += f" {self._encode_element('it')}\n" o += "}" return o @@ -1230,6 +1235,66 @@ class FixedArrayRepeatedType(TypeInfo): return underlying_size * self.array_size +class FixedArrayWithLengthRepeatedType(FixedArrayRepeatedType): + """Special type for fixed-size repeated fields with variable length tracking. + + Similar to FixedArrayRepeatedType but generates an additional length field + to track how many elements are actually in use. Only encodes/sends elements + up to the current length. + + Fixed arrays with length are only supported for encoding (SOURCE_SERVER) since + we cannot control how many items we receive when decoding. + """ + + @property + def public_content(self) -> list[str]: + # Return both the array and the length field + return [ + f"{self.cpp_type} {self.field_name}{{}};", + f"uint16_t {self.field_name}_len{{0}};", + ] + + @property + def encode_content(self) -> str: + # Always use a loop up to the current length + o = f"for (uint16_t i = 0; i < this->{self.field_name}_len; i++) {{\n" + o += f" {self._encode_element(f'this->{self.field_name}[i]')}\n" + o += "}" + return o + + @property + def dump_content(self) -> str: + # Dump only the active elements + o = f"for (uint16_t i = 0; i < this->{self.field_name}_len; i++) {{\n" + # Check if underlying type can use dump_field + if self._ti.can_use_dump_field(): + o += f' dump_field(out, "{self.name}", {self._ti.dump_field_value(f"this->{self.field_name}[i]")}, 4);\n' + else: + o += f' out.append(" {self.name}: ");\n' + o += indent(self._ti.dump(f"this->{self.field_name}[i]")) + "\n" + o += ' out.append("\\n");\n' + o += "}" + return o + + def get_size_calculation(self, name: str, force: bool = False) -> str: + # Calculate size only for active elements + o = f"for (uint16_t i = 0; i < {name}_len; i++) {{\n" + o += f" {self._ti.get_size_calculation(f'{name}[i]', True)}\n" + o += "}" + return o + + def get_estimated_size(self) -> int: + # For fixed arrays with length, estimate based on typical usage + # Assume on average half the array is used + underlying_size = self._ti.get_estimated_size() + if self.is_define: + # When using a define, estimate 8 elements as typical + return underlying_size * 8 + return underlying_size * ( + self.array_size // 2 if self.array_size > 2 else self.array_size + ) + + class RepeatedTypeInfo(TypeInfo): def __init__(self, field: descriptor.FieldDescriptorProto) -> None: super().__init__(field) @@ -1711,6 +1776,19 @@ def build_message_type( f"since we cannot trust or control the number of items received from clients." ) + # Validate that fixed_array_with_length_define is only used in encode-only messages + if ( + needs_decode + and field.label == 3 + and get_field_opt(field, pb.fixed_array_with_length_define) is not None + ): + raise ValueError( + f"Message '{desc.name}' uses fixed_array_with_length_define on field '{field.name}' " + f"but has source={SOURCE_NAMES[source]}. " + f"Fixed arrays with length are only supported for SOURCE_SERVER (encode-only) messages " + f"since we cannot trust or control the number of items received from clients." + ) + ti = create_field_type_info(field, needs_decode, needs_encode) # Skip field declarations for fields that are in the base class From c14c4fb658dae18deb53fe425ea18a476f5572e9 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 12 Aug 2025 09:12:54 +1200 Subject: [PATCH 0478/1626] [substitutions] Add some safe built-in functions to jinja parsing (#10178) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/substitutions/jinja.py | 24 ++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/esphome/components/substitutions/jinja.py b/esphome/components/substitutions/jinja.py index cf393d2a5d..c6e40a668d 100644 --- a/esphome/components/substitutions/jinja.py +++ b/esphome/components/substitutions/jinja.py @@ -25,6 +25,24 @@ def has_jinja(st): return detect_jinja_re.search(st) is not None +# SAFE_GLOBAL_FUNCTIONS defines a allowlist of built-in functions that are considered safe to expose +# in Jinja templates or other sandboxed evaluation contexts. Only functions that do not allow +# arbitrary code execution, file access, or other security risks are included. +# +# The following functions are considered safe: +# - ord: Converts a character to its Unicode code point integer. +# - chr: Converts an integer to its corresponding Unicode character. +# - len: Returns the length of a sequence or collection. +# +# These functions were chosen because they are pure, have no side effects, and do not provide access +# to the file system, environment, or other potentially sensitive resources. +SAFE_GLOBAL_FUNCTIONS = { + "ord": ord, + "chr": chr, + "len": len, +} + + class JinjaStr(str): """ Wraps a string containing an unresolved Jinja expression, @@ -66,7 +84,11 @@ class Jinja: self.env.add_extension("jinja2.ext.do") self.env.globals["math"] = math # Inject entire math module self.context_vars = {**context_vars} - self.env.globals = {**self.env.globals, **self.context_vars} + self.env.globals = { + **self.env.globals, + **self.context_vars, + **SAFE_GLOBAL_FUNCTIONS, + } def expand(self, content_str): """ From 82b7c1224c17d533db47c92ace97e27a11433eed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Aug 2025 16:58:51 -0500 Subject: [PATCH 0479/1626] [core] Improve entity duplicate validation error messages (#10184) --- esphome/config.py | 4 +- esphome/core/__init__.py | 22 +++++-- esphome/core/entity_helpers.py | 37 ++++++++++- esphome/types.py | 12 ++++ tests/unit_tests/core/test_entity_helpers.py | 66 ++++++++++++++++++- .../entity_conflict_components.yaml | 20 ++++++ 6 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 tests/unit_tests/fixtures/core/entity_helpers/entity_conflict_components.yaml diff --git a/esphome/config.py b/esphome/config.py index cf7a232d8e..ecd0cbb048 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -627,13 +627,15 @@ class SchemaValidationStep(ConfigValidationStep): def __init__( self, domain: str, path: ConfigPath, conf: ConfigType, comp: ComponentManifest ): + self.domain = domain self.path = path self.conf = conf self.comp = comp def run(self, result: Config) -> None: token = path_context.set(self.path) - with result.catch_error(self.path): + # The domain already contains the full component path (e.g., "sensor.template", "sensor.uptime") + with CORE.component_context(self.domain), result.catch_error(self.path): if self.comp.is_platform: # Remove 'platform' key for validation input_conf = OrderedDict(self.conf) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 39c6c3def1..9df5da1c78 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -1,4 +1,5 @@ from collections import defaultdict +from contextlib import contextmanager import logging import math import os @@ -38,7 +39,7 @@ from esphome.util import OrderedDict if TYPE_CHECKING: from ..cpp_generator import MockObj, MockObjClass, Statement - from ..types import ConfigType + from ..types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) @@ -571,14 +572,16 @@ class EsphomeCore: # Key: platform name (e.g. "sensor", "binary_sensor"), Value: count self.platform_counts: defaultdict[str, int] = defaultdict(int) # Track entity unique IDs to handle duplicates - # Set of (device_id, platform, sanitized_name) tuples - self.unique_ids: set[tuple[str, str, str]] = set() + # Dict mapping (device_id, platform, sanitized_name) -> entity metadata + self.unique_ids: dict[tuple[str, str, str], EntityMetadata] = {} # Whether ESPHome was started in verbose mode self.verbose = False # Whether ESPHome was started in quiet mode self.quiet = False # A list of all known ID classes self.id_classes = {} + # The current component being processed during validation + self.current_component: str | None = None def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -604,9 +607,20 @@ class EsphomeCore: self.loaded_integrations = set() self.component_ids = set() self.platform_counts = defaultdict(int) - self.unique_ids = set() + self.unique_ids = {} + self.current_component = None PIN_SCHEMA_REGISTRY.reset() + @contextmanager + def component_context(self, component: str): + """Context manager to set the current component being processed.""" + old_component = self.current_component + self.current_component = component + try: + yield + finally: + self.current_component = old_component + @property def address(self) -> str | None: if self.config is None: diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index cc388ffb4c..107b9fd739 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -16,7 +16,7 @@ from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, add, get_variable import esphome.final_validate as fv from esphome.helpers import sanitize, snake_case -from esphome.types import ConfigType +from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) @@ -214,14 +214,45 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Check for duplicates unique_key = (device_id, platform, name_key) if unique_key in CORE.unique_ids: + # Get the existing entity metadata + existing = CORE.unique_ids[unique_key] + existing_name = existing.get("name", entity_name) + existing_device = existing.get("device_id", "") + existing_id = existing.get("entity_id", "unknown") + + # Build detailed error message device_prefix = f" on device '{device_id}'" if device_id else "" + existing_device_prefix = ( + f" on device '{existing_device}'" if existing_device else "" + ) + existing_component = existing.get("component", "unknown") + + # Provide more context about where the duplicate was found + conflict_msg = ( + f"Conflicts with entity '{existing_name}'{existing_device_prefix}" + ) + if existing_id != "unknown": + conflict_msg += f" (id: {existing_id})" + if existing_component != "unknown": + conflict_msg += f" from component '{existing_component}'" + raise cv.Invalid( f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " + f"{conflict_msg}. " f"Each entity on a device must have a unique name within its platform." ) - # Add to tracking set - CORE.unique_ids.add(unique_key) + # Store metadata about this entity + entity_metadata: EntityMetadata = { + "name": entity_name, + "device_id": device_id, + "platform": platform, + "entity_id": str(config.get(CONF_ID, "unknown")), + "component": CORE.current_component or "unknown", + } + + # Add to tracking dict + CORE.unique_ids[unique_key] = entity_metadata return config return validator diff --git a/esphome/types.py b/esphome/types.py index f68f503993..62499a953c 100644 --- a/esphome/types.py +++ b/esphome/types.py @@ -1,5 +1,7 @@ """This helper module tracks commonly used types in the esphome python codebase.""" +from typing import TypedDict + from esphome.core import ID, EsphomeCore, Lambda ConfigFragmentType = ( @@ -16,3 +18,13 @@ ConfigFragmentType = ( ConfigType = dict[str, ConfigFragmentType] CoreType = EsphomeCore ConfigPathType = str | int + + +class EntityMetadata(TypedDict): + """Metadata stored for each entity to help with duplicate detection.""" + + name: str + device_id: str + platform: str + entity_id: str + component: str diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index c639ad94b2..2157bc20a9 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ICON, + CONF_ID, CONF_INTERNAL, CONF_NAME, ) @@ -511,12 +512,18 @@ def test_entity_duplicate_validator() -> None: validated1 = validator(config1) assert validated1 == config1 assert ("", "sensor", "temperature") in CORE.unique_ids + # Check metadata was stored + metadata = CORE.unique_ids[("", "sensor", "temperature")] + assert metadata["name"] == "Temperature" + assert metadata["platform"] == "sensor" # Second entity with different name should pass config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 assert ("", "sensor", "humidity") in CORE.unique_ids + metadata2 = CORE.unique_ids[("", "sensor", "humidity")] + assert metadata2["name"] == "Humidity" # Duplicate entity should fail config3 = {CONF_NAME: "Temperature"} @@ -540,11 +547,15 @@ def test_entity_duplicate_validator_with_devices() -> None: validated1 = validator(config1) assert validated1 == config1 assert ("device1", "sensor", "temperature") in CORE.unique_ids + metadata1 = CORE.unique_ids[("device1", "sensor", "temperature")] + assert metadata1["device_id"] == "device1" config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} validated2 = validator(config2) assert validated2 == config2 assert ("device2", "sensor", "temperature") in CORE.unique_ids + metadata2 = CORE.unique_ids[("device2", "sensor", "temperature")] + assert metadata2["device_id"] == "device2" # Duplicate on same device should fail config3 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} @@ -595,6 +606,54 @@ def test_entity_different_platforms_yaml_validation( assert result is not None +def test_entity_duplicate_validator_error_message() -> None: + """Test that duplicate entity error messages include helpful metadata.""" + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # Set current component to simulate validation context for uptime sensor + CORE.current_component = "sensor.uptime" + + # First entity should pass + config1 = {CONF_NAME: "Battery", CONF_ID: ID("battery_1")} + validated1 = validator(config1) + assert validated1 == config1 + + # Reset component to simulate template sensor + CORE.current_component = "sensor.template" + + # Duplicate entity should fail with detailed error + config2 = {CONF_NAME: "Battery", CONF_ID: ID("battery_2")} + with pytest.raises( + Invalid, + match=r"Duplicate sensor entity with name 'Battery' found.*" + r"Conflicts with entity 'Battery' \(id: battery_1\) from component 'sensor\.uptime'", + ): + validator(config2) + + # Clean up + CORE.current_component = None + + +def test_entity_conflict_between_components_yaml( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that conflicts between different components show helpful error messages.""" + result = load_config_from_fixture( + yaml_file, "entity_conflict_components.yaml", FIXTURES_DIR + ) + assert result is None + + # Check for the enhanced error message + captured = capsys.readouterr() + # The error should mention both the conflict and which component created it + assert "Duplicate sensor entity with name 'Battery' found" in captured.out + # Should mention it conflicts with an entity from a specific sensor platform + assert "from component 'sensor." in captured.out + # Should show it's a conflict between wifi_signal and template + assert "sensor.wifi_signal" in captured.out or "sensor.template" in captured.out + + def test_entity_duplicate_validator_internal_entities() -> None: """Test that internal entities are excluded from duplicate name validation.""" # Create validator for sensor platform @@ -612,14 +671,17 @@ def test_entity_duplicate_validator_internal_entities() -> None: validated2 = validator(config2) assert validated2 == config2 # Internal entity should not be added to unique_ids - assert len([k for k in CORE.unique_ids if k == ("", "sensor", "temperature")]) == 1 + # Count how many times the key appears (should still be 1) + count = sum(1 for k in CORE.unique_ids if k == ("", "sensor", "temperature")) + assert count == 1 # Another internal entity with same name should also pass config3 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} validated3 = validator(config3) assert validated3 == config3 # Still only one entry in unique_ids (from the non-internal entity) - assert len([k for k in CORE.unique_ids if k == ("", "sensor", "temperature")]) == 1 + count = sum(1 for k in CORE.unique_ids if k == ("", "sensor", "temperature")) + assert count == 1 # Non-internal entity with same name should fail config4 = {CONF_NAME: "Temperature"} diff --git a/tests/unit_tests/fixtures/core/entity_helpers/entity_conflict_components.yaml b/tests/unit_tests/fixtures/core/entity_helpers/entity_conflict_components.yaml new file mode 100644 index 0000000000..6a1df0f7b4 --- /dev/null +++ b/tests/unit_tests/fixtures/core/entity_helpers/entity_conflict_components.yaml @@ -0,0 +1,20 @@ +esphome: + name: test-device + +esp32: + board: esp32dev + +# Uptime sensor +sensor: + - platform: uptime + name: "Battery" + id: uptime_battery + +# Template sensor also named "Battery" - this should conflict + - platform: template + name: "Battery" + id: template_battery + lambda: |- + return 95.0; + unit_of_measurement: "%" + update_interval: 60s From ff52869b4c0d6552d17187fe773a702a8f162677 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Aug 2025 17:10:38 -0500 Subject: [PATCH 0480/1626] [api] Add constexpr optimizations to protobuf encoding (#10192) --- esphome/components/api/proto.h | 76 ++++++++++++++++------------------ 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 5c174b679c..0e5ec61050 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -15,6 +15,23 @@ namespace esphome::api { +// Helper functions for ZigZag encoding/decoding +inline constexpr uint32_t encode_zigzag32(int32_t value) { + return (static_cast(value) << 1) ^ (static_cast(value >> 31)); +} + +inline constexpr uint64_t encode_zigzag64(int64_t value) { + return (static_cast(value) << 1) ^ (static_cast(value >> 63)); +} + +inline constexpr int32_t decode_zigzag32(uint32_t value) { + return (value & 1) ? static_cast(~(value >> 1)) : static_cast(value >> 1); +} + +inline constexpr int64_t decode_zigzag64(uint64_t value) { + return (value & 1) ? static_cast(~(value >> 1)) : static_cast(value >> 1); +} + /* * StringRef Ownership Model for API Protocol Messages * =================================================== @@ -87,33 +104,25 @@ class ProtoVarInt { return {}; // Incomplete or invalid varint } - uint16_t as_uint16() const { return this->value_; } - uint32_t as_uint32() const { return this->value_; } - uint64_t as_uint64() const { return this->value_; } - bool as_bool() const { return this->value_; } - int32_t as_int32() const { + constexpr uint16_t as_uint16() const { return this->value_; } + constexpr uint32_t as_uint32() const { return this->value_; } + constexpr uint64_t as_uint64() const { return this->value_; } + constexpr bool as_bool() const { return this->value_; } + constexpr int32_t as_int32() const { // Not ZigZag encoded return static_cast(this->as_int64()); } - int64_t as_int64() const { + constexpr int64_t as_int64() const { // Not ZigZag encoded return static_cast(this->value_); } - int32_t as_sint32() const { + constexpr int32_t as_sint32() const { // with ZigZag encoding - if (this->value_ & 1) { - return static_cast(~(this->value_ >> 1)); - } else { - return static_cast(this->value_ >> 1); - } + return decode_zigzag32(static_cast(this->value_)); } - int64_t as_sint64() const { + constexpr int64_t as_sint64() const { // with ZigZag encoding - if (this->value_ & 1) { - return static_cast(~(this->value_ >> 1)); - } else { - return static_cast(this->value_ >> 1); - } + return decode_zigzag64(this->value_); } /** * Encode the varint value to a pre-allocated buffer without bounds checking. @@ -309,22 +318,10 @@ class ProtoWriteBuffer { this->encode_uint64(field_id, static_cast(value), force); } void encode_sint32(uint32_t field_id, int32_t value, bool force = false) { - uint32_t uvalue; - if (value < 0) { - uvalue = ~(value << 1); - } else { - uvalue = value << 1; - } - this->encode_uint32(field_id, uvalue, force); + this->encode_uint32(field_id, encode_zigzag32(value), force); } void encode_sint64(uint32_t field_id, int64_t value, bool force = false) { - uint64_t uvalue; - if (value < 0) { - uvalue = ~(value << 1); - } else { - uvalue = value << 1; - } - this->encode_uint64(field_id, uvalue, force); + this->encode_uint64(field_id, encode_zigzag64(value), force); } void encode_message(uint32_t field_id, const ProtoMessage &value, bool force = false); std::vector *get_buffer() const { return buffer_; } @@ -395,7 +392,7 @@ class ProtoSize { * @param value The uint32_t value to calculate size for * @return The number of bytes needed to encode the value */ - static inline uint32_t varint(uint32_t value) { + static constexpr uint32_t varint(uint32_t value) { // Optimized varint size calculation using leading zeros // Each 7 bits requires one byte in the varint encoding if (value < 128) @@ -419,7 +416,7 @@ class ProtoSize { * @param value The uint64_t value to calculate size for * @return The number of bytes needed to encode the value */ - static inline uint32_t varint(uint64_t value) { + static constexpr uint32_t varint(uint64_t value) { // Handle common case of values fitting in uint32_t (vast majority of use cases) if (value <= UINT32_MAX) { return varint(static_cast(value)); @@ -450,7 +447,7 @@ class ProtoSize { * @param value The int32_t value to calculate size for * @return The number of bytes needed to encode the value */ - static inline uint32_t varint(int32_t value) { + static constexpr uint32_t varint(int32_t value) { // Negative values are sign-extended to 64 bits in protocol buffers, // which always results in a 10-byte varint for negative int32 if (value < 0) { @@ -466,7 +463,7 @@ class ProtoSize { * @param value The int64_t value to calculate size for * @return The number of bytes needed to encode the value */ - static inline uint32_t varint(int64_t value) { + static constexpr uint32_t varint(int64_t value) { // For int64_t, we convert to uint64_t and calculate the size // This works because the bit pattern determines the encoding size, // and we've handled negative int32 values as a special case above @@ -480,7 +477,7 @@ class ProtoSize { * @param type The wire type value (from the WireType enum in the protobuf spec) * @return The number of bytes needed to encode the field ID and wire type */ - static inline uint32_t field(uint32_t field_id, uint32_t type) { + static constexpr uint32_t field(uint32_t field_id, uint32_t type) { uint32_t tag = (field_id << 3) | (type & 0b111); return varint(tag); } @@ -607,9 +604,8 @@ class ProtoSize { */ inline void add_sint32_force(uint32_t field_id_size, int32_t value) { // Always calculate size when force is true - // ZigZag encoding for sint32: (n << 1) ^ (n >> 31) - uint32_t zigzag = (static_cast(value) << 1) ^ (static_cast(value >> 31)); - total_size_ += field_id_size + varint(zigzag); + // ZigZag encoding for sint32 + total_size_ += field_id_size + varint(encode_zigzag32(value)); } /** From 6c48f3d719613c639d2674cc1aebdc1202da6496 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 12 Aug 2025 00:09:58 +0100 Subject: [PATCH 0481/1626] [wifi] Remove restriction from using NONE power saving mode with BLE (#10181) --- esphome/components/wifi/__init__.py | 38 ----------------------------- 1 file changed, 38 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index b74237ad2e..ac002eac53 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -194,45 +194,7 @@ def final_validate(config): ) -def final_validate_power_esp32_ble(value): - if not CORE.is_esp32: - return - if value != "NONE": - # WiFi should be in modem sleep (!=NONE) with BLE coexistence - # https://docs.espressif.com/projects/esp-idf/en/v3.3.5/api-guides/wifi.html#station-sleep - return - for conflicting in [ - "esp32_ble", - "esp32_ble_beacon", - "esp32_ble_server", - "esp32_ble_tracker", - ]: - if conflicting not in fv.full_config.get(): - continue - - try: - # Only arduino 1.0.5+ and esp-idf impacted - cv.require_framework_version( - esp32_arduino=cv.Version(1, 0, 5), - esp_idf=cv.Version(4, 0, 0), - )(None) - except cv.Invalid: - pass - else: - raise cv.Invalid( - f"power_save_mode NONE is incompatible with {conflicting}. " - f"Please remove the power save mode. See also " - f"https://github.com/esphome/issues/issues/2141#issuecomment-865688582" - ) - - FINAL_VALIDATE_SCHEMA = cv.All( - cv.Schema( - { - cv.Optional(CONF_POWER_SAVE_MODE): final_validate_power_esp32_ble, - }, - extra=cv.ALLOW_EXTRA, - ), final_validate, validate_variant, ) From bb40b7702d23d43f6c788f875e6f09ebe9ae5b94 Mon Sep 17 00:00:00 2001 From: Joshua Sing Date: Tue, 12 Aug 2025 09:13:24 +1000 Subject: [PATCH 0482/1626] [const] Add CONF_POWER_MODE (#10173) --- esphome/components/as5600/__init__.py | 2 +- esphome/components/as5600/sensor/__init__.py | 1 - esphome/const.py | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index 1a437a68a2..acb1c4d9db 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( CONF_DIRECTION, CONF_HYSTERESIS, CONF_ID, + CONF_POWER_MODE, CONF_RANGE, ) @@ -57,7 +58,6 @@ FAST_FILTER = { CONF_RAW_ANGLE = "raw_angle" CONF_RAW_POSITION = "raw_position" CONF_WATCHDOG = "watchdog" -CONF_POWER_MODE = "power_mode" CONF_SLOW_FILTER = "slow_filter" CONF_FAST_FILTER = "fast_filter" CONF_START_POSITION = "start_position" diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index cfc38d796d..1491852e07 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -24,7 +24,6 @@ AS5600Sensor = as5600_ns.class_("AS5600Sensor", sensor.Sensor, cg.PollingCompone CONF_RAW_ANGLE = "raw_angle" CONF_RAW_POSITION = "raw_position" CONF_WATCHDOG = "watchdog" -CONF_POWER_MODE = "power_mode" CONF_SLOW_FILTER = "slow_filter" CONF_FAST_FILTER = "fast_filter" CONF_PWM_FREQUENCY = "pwm_frequency" diff --git a/esphome/const.py b/esphome/const.py index 983850746d..49997ca766 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -761,6 +761,7 @@ CONF_POSITION_COMMAND_TOPIC = "position_command_topic" CONF_POSITION_STATE_TOPIC = "position_state_topic" CONF_POWER = "power" CONF_POWER_FACTOR = "power_factor" +CONF_POWER_MODE = "power_mode" CONF_POWER_ON_VALUE = "power_on_value" CONF_POWER_SAVE_MODE = "power_save_mode" CONF_POWER_SUPPLY = "power_supply" From ef2121a215890d46dc1d25ad363611ecadc9e25e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:47:53 -0500 Subject: [PATCH 0483/1626] Bump aioesphomeapi from 38.1.0 to 38.2.1 (#10197) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 009a6850e8..9793336cf3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==38.1.0 +aioesphomeapi==38.2.1 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From c65af68e63e836460b9f6e92745200838bcb4a10 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 12 Aug 2025 16:33:07 +1200 Subject: [PATCH 0484/1626] [core] Reset pin registry after target platform validations (#10199) --- esphome/config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/config.py b/esphome/config.py index ecd0cbb048..90325cbf6e 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -942,6 +942,9 @@ def validate_config( # do not try to validate further as we don't know what the target is return result + # Reset the pin registry so that any target platforms with pin validations do not get the duplicate pin warning. + pins.PIN_SCHEMA_REGISTRY.reset() + for domain, conf in config.items(): result.add_validation_step(LoadValidationStep(domain, conf)) result.add_validation_step(IDPassValidationStep()) From 0256e0005e703ead256361106c8b5cd770f9f3db Mon Sep 17 00:00:00 2001 From: Rihan9 <33684020+Rihan9@users.noreply.github.com> Date: Tue, 12 Aug 2025 07:34:37 +0200 Subject: [PATCH 0485/1626] [ld2412] New component (#9075) Co-authored-by: Keith Burzinski Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ld2412/__init__.py | 46 + esphome/components/ld2412/binary_sensor.py | 70 ++ esphome/components/ld2412/button/__init__.py | 74 ++ .../ld2412/button/factory_reset_button.cpp | 9 + .../ld2412/button/factory_reset_button.h | 18 + .../components/ld2412/button/query_button.cpp | 9 + .../components/ld2412/button/query_button.h | 18 + .../ld2412/button/restart_button.cpp | 9 + .../components/ld2412/button/restart_button.h | 18 + ...t_dynamic_background_correction_button.cpp | 11 + ...art_dynamic_background_correction_button.h | 18 + esphome/components/ld2412/ld2412.cpp | 861 ++++++++++++++++++ esphome/components/ld2412/ld2412.h | 141 +++ esphome/components/ld2412/number/__init__.py | 126 +++ .../ld2412/number/gate_threshold_number.cpp | 14 + .../ld2412/number/gate_threshold_number.h | 19 + .../ld2412/number/light_threshold_number.cpp | 12 + .../ld2412/number/light_threshold_number.h | 18 + .../number/max_distance_timeout_number.cpp | 12 + .../number/max_distance_timeout_number.h | 18 + esphome/components/ld2412/select/__init__.py | 82 ++ .../ld2412/select/baud_rate_select.cpp | 12 + .../ld2412/select/baud_rate_select.h | 18 + .../select/distance_resolution_select.cpp | 12 + .../select/distance_resolution_select.h | 18 + .../select/light_out_control_select.cpp | 12 + .../ld2412/select/light_out_control_select.h | 18 + esphome/components/ld2412/sensor.py | 124 +++ esphome/components/ld2412/switch/__init__.py | 45 + .../ld2412/switch/bluetooth_switch.cpp | 12 + .../ld2412/switch/bluetooth_switch.h | 18 + .../ld2412/switch/engineering_mode_switch.cpp | 12 + .../ld2412/switch/engineering_mode_switch.h | 18 + esphome/components/ld2412/text_sensor.py | 34 + tests/components/ld2412/common.yaml | 233 +++++ tests/components/ld2412/test.esp32-ard.yaml | 5 + .../components/ld2412/test.esp32-c3-ard.yaml | 5 + .../components/ld2412/test.esp32-c3-idf.yaml | 5 + tests/components/ld2412/test.esp32-idf.yaml | 5 + tests/components/ld2412/test.esp8266-ard.yaml | 5 + tests/components/ld2412/test.rp2040-ard.yaml | 5 + 42 files changed, 2220 insertions(+) create mode 100644 esphome/components/ld2412/__init__.py create mode 100644 esphome/components/ld2412/binary_sensor.py create mode 100644 esphome/components/ld2412/button/__init__.py create mode 100644 esphome/components/ld2412/button/factory_reset_button.cpp create mode 100644 esphome/components/ld2412/button/factory_reset_button.h create mode 100644 esphome/components/ld2412/button/query_button.cpp create mode 100644 esphome/components/ld2412/button/query_button.h create mode 100644 esphome/components/ld2412/button/restart_button.cpp create mode 100644 esphome/components/ld2412/button/restart_button.h create mode 100644 esphome/components/ld2412/button/start_dynamic_background_correction_button.cpp create mode 100644 esphome/components/ld2412/button/start_dynamic_background_correction_button.h create mode 100644 esphome/components/ld2412/ld2412.cpp create mode 100644 esphome/components/ld2412/ld2412.h create mode 100644 esphome/components/ld2412/number/__init__.py create mode 100644 esphome/components/ld2412/number/gate_threshold_number.cpp create mode 100644 esphome/components/ld2412/number/gate_threshold_number.h create mode 100644 esphome/components/ld2412/number/light_threshold_number.cpp create mode 100644 esphome/components/ld2412/number/light_threshold_number.h create mode 100644 esphome/components/ld2412/number/max_distance_timeout_number.cpp create mode 100644 esphome/components/ld2412/number/max_distance_timeout_number.h create mode 100644 esphome/components/ld2412/select/__init__.py create mode 100644 esphome/components/ld2412/select/baud_rate_select.cpp create mode 100644 esphome/components/ld2412/select/baud_rate_select.h create mode 100644 esphome/components/ld2412/select/distance_resolution_select.cpp create mode 100644 esphome/components/ld2412/select/distance_resolution_select.h create mode 100644 esphome/components/ld2412/select/light_out_control_select.cpp create mode 100644 esphome/components/ld2412/select/light_out_control_select.h create mode 100644 esphome/components/ld2412/sensor.py create mode 100644 esphome/components/ld2412/switch/__init__.py create mode 100644 esphome/components/ld2412/switch/bluetooth_switch.cpp create mode 100644 esphome/components/ld2412/switch/bluetooth_switch.h create mode 100644 esphome/components/ld2412/switch/engineering_mode_switch.cpp create mode 100644 esphome/components/ld2412/switch/engineering_mode_switch.h create mode 100644 esphome/components/ld2412/text_sensor.py create mode 100644 tests/components/ld2412/common.yaml create mode 100644 tests/components/ld2412/test.esp32-ard.yaml create mode 100644 tests/components/ld2412/test.esp32-c3-ard.yaml create mode 100644 tests/components/ld2412/test.esp32-c3-idf.yaml create mode 100644 tests/components/ld2412/test.esp32-idf.yaml create mode 100644 tests/components/ld2412/test.esp8266-ard.yaml create mode 100644 tests/components/ld2412/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 509b0c0b9e..a1a74e9c99 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -246,6 +246,7 @@ esphome/components/kuntze/* @ssieb esphome/components/lc709203f/* @ilikecake esphome/components/lcd_menu/* @numo68 esphome/components/ld2410/* @regevbr @sebcaps +esphome/components/ld2412/* @Rihan9 esphome/components/ld2420/* @descipher esphome/components/ld2450/* @hareeshmu esphome/components/ld24xx/* @kbx81 diff --git a/esphome/components/ld2412/__init__.py b/esphome/components/ld2412/__init__.py new file mode 100644 index 0000000000..e701d0bda9 --- /dev/null +++ b/esphome/components/ld2412/__init__.py @@ -0,0 +1,46 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_THROTTLE + +AUTO_LOAD = ["ld24xx"] +CODEOWNERS = ["@Rihan9"] +DEPENDENCIES = ["uart"] +MULTI_CONF = True + +LD2412_ns = cg.esphome_ns.namespace("ld2412") +LD2412Component = LD2412_ns.class_("LD2412Component", cg.Component, uart.UARTDevice) + +CONF_LD2412_ID = "ld2412_id" + +CONF_MAX_MOVE_DISTANCE = "max_move_distance" +CONF_MAX_STILL_DISTANCE = "max_still_distance" +CONF_MOVE_THRESHOLDS = [f"g{x}_move_threshold" for x in range(9)] +CONF_STILL_THRESHOLDS = [f"g{x}_still_threshold" for x in range(9)] + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD2412Component), + cv.Optional(CONF_THROTTLE): cv.invalid( + f"{CONF_THROTTLE} has been removed; use per-sensor filters, instead" + ), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "ld2412", + require_tx=True, + require_rx=True, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/ld2412/binary_sensor.py b/esphome/components/ld2412/binary_sensor.py new file mode 100644 index 0000000000..aa1b0d2cd8 --- /dev/null +++ b/esphome/components/ld2412/binary_sensor.py @@ -0,0 +1,70 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_HAS_MOVING_TARGET, + CONF_HAS_STILL_TARGET, + CONF_HAS_TARGET, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_RUNNING, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_ACCOUNT, + ICON_MOTION_SENSOR, +) + +from . import CONF_LD2412_ID, LD2412Component + +DEPENDENCIES = ["ld2412"] + +CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS = "dynamic_background_correction_status" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component), + cv.Optional( + CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS + ): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_RUNNING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_ACCOUNT, + ), + cv.Optional(CONF_HAS_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], + icon=ICON_ACCOUNT, + ), + cv.Optional(CONF_HAS_MOVING_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_MOTION, + filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], + icon=ICON_MOTION_SENSOR, + ), + cv.Optional(CONF_HAS_STILL_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], + icon=ICON_MOTION_SENSOR, + ), +} + + +async def to_code(config): + LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) + if dynamic_background_correction_status_config := config.get( + CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS + ): + sens = await binary_sensor.new_binary_sensor( + dynamic_background_correction_status_config + ) + cg.add( + LD2412_component.set_dynamic_background_correction_status_binary_sensor( + sens + ) + ) + if has_target_config := config.get(CONF_HAS_TARGET): + sens = await binary_sensor.new_binary_sensor(has_target_config) + cg.add(LD2412_component.set_target_binary_sensor(sens)) + if has_moving_target_config := config.get(CONF_HAS_MOVING_TARGET): + sens = await binary_sensor.new_binary_sensor(has_moving_target_config) + cg.add(LD2412_component.set_moving_target_binary_sensor(sens)) + if has_still_target_config := config.get(CONF_HAS_STILL_TARGET): + sens = await binary_sensor.new_binary_sensor(has_still_target_config) + cg.add(LD2412_component.set_still_target_binary_sensor(sens)) diff --git a/esphome/components/ld2412/button/__init__.py b/esphome/components/ld2412/button/__init__.py new file mode 100644 index 0000000000..e78cad4b88 --- /dev/null +++ b/esphome/components/ld2412/button/__init__.py @@ -0,0 +1,74 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + CONF_FACTORY_RESET, + CONF_RESTART, + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_DATABASE, + ICON_PULSE, + ICON_RESTART, + ICON_RESTART_ALERT, +) + +from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component + +FactoryResetButton = LD2412_ns.class_("FactoryResetButton", button.Button) +QueryButton = LD2412_ns.class_("QueryButton", button.Button) +RestartButton = LD2412_ns.class_("RestartButton", button.Button) +StartDynamicBackgroundCorrectionButton = LD2412_ns.class_( + "StartDynamicBackgroundCorrectionButton", button.Button +) + +CONF_QUERY_PARAMS = "query_params" +CONF_START_DYNAMIC_BACKGROUND_CORRECTION = "start_dynamic_background_correction" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component), + cv.Optional(CONF_FACTORY_RESET): button.button_schema( + FactoryResetButton, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RESTART_ALERT, + ), + cv.Optional(CONF_QUERY_PARAMS): button.button_schema( + QueryButton, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_DATABASE, + ), + cv.Optional(CONF_RESTART): button.button_schema( + RestartButton, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_RESTART, + ), + cv.Optional(CONF_START_DYNAMIC_BACKGROUND_CORRECTION): button.button_schema( + StartDynamicBackgroundCorrectionButton, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_PULSE, + ), +} + + +async def to_code(config): + LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) + if factory_reset_config := config.get(CONF_FACTORY_RESET): + b = await button.new_button(factory_reset_config) + await cg.register_parented(b, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_factory_reset_button(b)) + if query_params_config := config.get(CONF_QUERY_PARAMS): + b = await button.new_button(query_params_config) + await cg.register_parented(b, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_query_button(b)) + if restart_config := config.get(CONF_RESTART): + b = await button.new_button(restart_config) + await cg.register_parented(b, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_restart_button(b)) + if start_dynamic_background_correction_config := config.get( + CONF_START_DYNAMIC_BACKGROUND_CORRECTION + ): + b = await button.new_button(start_dynamic_background_correction_config) + await cg.register_parented(b, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_start_dynamic_background_correction_button(b)) diff --git a/esphome/components/ld2412/button/factory_reset_button.cpp b/esphome/components/ld2412/button/factory_reset_button.cpp new file mode 100644 index 0000000000..7ee85bc8f9 --- /dev/null +++ b/esphome/components/ld2412/button/factory_reset_button.cpp @@ -0,0 +1,9 @@ +#include "factory_reset_button.h" + +namespace esphome { +namespace ld2412 { + +void FactoryResetButton::press_action() { this->parent_->factory_reset(); } + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/button/factory_reset_button.h b/esphome/components/ld2412/button/factory_reset_button.h new file mode 100644 index 0000000000..36a3fffcd5 --- /dev/null +++ b/esphome/components/ld2412/button/factory_reset_button.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../ld2412.h" + +namespace esphome { +namespace ld2412 { + +class FactoryResetButton : public button::Button, public Parented { + public: + FactoryResetButton() = default; + + protected: + void press_action() override; +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/button/query_button.cpp b/esphome/components/ld2412/button/query_button.cpp new file mode 100644 index 0000000000..536f74427f --- /dev/null +++ b/esphome/components/ld2412/button/query_button.cpp @@ -0,0 +1,9 @@ +#include "query_button.h" + +namespace esphome { +namespace ld2412 { + +void QueryButton::press_action() { this->parent_->read_all_info(); } + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/button/query_button.h b/esphome/components/ld2412/button/query_button.h new file mode 100644 index 0000000000..595ef6d1e9 --- /dev/null +++ b/esphome/components/ld2412/button/query_button.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../ld2412.h" + +namespace esphome { +namespace ld2412 { + +class QueryButton : public button::Button, public Parented { + public: + QueryButton() = default; + + protected: + void press_action() override; +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/button/restart_button.cpp b/esphome/components/ld2412/button/restart_button.cpp new file mode 100644 index 0000000000..aca0d17841 --- /dev/null +++ b/esphome/components/ld2412/button/restart_button.cpp @@ -0,0 +1,9 @@ +#include "restart_button.h" + +namespace esphome { +namespace ld2412 { + +void RestartButton::press_action() { this->parent_->restart_and_read_all_info(); } + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/button/restart_button.h b/esphome/components/ld2412/button/restart_button.h new file mode 100644 index 0000000000..5cd582e2a3 --- /dev/null +++ b/esphome/components/ld2412/button/restart_button.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../ld2412.h" + +namespace esphome { +namespace ld2412 { + +class RestartButton : public button::Button, public Parented { + public: + RestartButton() = default; + + protected: + void press_action() override; +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/button/start_dynamic_background_correction_button.cpp b/esphome/components/ld2412/button/start_dynamic_background_correction_button.cpp new file mode 100644 index 0000000000..9b37243b82 --- /dev/null +++ b/esphome/components/ld2412/button/start_dynamic_background_correction_button.cpp @@ -0,0 +1,11 @@ +#include "start_dynamic_background_correction_button.h" + +#include "restart_button.h" + +namespace esphome { +namespace ld2412 { + +void StartDynamicBackgroundCorrectionButton::press_action() { this->parent_->start_dynamic_background_correction(); } + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h new file mode 100644 index 0000000000..3af0a8a149 --- /dev/null +++ b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../ld2412.h" + +namespace esphome { +namespace ld2412 { + +class StartDynamicBackgroundCorrectionButton : public button::Button, public Parented { + public: + StartDynamicBackgroundCorrectionButton() = default; + + protected: + void press_action() override; +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp new file mode 100644 index 0000000000..63af69ce0d --- /dev/null +++ b/esphome/components/ld2412/ld2412.cpp @@ -0,0 +1,861 @@ +#include "ld2412.h" + +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif + +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace ld2412 { + +static const char *const TAG = "ld2412"; +static const char *const UNKNOWN_MAC = "unknown"; +static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; + +enum BaudRate : uint8_t { + BAUD_RATE_9600 = 1, + BAUD_RATE_19200 = 2, + BAUD_RATE_38400 = 3, + BAUD_RATE_57600 = 4, + BAUD_RATE_115200 = 5, + BAUD_RATE_230400 = 6, + BAUD_RATE_256000 = 7, + BAUD_RATE_460800 = 8, +}; + +enum DistanceResolution : uint8_t { + DISTANCE_RESOLUTION_0_2 = 0x03, + DISTANCE_RESOLUTION_0_5 = 0x01, + DISTANCE_RESOLUTION_0_75 = 0x00, +}; + +enum LightFunction : uint8_t { + LIGHT_FUNCTION_OFF = 0x00, + LIGHT_FUNCTION_BELOW = 0x01, + LIGHT_FUNCTION_ABOVE = 0x02, +}; + +enum OutPinLevel : uint8_t { + OUT_PIN_LEVEL_LOW = 0x01, + OUT_PIN_LEVEL_HIGH = 0x00, +}; + +/* +Data Type: 6th byte +Target states: 9th byte + Moving target distance: 10~11th bytes + Moving target energy: 12th byte + Still target distance: 13~14th bytes + Still target energy: 15th byte + Detect distance: 16~17th bytes +*/ +enum PeriodicData : uint8_t { + DATA_TYPES = 6, + TARGET_STATES = 8, + MOVING_TARGET_LOW = 9, + MOVING_TARGET_HIGH = 10, + MOVING_ENERGY = 11, + STILL_TARGET_LOW = 12, + STILL_TARGET_HIGH = 13, + STILL_ENERGY = 14, + MOVING_SENSOR_START = 17, + STILL_SENSOR_START = 31, + LIGHT_SENSOR = 45, + OUT_PIN_SENSOR = 38, +}; + +enum PeriodicDataValue : uint8_t { + HEADER = 0XAA, + FOOTER = 0x55, + CHECK = 0x00, +}; + +enum AckData : uint8_t { + COMMAND = 6, + COMMAND_STATUS = 7, +}; + +// Memory-efficient lookup tables +struct StringToUint8 { + const char *str; + const uint8_t value; +}; + +struct Uint8ToString { + const uint8_t value; + const char *str; +}; + +constexpr StringToUint8 BAUD_RATES_BY_STR[] = { + {"9600", BAUD_RATE_9600}, {"19200", BAUD_RATE_19200}, {"38400", BAUD_RATE_38400}, + {"57600", BAUD_RATE_57600}, {"115200", BAUD_RATE_115200}, {"230400", BAUD_RATE_230400}, + {"256000", BAUD_RATE_256000}, {"460800", BAUD_RATE_460800}, +}; + +constexpr StringToUint8 DISTANCE_RESOLUTIONS_BY_STR[] = { + {"0.2m", DISTANCE_RESOLUTION_0_2}, + {"0.5m", DISTANCE_RESOLUTION_0_5}, + {"0.75m", DISTANCE_RESOLUTION_0_75}, +}; + +constexpr Uint8ToString DISTANCE_RESOLUTIONS_BY_UINT[] = { + {DISTANCE_RESOLUTION_0_2, "0.2m"}, + {DISTANCE_RESOLUTION_0_5, "0.5m"}, + {DISTANCE_RESOLUTION_0_75, "0.75m"}, +}; + +constexpr StringToUint8 LIGHT_FUNCTIONS_BY_STR[] = { + {"off", LIGHT_FUNCTION_OFF}, + {"below", LIGHT_FUNCTION_BELOW}, + {"above", LIGHT_FUNCTION_ABOVE}, +}; + +constexpr Uint8ToString LIGHT_FUNCTIONS_BY_UINT[] = { + {LIGHT_FUNCTION_OFF, "off"}, + {LIGHT_FUNCTION_BELOW, "below"}, + {LIGHT_FUNCTION_ABOVE, "above"}, +}; + +constexpr StringToUint8 OUT_PIN_LEVELS_BY_STR[] = { + {"low", OUT_PIN_LEVEL_LOW}, + {"high", OUT_PIN_LEVEL_HIGH}, +}; + +constexpr Uint8ToString OUT_PIN_LEVELS_BY_UINT[] = { + {OUT_PIN_LEVEL_LOW, "low"}, + {OUT_PIN_LEVEL_HIGH, "high"}, +}; + +// Helper functions for lookups +template uint8_t find_uint8(const StringToUint8 (&arr)[N], const std::string &str) { + for (const auto &entry : arr) { + if (str == entry.str) { + return entry.value; + } + } + return 0xFF; // Not found +} + +template const char *find_str(const Uint8ToString (&arr)[N], uint8_t value) { + for (const auto &entry : arr) { + if (value == entry.value) { + return entry.str; + } + } + return ""; // Not found +} + +static constexpr uint8_t DEFAULT_PRESENCE_TIMEOUT = 5; // Default used when number component is not defined +// Commands +static constexpr uint8_t CMD_ENABLE_CONF = 0xFF; +static constexpr uint8_t CMD_DISABLE_CONF = 0xFE; +static constexpr uint8_t CMD_ENABLE_ENG = 0x62; +static constexpr uint8_t CMD_DISABLE_ENG = 0x63; +static constexpr uint8_t CMD_QUERY_BASIC_CONF = 0x12; +static constexpr uint8_t CMD_BASIC_CONF = 0x02; +static constexpr uint8_t CMD_QUERY_VERSION = 0xA0; +static constexpr uint8_t CMD_QUERY_DISTANCE_RESOLUTION = 0x11; +static constexpr uint8_t CMD_SET_DISTANCE_RESOLUTION = 0x01; +static constexpr uint8_t CMD_QUERY_LIGHT_CONTROL = 0x1C; +static constexpr uint8_t CMD_SET_LIGHT_CONTROL = 0x0C; +static constexpr uint8_t CMD_SET_BAUD_RATE = 0xA1; +static constexpr uint8_t CMD_QUERY_MAC_ADDRESS = 0xA5; +static constexpr uint8_t CMD_FACTORY_RESET = 0xA2; +static constexpr uint8_t CMD_RESTART = 0xA3; +static constexpr uint8_t CMD_BLUETOOTH = 0xA4; +static constexpr uint8_t CMD_DYNAMIC_BACKGROUND_CORRECTION = 0x0B; +static constexpr uint8_t CMD_QUERY_DYNAMIC_BACKGROUND_CORRECTION = 0x1B; +static constexpr uint8_t CMD_MOTION_GATE_SENS = 0x03; +static constexpr uint8_t CMD_QUERY_MOTION_GATE_SENS = 0x13; +static constexpr uint8_t CMD_STATIC_GATE_SENS = 0x04; +static constexpr uint8_t CMD_QUERY_STATIC_GATE_SENS = 0x14; +static constexpr uint8_t CMD_NONE = 0x00; +// Commands values +static constexpr uint8_t CMD_MAX_MOVE_VALUE = 0x00; +static constexpr uint8_t CMD_MAX_STILL_VALUE = 0x01; +static constexpr uint8_t CMD_DURATION_VALUE = 0x02; +// Bitmasks for target states +static constexpr uint8_t MOVE_BITMASK = 0x01; +static constexpr uint8_t STILL_BITMASK = 0x02; +// Header & Footer size +static constexpr uint8_t HEADER_FOOTER_SIZE = 4; +// Command Header & Footer +static constexpr uint8_t CMD_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xFD, 0xFC, 0xFB, 0xFA}; +static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0x04, 0x03, 0x02, 0x01}; +// Data Header & Footer +static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1}; +static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5}; +// MAC address the module uses when Bluetooth is disabled +static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01}; + +static inline int two_byte_to_int(char firstbyte, char secondbyte) { return (int16_t) (secondbyte << 8) + firstbyte; } + +static inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) { + return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0; +} + +void LD2412Component::dump_config() { + std::string mac_str = + mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; + std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], + this->version_[4], this->version_[3], this->version_[2]); + ESP_LOGCONFIG(TAG, + "LD2412:\n" + " Firmware version: %s\n" + " MAC address: %s", + version.c_str(), mac_str.c_str()); +#ifdef USE_BINARY_SENSOR + ESP_LOGCONFIG(TAG, "Binary Sensors:"); + LOG_BINARY_SENSOR(" ", "DynamicBackgroundCorrectionStatus", + this->dynamic_background_correction_status_binary_sensor_); + LOG_BINARY_SENSOR(" ", "MovingTarget", this->moving_target_binary_sensor_); + LOG_BINARY_SENSOR(" ", "StillTarget", this->still_target_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Target", this->target_binary_sensor_); +#endif +#ifdef USE_SENSOR + ESP_LOGCONFIG(TAG, "Sensors:"); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "Light", this->light_sensor_); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "DetectionDistance", this->detection_distance_sensor_); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "MovingTargetDistance", this->moving_target_distance_sensor_); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "MovingTargetEnergy", this->moving_target_energy_sensor_); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "StillTargetDistance", this->still_target_distance_sensor_); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "StillTargetEnergy", this->still_target_energy_sensor_); + for (auto &s : this->gate_still_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "GateStill", s); + } + for (auto &s : this->gate_move_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "GateMove", s); + } +#endif +#ifdef USE_TEXT_SENSOR + ESP_LOGCONFIG(TAG, "Text Sensors:"); + LOG_TEXT_SENSOR(" ", "MAC address", this->mac_text_sensor_); + LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); +#endif +#ifdef USE_NUMBER + ESP_LOGCONFIG(TAG, "Numbers:"); + LOG_NUMBER(" ", "LightThreshold", this->light_threshold_number_); + LOG_NUMBER(" ", "MaxDistanceGate", this->max_distance_gate_number_); + LOG_NUMBER(" ", "MinDistanceGate", this->min_distance_gate_number_); + LOG_NUMBER(" ", "Timeout", this->timeout_number_); + for (number::Number *n : this->gate_move_threshold_numbers_) { + LOG_NUMBER(" ", "Move Thresholds", n); + } + for (number::Number *n : this->gate_still_threshold_numbers_) { + LOG_NUMBER(" ", "Still Thresholds", n); + } +#endif +#ifdef USE_SELECT + ESP_LOGCONFIG(TAG, "Selects:"); + LOG_SELECT(" ", "BaudRate", this->baud_rate_select_); + LOG_SELECT(" ", "DistanceResolution", this->distance_resolution_select_); + LOG_SELECT(" ", "LightFunction", this->light_function_select_); + LOG_SELECT(" ", "OutPinLevel", this->out_pin_level_select_); +#endif +#ifdef USE_SWITCH + ESP_LOGCONFIG(TAG, "Switches:"); + LOG_SWITCH(" ", "Bluetooth", this->bluetooth_switch_); + LOG_SWITCH(" ", "EngineeringMode", this->engineering_mode_switch_); +#endif +#ifdef USE_BUTTON + ESP_LOGCONFIG(TAG, "Buttons:"); + LOG_BUTTON(" ", "FactoryReset", this->factory_reset_button_); + LOG_BUTTON(" ", "Query", this->query_button_); + LOG_BUTTON(" ", "Restart", this->restart_button_); + LOG_BUTTON(" ", "StartDynamicBackgroundCorrection", this->start_dynamic_background_correction_button_); +#endif +} + +void LD2412Component::setup() { + ESP_LOGCONFIG(TAG, "Running setup"); + this->read_all_info(); +} + +void LD2412Component::read_all_info() { + this->set_config_mode_(true); + this->get_version_(); + delay(10); // NOLINT + this->get_mac_(); + delay(10); // NOLINT + this->get_distance_resolution_(); + delay(10); // NOLINT + this->query_parameters_(); + delay(10); // NOLINT + this->query_dynamic_background_correction_(); + delay(10); // NOLINT + this->query_light_control_(); + delay(10); // NOLINT +#ifdef USE_NUMBER + this->get_gate_threshold(); + delay(10); // NOLINT +#endif + this->set_config_mode_(false); +#ifdef USE_SELECT + const auto baud_rate = std::to_string(this->parent_->get_baud_rate()); + if (this->baud_rate_select_ != nullptr) { + this->baud_rate_select_->publish_state(baud_rate); + } +#endif +} + +void LD2412Component::restart_and_read_all_info() { + this->set_config_mode_(true); + this->restart_(); + this->set_timeout(1000, [this]() { this->read_all_info(); }); +} + +void LD2412Component::loop() { + while (this->available()) { + this->readline_(this->read()); + } +} + +void LD2412Component::send_command_(uint8_t command, const uint8_t *command_value, uint8_t command_value_len) { + ESP_LOGV(TAG, "Sending COMMAND %02X", command); + // frame header bytes + this->write_array(CMD_FRAME_HEADER, HEADER_FOOTER_SIZE); + // length bytes + uint8_t len = 2; + if (command_value != nullptr) { + len += command_value_len; + } + // 2 length bytes (low, high) + 2 command bytes (low, high) + uint8_t len_cmd[] = {len, 0x00, command, 0x00}; + this->write_array(len_cmd, sizeof(len_cmd)); + + // command value bytes + if (command_value != nullptr) { + this->write_array(command_value, command_value_len); + } + // frame footer bytes + this->write_array(CMD_FRAME_FOOTER, HEADER_FOOTER_SIZE); + + if (command != CMD_ENABLE_CONF && command != CMD_DISABLE_CONF) { + delay(30); // NOLINT + } + delay(20); // NOLINT +} + +void LD2412Component::handle_periodic_data_() { + // 4 frame header bytes + 2 length bytes + 1 data end byte + 1 crc byte + 4 frame footer bytes + // data header=0xAA, data footer=0x55, crc=0x00 + if (this->buffer_pos_ < 12 || !ld2412::validate_header_footer(DATA_FRAME_HEADER, this->buffer_data_) || + this->buffer_data_[7] != HEADER || this->buffer_data_[this->buffer_pos_ - 6] != FOOTER) { + return; + } + /* + Data Type: 7th + 0x01: Engineering mode + 0x02: Normal mode + */ + bool engineering_mode = this->buffer_data_[DATA_TYPES] == 0x01; +#ifdef USE_SWITCH + if (this->engineering_mode_switch_ != nullptr) { + this->engineering_mode_switch_->publish_state(engineering_mode); + } +#endif + +#ifdef USE_BINARY_SENSOR + /* + Target states: 9th + 0x00 = No target + 0x01 = Moving targets + 0x02 = Still targets + 0x03 = Moving+Still targets + */ + char target_state = this->buffer_data_[TARGET_STATES]; + if (this->target_binary_sensor_ != nullptr) { + this->target_binary_sensor_->publish_state(target_state != 0x00); + } + if (this->moving_target_binary_sensor_ != nullptr) { + this->moving_target_binary_sensor_->publish_state(target_state & MOVE_BITMASK); + } + if (this->still_target_binary_sensor_ != nullptr) { + this->still_target_binary_sensor_->publish_state(target_state & STILL_BITMASK); + } +#endif + /* + Moving target distance: 10~11th bytes + Moving target energy: 12th byte + Still target distance: 13~14th bytes + Still target energy: 15th byte + Detect distance: 16~17th bytes + */ +#ifdef USE_SENSOR + SAFE_PUBLISH_SENSOR( + this->moving_target_distance_sensor_, + ld2412::two_byte_to_int(this->buffer_data_[MOVING_TARGET_LOW], this->buffer_data_[MOVING_TARGET_HIGH])) + SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]) + SAFE_PUBLISH_SENSOR( + this->still_target_distance_sensor_, + ld2412::two_byte_to_int(this->buffer_data_[STILL_TARGET_LOW], this->buffer_data_[STILL_TARGET_HIGH])) + SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]) + if (this->detection_distance_sensor_ != nullptr) { + int new_detect_distance = 0; + if (target_state != 0x00 && (target_state & MOVE_BITMASK)) { + new_detect_distance = + ld2412::two_byte_to_int(this->buffer_data_[MOVING_TARGET_LOW], this->buffer_data_[MOVING_TARGET_HIGH]); + } else if (target_state != 0x00) { + new_detect_distance = + ld2412::two_byte_to_int(this->buffer_data_[STILL_TARGET_LOW], this->buffer_data_[STILL_TARGET_HIGH]); + } + this->detection_distance_sensor_->publish_state_if_not_dup(new_detect_distance); + } + if (engineering_mode) { + /* + Moving distance range: 18th byte + Still distance range: 19th byte + Moving energy: 20~28th bytes + */ + for (uint8_t i = 0; i < TOTAL_GATES; i++) { + SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]) + } + /* + Still energy: 29~37th bytes + */ + for (uint8_t i = 0; i < TOTAL_GATES; i++) { + SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]) + } + /* + Light sensor: 38th bytes + */ + SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]) + } else { + for (auto &gate_move_sensor : this->gate_move_sensors_) { + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor) + } + for (auto &gate_still_sensor : this->gate_still_sensors_) { + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor) + } + SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_) + } +#endif + // the radar module won't tell us when it's done, so we just have to keep polling... + if (this->dynamic_background_correction_active_) { + this->set_config_mode_(true); + this->query_dynamic_background_correction_(); + this->set_config_mode_(false); + } +} + +#ifdef USE_NUMBER +std::function set_number_value(number::Number *n, float value) { + if (n != nullptr && (!n->has_state() || n->state != value)) { + n->state = value; + return [n, value]() { n->publish_state(value); }; + } + return []() {}; +} +#endif + +bool LD2412Component::handle_ack_data_() { + ESP_LOGV(TAG, "Handling ACK DATA for COMMAND %02X", this->buffer_data_[COMMAND]); + if (this->buffer_pos_ < 10) { + ESP_LOGW(TAG, "Invalid length"); + return true; + } + if (!ld2412::validate_header_footer(CMD_FRAME_HEADER, this->buffer_data_)) { + ESP_LOGW(TAG, "Invalid header: %s", format_hex_pretty(this->buffer_data_, HEADER_FOOTER_SIZE).c_str()); + return true; + } + if (this->buffer_data_[COMMAND_STATUS] != 0x01) { + ESP_LOGW(TAG, "Invalid status"); + return true; + } + if (this->buffer_data_[8] || this->buffer_data_[9]) { + ESP_LOGW(TAG, "Invalid command: %02X, %02X", this->buffer_data_[8], this->buffer_data_[9]); + return true; + } + + switch (this->buffer_data_[COMMAND]) { + case CMD_ENABLE_CONF: + ESP_LOGV(TAG, "Enable conf"); + break; + + case CMD_DISABLE_CONF: + ESP_LOGV(TAG, "Disabled conf"); + break; + + case CMD_SET_BAUD_RATE: + ESP_LOGV(TAG, "Baud rate change"); +#ifdef USE_SELECT + if (this->baud_rate_select_ != nullptr) { + ESP_LOGW(TAG, "Change baud rate to %s and reinstall", this->baud_rate_select_->state.c_str()); + } +#endif + break; + + case CMD_QUERY_VERSION: { + std::memcpy(this->version_, &this->buffer_data_[12], sizeof(this->version_)); + std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], + this->version_[4], this->version_[3], this->version_[2]); + ESP_LOGV(TAG, "Firmware version: %s", version.c_str()); +#ifdef USE_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) { + this->version_text_sensor_->publish_state(version); + } +#endif + break; + } + case CMD_QUERY_DISTANCE_RESOLUTION: { + const auto *distance_resolution = find_str(DISTANCE_RESOLUTIONS_BY_UINT, this->buffer_data_[10]); + ESP_LOGV(TAG, "Distance resolution: %s", distance_resolution); +#ifdef USE_SELECT + if (this->distance_resolution_select_ != nullptr) { + this->distance_resolution_select_->publish_state(distance_resolution); + } +#endif + break; + } + + case CMD_QUERY_LIGHT_CONTROL: { + this->light_function_ = this->buffer_data_[10]; + this->light_threshold_ = this->buffer_data_[11]; + const auto *light_function_str = find_str(LIGHT_FUNCTIONS_BY_UINT, this->light_function_); + ESP_LOGV(TAG, + "Light function: %s\n" + "Light threshold: %u", + light_function_str, this->light_threshold_); +#ifdef USE_SELECT + if (this->light_function_select_ != nullptr) { + this->light_function_select_->publish_state(light_function_str); + } +#endif +#ifdef USE_NUMBER + if (this->light_threshold_number_ != nullptr) { + this->light_threshold_number_->publish_state(static_cast(this->light_threshold_)); + } +#endif + break; + } + + case CMD_QUERY_MAC_ADDRESS: { + if (this->buffer_pos_ < 20) { + return false; + } + + this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0; + if (this->bluetooth_on_) { + std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); + } + + std::string mac_str = + mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; + ESP_LOGV(TAG, "MAC address: %s", mac_str.c_str()); +#ifdef USE_TEXT_SENSOR + if (this->mac_text_sensor_ != nullptr) { + this->mac_text_sensor_->publish_state(mac_str); + } +#endif +#ifdef USE_SWITCH + if (this->bluetooth_switch_ != nullptr) { + this->bluetooth_switch_->publish_state(this->bluetooth_on_); + } +#endif + break; + } + + case CMD_SET_DISTANCE_RESOLUTION: + ESP_LOGV(TAG, "Handled set distance resolution command"); + break; + + case CMD_QUERY_DYNAMIC_BACKGROUND_CORRECTION: { + ESP_LOGV(TAG, "Handled query dynamic background correction"); + bool dynamic_background_correction_active = (this->buffer_data_[10] != 0x00); +#ifdef USE_BINARY_SENSOR + if (this->dynamic_background_correction_status_binary_sensor_ != nullptr) { + this->dynamic_background_correction_status_binary_sensor_->publish_state(dynamic_background_correction_active); + } +#endif + this->dynamic_background_correction_active_ = dynamic_background_correction_active; + break; + } + + case CMD_BLUETOOTH: + ESP_LOGV(TAG, "Handled bluetooth command"); + break; + + case CMD_SET_LIGHT_CONTROL: + ESP_LOGV(TAG, "Handled set light control command"); + break; + + case CMD_QUERY_MOTION_GATE_SENS: { +#ifdef USE_NUMBER + std::vector> updates; + updates.reserve(this->gate_still_threshold_numbers_.size()); + for (size_t i = 0; i < this->gate_still_threshold_numbers_.size(); i++) { + updates.push_back(set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[10 + i])); + } + for (auto &update : updates) { + update(); + } +#endif + break; + } + + case CMD_QUERY_STATIC_GATE_SENS: { +#ifdef USE_NUMBER + std::vector> updates; + updates.reserve(this->gate_still_threshold_numbers_.size()); + for (size_t i = 0; i < this->gate_still_threshold_numbers_.size(); i++) { + updates.push_back(set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[10 + i])); + } + for (auto &update : updates) { + update(); + } +#endif + break; + } + + case CMD_QUERY_BASIC_CONF: // Query parameters response + { +#ifdef USE_NUMBER + /* + Moving distance range: 9th byte + Still distance range: 10th byte + */ + std::vector> updates; + updates.push_back(set_number_value(this->min_distance_gate_number_, this->buffer_data_[10])); + updates.push_back(set_number_value(this->max_distance_gate_number_, this->buffer_data_[11] - 1)); + ESP_LOGV(TAG, "min_distance_gate_number_: %u, max_distance_gate_number_ %u", this->buffer_data_[10], + this->buffer_data_[11]); + /* + None Duration: 11~12th bytes + */ + updates.push_back(set_number_value(this->timeout_number_, + ld2412::two_byte_to_int(this->buffer_data_[12], this->buffer_data_[13]))); + ESP_LOGV(TAG, "timeout_number_: %u", ld2412::two_byte_to_int(this->buffer_data_[12], this->buffer_data_[13])); + /* + Output pin configuration: 13th bytes + */ + this->out_pin_level_ = this->buffer_data_[14]; +#ifdef USE_SELECT + const auto *out_pin_level_str = find_str(OUT_PIN_LEVELS_BY_UINT, this->out_pin_level_); + if (this->out_pin_level_select_ != nullptr) { + this->out_pin_level_select_->publish_state(out_pin_level_str); + } +#endif + for (auto &update : updates) { + update(); + } +#endif + } break; + default: + break; + } + + return true; +} + +void LD2412Component::readline_(int readch) { + if (readch < 0) { + return; // No data available + } + if (this->buffer_pos_ < HEADER_FOOTER_SIZE && readch != DATA_FRAME_HEADER[this->buffer_pos_] && + readch != CMD_FRAME_HEADER[this->buffer_pos_]) { + this->buffer_pos_ = 0; + return; + } + if (this->buffer_pos_ < MAX_LINE_LENGTH - 1) { + this->buffer_data_[this->buffer_pos_++] = readch; + this->buffer_data_[this->buffer_pos_] = 0; + } else { + // We should never get here, but just in case... + ESP_LOGW(TAG, "Max command length exceeded; ignoring"); + this->buffer_pos_ = 0; + } + if (this->buffer_pos_ < 4) { + return; // Not enough data to process yet + } + if (ld2412::validate_header_footer(DATA_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { + ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty(this->buffer_data_, this->buffer_pos_).c_str()); + this->handle_periodic_data_(); + this->buffer_pos_ = 0; // Reset position index for next message + } else if (ld2412::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { + ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty(this->buffer_data_, this->buffer_pos_).c_str()); + if (this->handle_ack_data_()) { + this->buffer_pos_ = 0; // Reset position index for next message + } else { + ESP_LOGV(TAG, "Ack Data incomplete"); + } + } +} + +void LD2412Component::set_config_mode_(bool enable) { + const uint8_t cmd = enable ? CMD_ENABLE_CONF : CMD_DISABLE_CONF; + const uint8_t cmd_value[2] = {0x01, 0x00}; + this->send_command_(cmd, enable ? cmd_value : nullptr, sizeof(cmd_value)); +} + +void LD2412Component::set_bluetooth(bool enable) { + this->set_config_mode_(true); + const uint8_t cmd_value[2] = {enable ? (uint8_t) 0x01 : (uint8_t) 0x00, 0x00}; + this->send_command_(CMD_BLUETOOTH, cmd_value, sizeof(cmd_value)); + this->set_timeout(200, [this]() { this->restart_and_read_all_info(); }); +} + +void LD2412Component::set_distance_resolution(const std::string &state) { + this->set_config_mode_(true); + const uint8_t cmd_value[6] = {find_uint8(DISTANCE_RESOLUTIONS_BY_STR, state), 0x00, 0x00, 0x00, 0x00, 0x00}; + this->send_command_(CMD_SET_DISTANCE_RESOLUTION, cmd_value, sizeof(cmd_value)); + this->set_timeout(200, [this]() { this->restart_and_read_all_info(); }); +} + +void LD2412Component::set_baud_rate(const std::string &state) { + this->set_config_mode_(true); + const uint8_t cmd_value[2] = {find_uint8(BAUD_RATES_BY_STR, state), 0x00}; + this->send_command_(CMD_SET_BAUD_RATE, cmd_value, sizeof(cmd_value)); + this->set_timeout(200, [this]() { this->restart_(); }); +} + +void LD2412Component::query_dynamic_background_correction_() { + this->send_command_(CMD_QUERY_DYNAMIC_BACKGROUND_CORRECTION, nullptr, 0); +} + +void LD2412Component::start_dynamic_background_correction() { + if (this->dynamic_background_correction_active_) { + return; // Already in progress + } +#ifdef USE_BINARY_SENSOR + if (this->dynamic_background_correction_status_binary_sensor_ != nullptr) { + this->dynamic_background_correction_status_binary_sensor_->publish_state(true); + } +#endif + this->dynamic_background_correction_active_ = true; + this->set_config_mode_(true); + this->send_command_(CMD_DYNAMIC_BACKGROUND_CORRECTION, nullptr, 0); + this->set_config_mode_(false); +} + +void LD2412Component::set_engineering_mode(bool enable) { + const uint8_t cmd = enable ? CMD_ENABLE_ENG : CMD_DISABLE_ENG; + this->set_config_mode_(true); + this->send_command_(cmd, nullptr, 0); + this->set_config_mode_(false); +} + +void LD2412Component::factory_reset() { + this->set_config_mode_(true); + this->send_command_(CMD_FACTORY_RESET, nullptr, 0); + this->set_timeout(2000, [this]() { this->restart_and_read_all_info(); }); +} + +void LD2412Component::restart_() { this->send_command_(CMD_RESTART, nullptr, 0); } + +void LD2412Component::query_parameters_() { this->send_command_(CMD_QUERY_BASIC_CONF, nullptr, 0); } + +void LD2412Component::get_version_() { this->send_command_(CMD_QUERY_VERSION, nullptr, 0); } + +void LD2412Component::get_mac_() { + const uint8_t cmd_value[2] = {0x01, 0x00}; + this->send_command_(CMD_QUERY_MAC_ADDRESS, cmd_value, sizeof(cmd_value)); +} + +void LD2412Component::get_distance_resolution_() { this->send_command_(CMD_QUERY_DISTANCE_RESOLUTION, nullptr, 0); } + +void LD2412Component::query_light_control_() { this->send_command_(CMD_QUERY_LIGHT_CONTROL, nullptr, 0); } + +void LD2412Component::set_basic_config() { +#ifdef USE_NUMBER + if (!this->min_distance_gate_number_->has_state() || !this->max_distance_gate_number_->has_state() || + !this->timeout_number_->has_state()) { + return; + } +#endif +#ifdef USE_SELECT + if (!this->out_pin_level_select_->has_state()) { + return; + } +#endif + + uint8_t value[5] = { +#ifdef USE_NUMBER + lowbyte(static_cast(this->min_distance_gate_number_->state)), + lowbyte(static_cast(this->max_distance_gate_number_->state) + 1), + lowbyte(static_cast(this->timeout_number_->state)), + highbyte(static_cast(this->timeout_number_->state)), +#else + 1, TOTAL_GATES, DEFAULT_PRESENCE_TIMEOUT, 0, +#endif +#ifdef USE_SELECT + find_uint8(OUT_PIN_LEVELS_BY_STR, this->out_pin_level_select_->state), +#else + 0x01, // Default value if not using select +#endif + }; + this->set_config_mode_(true); + this->send_command_(CMD_BASIC_CONF, value, sizeof(value)); + this->set_config_mode_(false); +} + +#ifdef USE_NUMBER +void LD2412Component::set_gate_threshold() { + if (this->gate_move_threshold_numbers_.empty() && this->gate_still_threshold_numbers_.empty()) { + return; // No gate threshold numbers set; nothing to do here + } + uint8_t value[TOTAL_GATES] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + this->set_config_mode_(true); + if (!this->gate_move_threshold_numbers_.empty()) { + for (size_t i = 0; i < this->gate_move_threshold_numbers_.size(); i++) { + value[i] = lowbyte(static_cast(this->gate_move_threshold_numbers_[i]->state)); + } + this->send_command_(CMD_MOTION_GATE_SENS, value, sizeof(value)); + } + if (!this->gate_still_threshold_numbers_.empty()) { + for (size_t i = 0; i < this->gate_still_threshold_numbers_.size(); i++) { + value[i] = lowbyte(static_cast(this->gate_still_threshold_numbers_[i]->state)); + } + this->send_command_(CMD_STATIC_GATE_SENS, value, sizeof(value)); + } + this->set_config_mode_(false); +} + +void LD2412Component::get_gate_threshold() { + this->send_command_(CMD_QUERY_MOTION_GATE_SENS, nullptr, 0); + this->send_command_(CMD_QUERY_STATIC_GATE_SENS, nullptr, 0); +} + +void LD2412Component::set_gate_still_threshold_number(uint8_t gate, number::Number *n) { + this->gate_still_threshold_numbers_[gate] = n; +} + +void LD2412Component::set_gate_move_threshold_number(uint8_t gate, number::Number *n) { + this->gate_move_threshold_numbers_[gate] = n; +} +#endif + +void LD2412Component::set_light_out_control() { +#ifdef USE_NUMBER + if (this->light_threshold_number_ != nullptr && this->light_threshold_number_->has_state()) { + this->light_threshold_ = static_cast(this->light_threshold_number_->state); + } +#endif +#ifdef USE_SELECT + if (this->light_function_select_ != nullptr && this->light_function_select_->has_state()) { + this->light_function_ = find_uint8(LIGHT_FUNCTIONS_BY_STR, this->light_function_select_->state); + } +#endif + uint8_t value[2] = {this->light_function_, this->light_threshold_}; + this->set_config_mode_(true); + this->send_command_(CMD_SET_LIGHT_CONTROL, value, sizeof(value)); + this->query_light_control_(); + this->set_timeout(200, [this]() { this->restart_and_read_all_info(); }); +} + +#ifdef USE_SENSOR +// These could leak memory, but they are only set once prior to 'setup()' and should never be used again. +void LD2412Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) { + this->gate_move_sensors_[gate] = new SensorWithDedup(s); +} +void LD2412Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) { + this->gate_still_sensors_[gate] = new SensorWithDedup(s); +} +#endif + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h new file mode 100644 index 0000000000..41f96ab301 --- /dev/null +++ b/esphome/components/ld2412/ld2412.h @@ -0,0 +1,141 @@ +#pragma once +#include "esphome/core/defines.h" +#include "esphome/core/component.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif +#ifdef USE_BUTTON +#include "esphome/components/button/button.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#include "esphome/components/ld24xx/ld24xx.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome { +namespace ld2412 { + +using namespace ld24xx; + +static constexpr uint8_t MAX_LINE_LENGTH = 54; // Max characters for serial buffer +static constexpr uint8_t TOTAL_GATES = 14; // Total number of gates supported by the LD2412 + +class LD2412Component : public Component, public uart::UARTDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(dynamic_background_correction_status) + SUB_BINARY_SENSOR(moving_target) + SUB_BINARY_SENSOR(still_target) + SUB_BINARY_SENSOR(target) +#endif +#ifdef USE_SENSOR + SUB_SENSOR_WITH_DEDUP(light, uint8_t) + SUB_SENSOR_WITH_DEDUP(detection_distance, int) + SUB_SENSOR_WITH_DEDUP(moving_target_distance, int) + SUB_SENSOR_WITH_DEDUP(moving_target_energy, uint8_t) + SUB_SENSOR_WITH_DEDUP(still_target_distance, int) + SUB_SENSOR_WITH_DEDUP(still_target_energy, uint8_t) +#endif +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(mac) + SUB_TEXT_SENSOR(version) +#endif +#ifdef USE_NUMBER + SUB_NUMBER(light_threshold) + SUB_NUMBER(max_distance_gate) + SUB_NUMBER(min_distance_gate) + SUB_NUMBER(timeout) +#endif +#ifdef USE_SELECT + SUB_SELECT(baud_rate) + SUB_SELECT(distance_resolution) + SUB_SELECT(light_function) + SUB_SELECT(out_pin_level) +#endif +#ifdef USE_SWITCH + SUB_SWITCH(bluetooth) + SUB_SWITCH(engineering_mode) +#endif +#ifdef USE_BUTTON + SUB_BUTTON(factory_reset) + SUB_BUTTON(query) + SUB_BUTTON(restart) + SUB_BUTTON(start_dynamic_background_correction) +#endif + + public: + void setup() override; + void dump_config() override; + void loop() override; + void set_light_out_control(); + void set_basic_config(); +#ifdef USE_NUMBER + void set_gate_move_threshold_number(uint8_t gate, number::Number *n); + void set_gate_still_threshold_number(uint8_t gate, number::Number *n); + void set_gate_threshold(); + void get_gate_threshold(); +#endif +#ifdef USE_SENSOR + void set_gate_move_sensor(uint8_t gate, sensor::Sensor *s); + void set_gate_still_sensor(uint8_t gate, sensor::Sensor *s); +#endif + void set_engineering_mode(bool enable); + void read_all_info(); + void restart_and_read_all_info(); + void set_bluetooth(bool enable); + void set_distance_resolution(const std::string &state); + void set_baud_rate(const std::string &state); + void factory_reset(); + void start_dynamic_background_correction(); + + protected: + void send_command_(uint8_t command_str, const uint8_t *command_value, uint8_t command_value_len); + void set_config_mode_(bool enable); + void handle_periodic_data_(); + bool handle_ack_data_(); + void readline_(int readch); + void query_parameters_(); + void get_version_(); + void get_mac_(); + void get_distance_resolution_(); + void query_light_control_(); + void restart_(); + void query_dynamic_background_correction_(); + + uint8_t light_function_ = 0; + uint8_t light_threshold_ = 0; + uint8_t out_pin_level_ = 0; + uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer + uint8_t buffer_data_[MAX_LINE_LENGTH]; + uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; + bool bluetooth_on_{false}; + bool dynamic_background_correction_active_{false}; +#ifdef USE_NUMBER + std::array gate_move_threshold_numbers_{}; + std::array gate_still_threshold_numbers_{}; +#endif +#ifdef USE_SENSOR + std::array *, TOTAL_GATES> gate_move_sensors_{}; + std::array *, TOTAL_GATES> gate_still_sensors_{}; +#endif +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py new file mode 100644 index 0000000000..5b0d6d8749 --- /dev/null +++ b/esphome/components/ld2412/number/__init__.py @@ -0,0 +1,126 @@ +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_MOVE_THRESHOLD, + CONF_STILL_THRESHOLD, + CONF_TIMEOUT, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_SIGNAL_STRENGTH, + ENTITY_CATEGORY_CONFIG, + ICON_LIGHTBULB, + ICON_MOTION_SENSOR, + ICON_TIMELAPSE, + UNIT_PERCENT, + UNIT_SECOND, +) + +from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component + +GateThresholdNumber = LD2412_ns.class_("GateThresholdNumber", number.Number) +LightThresholdNumber = LD2412_ns.class_("LightThresholdNumber", number.Number) +MaxDistanceTimeoutNumber = LD2412_ns.class_("MaxDistanceTimeoutNumber", number.Number) + +CONF_LIGHT_THRESHOLD = "light_threshold" +CONF_MAX_DISTANCE_GATE = "max_distance_gate" +CONF_MIN_DISTANCE_GATE = "min_distance_gate" + +TIMEOUT_GROUP = "timeout" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component), + cv.Optional(CONF_LIGHT_THRESHOLD): number.number_schema( + LightThresholdNumber, + device_class=DEVICE_CLASS_ILLUMINANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_LIGHTBULB, + ), + cv.Optional(CONF_MAX_DISTANCE_GATE): number.number_schema( + MaxDistanceTimeoutNumber, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_MOTION_SENSOR, + ), + cv.Optional(CONF_MIN_DISTANCE_GATE): number.number_schema( + MaxDistanceTimeoutNumber, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_MOTION_SENSOR, + ), + cv.Optional(CONF_TIMEOUT): number.number_schema( + MaxDistanceTimeoutNumber, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_TIMELAPSE, + unit_of_measurement=UNIT_SECOND, + ), + } +) + +CONFIG_SCHEMA = CONFIG_SCHEMA.extend( + { + cv.Optional(f"gate_{x}"): ( + { + cv.Required(CONF_MOVE_THRESHOLD): number.number_schema( + GateThresholdNumber, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_MOTION_SENSOR, + unit_of_measurement=UNIT_PERCENT, + ), + cv.Required(CONF_STILL_THRESHOLD): number.number_schema( + GateThresholdNumber, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_MOTION_SENSOR, + unit_of_measurement=UNIT_PERCENT, + ), + } + ) + for x in range(14) + } +) + + +async def to_code(config): + LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) + if light_threshold_config := config.get(CONF_LIGHT_THRESHOLD): + n = await number.new_number( + light_threshold_config, min_value=0, max_value=255, step=1 + ) + await cg.register_parented(n, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_light_threshold_number(n)) + if max_distance_gate_config := config.get(CONF_MAX_DISTANCE_GATE): + n = await number.new_number( + max_distance_gate_config, min_value=2, max_value=13, step=1 + ) + await cg.register_parented(n, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_max_distance_gate_number(n)) + if min_distance_gate_config := config.get(CONF_MIN_DISTANCE_GATE): + n = await number.new_number( + min_distance_gate_config, min_value=1, max_value=12, step=1 + ) + await cg.register_parented(n, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_min_distance_gate_number(n)) + for x in range(14): + if gate_conf := config.get(f"gate_{x}"): + move_config = gate_conf[CONF_MOVE_THRESHOLD] + n = cg.new_Pvariable(move_config[CONF_ID], x) + await number.register_number( + n, move_config, min_value=0, max_value=100, step=1 + ) + await cg.register_parented(n, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_gate_move_threshold_number(x, n)) + still_config = gate_conf[CONF_STILL_THRESHOLD] + n = cg.new_Pvariable(still_config[CONF_ID], x) + await number.register_number( + n, still_config, min_value=0, max_value=100, step=1 + ) + await cg.register_parented(n, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_gate_still_threshold_number(x, n)) + if timeout_config := config.get(CONF_TIMEOUT): + n = await number.new_number(timeout_config, min_value=0, max_value=900, step=1) + await cg.register_parented(n, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_timeout_number(n)) diff --git a/esphome/components/ld2412/number/gate_threshold_number.cpp b/esphome/components/ld2412/number/gate_threshold_number.cpp new file mode 100644 index 0000000000..47f8cd9107 --- /dev/null +++ b/esphome/components/ld2412/number/gate_threshold_number.cpp @@ -0,0 +1,14 @@ +#include "gate_threshold_number.h" + +namespace esphome { +namespace ld2412 { + +GateThresholdNumber::GateThresholdNumber(uint8_t gate) : gate_(gate) {} + +void GateThresholdNumber::control(float value) { + this->publish_state(value); + this->parent_->set_gate_threshold(); +} + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/number/gate_threshold_number.h b/esphome/components/ld2412/number/gate_threshold_number.h new file mode 100644 index 0000000000..61d9945a0a --- /dev/null +++ b/esphome/components/ld2412/number/gate_threshold_number.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "../ld2412.h" + +namespace esphome { +namespace ld2412 { + +class GateThresholdNumber : public number::Number, public Parented { + public: + GateThresholdNumber(uint8_t gate); + + protected: + uint8_t gate_; + void control(float value) override; +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/number/light_threshold_number.cpp b/esphome/components/ld2412/number/light_threshold_number.cpp new file mode 100644 index 0000000000..5dab1716bf --- /dev/null +++ b/esphome/components/ld2412/number/light_threshold_number.cpp @@ -0,0 +1,12 @@ +#include "light_threshold_number.h" + +namespace esphome { +namespace ld2412 { + +void LightThresholdNumber::control(float value) { + this->publish_state(value); + this->parent_->set_light_out_control(); +} + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/number/light_threshold_number.h b/esphome/components/ld2412/number/light_threshold_number.h new file mode 100644 index 0000000000..d8727d3c98 --- /dev/null +++ b/esphome/components/ld2412/number/light_threshold_number.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "../ld2412.h" + +namespace esphome { +namespace ld2412 { + +class LightThresholdNumber : public number::Number, public Parented { + public: + LightThresholdNumber() = default; + + protected: + void control(float value) override; +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/number/max_distance_timeout_number.cpp b/esphome/components/ld2412/number/max_distance_timeout_number.cpp new file mode 100644 index 0000000000..1c6471bc72 --- /dev/null +++ b/esphome/components/ld2412/number/max_distance_timeout_number.cpp @@ -0,0 +1,12 @@ +#include "max_distance_timeout_number.h" + +namespace esphome { +namespace ld2412 { + +void MaxDistanceTimeoutNumber::control(float value) { + this->publish_state(value); + this->parent_->set_basic_config(); +} + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/number/max_distance_timeout_number.h b/esphome/components/ld2412/number/max_distance_timeout_number.h new file mode 100644 index 0000000000..af0dcf68c5 --- /dev/null +++ b/esphome/components/ld2412/number/max_distance_timeout_number.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "../ld2412.h" + +namespace esphome { +namespace ld2412 { + +class MaxDistanceTimeoutNumber : public number::Number, public Parented { + public: + MaxDistanceTimeoutNumber() = default; + + protected: + void control(float value) override; +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select/__init__.py new file mode 100644 index 0000000000..d71ce460d9 --- /dev/null +++ b/esphome/components/ld2412/select/__init__.py @@ -0,0 +1,82 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import ( + CONF_BAUD_RATE, + ENTITY_CATEGORY_CONFIG, + ICON_LIGHTBULB, + ICON_RULER, + ICON_SCALE, + ICON_THERMOMETER, +) + +from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component + +BaudRateSelect = LD2412_ns.class_("BaudRateSelect", select.Select) +DistanceResolutionSelect = LD2412_ns.class_("DistanceResolutionSelect", select.Select) +LightOutControlSelect = LD2412_ns.class_("LightOutControlSelect", select.Select) + +CONF_DISTANCE_RESOLUTION = "distance_resolution" +CONF_LIGHT_FUNCTION = "light_function" +CONF_OUT_PIN_LEVEL = "out_pin_level" + + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component), + cv.Optional(CONF_BAUD_RATE): select.select_schema( + BaudRateSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_THERMOMETER, + ), + cv.Optional(CONF_DISTANCE_RESOLUTION): select.select_schema( + DistanceResolutionSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RULER, + ), + cv.Optional(CONF_LIGHT_FUNCTION): select.select_schema( + LightOutControlSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_LIGHTBULB, + ), + cv.Optional(CONF_OUT_PIN_LEVEL): select.select_schema( + LightOutControlSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_SCALE, + ), +} + + +async def to_code(config): + LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) + if baud_rate_config := config.get(CONF_BAUD_RATE): + s = await select.new_select( + baud_rate_config, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) + await cg.register_parented(s, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_baud_rate_select(s)) + if distance_resolution_config := config.get(CONF_DISTANCE_RESOLUTION): + s = await select.new_select( + distance_resolution_config, options=["0.2m", "0.5m", "0.75m"] + ) + await cg.register_parented(s, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_distance_resolution_select(s)) + if light_function_config := config.get(CONF_LIGHT_FUNCTION): + s = await select.new_select( + light_function_config, options=["off", "below", "above"] + ) + await cg.register_parented(s, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_light_function_select(s)) + if out_pin_level_config := config.get(CONF_OUT_PIN_LEVEL): + s = await select.new_select(out_pin_level_config, options=["low", "high"]) + await cg.register_parented(s, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_out_pin_level_select(s)) diff --git a/esphome/components/ld2412/select/baud_rate_select.cpp b/esphome/components/ld2412/select/baud_rate_select.cpp new file mode 100644 index 0000000000..2291a81896 --- /dev/null +++ b/esphome/components/ld2412/select/baud_rate_select.cpp @@ -0,0 +1,12 @@ +#include "baud_rate_select.h" + +namespace esphome { +namespace ld2412 { + +void BaudRateSelect::control(const std::string &value) { + this->publish_state(value); + this->parent_->set_baud_rate(state); +} + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h new file mode 100644 index 0000000000..2ae33551fb --- /dev/null +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../ld2412.h" + +namespace esphome { +namespace ld2412 { + +class BaudRateSelect : public select::Select, public Parented { + public: + BaudRateSelect() = default; + + protected: + void control(const std::string &value) override; +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/select/distance_resolution_select.cpp b/esphome/components/ld2412/select/distance_resolution_select.cpp new file mode 100644 index 0000000000..a282215fbd --- /dev/null +++ b/esphome/components/ld2412/select/distance_resolution_select.cpp @@ -0,0 +1,12 @@ +#include "distance_resolution_select.h" + +namespace esphome { +namespace ld2412 { + +void DistanceResolutionSelect::control(const std::string &value) { + this->publish_state(value); + this->parent_->set_distance_resolution(state); +} + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h new file mode 100644 index 0000000000..0658f5d1a7 --- /dev/null +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../ld2412.h" + +namespace esphome { +namespace ld2412 { + +class DistanceResolutionSelect : public select::Select, public Parented { + public: + DistanceResolutionSelect() = default; + + protected: + void control(const std::string &value) override; +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/select/light_out_control_select.cpp b/esphome/components/ld2412/select/light_out_control_select.cpp new file mode 100644 index 0000000000..c331729d40 --- /dev/null +++ b/esphome/components/ld2412/select/light_out_control_select.cpp @@ -0,0 +1,12 @@ +#include "light_out_control_select.h" + +namespace esphome { +namespace ld2412 { + +void LightOutControlSelect::control(const std::string &value) { + this->publish_state(value); + this->parent_->set_light_out_control(); +} + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h new file mode 100644 index 0000000000..a71bab1e14 --- /dev/null +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../ld2412.h" + +namespace esphome { +namespace ld2412 { + +class LightOutControlSelect : public select::Select, public Parented { + public: + LightOutControlSelect() = default; + + protected: + void control(const std::string &value) override; +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py new file mode 100644 index 0000000000..abb823faad --- /dev/null +++ b/esphome/components/ld2412/sensor.py @@ -0,0 +1,124 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_LIGHT, + CONF_MOVING_DISTANCE, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_ILLUMINANCE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_FLASH, + ICON_LIGHTBULB, + ICON_MOTION_SENSOR, + ICON_SIGNAL, + UNIT_CENTIMETER, + UNIT_EMPTY, + UNIT_PERCENT, +) + +from . import CONF_LD2412_ID, LD2412Component + +DEPENDENCIES = ["ld2412"] + +CONF_DETECTION_DISTANCE = "detection_distance" +CONF_MOVE_ENERGY = "move_energy" +CONF_MOVING_ENERGY = "moving_energy" +CONF_STILL_DISTANCE = "still_distance" +CONF_STILL_ENERGY = "still_energy" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component), + cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + icon=ICON_SIGNAL, + unit_of_measurement=UNIT_CENTIMETER, + ), + cv.Optional(CONF_LIGHT): sensor.sensor_schema( + device_class=DEVICE_CLASS_ILLUMINANCE, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + icon=ICON_LIGHTBULB, + unit_of_measurement=UNIT_EMPTY, # No standard unit for this light sensor + ), + cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + icon=ICON_SIGNAL, + unit_of_measurement=UNIT_CENTIMETER, + ), + cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema( + filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + icon=ICON_MOTION_SENSOR, + unit_of_measurement=UNIT_PERCENT, + ), + cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + icon=ICON_SIGNAL, + unit_of_measurement=UNIT_CENTIMETER, + ), + cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( + filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + icon=ICON_FLASH, + unit_of_measurement=UNIT_PERCENT, + ), + } +) + +CONFIG_SCHEMA = CONFIG_SCHEMA.extend( + { + cv.Optional(f"gate_{x}"): ( + { + cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + filters=[ + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)} + ], + icon=ICON_MOTION_SENSOR, + unit_of_measurement=UNIT_PERCENT, + ), + cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + filters=[ + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)} + ], + icon=ICON_FLASH, + unit_of_measurement=UNIT_PERCENT, + ), + } + ) + for x in range(14) + } +) + + +async def to_code(config): + LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) + if detection_distance_config := config.get(CONF_DETECTION_DISTANCE): + sens = await sensor.new_sensor(detection_distance_config) + cg.add(LD2412_component.set_detection_distance_sensor(sens)) + if light_config := config.get(CONF_LIGHT): + sens = await sensor.new_sensor(light_config) + cg.add(LD2412_component.set_light_sensor(sens)) + if moving_distance_config := config.get(CONF_MOVING_DISTANCE): + sens = await sensor.new_sensor(moving_distance_config) + cg.add(LD2412_component.set_moving_target_distance_sensor(sens)) + if moving_energy_config := config.get(CONF_MOVING_ENERGY): + sens = await sensor.new_sensor(moving_energy_config) + cg.add(LD2412_component.set_moving_target_energy_sensor(sens)) + if still_distance_config := config.get(CONF_STILL_DISTANCE): + sens = await sensor.new_sensor(still_distance_config) + cg.add(LD2412_component.set_still_target_distance_sensor(sens)) + if still_energy_config := config.get(CONF_STILL_ENERGY): + sens = await sensor.new_sensor(still_energy_config) + cg.add(LD2412_component.set_still_target_energy_sensor(sens)) + for x in range(14): + if gate_conf := config.get(f"gate_{x}"): + if move_config := gate_conf.get(CONF_MOVE_ENERGY): + sens = await sensor.new_sensor(move_config) + cg.add(LD2412_component.set_gate_move_sensor(x, sens)) + if still_config := gate_conf.get(CONF_STILL_ENERGY): + sens = await sensor.new_sensor(still_config) + cg.add(LD2412_component.set_gate_still_sensor(x, sens)) diff --git a/esphome/components/ld2412/switch/__init__.py b/esphome/components/ld2412/switch/__init__.py new file mode 100644 index 0000000000..df994687ec --- /dev/null +++ b/esphome/components/ld2412/switch/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import ( + CONF_BLUETOOTH, + DEVICE_CLASS_SWITCH, + ENTITY_CATEGORY_CONFIG, + ICON_BLUETOOTH, + ICON_PULSE, +) + +from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component + +BluetoothSwitch = LD2412_ns.class_("BluetoothSwitch", switch.Switch) +EngineeringModeSwitch = LD2412_ns.class_("EngineeringModeSwitch", switch.Switch) + +CONF_ENGINEERING_MODE = "engineering_mode" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component), + cv.Optional(CONF_BLUETOOTH): switch.switch_schema( + BluetoothSwitch, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_BLUETOOTH, + ), + cv.Optional(CONF_ENGINEERING_MODE): switch.switch_schema( + EngineeringModeSwitch, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_PULSE, + ), +} + + +async def to_code(config): + LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) + if bluetooth_config := config.get(CONF_BLUETOOTH): + s = await switch.new_switch(bluetooth_config) + await cg.register_parented(s, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_bluetooth_switch(s)) + if engineering_mode_config := config.get(CONF_ENGINEERING_MODE): + s = await switch.new_switch(engineering_mode_config) + await cg.register_parented(s, config[CONF_LD2412_ID]) + cg.add(LD2412_component.set_engineering_mode_switch(s)) diff --git a/esphome/components/ld2412/switch/bluetooth_switch.cpp b/esphome/components/ld2412/switch/bluetooth_switch.cpp new file mode 100644 index 0000000000..14387aa276 --- /dev/null +++ b/esphome/components/ld2412/switch/bluetooth_switch.cpp @@ -0,0 +1,12 @@ +#include "bluetooth_switch.h" + +namespace esphome { +namespace ld2412 { + +void BluetoothSwitch::write_state(bool state) { + this->publish_state(state); + this->parent_->set_bluetooth(state); +} + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/switch/bluetooth_switch.h b/esphome/components/ld2412/switch/bluetooth_switch.h new file mode 100644 index 0000000000..730d338d87 --- /dev/null +++ b/esphome/components/ld2412/switch/bluetooth_switch.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/switch/switch.h" +#include "../ld2412.h" + +namespace esphome { +namespace ld2412 { + +class BluetoothSwitch : public switch_::Switch, public Parented { + public: + BluetoothSwitch() = default; + + protected: + void write_state(bool state) override; +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.cpp b/esphome/components/ld2412/switch/engineering_mode_switch.cpp new file mode 100644 index 0000000000..29ca0c22a8 --- /dev/null +++ b/esphome/components/ld2412/switch/engineering_mode_switch.cpp @@ -0,0 +1,12 @@ +#include "engineering_mode_switch.h" + +namespace esphome { +namespace ld2412 { + +void EngineeringModeSwitch::write_state(bool state) { + this->publish_state(state); + this->parent_->set_engineering_mode(state); +} + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.h b/esphome/components/ld2412/switch/engineering_mode_switch.h new file mode 100644 index 0000000000..aaa404c673 --- /dev/null +++ b/esphome/components/ld2412/switch/engineering_mode_switch.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/switch/switch.h" +#include "../ld2412.h" + +namespace esphome { +namespace ld2412 { + +class EngineeringModeSwitch : public switch_::Switch, public Parented { + public: + EngineeringModeSwitch() = default; + + protected: + void write_state(bool state) override; +}; + +} // namespace ld2412 +} // namespace esphome diff --git a/esphome/components/ld2412/text_sensor.py b/esphome/components/ld2412/text_sensor.py new file mode 100644 index 0000000000..1074494933 --- /dev/null +++ b/esphome/components/ld2412/text_sensor.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_MAC_ADDRESS, + CONF_VERSION, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_BLUETOOTH, + ICON_CHIP, +) + +from . import CONF_LD2412_ID, LD2412Component + +DEPENDENCIES = ["ld2412"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), + cv.Optional(CONF_MAC_ADDRESS): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_BLUETOOTH + ), +} + + +async def to_code(config): + LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) + if version_config := config.get(CONF_VERSION): + sens = await text_sensor.new_text_sensor(version_config) + cg.add(LD2412_component.set_version_text_sensor(sens)) + if mac_address_config := config.get(CONF_MAC_ADDRESS): + sens = await text_sensor.new_text_sensor(mac_address_config) + cg.add(LD2412_component.set_mac_text_sensor(sens)) diff --git a/tests/components/ld2412/common.yaml b/tests/components/ld2412/common.yaml new file mode 100644 index 0000000000..9176c61fd5 --- /dev/null +++ b/tests/components/ld2412/common.yaml @@ -0,0 +1,233 @@ +uart: + - id: uart_ld2412 + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 9600 + +ld2412: + id: my_ld2412 + +binary_sensor: + - platform: ld2412 + dynamic_background_correction_status: + name: Dynamic Background Correction Status + has_target: + name: Presence + has_moving_target: + name: Moving Target + has_still_target: + name: Still Target + +button: + - platform: ld2412 + factory_reset: + name: Factory reset + restart: + name: Restart + query_params: + name: Query params + start_dynamic_background_correction: + name: Start Dynamic Background Correction + +number: + - platform: ld2412 + light_threshold: + name: Light Threshold + timeout: + name: Presence timeout + min_distance_gate: + name: Minimum distance gate + max_distance_gate: + name: Maximum distance gate + gate_0: + move_threshold: + name: Gate 0 Move Threshold + still_threshold: + name: Gate 0 Still Threshold + gate_1: + move_threshold: + name: Gate 1 Move Threshold + still_threshold: + name: Gate 1 Still Threshold + gate_2: + move_threshold: + name: Gate 2 Move Threshold + still_threshold: + name: Gate 2 Still Threshold + gate_3: + move_threshold: + name: Gate 3 Move Threshold + still_threshold: + name: Gate 3 Still Threshold + gate_4: + move_threshold: + name: Gate 4 Move Threshold + still_threshold: + name: Gate 4 Still Threshold + gate_5: + move_threshold: + name: Gate 5 Move Threshold + still_threshold: + name: Gate 5 Still Threshold + gate_6: + move_threshold: + name: Gate 6 Move Threshold + still_threshold: + name: Gate 6 Still Threshold + gate_7: + move_threshold: + name: Gate 7 Move Threshold + still_threshold: + name: Gate 7 Still Threshold + gate_8: + move_threshold: + name: Gate 8 Move Threshold + still_threshold: + name: Gate 8 Still Threshold + gate_9: + move_threshold: + name: Gate 9 Move Threshold + still_threshold: + name: Gate 9 Still Threshold + gate_10: + move_threshold: + name: Gate 10 Move Threshold + still_threshold: + name: Gate 10 Still Threshold + gate_11: + move_threshold: + name: Gate 11 Move Threshold + still_threshold: + name: Gate 11 Still Threshold + gate_12: + move_threshold: + name: Gate 12 Move Threshold + still_threshold: + name: Gate 12 Still Threshold + gate_13: + move_threshold: + name: Gate 13 Move Threshold + still_threshold: + name: Gate 13 Still Threshold + +select: + - platform: ld2412 + light_function: + name: Light Function + out_pin_level: + name: Hardware output pin level + distance_resolution: + name: Distance resolution + baud_rate: + name: Baud rate + on_value: + - delay: 3s + - lambda: |- + id(uart_ld2412).flush(); + uint32_t new_baud_rate = stoi(x); + ESP_LOGD("change_baud_rate", "Changing baud rate from %i to %i",id(uart_ld2412).get_baud_rate(), new_baud_rate); + if (id(uart_ld2412).get_baud_rate() != new_baud_rate) { + id(uart_ld2412).set_baud_rate(new_baud_rate); + #if defined(USE_ESP8266) || defined(USE_ESP32) + id(uart_ld2412).load_settings(); + #endif + } + +sensor: + - platform: ld2412 + light: + name: Light + moving_distance: + name: Moving Distance + still_distance: + name: Still Distance + moving_energy: + name: Move Energy + still_energy: + name: Still Energy + detection_distance: + name: Detection Distance + gate_0: + move_energy: + name: Gate 0 Move Energy + still_energy: + name: Gate 0 Still Energy + gate_1: + move_energy: + name: Gate 1 Move Energy + still_energy: + name: Gate 1 Still Energy + gate_2: + move_energy: + name: Gate 2 Move Energy + still_energy: + name: Gate 2 Still Energy + gate_3: + move_energy: + name: Gate 3 Move Energy + still_energy: + name: Gate 3 Still Energy + gate_4: + move_energy: + name: Gate 4 Move Energy + still_energy: + name: Gate 4 Still Energy + gate_5: + move_energy: + name: Gate 5 Move Energy + still_energy: + name: Gate 5 Still Energy + gate_6: + move_energy: + name: Gate 6 Move Energy + still_energy: + name: Gate 6 Still Energy + gate_7: + move_energy: + name: Gate 7 Move Energy + still_energy: + name: Gate 7 Still Energy + gate_8: + move_energy: + name: Gate 8 Move Energy + still_energy: + name: Gate 8 Still Energy + gate_9: + move_energy: + name: Gate 9 Move Energy + still_energy: + name: Gate 9 Still Energy + gate_10: + move_energy: + name: Gate 10 Move Energy + still_energy: + name: Gate 10 Still Energy + gate_11: + move_energy: + name: Gate 11 Move Energy + still_energy: + name: Gate 11 Still Energy + gate_12: + move_energy: + name: Gate 12 Move Energy + still_energy: + name: Gate 12 Still Energy + gate_13: + move_energy: + name: Gate 13 Move Energy + still_energy: + name: Gate 13 Still Energy + +switch: + - platform: ld2412 + bluetooth: + name: Bluetooth + engineering_mode: + name: Engineering Mode + +text_sensor: + - platform: ld2412 + version: + name: Firmware version + mac_address: + name: MAC address diff --git a/tests/components/ld2412/test.esp32-ard.yaml b/tests/components/ld2412/test.esp32-ard.yaml new file mode 100644 index 0000000000..f486544afa --- /dev/null +++ b/tests/components/ld2412/test.esp32-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +<<: !include common.yaml diff --git a/tests/components/ld2412/test.esp32-c3-ard.yaml b/tests/components/ld2412/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/ld2412/test.esp32-c3-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/ld2412/test.esp32-c3-idf.yaml b/tests/components/ld2412/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/ld2412/test.esp32-c3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/ld2412/test.esp32-idf.yaml b/tests/components/ld2412/test.esp32-idf.yaml new file mode 100644 index 0000000000..f486544afa --- /dev/null +++ b/tests/components/ld2412/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +<<: !include common.yaml diff --git a/tests/components/ld2412/test.esp8266-ard.yaml b/tests/components/ld2412/test.esp8266-ard.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/ld2412/test.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/ld2412/test.rp2040-ard.yaml b/tests/components/ld2412/test.rp2040-ard.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/ld2412/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml From 3a857950bfd960b67766ef4391ff5d20e159991d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 20:23:41 +1200 Subject: [PATCH 0486/1626] Bump actions/checkout from 4 to 5 (#10198) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-clang-tidy-hash.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 22 +++++++++++----------- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 8 ++++---- .github/workflows/sync-device-classes.yml | 4 ++-- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 36086579fc..63f059eb6d 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -22,7 +22,7 @@ jobs: if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index f51bd84186..c7cc720323 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python uses: actions/setup-python@v5.6.0 with: diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 1c7a62e40b..c7da7f6672 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python uses: actions/setup-python@v5.6.0 diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index d6dac66359..61ecf8183b 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -43,7 +43,7 @@ jobs: - "docker" # - "lint" steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v5.0.0 - name: Set up Python uses: actions/setup-python@v5.6.0 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e503bf556e..e50f2aef40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -70,7 +70,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -91,7 +91,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -136,7 +136,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -179,7 +179,7 @@ jobs: component-test-count: ${{ steps.determine.outputs.component-test-count }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -214,7 +214,7 @@ jobs: if: needs.determine-jobs.outputs.integration-tests == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Set up Python 3.13 id: python uses: actions/setup-python@v5.6.0 @@ -287,7 +287,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -374,7 +374,7 @@ jobs: sudo apt-get install libsdl2-dev - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -400,7 +400,7 @@ jobs: matrix: ${{ steps.split.outputs.components }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Split components into 20 groups id: split run: | @@ -430,7 +430,7 @@ jobs: sudo apt-get install libsdl2-dev - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -459,7 +459,7 @@ jobs: if: github.event_name == 'pull_request' && github.base_ref != 'beta' && github.base_ref != 'release' steps: - name: Check out code from GitHub - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ddeb0a99d2..7a7c39aeec 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -54,7 +54,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5.0.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82cf605342..9af67aa310 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v5.0.0 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v5.0.0 - name: Set up Python uses: actions/setup-python@v5.6.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v5.0.0 - name: Set up Python uses: actions/setup-python@v5.6.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v5.0.0 - name: Download digests uses: actions/download-artifact@v5.0.0 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a38825fc45..cc03ed3e3f 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -13,10 +13,10 @@ jobs: if: github.repository == 'esphome/esphome' steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Checkout Home Assistant - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 with: repository: home-assistant/core path: lib/home-assistant From 7ffdaa1f06e7ecf09a545dcb858b2358bbdc4190 Mon Sep 17 00:00:00 2001 From: John <34163498+CircuitSetup@users.noreply.github.com> Date: Tue, 12 Aug 2025 04:26:53 -0400 Subject: [PATCH 0487/1626] [atm90e32] energy meter calibration log output enhancements & software SPI fix (#10143) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/atm90e32/atm90e32.cpp | 629 ++++++++++++++++++----- esphome/components/atm90e32/atm90e32.h | 49 +- 2 files changed, 544 insertions(+), 134 deletions(-) diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index a887e7a9e6..d5fee6ea04 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -110,6 +110,8 @@ void ATM90E32Component::update() { void ATM90E32Component::setup() { this->spi_setup(); + this->cs_summary_ = this->cs_->dump_summary(); + const char *cs = this->cs_summary_.c_str(); uint16_t mmode0 = 0x87; // 3P4W 50Hz uint16_t high_thresh = 0; @@ -130,9 +132,9 @@ void ATM90E32Component::setup() { mmode0 |= 0 << 1; // sets 1st bit to 0, phase b is not counted into the all-phase sum energy/power (P/Q/S) } - this->write16_(ATM90E32_REGISTER_SOFTRESET, 0x789A); // Perform soft reset - delay(6); // Wait for the minimum 5ms + 1ms - this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); // enable register config access + this->write16_(ATM90E32_REGISTER_SOFTRESET, 0x789A, false); // Perform soft reset + delay(6); // Wait for the minimum 5ms + 1ms + this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); // enable register config access if (!this->validate_spi_read_(0x55AA, "setup()")) { ESP_LOGW(TAG, "Could not initialize ATM90E32 IC, check SPI settings"); this->mark_failed(); @@ -156,16 +158,17 @@ void ATM90E32Component::setup() { if (this->enable_offset_calibration_) { // Initialize flash storage for offset calibrations - uint32_t o_hash = fnv1_hash(std::string("_offset_calibration_") + this->cs_->dump_summary()); + uint32_t o_hash = fnv1_hash(std::string("_offset_calibration_") + this->cs_summary_); this->offset_pref_ = global_preferences->make_preference(o_hash, true); this->restore_offset_calibrations_(); // Initialize flash storage for power offset calibrations - uint32_t po_hash = fnv1_hash(std::string("_power_offset_calibration_") + this->cs_->dump_summary()); + uint32_t po_hash = fnv1_hash(std::string("_power_offset_calibration_") + this->cs_summary_); this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); this->restore_power_offset_calibrations_(); } else { - ESP_LOGI(TAG, "[CALIBRATION] Power & Voltage/Current offset calibration is disabled. Using config file values."); + ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.", + cs); for (uint8_t phase = 0; phase < 3; ++phase) { this->write16_(this->voltage_offset_registers[phase], static_cast(this->offset_phase_[phase].voltage_offset_)); @@ -180,21 +183,18 @@ void ATM90E32Component::setup() { if (this->enable_gain_calibration_) { // Initialize flash storage for gain calibration - uint32_t g_hash = fnv1_hash(std::string("_gain_calibration_") + this->cs_->dump_summary()); + uint32_t g_hash = fnv1_hash(std::string("_gain_calibration_") + this->cs_summary_); this->gain_calibration_pref_ = global_preferences->make_preference(g_hash, true); this->restore_gain_calibrations_(); - if (this->using_saved_calibrations_) { - ESP_LOGI(TAG, "[CALIBRATION] Successfully restored gain calibration from memory."); - } else { + if (!this->using_saved_calibrations_) { for (uint8_t phase = 0; phase < 3; ++phase) { this->write16_(voltage_gain_registers[phase], this->phase_[phase].voltage_gain_); this->write16_(current_gain_registers[phase], this->phase_[phase].ct_gain_); } } } else { - ESP_LOGI(TAG, "[CALIBRATION] Gain calibration is disabled. Using config file values."); - + ESP_LOGI(TAG, "[CALIBRATION][%s] Gain calibration is disabled. Using config file values.", cs); for (uint8_t phase = 0; phase < 3; ++phase) { this->write16_(voltage_gain_registers[phase], this->phase_[phase].voltage_gain_); this->write16_(current_gain_registers[phase], this->phase_[phase].ct_gain_); @@ -213,6 +213,122 @@ void ATM90E32Component::setup() { this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); // end configuration } +void ATM90E32Component::log_calibration_status_() { + const char *cs = this->cs_summary_.c_str(); + + bool offset_mismatch = false; + bool power_mismatch = false; + bool gain_mismatch = false; + + for (uint8_t phase = 0; phase < 3; ++phase) { + offset_mismatch |= this->offset_calibration_mismatch_[phase]; + power_mismatch |= this->power_offset_calibration_mismatch_[phase]; + gain_mismatch |= this->gain_calibration_mismatch_[phase]; + } + + if (offset_mismatch) { + ESP_LOGW(TAG, "[CALIBRATION][%s] ", cs); + ESP_LOGW(TAG, + "[CALIBRATION][%s] ===================== Offset mismatch: using flash values =====================", cs); + ESP_LOGW(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------------------", + cs); + ESP_LOGW(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); + ESP_LOGW(TAG, "[CALIBRATION][%s] | | config | flash | config | flash |", cs); + ESP_LOGW(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------------------", + cs); + for (uint8_t phase = 0; phase < 3; ++phase) { + ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase, + this->config_offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].voltage_offset_, + this->config_offset_phase_[phase].current_offset_, this->offset_phase_[phase].current_offset_); + } + ESP_LOGW(TAG, + "[CALIBRATION][%s] ===============================================================================", cs); + } + if (power_mismatch) { + ESP_LOGW(TAG, "[CALIBRATION][%s] ", cs); + ESP_LOGW(TAG, + "[CALIBRATION][%s] ================= Power offset mismatch: using flash values =================", cs); + ESP_LOGW(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------------------", + cs); + ESP_LOGW(TAG, "[CALIBRATION][%s] | Phase | offset_active_power|offset_reactive_power|", cs); + ESP_LOGW(TAG, "[CALIBRATION][%s] | | config | flash | config | flash |", cs); + ESP_LOGW(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------------------", + cs); + for (uint8_t phase = 0; phase < 3; ++phase) { + ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase, + this->config_power_offset_phase_[phase].active_power_offset, + this->power_offset_phase_[phase].active_power_offset, + this->config_power_offset_phase_[phase].reactive_power_offset, + this->power_offset_phase_[phase].reactive_power_offset); + } + ESP_LOGW(TAG, + "[CALIBRATION][%s] ===============================================================================", cs); + } + if (gain_mismatch) { + ESP_LOGW(TAG, "[CALIBRATION][%s] ", cs); + ESP_LOGW(TAG, + "[CALIBRATION][%s] ====================== Gain mismatch: using flash values =====================", cs); + ESP_LOGW(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------------------", + cs); + ESP_LOGW(TAG, "[CALIBRATION][%s] | Phase | voltage_gain | current_gain |", cs); + ESP_LOGW(TAG, "[CALIBRATION][%s] | | config | flash | config | flash |", cs); + ESP_LOGW(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------------------", + cs); + for (uint8_t phase = 0; phase < 3; ++phase) { + ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6u | %6u | %6u | %6u |", cs, 'A' + phase, + this->config_gain_phase_[phase].voltage_gain, this->gain_phase_[phase].voltage_gain, + this->config_gain_phase_[phase].current_gain, this->gain_phase_[phase].current_gain); + } + ESP_LOGW(TAG, + "[CALIBRATION][%s] ===============================================================================", cs); + } + if (!this->enable_offset_calibration_) { + ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.", + cs); + } else if (this->restored_offset_calibration_ && !offset_mismatch) { + ESP_LOGI(TAG, "[CALIBRATION][%s] ", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ============== Restored offset calibration from memory ==============", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); + for (uint8_t phase = 0; phase < 3; phase++) { + ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, + this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_); + } + ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\\n", cs); + } + + if (this->restored_power_offset_calibration_ && !power_mismatch) { + ESP_LOGI(TAG, "[CALIBRATION][%s] ", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ============ Restored power offset calibration from memory ============", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + for (uint8_t phase = 0; phase < 3; phase++) { + ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, + this->power_offset_phase_[phase].active_power_offset, + this->power_offset_phase_[phase].reactive_power_offset); + } + ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); + } + if (!this->enable_gain_calibration_) { + ESP_LOGI(TAG, "[CALIBRATION][%s] Gain calibration is disabled. Using config file values.", cs); + } else if (this->restored_gain_calibration_ && !gain_mismatch) { + ESP_LOGI(TAG, "[CALIBRATION][%s] ", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ============ Restoring saved gain calibrations to registers ============", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | voltage_gain | current_gain |", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + for (uint8_t phase = 0; phase < 3; phase++) { + ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6u | %6u |", cs, 'A' + phase, + this->gain_phase_[phase].voltage_gain, this->gain_phase_[phase].current_gain); + } + ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\\n", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] Gain calibration loaded and verified successfully.\n", cs); + } + this->calibration_message_printed_ = true; +} + void ATM90E32Component::dump_config() { ESP_LOGCONFIG("", "ATM90E32:"); LOG_PIN(" CS Pin: ", this->cs_); @@ -255,6 +371,10 @@ void ATM90E32Component::dump_config() { LOG_SENSOR(" ", "Peak Current C", this->phase_[PHASEC].peak_current_sensor_); LOG_SENSOR(" ", "Frequency", this->freq_sensor_); LOG_SENSOR(" ", "Chip Temp", this->chip_temperature_sensor_); + if (this->restored_offset_calibration_ || this->restored_power_offset_calibration_ || + this->restored_gain_calibration_ || !this->enable_offset_calibration_ || !this->enable_gain_calibration_) { + this->log_calibration_status_(); + } } float ATM90E32Component::get_setup_priority() const { return setup_priority::IO; } @@ -262,26 +382,35 @@ float ATM90E32Component::get_setup_priority() const { return setup_priority::IO; // R/C registers can conly be cleared after the LastSPIData register is updated (register 78H) // Peakdetect period: 05H. Bit 15:8 are PeakDet_period in ms. 7:0 are Sag_period // Default is 143FH (20ms, 63ms) -uint16_t ATM90E32Component::read16_(uint16_t a_register) { +uint16_t ATM90E32Component::read16_transaction_(uint16_t a_register) { uint8_t addrh = (1 << 7) | ((a_register >> 8) & 0x03); uint8_t addrl = (a_register & 0xFF); - uint8_t data[2]; - uint16_t output; - this->enable(); - delay_microseconds_safe(1); // min delay between CS low and first SCK is 200ns - 1ms is plenty - this->write_byte(addrh); - this->write_byte(addrl); - this->read_array(data, 2); - this->disable(); - - output = (uint16_t(data[0] & 0xFF) << 8) | (data[1] & 0xFF); + uint8_t data[4] = {addrh, addrl, 0x00, 0x00}; + this->transfer_array(data, 4); + uint16_t output = encode_uint16(data[2], data[3]); ESP_LOGVV(TAG, "read16_ 0x%04" PRIX16 " output 0x%04" PRIX16, a_register, output); return output; } +uint16_t ATM90E32Component::read16_(uint16_t a_register) { + this->enable(); + delay_microseconds_safe(1); // min delay between CS low and first SCK is 200ns - 1us is plenty + uint16_t output = this->read16_transaction_(a_register); + delay_microseconds_safe(1); // allow the last clock to propagate before releasing CS + this->disable(); + delay_microseconds_safe(1); // meet minimum CS high time before next transaction + return output; +} + int ATM90E32Component::read32_(uint16_t addr_h, uint16_t addr_l) { - const uint16_t val_h = this->read16_(addr_h); - const uint16_t val_l = this->read16_(addr_l); + this->enable(); + delay_microseconds_safe(1); + const uint16_t val_h = this->read16_transaction_(addr_h); + delay_microseconds_safe(1); + const uint16_t val_l = this->read16_transaction_(addr_l); + delay_microseconds_safe(1); + this->disable(); + delay_microseconds_safe(1); const int32_t val = (val_h << 16) | val_l; ESP_LOGVV(TAG, @@ -292,13 +421,19 @@ int ATM90E32Component::read32_(uint16_t addr_h, uint16_t addr_l) { return val; } -void ATM90E32Component::write16_(uint16_t a_register, uint16_t val) { +void ATM90E32Component::write16_(uint16_t a_register, uint16_t val, bool validate) { ESP_LOGVV(TAG, "write16_ 0x%04" PRIX16 " val 0x%04" PRIX16, a_register, val); + uint8_t addrh = ((a_register >> 8) & 0x03); + uint8_t addrl = (a_register & 0xFF); + uint8_t data[4] = {addrh, addrl, uint8_t((val >> 8) & 0xFF), uint8_t(val & 0xFF)}; this->enable(); - this->write_byte16(a_register); - this->write_byte16(val); + delay_microseconds_safe(1); // ensure CS setup time + this->write_array(data, 4); + delay_microseconds_safe(1); // allow clock to settle before raising CS this->disable(); - this->validate_spi_read_(val, "write16()"); + delay_microseconds_safe(1); // ensure minimum CS high time + if (validate) + this->validate_spi_read_(val, "write16()"); } float ATM90E32Component::get_local_phase_voltage_(uint8_t phase) { return this->phase_[phase].voltage_; } @@ -441,8 +576,10 @@ float ATM90E32Component::get_chip_temperature_() { } void ATM90E32Component::run_gain_calibrations() { + const char *cs = this->cs_summary_.c_str(); if (!this->enable_gain_calibration_) { - ESP_LOGW(TAG, "[CALIBRATION] Gain calibration is disabled! Enable it first with enable_gain_calibration: true"); + ESP_LOGW(TAG, "[CALIBRATION][%s] Gain calibration is disabled! Enable it first with enable_gain_calibration: true", + cs); return; } @@ -454,12 +591,14 @@ void ATM90E32Component::run_gain_calibrations() { float ref_currents[3] = {this->get_reference_current(0), this->get_reference_current(1), this->get_reference_current(2)}; - ESP_LOGI(TAG, "[CALIBRATION] "); - ESP_LOGI(TAG, "[CALIBRATION] ========================= Gain Calibration ========================="); - ESP_LOGI(TAG, "[CALIBRATION] ---------------------------------------------------------------------"); - ESP_LOGI(TAG, - "[CALIBRATION] | Phase | V_meas (V) | I_meas (A) | V_ref | I_ref | V_gain (old→new) | I_gain (old→new) |"); - ESP_LOGI(TAG, "[CALIBRATION] ---------------------------------------------------------------------"); + ESP_LOGI(TAG, "[CALIBRATION][%s] ", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ========================= Gain Calibration =========================", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + ESP_LOGI( + TAG, + "[CALIBRATION][%s] | Phase | V_meas (V) | I_meas (A) | V_ref | I_ref | V_gain (old→new) | I_gain (old→new) |", + cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { float measured_voltage = this->get_phase_voltage_avg_(phase); @@ -476,22 +615,22 @@ void ATM90E32Component::run_gain_calibrations() { // Voltage calibration if (ref_voltage <= 0.0f) { - ESP_LOGW(TAG, "[CALIBRATION] Phase %s - Skipping voltage calibration: reference voltage is 0.", + ESP_LOGW(TAG, "[CALIBRATION][%s] Phase %s - Skipping voltage calibration: reference voltage is 0.", cs, phase_labels[phase]); } else if (measured_voltage == 0.0f) { - ESP_LOGW(TAG, "[CALIBRATION] Phase %s - Skipping voltage calibration: measured voltage is 0.", + ESP_LOGW(TAG, "[CALIBRATION][%s] Phase %s - Skipping voltage calibration: measured voltage is 0.", cs, phase_labels[phase]); } else { uint32_t new_voltage_gain = static_cast((ref_voltage / measured_voltage) * current_voltage_gain); if (new_voltage_gain == 0) { - ESP_LOGW(TAG, "[CALIBRATION] Phase %s - Voltage gain would be 0. Check reference and measured voltage.", + ESP_LOGW(TAG, "[CALIBRATION][%s] Phase %s - Voltage gain would be 0. Check reference and measured voltage.", cs, phase_labels[phase]); } else { if (new_voltage_gain >= 65535) { - ESP_LOGW( - TAG, - "[CALIBRATION] Phase %s - Voltage gain exceeds 65535. You may need a higher output voltage transformer.", - phase_labels[phase]); + ESP_LOGW(TAG, + "[CALIBRATION][%s] Phase %s - Voltage gain exceeds 65535. You may need a higher output voltage " + "transformer.", + cs, phase_labels[phase]); new_voltage_gain = 65535; } this->gain_phase_[phase].voltage_gain = static_cast(new_voltage_gain); @@ -501,20 +640,20 @@ void ATM90E32Component::run_gain_calibrations() { // Current calibration if (ref_current == 0.0f) { - ESP_LOGW(TAG, "[CALIBRATION] Phase %s - Skipping current calibration: reference current is 0.", + ESP_LOGW(TAG, "[CALIBRATION][%s] Phase %s - Skipping current calibration: reference current is 0.", cs, phase_labels[phase]); } else if (measured_current == 0.0f) { - ESP_LOGW(TAG, "[CALIBRATION] Phase %s - Skipping current calibration: measured current is 0.", + ESP_LOGW(TAG, "[CALIBRATION][%s] Phase %s - Skipping current calibration: measured current is 0.", cs, phase_labels[phase]); } else { uint32_t new_current_gain = static_cast((ref_current / measured_current) * current_current_gain); if (new_current_gain == 0) { - ESP_LOGW(TAG, "[CALIBRATION] Phase %s - Current gain would be 0. Check reference and measured current.", + ESP_LOGW(TAG, "[CALIBRATION][%s] Phase %s - Current gain would be 0. Check reference and measured current.", cs, phase_labels[phase]); } else { if (new_current_gain >= 65535) { - ESP_LOGW(TAG, "[CALIBRATION] Phase %s - Current gain exceeds 65535. You may need to turn up pga gain.", - phase_labels[phase]); + ESP_LOGW(TAG, "[CALIBRATION][%s] Phase %s - Current gain exceeds 65535. You may need to turn up pga gain.", + cs, phase_labels[phase]); new_current_gain = 65535; } this->gain_phase_[phase].current_gain = static_cast(new_current_gain); @@ -523,13 +662,13 @@ void ATM90E32Component::run_gain_calibrations() { } // Final row output - ESP_LOGI(TAG, "[CALIBRATION] | %c | %9.2f | %9.4f | %5.2f | %6.4f | %5u → %-5u | %5u → %-5u |", + ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %9.2f | %9.4f | %5.2f | %6.4f | %5u → %-5u | %5u → %-5u |", cs, 'A' + phase, measured_voltage, measured_current, ref_voltage, ref_current, current_voltage_gain, did_voltage ? this->gain_phase_[phase].voltage_gain : current_voltage_gain, current_current_gain, did_current ? this->gain_phase_[phase].current_gain : current_current_gain); } - ESP_LOGI(TAG, "[CALIBRATION] =====================================================================\n"); + ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); this->save_gain_calibration_to_memory_(); this->write_gains_to_registers_(); @@ -537,54 +676,108 @@ void ATM90E32Component::run_gain_calibrations() { } void ATM90E32Component::save_gain_calibration_to_memory_() { + const char *cs = this->cs_summary_.c_str(); bool success = this->gain_calibration_pref_.save(&this->gain_phase_); + global_preferences->sync(); if (success) { this->using_saved_calibrations_ = true; - ESP_LOGI(TAG, "[CALIBRATION] Gain calibration saved to memory."); + ESP_LOGI(TAG, "[CALIBRATION][%s] Gain calibration saved to memory.", cs); } else { this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION] Failed to save gain calibration to memory!"); + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save gain calibration to memory!", cs); + } +} + +void ATM90E32Component::save_offset_calibration_to_memory_() { + const char *cs = this->cs_summary_.c_str(); + bool success = this->offset_pref_.save(&this->offset_phase_); + global_preferences->sync(); + if (success) { + this->using_saved_calibrations_ = true; + this->restored_offset_calibration_ = true; + for (bool &phase : this->offset_calibration_mismatch_) + phase = false; + ESP_LOGI(TAG, "[CALIBRATION][%s] Offset calibration saved to memory.", cs); + } else { + this->using_saved_calibrations_ = false; + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save offset calibration to memory!", cs); + } +} + +void ATM90E32Component::save_power_offset_calibration_to_memory_() { + const char *cs = this->cs_summary_.c_str(); + bool success = this->power_offset_pref_.save(&this->power_offset_phase_); + global_preferences->sync(); + if (success) { + this->using_saved_calibrations_ = true; + this->restored_power_offset_calibration_ = true; + for (bool &phase : this->power_offset_calibration_mismatch_) + phase = false; + ESP_LOGI(TAG, "[CALIBRATION][%s] Power offset calibration saved to memory.", cs); + } else { + this->using_saved_calibrations_ = false; + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save power offset calibration to memory!", cs); } } void ATM90E32Component::run_offset_calibrations() { + const char *cs = this->cs_summary_.c_str(); if (!this->enable_offset_calibration_) { - ESP_LOGW(TAG, "[CALIBRATION] Offset calibration is disabled! Enable it first with enable_offset_calibration: true"); + ESP_LOGW(TAG, + "[CALIBRATION][%s] Offset calibration is disabled! Enable it first with enable_offset_calibration: true", + cs); return; } + ESP_LOGI(TAG, "[CALIBRATION][%s] ", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ======================== Offset Calibration ========================", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------", cs); + for (uint8_t phase = 0; phase < 3; phase++) { int16_t voltage_offset = calibrate_offset(phase, true); int16_t current_offset = calibrate_offset(phase, false); this->write_offsets_to_registers_(phase, voltage_offset, current_offset); - ESP_LOGI(TAG, "[CALIBRATION] Phase %c - offset_voltage: %d, offset_current: %d", 'A' + phase, voltage_offset, + ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset, current_offset); } - this->offset_pref_.save(&this->offset_phase_); // Save to flash + ESP_LOGI(TAG, "[CALIBRATION][%s] ==================================================================\n", cs); + + this->save_offset_calibration_to_memory_(); } void ATM90E32Component::run_power_offset_calibrations() { + const char *cs = this->cs_summary_.c_str(); if (!this->enable_offset_calibration_) { ESP_LOGW( TAG, - "[CALIBRATION] Offset power calibration is disabled! Enable it first with enable_offset_calibration: true"); + "[CALIBRATION][%s] Offset power calibration is disabled! Enable it first with enable_offset_calibration: true", + cs); return; } + ESP_LOGI(TAG, "[CALIBRATION][%s] ", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ===================== Power Offset Calibration =====================", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + for (uint8_t phase = 0; phase < 3; ++phase) { int16_t active_offset = calibrate_power_offset(phase, false); int16_t reactive_offset = calibrate_power_offset(phase, true); this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset); - ESP_LOGI(TAG, "[CALIBRATION] Phase %c - offset_active_power: %d, offset_reactive_power: %d", 'A' + phase, - active_offset, reactive_offset); + ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset, + reactive_offset); } + ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - this->power_offset_pref_.save(&this->power_offset_phase_); // Save to flash + this->save_power_offset_calibration_to_memory_(); } void ATM90E32Component::write_gains_to_registers_() { @@ -631,102 +824,276 @@ void ATM90E32Component::write_power_offsets_to_registers_(uint8_t phase, int16_t } void ATM90E32Component::restore_gain_calibrations_() { - if (this->gain_calibration_pref_.load(&this->gain_phase_)) { - ESP_LOGI(TAG, "[CALIBRATION] Restoring saved gain calibrations to registers:"); - - for (uint8_t phase = 0; phase < 3; phase++) { - uint16_t v_gain = this->gain_phase_[phase].voltage_gain; - uint16_t i_gain = this->gain_phase_[phase].current_gain; - ESP_LOGI(TAG, "[CALIBRATION] Phase %c - Voltage Gain: %u, Current Gain: %u", 'A' + phase, v_gain, i_gain); - } - - this->write_gains_to_registers_(); - - if (this->verify_gain_writes_()) { - this->using_saved_calibrations_ = true; - ESP_LOGI(TAG, "[CALIBRATION] Gain calibration loaded and verified successfully."); - } else { - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION] Gain verification failed! Calibration may not be applied correctly."); - } - } else { - this->using_saved_calibrations_ = false; - ESP_LOGW(TAG, "[CALIBRATION] No stored gain calibrations found. Using config file values."); + const char *cs = this->cs_summary_.c_str(); + for (uint8_t i = 0; i < 3; ++i) { + this->config_gain_phase_[i].voltage_gain = this->phase_[i].voltage_gain_; + this->config_gain_phase_[i].current_gain = this->phase_[i].ct_gain_; + this->gain_phase_[i] = this->config_gain_phase_[i]; } + + if (this->gain_calibration_pref_.load(&this->gain_phase_)) { + bool all_zero = true; + bool same_as_config = true; + for (uint8_t phase = 0; phase < 3; ++phase) { + const auto &cfg = this->config_gain_phase_[phase]; + const auto &saved = this->gain_phase_[phase]; + if (saved.voltage_gain != 0 || saved.current_gain != 0) + all_zero = false; + if (saved.voltage_gain != cfg.voltage_gain || saved.current_gain != cfg.current_gain) + same_as_config = false; + } + + if (!all_zero && !same_as_config) { + for (uint8_t phase = 0; phase < 3; ++phase) { + bool mismatch = false; + if (this->has_config_voltage_gain_[phase] && + this->gain_phase_[phase].voltage_gain != this->config_gain_phase_[phase].voltage_gain) + mismatch = true; + if (this->has_config_current_gain_[phase] && + this->gain_phase_[phase].current_gain != this->config_gain_phase_[phase].current_gain) + mismatch = true; + if (mismatch) + this->gain_calibration_mismatch_[phase] = true; + } + + this->write_gains_to_registers_(); + + if (this->verify_gain_writes_()) { + this->using_saved_calibrations_ = true; + this->restored_gain_calibration_ = true; + return; + } + + this->using_saved_calibrations_ = false; + ESP_LOGE(TAG, "[CALIBRATION][%s] Gain verification failed! Calibration may not be applied correctly.", cs); + } + } + + this->using_saved_calibrations_ = false; + for (uint8_t i = 0; i < 3; ++i) + this->gain_phase_[i] = this->config_gain_phase_[i]; + this->write_gains_to_registers_(); + + ESP_LOGW(TAG, "[CALIBRATION][%s] No stored gain calibrations found. Using config file values.", cs); } void ATM90E32Component::restore_offset_calibrations_() { - if (this->offset_pref_.load(&this->offset_phase_)) { - ESP_LOGI(TAG, "[CALIBRATION] Successfully restored offset calibration from memory."); + const char *cs = this->cs_summary_.c_str(); + for (uint8_t i = 0; i < 3; ++i) + this->config_offset_phase_[i] = this->offset_phase_[i]; + bool have_data = this->offset_pref_.load(&this->offset_phase_); + bool all_zero = true; + if (have_data) { + for (auto &phase : this->offset_phase_) { + if (phase.voltage_offset_ != 0 || phase.current_offset_ != 0) { + all_zero = false; + break; + } + } + } + + if (have_data && !all_zero) { + this->restored_offset_calibration_ = true; for (uint8_t phase = 0; phase < 3; phase++) { auto &offset = this->offset_phase_[phase]; - write_offsets_to_registers_(phase, offset.voltage_offset_, offset.current_offset_); - ESP_LOGI(TAG, "[CALIBRATION] Phase %c - offset_voltage:: %d, offset_current: %d", 'A' + phase, - offset.voltage_offset_, offset.current_offset_); + bool mismatch = false; + if (this->has_config_voltage_offset_[phase] && + offset.voltage_offset_ != this->config_offset_phase_[phase].voltage_offset_) + mismatch = true; + if (this->has_config_current_offset_[phase] && + offset.current_offset_ != this->config_offset_phase_[phase].current_offset_) + mismatch = true; + if (mismatch) + this->offset_calibration_mismatch_[phase] = true; } } else { - ESP_LOGW(TAG, "[CALIBRATION] No stored offset calibrations found. Using default values."); + for (uint8_t phase = 0; phase < 3; phase++) + this->offset_phase_[phase] = this->config_offset_phase_[phase]; + ESP_LOGW(TAG, "[CALIBRATION][%s] No stored offset calibrations found. Using default values.", cs); + } + + for (uint8_t phase = 0; phase < 3; phase++) { + write_offsets_to_registers_(phase, this->offset_phase_[phase].voltage_offset_, + this->offset_phase_[phase].current_offset_); } } void ATM90E32Component::restore_power_offset_calibrations_() { - if (this->power_offset_pref_.load(&this->power_offset_phase_)) { - ESP_LOGI(TAG, "[CALIBRATION] Successfully restored power offset calibration from memory."); + const char *cs = this->cs_summary_.c_str(); + for (uint8_t i = 0; i < 3; ++i) + this->config_power_offset_phase_[i] = this->power_offset_phase_[i]; + bool have_data = this->power_offset_pref_.load(&this->power_offset_phase_); + bool all_zero = true; + if (have_data) { + for (auto &phase : this->power_offset_phase_) { + if (phase.active_power_offset != 0 || phase.reactive_power_offset != 0) { + all_zero = false; + break; + } + } + } + + if (have_data && !all_zero) { + this->restored_power_offset_calibration_ = true; for (uint8_t phase = 0; phase < 3; ++phase) { auto &offset = this->power_offset_phase_[phase]; - write_power_offsets_to_registers_(phase, offset.active_power_offset, offset.reactive_power_offset); - ESP_LOGI(TAG, "[CALIBRATION] Phase %c - offset_active_power: %d, offset_reactive_power: %d", 'A' + phase, - offset.active_power_offset, offset.reactive_power_offset); + bool mismatch = false; + if (this->has_config_active_power_offset_[phase] && + offset.active_power_offset != this->config_power_offset_phase_[phase].active_power_offset) + mismatch = true; + if (this->has_config_reactive_power_offset_[phase] && + offset.reactive_power_offset != this->config_power_offset_phase_[phase].reactive_power_offset) + mismatch = true; + if (mismatch) + this->power_offset_calibration_mismatch_[phase] = true; } } else { - ESP_LOGW(TAG, "[CALIBRATION] No stored power offsets found. Using default values."); + for (uint8_t phase = 0; phase < 3; ++phase) + this->power_offset_phase_[phase] = this->config_power_offset_phase_[phase]; + ESP_LOGW(TAG, "[CALIBRATION][%s] No stored power offsets found. Using default values.", cs); + } + + for (uint8_t phase = 0; phase < 3; ++phase) { + write_power_offsets_to_registers_(phase, this->power_offset_phase_[phase].active_power_offset, + this->power_offset_phase_[phase].reactive_power_offset); } } void ATM90E32Component::clear_gain_calibrations() { - ESP_LOGI(TAG, "[CALIBRATION] Clearing stored gain calibrations and restoring config-defined values"); - - for (int phase = 0; phase < 3; phase++) { - gain_phase_[phase].voltage_gain = this->phase_[phase].voltage_gain_; - gain_phase_[phase].current_gain = this->phase_[phase].ct_gain_; + const char *cs = this->cs_summary_.c_str(); + if (!this->using_saved_calibrations_) { + ESP_LOGI(TAG, "[CALIBRATION][%s] No stored gain calibrations to clear. Current values:", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ----------------------------------------------------------", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | voltage_gain | current_gain |", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ----------------------------------------------------------", cs); + for (int phase = 0; phase < 3; phase++) { + ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6u | %6u |", cs, 'A' + phase, + this->gain_phase_[phase].voltage_gain, this->gain_phase_[phase].current_gain); + } + ESP_LOGI(TAG, "[CALIBRATION][%s] ==========================================================\n", cs); + return; } - bool success = this->gain_calibration_pref_.save(&this->gain_phase_); - this->using_saved_calibrations_ = false; + ESP_LOGI(TAG, "[CALIBRATION][%s] Clearing stored gain calibrations and restoring config-defined values", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ----------------------------------------------------------", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | voltage_gain | current_gain |", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ----------------------------------------------------------", cs); - if (success) { - ESP_LOGI(TAG, "[CALIBRATION] Gain calibrations cleared. Config values restored:"); - for (int phase = 0; phase < 3; phase++) { - ESP_LOGI(TAG, "[CALIBRATION] Phase %c - Voltage Gain: %u, Current Gain: %u", 'A' + phase, - gain_phase_[phase].voltage_gain, gain_phase_[phase].current_gain); - } - } else { - ESP_LOGE(TAG, "[CALIBRATION] Failed to clear gain calibrations!"); + for (int phase = 0; phase < 3; phase++) { + uint16_t voltage_gain = this->phase_[phase].voltage_gain_; + uint16_t current_gain = this->phase_[phase].ct_gain_; + + this->config_gain_phase_[phase].voltage_gain = voltage_gain; + this->config_gain_phase_[phase].current_gain = current_gain; + this->gain_phase_[phase].voltage_gain = voltage_gain; + this->gain_phase_[phase].current_gain = current_gain; + + ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6u | %6u |", cs, 'A' + phase, voltage_gain, current_gain); + } + ESP_LOGI(TAG, "[CALIBRATION][%s] ==========================================================\n", cs); + + GainCalibration zero_gains[3]{{0, 0}, {0, 0}, {0, 0}}; + bool success = this->gain_calibration_pref_.save(&zero_gains); + global_preferences->sync(); + + this->using_saved_calibrations_ = false; + this->restored_gain_calibration_ = false; + for (bool &phase : this->gain_calibration_mismatch_) + phase = false; + + if (!success) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to clear gain calibrations!", cs); } this->write_gains_to_registers_(); // Apply them to the chip immediately } void ATM90E32Component::clear_offset_calibrations() { - for (uint8_t phase = 0; phase < 3; phase++) { - this->write_offsets_to_registers_(phase, 0, 0); + const char *cs = this->cs_summary_.c_str(); + if (!this->restored_offset_calibration_) { + ESP_LOGI(TAG, "[CALIBRATION][%s] No stored offset calibrations to clear. Current values:", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); + for (uint8_t phase = 0; phase < 3; phase++) { + ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, + this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_); + } + ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\n", cs); + return; } - this->offset_pref_.save(&this->offset_phase_); // Save cleared values to flash memory + ESP_LOGI(TAG, "[CALIBRATION][%s] Clearing stored offset calibrations and restoring config-defined values", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); - ESP_LOGI(TAG, "[CALIBRATION] Offsets cleared."); + for (uint8_t phase = 0; phase < 3; phase++) { + int16_t voltage_offset = + this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].voltage_offset_ : 0; + int16_t current_offset = + this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].current_offset_ : 0; + this->write_offsets_to_registers_(phase, voltage_offset, current_offset); + ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset, + current_offset); + } + ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\n", cs); + + OffsetCalibration zero_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; + this->offset_pref_.save(&zero_offsets); // Clear stored values in flash + global_preferences->sync(); + + this->restored_offset_calibration_ = false; + for (bool &phase : this->offset_calibration_mismatch_) + phase = false; + + ESP_LOGI(TAG, "[CALIBRATION][%s] Offsets cleared.", cs); } void ATM90E32Component::clear_power_offset_calibrations() { - for (uint8_t phase = 0; phase < 3; phase++) { - this->write_power_offsets_to_registers_(phase, 0, 0); + const char *cs = this->cs_summary_.c_str(); + if (!this->restored_power_offset_calibration_) { + ESP_LOGI(TAG, "[CALIBRATION][%s] No stored power offsets to clear. Current values:", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + for (uint8_t phase = 0; phase < 3; phase++) { + ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, + this->power_offset_phase_[phase].active_power_offset, + this->power_offset_phase_[phase].reactive_power_offset); + } + ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); + return; } - this->power_offset_pref_.save(&this->power_offset_phase_); + ESP_LOGI(TAG, "[CALIBRATION][%s] Clearing stored power offsets and restoring config-defined values", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); + ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); - ESP_LOGI(TAG, "[CALIBRATION] Power offsets cleared."); + for (uint8_t phase = 0; phase < 3; phase++) { + int16_t active_offset = + this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].active_power_offset : 0; + int16_t reactive_offset = this->has_config_reactive_power_offset_[phase] + ? this->config_power_offset_phase_[phase].reactive_power_offset + : 0; + this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset); + ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset, + reactive_offset); + } + ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); + + PowerOffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; + this->power_offset_pref_.save(&zero_power_offsets); + global_preferences->sync(); + + this->restored_power_offset_calibration_ = false; + for (bool &phase : this->power_offset_calibration_mismatch_) + phase = false; + + ESP_LOGI(TAG, "[CALIBRATION][%s] Power offsets cleared.", cs); } int16_t ATM90E32Component::calibrate_offset(uint8_t phase, bool voltage) { @@ -747,20 +1114,21 @@ int16_t ATM90E32Component::calibrate_offset(uint8_t phase, bool voltage) { int16_t ATM90E32Component::calibrate_power_offset(uint8_t phase, bool reactive) { const uint8_t num_reads = 5; - uint64_t total_value = 0; + int64_t total_value = 0; for (uint8_t i = 0; i < num_reads; ++i) { - uint32_t reading = reactive ? this->read32_(ATM90E32_REGISTER_QMEAN + phase, ATM90E32_REGISTER_QMEANLSB + phase) - : this->read32_(ATM90E32_REGISTER_PMEAN + phase, ATM90E32_REGISTER_PMEANLSB + phase); + int32_t reading = reactive ? this->read32_(ATM90E32_REGISTER_QMEAN + phase, ATM90E32_REGISTER_QMEANLSB + phase) + : this->read32_(ATM90E32_REGISTER_PMEAN + phase, ATM90E32_REGISTER_PMEANLSB + phase); total_value += reading; } - const uint32_t average_value = total_value / num_reads; - const uint32_t power_offset = ~average_value + 1; + int32_t average_value = total_value / num_reads; + int32_t power_offset = -average_value; return static_cast(power_offset); // Takes the lower 16 bits } bool ATM90E32Component::verify_gain_writes_() { + const char *cs = this->cs_summary_.c_str(); bool success = true; for (uint8_t phase = 0; phase < 3; phase++) { uint16_t read_voltage = this->read16_(voltage_gain_registers[phase]); @@ -768,7 +1136,7 @@ bool ATM90E32Component::verify_gain_writes_() { if (read_voltage != this->gain_phase_[phase].voltage_gain || read_current != this->gain_phase_[phase].current_gain) { - ESP_LOGE(TAG, "[CALIBRATION] Mismatch detected for Phase %s!", phase_labels[phase]); + ESP_LOGE(TAG, "[CALIBRATION][%s] Mismatch detected for Phase %s!", cs, phase_labels[phase]); success = false; } } @@ -791,16 +1159,16 @@ void ATM90E32Component::check_phase_status() { status += "Phase Loss; "; auto *sensor = this->phase_status_text_sensor_[phase]; - const char *phase_name = sensor ? sensor->get_name().c_str() : "Unknown Phase"; + if (sensor == nullptr) + continue; + if (!status.empty()) { status.pop_back(); // remove space status.pop_back(); // remove semicolon - ESP_LOGW(TAG, "%s: %s", phase_name, status.c_str()); - if (sensor != nullptr) - sensor->publish_state(status); + ESP_LOGW(TAG, "%s: %s", sensor->get_name().c_str(), status.c_str()); + sensor->publish_state(status); } else { - if (sensor != nullptr) - sensor->publish_state("Okay"); + sensor->publish_state("Okay"); } } } @@ -817,9 +1185,12 @@ void ATM90E32Component::check_freq_status() { } else { freq_status = "Normal"; } - ESP_LOGW(TAG, "Frequency status: %s", freq_status.c_str()); - if (this->freq_status_text_sensor_ != nullptr) { + if (freq_status == "Normal") { + ESP_LOGD(TAG, "Frequency status: %s", freq_status.c_str()); + } else { + ESP_LOGW(TAG, "Frequency status: %s", freq_status.c_str()); + } this->freq_status_text_sensor_->publish_state(freq_status); } } diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index 0703c40ae0..afbd9cf941 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -61,15 +61,29 @@ class ATM90E32Component : public PollingComponent, this->phase_[phase].harmonic_active_power_sensor_ = obj; } void set_peak_current_sensor(int phase, sensor::Sensor *obj) { this->phase_[phase].peak_current_sensor_ = obj; } - void set_volt_gain(int phase, uint16_t gain) { this->phase_[phase].voltage_gain_ = gain; } - void set_ct_gain(int phase, uint16_t gain) { this->phase_[phase].ct_gain_ = gain; } - void set_voltage_offset(uint8_t phase, int16_t offset) { this->offset_phase_[phase].voltage_offset_ = offset; } - void set_current_offset(uint8_t phase, int16_t offset) { this->offset_phase_[phase].current_offset_ = offset; } + void set_volt_gain(int phase, uint16_t gain) { + this->phase_[phase].voltage_gain_ = gain; + this->has_config_voltage_gain_[phase] = true; + } + void set_ct_gain(int phase, uint16_t gain) { + this->phase_[phase].ct_gain_ = gain; + this->has_config_current_gain_[phase] = true; + } + void set_voltage_offset(uint8_t phase, int16_t offset) { + this->offset_phase_[phase].voltage_offset_ = offset; + this->has_config_voltage_offset_[phase] = true; + } + void set_current_offset(uint8_t phase, int16_t offset) { + this->offset_phase_[phase].current_offset_ = offset; + this->has_config_current_offset_[phase] = true; + } void set_active_power_offset(uint8_t phase, int16_t offset) { this->power_offset_phase_[phase].active_power_offset = offset; + this->has_config_active_power_offset_[phase] = true; } void set_reactive_power_offset(uint8_t phase, int16_t offset) { this->power_offset_phase_[phase].reactive_power_offset = offset; + this->has_config_reactive_power_offset_[phase] = true; } void set_freq_sensor(sensor::Sensor *freq_sensor) { freq_sensor_ = freq_sensor; } void set_peak_current_signed(bool flag) { peak_current_signed_ = flag; } @@ -126,8 +140,9 @@ class ATM90E32Component : public PollingComponent, number::Number *ref_currents_[3]{nullptr, nullptr, nullptr}; #endif uint16_t read16_(uint16_t a_register); + uint16_t read16_transaction_(uint16_t a_register); int read32_(uint16_t addr_h, uint16_t addr_l); - void write16_(uint16_t a_register, uint16_t val); + void write16_(uint16_t a_register, uint16_t val, bool validate = true); float get_local_phase_voltage_(uint8_t phase); float get_local_phase_current_(uint8_t phase); float get_local_phase_active_power_(uint8_t phase); @@ -159,12 +174,15 @@ class ATM90E32Component : public PollingComponent, void restore_offset_calibrations_(); void restore_power_offset_calibrations_(); void restore_gain_calibrations_(); + void save_offset_calibration_to_memory_(); void save_gain_calibration_to_memory_(); + void save_power_offset_calibration_to_memory_(); void write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset); void write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset); void write_gains_to_registers_(); bool verify_gain_writes_(); bool validate_spi_read_(uint16_t expected, const char *context = nullptr); + void log_calibration_status_(); struct ATM90E32Phase { uint16_t voltage_gain_{0}; @@ -204,19 +222,33 @@ class ATM90E32Component : public PollingComponent, int16_t current_offset_{0}; } offset_phase_[3]; + OffsetCalibration config_offset_phase_[3]; + struct PowerOffsetCalibration { int16_t active_power_offset{0}; int16_t reactive_power_offset{0}; } power_offset_phase_[3]; + PowerOffsetCalibration config_power_offset_phase_[3]; + struct GainCalibration { uint16_t voltage_gain{1}; uint16_t current_gain{1}; } gain_phase_[3]; + GainCalibration config_gain_phase_[3]; + + bool has_config_voltage_offset_[3]{false, false, false}; + bool has_config_current_offset_[3]{false, false, false}; + bool has_config_active_power_offset_[3]{false, false, false}; + bool has_config_reactive_power_offset_[3]{false, false, false}; + bool has_config_voltage_gain_[3]{false, false, false}; + bool has_config_current_gain_[3]{false, false, false}; + ESPPreferenceObject offset_pref_; ESPPreferenceObject power_offset_pref_; ESPPreferenceObject gain_calibration_pref_; + std::string cs_summary_; sensor::Sensor *freq_sensor_{nullptr}; #ifdef USE_TEXT_SENSOR @@ -231,6 +263,13 @@ class ATM90E32Component : public PollingComponent, bool peak_current_signed_{false}; bool enable_offset_calibration_{false}; bool enable_gain_calibration_{false}; + bool restored_offset_calibration_{false}; + bool restored_power_offset_calibration_{false}; + bool restored_gain_calibration_{false}; + bool calibration_message_printed_{false}; + bool offset_calibration_mismatch_[3]{false, false, false}; + bool power_offset_calibration_mismatch_[3]{false, false, false}; + bool gain_calibration_mismatch_[3]{false, false, false}; }; } // namespace atm90e32 From b85185f82189d08d8f6d8e564a9ed62c5033b781 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 15:12:58 -0500 Subject: [PATCH 0488/1626] [bluetooth_proxy] Remove redundant connection type check after V1 removal --- .../components/bluetooth_proxy/bluetooth_connection.cpp | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index b16b894188..6eb38d5b88 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -119,7 +119,7 @@ void BluetoothConnection::loop() { // Check if we should disable the loop // - For V3_WITH_CACHE: Services are never sent, disable after INIT state - // - For other connections: Disable only after service discovery is complete + // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) if (this->state_ != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || this->send_service_ == DONE_SENDING_SERVICES)) { @@ -146,10 +146,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (this->send_service_ >= this->service_count_) { this->send_service_ = DONE_SENDING_SERVICES; this->proxy_->send_gatt_services_done(this->address_); - if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { - this->release_services(); - } + this->release_services(); return; } From 7b116be48be0ed54654333154e16e8a5fed569ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 18:54:23 -0500 Subject: [PATCH 0489/1626] [bluetooth_proxy] Optimize UUID conversion and reduce flash usage by 296 bytes --- .../bluetooth_proxy/bluetooth_connection.cpp | 29 +++++++++++++------ esphome/components/esp32_ble/ble_uuid.cpp | 4 ++- esphome/components/esp32_ble/ble_uuid.h | 4 ++- .../esp32_ble_tracker/esp32_ble_tracker.h | 2 +- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index b16b894188..37447e1ed9 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -1,5 +1,6 @@ #include "bluetooth_connection.h" +#include #include "esphome/components/api/api_pb2.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -12,16 +13,26 @@ namespace esphome::bluetooth_proxy { static const char *const TAG = "bluetooth_proxy.connection"; +// This function allocates nothing and directly packs UUIDs into the output array +// The base UUID is stored in flash memory as constexpr static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t uuid_source) { - esp_bt_uuid_t uuid = espbt::ESPBTUUID::from_uuid(uuid_source).as_128bit().get_uuid(); - out[0] = ((uint64_t) uuid.uuid.uuid128[15] << 56) | ((uint64_t) uuid.uuid.uuid128[14] << 48) | - ((uint64_t) uuid.uuid.uuid128[13] << 40) | ((uint64_t) uuid.uuid.uuid128[12] << 32) | - ((uint64_t) uuid.uuid.uuid128[11] << 24) | ((uint64_t) uuid.uuid.uuid128[10] << 16) | - ((uint64_t) uuid.uuid.uuid128[9] << 8) | ((uint64_t) uuid.uuid.uuid128[8]); - out[1] = ((uint64_t) uuid.uuid.uuid128[7] << 56) | ((uint64_t) uuid.uuid.uuid128[6] << 48) | - ((uint64_t) uuid.uuid.uuid128[5] << 40) | ((uint64_t) uuid.uuid.uuid128[4] << 32) | - ((uint64_t) uuid.uuid.uuid128[3] << 24) | ((uint64_t) uuid.uuid.uuid128[2] << 16) | - ((uint64_t) uuid.uuid.uuid128[1] << 8) | ((uint64_t) uuid.uuid.uuid128[0]); + // Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB + // Pack 32/16-bit UUID directly into out[0] with bytes 12-15 + out[0] = uuid_source.len == ESP_UUID_LEN_128 + ? (((uint64_t) uuid_source.uuid.uuid128[15] << 56) | ((uint64_t) uuid_source.uuid.uuid128[14] << 48) | + ((uint64_t) uuid_source.uuid.uuid128[13] << 40) | ((uint64_t) uuid_source.uuid.uuid128[12] << 32) | + ((uint64_t) uuid_source.uuid.uuid128[11] << 24) | ((uint64_t) uuid_source.uuid.uuid128[10] << 16) | + ((uint64_t) uuid_source.uuid.uuid128[9] << 8) | ((uint64_t) uuid_source.uuid.uuid128[8])) + : (((uint64_t) (uuid_source.len == ESP_UUID_LEN_16 ? uuid_source.uuid.uuid16 : uuid_source.uuid.uuid32) + << 32) | + 0x00001000ULL); + // Pack bytes 0-7 into out[1] - this part is always the same for the base UUID + out[1] = uuid_source.len == ESP_UUID_LEN_128 + ? ((uint64_t) uuid_source.uuid.uuid128[7] << 56) | ((uint64_t) uuid_source.uuid.uuid128[6] << 48) | + ((uint64_t) uuid_source.uuid.uuid128[5] << 40) | ((uint64_t) uuid_source.uuid.uuid128[4] << 32) | + ((uint64_t) uuid_source.uuid.uuid128[3] << 24) | ((uint64_t) uuid_source.uuid.uuid128[2] << 16) | + ((uint64_t) uuid_source.uuid.uuid128[1] << 8) | ((uint64_t) uuid_source.uuid.uuid128[0]) + : 0x800000805F9B34FBULL; // Precalculated base UUID bytes 0-7 } // Helper to fill UUID in the appropriate format based on client support and UUID type diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index fc6981acd3..7b5ccdf5e2 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -1,6 +1,7 @@ #include "ble_uuid.h" #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_DEVICE #include #include @@ -190,4 +191,5 @@ std::string ESPBTUUID::to_string() const { } // namespace esphome::esp32_ble -#endif +#endif // USE_ESP32_BLE_DEVICE +#endif // USE_ESP32 diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 150ca359d3..314d59379b 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -4,6 +4,7 @@ #include "esphome/core/helpers.h" #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_DEVICE #include #include @@ -42,4 +43,5 @@ class ESPBTUUID { } // namespace esphome::esp32_ble -#endif +#endif // USE_ESP32_BLE_DEVICE +#endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index bf99026810..5d95bcb995 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -33,12 +33,12 @@ enum AdvertisementParserType { RAW_ADVERTISEMENTS, }; +#ifdef USE_ESP32_BLE_DEVICE struct ServiceData { ESPBTUUID uuid; adv_data_t data; }; -#ifdef USE_ESP32_BLE_DEVICE class ESPBLEiBeacon { public: ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } From c2ebfe8f2774c32891602b6b7a6841f854efb150 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 18:56:20 -0500 Subject: [PATCH 0490/1626] [bluetooth_proxy] Optimize UUID conversion and reduce flash usage by 296 bytes --- .../bluetooth_proxy/bluetooth_connection.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 37447e1ed9..4826b1dd38 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -17,7 +17,9 @@ static const char *const TAG = "bluetooth_proxy.connection"; // The base UUID is stored in flash memory as constexpr static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t uuid_source) { // Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB - // Pack 32/16-bit UUID directly into out[0] with bytes 12-15 + // out[0] = bytes 8-15 (big-endian) + // - For 128-bit UUIDs: use bytes 8-15 as-is + // - For 16/32-bit UUIDs: insert into bytes 12-15, use 0x00001000 for bytes 8-11 out[0] = uuid_source.len == ESP_UUID_LEN_128 ? (((uint64_t) uuid_source.uuid.uuid128[15] << 56) | ((uint64_t) uuid_source.uuid.uuid128[14] << 48) | ((uint64_t) uuid_source.uuid.uuid128[13] << 40) | ((uint64_t) uuid_source.uuid.uuid128[12] << 32) | @@ -25,14 +27,16 @@ static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t u ((uint64_t) uuid_source.uuid.uuid128[9] << 8) | ((uint64_t) uuid_source.uuid.uuid128[8])) : (((uint64_t) (uuid_source.len == ESP_UUID_LEN_16 ? uuid_source.uuid.uuid16 : uuid_source.uuid.uuid32) << 32) | - 0x00001000ULL); - // Pack bytes 0-7 into out[1] - this part is always the same for the base UUID + 0x00001000ULL); // Base UUID bytes 8-11 + // out[1] = bytes 0-7 (big-endian) + // - For 128-bit UUIDs: use bytes 0-7 as-is + // - For 16/32-bit UUIDs: use precalculated base UUID constant out[1] = uuid_source.len == ESP_UUID_LEN_128 ? ((uint64_t) uuid_source.uuid.uuid128[7] << 56) | ((uint64_t) uuid_source.uuid.uuid128[6] << 48) | ((uint64_t) uuid_source.uuid.uuid128[5] << 40) | ((uint64_t) uuid_source.uuid.uuid128[4] << 32) | ((uint64_t) uuid_source.uuid.uuid128[3] << 24) | ((uint64_t) uuid_source.uuid.uuid128[2] << 16) | ((uint64_t) uuid_source.uuid.uuid128[1] << 8) | ((uint64_t) uuid_source.uuid.uuid128[0]) - : 0x800000805F9B34FBULL; // Precalculated base UUID bytes 0-7 + : 0x800000805F9B34FBULL; // Base UUID bytes 0-7: 80-00-00-80-5F-9B-34-FB } // Helper to fill UUID in the appropriate format based on client support and UUID type From 841deff578a8d8460344753d16d3f4c2658d6dcc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 18:57:17 -0500 Subject: [PATCH 0491/1626] [bluetooth_proxy] Optimize UUID conversion and reduce flash usage by 296 bytes --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 4826b1dd38..6d41b083d0 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -1,6 +1,5 @@ #include "bluetooth_connection.h" -#include #include "esphome/components/api/api_pb2.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" From 5b279f5f92f9a32381fcd7c795a3111c6f544694 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 19:14:37 -0500 Subject: [PATCH 0492/1626] tweak --- esphome/components/esp32_ble/ble_uuid.cpp | 4 ++-- esphome/components/esp32_ble/ble_uuid.h | 4 ++-- esphome/components/esp32_ble_server/__init__.py | 1 + esphome/components/esp32_ble_tracker/__init__.py | 1 + esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 4 +++- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index 7b5ccdf5e2..be9c6945d7 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -1,7 +1,7 @@ #include "ble_uuid.h" #ifdef USE_ESP32 -#ifdef USE_ESP32_BLE_DEVICE +#ifdef USE_ESP32_BLE_UUID #include #include @@ -191,5 +191,5 @@ std::string ESPBTUUID::to_string() const { } // namespace esphome::esp32_ble -#endif // USE_ESP32_BLE_DEVICE +#endif // USE_ESP32_BLE_UUID #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 314d59379b..b3bdd46e06 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -4,7 +4,7 @@ #include "esphome/core/helpers.h" #ifdef USE_ESP32 -#ifdef USE_ESP32_BLE_DEVICE +#ifdef USE_ESP32_BLE_UUID #include #include @@ -43,5 +43,5 @@ class ESPBTUUID { } // namespace esphome::esp32_ble -#endif // USE_ESP32_BLE_DEVICE +#endif // USE_ESP32_BLE_UUID #endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index feeb055600..aea6de599e 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -447,6 +447,7 @@ def parse_properties(char_conf): def parse_uuid(uuid): # If the UUID is a int, use from_uint32 + cg.add_define("USE_ESP32_BLE_UUID") if isinstance(uuid, int): return ESPBTUUID_ns.from_uint32(uuid) # Otherwise, use ESPBTUUID_ns.from_raw diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index d03e968e2d..9ad2f3b25f 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -373,6 +373,7 @@ async def _add_ble_features(): # Add feature-specific defines based on what's needed if BLEFeatures.ESP_BT_DEVICE in _required_features: cg.add_define("USE_ESP32_BLE_DEVICE") + cg.add_define("USE_ESP32_BLE_UUID") ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 5d95bcb995..3022eb25d2 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -33,12 +33,14 @@ enum AdvertisementParserType { RAW_ADVERTISEMENTS, }; -#ifdef USE_ESP32_BLE_DEVICE +#ifdef USE_ESP32_BLE_UUID struct ServiceData { ESPBTUUID uuid; adv_data_t data; }; +#endif +#ifdef USE_ESP32_BLE_DEVICE class ESPBLEiBeacon { public: ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } From 803d665a948bcbdff39d88d313edcdcffcd895c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 19:16:02 -0500 Subject: [PATCH 0493/1626] tweak --- esphome/core/defines.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 01f6811e05..5df3bcf475 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -154,6 +154,7 @@ #define USE_ESP32_BLE_CLIENT #define USE_ESP32_BLE_DEVICE #define USE_ESP32_BLE_SERVER +#define USE_ESP32_BLE_UUID #define USE_ESP32_BLE_ADVERTISING #define USE_I2C #define USE_IMPROV From 36613507be8e3b03f047e72579d253d3f4e6010e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 19:19:21 -0500 Subject: [PATCH 0494/1626] fix --- esphome/components/ble_client/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 0f3869c23b..014170ea42 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -285,6 +285,7 @@ async def remove_bond_to_code(config, action_id, template_arg, args): async def to_code(config): # Register the loggers this component needs + cg.add_define("USE_ESP32_BLE_UUID") esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) var = cg.new_Pvariable(config[CONF_ID]) From 5c12f638bd80b727c3289d5560622542489a5bc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 19:22:27 -0500 Subject: [PATCH 0495/1626] fix --- esphome/components/ble_client/__init__.py | 1 - esphome/components/esp32_ble/ble_uuid.h | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 014170ea42..0f3869c23b 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -285,7 +285,6 @@ async def remove_bond_to_code(config, action_id, template_arg, args): async def to_code(config): # Register the loggers this component needs - cg.add_define("USE_ESP32_BLE_UUID") esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index b3bdd46e06..4cf2d10abd 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -1,5 +1,6 @@ #pragma once +#include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" From b5c381982cb58131165e9fd8dc700778fd8a428a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 19:23:44 -0500 Subject: [PATCH 0496/1626] fix --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 6d41b083d0..5d81f680d9 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -12,8 +12,8 @@ namespace esphome::bluetooth_proxy { static const char *const TAG = "bluetooth_proxy.connection"; -// This function allocates nothing and directly packs UUIDs into the output array -// The base UUID is stored in flash memory as constexpr +// This function is designed to be allocation-free and only called in the event loop (not thread-safe) +// It directly packs UUIDs into the output array with precalculated constants for the base UUID static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t uuid_source) { // Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB // out[0] = bytes 8-15 (big-endian) From 235050fe58a339da05387bfcaad5fdadfb5976b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 19:25:20 -0500 Subject: [PATCH 0497/1626] fix --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 4 ++-- esphome/components/esp32_ble_server/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 5d81f680d9..347f60c28f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -12,8 +12,8 @@ namespace esphome::bluetooth_proxy { static const char *const TAG = "bluetooth_proxy.connection"; -// This function is designed to be allocation-free and only called in the event loop (not thread-safe) -// It directly packs UUIDs into the output array with precalculated constants for the base UUID +// This function is allocation-free and directly packs UUIDs into the output array +// using precalculated constants for the Bluetooth base UUID static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t uuid_source) { // Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB // out[0] = bytes 8-15 (big-endian) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index aea6de599e..8ddb15a7f8 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -447,7 +447,6 @@ def parse_properties(char_conf): def parse_uuid(uuid): # If the UUID is a int, use from_uint32 - cg.add_define("USE_ESP32_BLE_UUID") if isinstance(uuid, int): return ESPBTUUID_ns.from_uint32(uuid) # Otherwise, use ESPBTUUID_ns.from_raw @@ -530,6 +529,7 @@ async def to_code_characteristic(service_var, char_conf): async def to_code(config): # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) + cg.add_define("USE_ESP32_BLE_UUID") var = cg.new_Pvariable(config[CONF_ID]) From dec9810177506a248ad4cf4cf7c20c18ca464571 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 19:26:41 -0500 Subject: [PATCH 0498/1626] fix --- esphome/components/ble_client/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 0f3869c23b..5f4ea8afd1 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -286,6 +286,7 @@ async def remove_bond_to_code(config, action_id, template_arg, args): async def to_code(config): # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) + cg.add_define("USE_ESP32_BLE_UUID") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) From c3a209d3f4d44dc358aa25b2629a19ee21ab5905 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 12 Aug 2025 19:35:19 -0500 Subject: [PATCH 0499/1626] [ld2450] Replace ``throttle`` with native filters (#10196) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/ld2450/__init__.py | 6 +- esphome/components/ld2450/binary_sensor.py | 3 + esphome/components/ld2450/ld2450.cpp | 12 +- esphome/components/ld2450/ld2450.h | 3 - esphome/components/ld2450/sensor.py | 132 +++++++++++++++++++-- tests/components/ld2450/common.yaml | 1 - 6 files changed, 127 insertions(+), 30 deletions(-) diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index cdbf8a17c4..bd6d697c90 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -17,9 +17,8 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(LD2450Component), - cv.Optional(CONF_THROTTLE, default="1000ms"): cv.All( - cv.positive_time_period_milliseconds, - cv.Range(min=cv.TimePeriod(milliseconds=1)), + cv.Optional(CONF_THROTTLE): cv.invalid( + f"{CONF_THROTTLE} has been removed; use per-sensor filters, instead" ), } ) @@ -46,4 +45,3 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - cg.add(var.set_throttle(config[CONF_THROTTLE])) diff --git a/esphome/components/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index d0082ac21a..37f722b0fa 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -21,14 +21,17 @@ CONFIG_SCHEMA = { cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), cv.Optional(CONF_HAS_TARGET): binary_sensor.binary_sensor_schema( device_class=DEVICE_CLASS_OCCUPANCY, + filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], icon=ICON_SHIELD_ACCOUNT, ), cv.Optional(CONF_HAS_MOVING_TARGET): binary_sensor.binary_sensor_schema( device_class=DEVICE_CLASS_MOTION, + filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], icon=ICON_TARGET_ACCOUNT, ), cv.Optional(CONF_HAS_STILL_TARGET): binary_sensor.binary_sensor_schema( device_class=DEVICE_CLASS_OCCUPANCY, + filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], icon=ICON_MEDITATION, ), } diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 642684266e..b123d541d9 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -199,9 +199,8 @@ void LD2450Component::dump_config() { ESP_LOGCONFIG(TAG, "LD2450:\n" " Firmware version: %s\n" - " MAC address: %s\n" - " Throttle: %u ms", - version.c_str(), mac_str.c_str(), this->throttle_); + " MAC address: %s", + version.c_str(), mac_str.c_str()); #ifdef USE_BINARY_SENSOR ESP_LOGCONFIG(TAG, "Binary Sensors:"); LOG_BINARY_SENSOR(" ", "MovingTarget", this->moving_target_binary_sensor_); @@ -431,11 +430,6 @@ void LD2450Component::send_command_(uint8_t command, const uint8_t *command_valu // [AA FF 03 00] [0E 03 B1 86 10 00 40 01] [00 00 00 00 00 00 00 00] [00 00 00 00 00 00 00 00] [55 CC] // Header Target 1 Target 2 Target 3 End void LD2450Component::handle_periodic_data_() { - // Early throttle check - moved before any processing to save CPU cycles - if (App.get_loop_component_start_time() - this->last_periodic_millis_ < this->throttle_) { - return; - } - if (this->buffer_pos_ < 29) { // header (4 bytes) + 8 x 3 target data + footer (2 bytes) ESP_LOGE(TAG, "Invalid length"); return; @@ -446,8 +440,6 @@ void LD2450Component::handle_periodic_data_() { ESP_LOGE(TAG, "Invalid header/footer"); return; } - // Save the timestamp after validating the frame so, if invalid, we'll take the next frame immediately - this->last_periodic_millis_ = App.get_loop_component_start_time(); int16_t target_count = 0; int16_t still_target_count = 0; diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index 0fba0f9be3..9faa189019 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -110,7 +110,6 @@ class LD2450Component : public Component, public uart::UARTDevice { void dump_config() override; void loop() override; void set_presence_timeout(); - void set_throttle(uint16_t value) { this->throttle_ = value; } void read_all_info(); void query_zone_info(); void restart_and_read_all_info(); @@ -161,11 +160,9 @@ class LD2450Component : public Component, public uart::UARTDevice { bool get_timeout_status_(uint32_t check_millis); uint8_t count_targets_in_zone_(const Zone &zone, bool is_moving); - uint32_t last_periodic_millis_ = 0; uint32_t presence_millis_ = 0; uint32_t still_presence_millis_ = 0; uint32_t moving_presence_millis_ = 0; - uint16_t throttle_ = 0; uint16_t timeout_ = 5; uint8_t buffer_data_[MAX_LINE_LENGTH]; uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index d16d9c834d..4a3597d583 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -42,16 +42,43 @@ CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( - icon=ICON_ACCOUNT_GROUP, accuracy_decimals=0, + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], + icon=ICON_ACCOUNT_GROUP, ), cv.Optional(CONF_STILL_TARGET_COUNT): sensor.sensor_schema( - icon=ICON_HUMAN_GREETING_PROXIMITY, accuracy_decimals=0, + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], + icon=ICON_HUMAN_GREETING_PROXIMITY, ), cv.Optional(CONF_MOVING_TARGET_COUNT): sensor.sensor_schema( - icon=ICON_ACCOUNT_SWITCH, accuracy_decimals=0, + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], + icon=ICON_ACCOUNT_SWITCH, ), } ) @@ -62,32 +89,86 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_X): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - unit_of_measurement=UNIT_MILLIMETER, + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_ALPHA_X_BOX_OUTLINE, + unit_of_measurement=UNIT_MILLIMETER, ), cv.Optional(CONF_Y): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - unit_of_measurement=UNIT_MILLIMETER, + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_ALPHA_Y_BOX_OUTLINE, + unit_of_measurement=UNIT_MILLIMETER, ), cv.Optional(CONF_SPEED): sensor.sensor_schema( device_class=DEVICE_CLASS_SPEED, - unit_of_measurement=UNIT_MILLIMETER_PER_SECOND, + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SPEEDOMETER_SLOW, + unit_of_measurement=UNIT_MILLIMETER_PER_SECOND, ), cv.Optional(CONF_ANGLE): sensor.sensor_schema( - unit_of_measurement=UNIT_DEGREES, + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP, + unit_of_measurement=UNIT_DEGREES, ), cv.Optional(CONF_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - unit_of_measurement=UNIT_MILLIMETER, + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_MAP_MARKER_DISTANCE, + unit_of_measurement=UNIT_MILLIMETER, ), cv.Optional(CONF_RESOLUTION): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - unit_of_measurement=UNIT_MILLIMETER, + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_RELATION_ZERO_OR_ONE_TO_ZERO_OR_ONE, + unit_of_measurement=UNIT_MILLIMETER, ), } ) @@ -97,16 +178,43 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( cv.Optional(f"zone_{n + 1}"): cv.Schema( { cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( - icon=ICON_MAP_MARKER_ACCOUNT, accuracy_decimals=0, + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], + icon=ICON_MAP_MARKER_ACCOUNT, ), cv.Optional(CONF_STILL_TARGET_COUNT): sensor.sensor_schema( - icon=ICON_MAP_MARKER_ACCOUNT, accuracy_decimals=0, + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], + icon=ICON_MAP_MARKER_ACCOUNT, ), cv.Optional(CONF_MOVING_TARGET_COUNT): sensor.sensor_schema( - icon=ICON_MAP_MARKER_ACCOUNT, accuracy_decimals=0, + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], + icon=ICON_MAP_MARKER_ACCOUNT, ), } ) diff --git a/tests/components/ld2450/common.yaml b/tests/components/ld2450/common.yaml index 2e62efb0f5..c18bed46b0 100644 --- a/tests/components/ld2450/common.yaml +++ b/tests/components/ld2450/common.yaml @@ -9,7 +9,6 @@ uart: ld2450: - id: ld2450_radar uart_id: ld2450_uart - throttle: 1000ms button: - platform: ld2450 From 4acc7f77cc962bf24be9a31e5f98dc5040996926 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 20:22:10 -0500 Subject: [PATCH 0500/1626] one more --- esphome/components/esp32_ble_beacon/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 7ee0926eea..8fc4fe941d 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -65,6 +65,8 @@ FINAL_VALIDATE_SCHEMA = esp32_ble.validate_variant async def to_code(config): + cg.add_define("USE_ESP32_BLE_UUID") + uuid = config[CONF_UUID].hex uuid_arr = [ cg.RawExpression(f"0x{uuid[i : i + 2]}") for i in range(0, len(uuid), 2) From 113813617d758bb90bbe501c2d0dddf09d10f46a Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Tue, 12 Aug 2025 19:05:22 -0700 Subject: [PATCH 0501/1626] [bme280_base, bmp280_base] add reasons to the fails, clean up logging (#10209) Co-authored-by: Keith Burzinski --- .../components/bme280_base/bme280_base.cpp | 30 +++++++++---------- .../components/bmp280_base/bmp280_base.cpp | 26 ++++++++-------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index e5cea0d06d..86b65d361d 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -7,6 +7,8 @@ #include #include +#define BME280_ERROR_WRONG_CHIP_ID "Wrong chip ID" + namespace esphome { namespace bme280_base { @@ -98,18 +100,18 @@ void BME280Component::setup() { if (!this->read_byte(BME280_REGISTER_CHIPID, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); return; } if (chip_id != 0x60) { this->error_code_ = WRONG_CHIP_ID; - this->mark_failed(); + this->mark_failed(BME280_ERROR_WRONG_CHIP_ID); return; } // Send a soft reset. if (!this->write_byte(BME280_REGISTER_RESET, BME280_SOFT_RESET)) { - this->mark_failed(); + this->mark_failed("Reset failed"); return; } // Wait until the NVM data has finished loading. @@ -118,14 +120,12 @@ void BME280Component::setup() { do { // NOLINT delay(2); if (!this->read_byte(BME280_REGISTER_STATUS, &status)) { - ESP_LOGW(TAG, "Error reading status register."); - this->mark_failed(); + this->mark_failed("Error reading status register"); return; } } while ((status & BME280_STATUS_IM_UPDATE) && (--retry)); if (status & BME280_STATUS_IM_UPDATE) { - ESP_LOGW(TAG, "Timeout loading NVM."); - this->mark_failed(); + this->mark_failed("Timeout loading NVM"); return; } @@ -153,26 +153,26 @@ void BME280Component::setup() { uint8_t humid_control_val = 0; if (!this->read_byte(BME280_REGISTER_CONTROLHUMID, &humid_control_val)) { - this->mark_failed(); + this->mark_failed("Read humidity control"); return; } humid_control_val &= ~0b00000111; humid_control_val |= this->humidity_oversampling_ & 0b111; if (!this->write_byte(BME280_REGISTER_CONTROLHUMID, humid_control_val)) { - this->mark_failed(); + this->mark_failed("Write humidity control"); return; } uint8_t config_register = 0; if (!this->read_byte(BME280_REGISTER_CONFIG, &config_register)) { - this->mark_failed(); + this->mark_failed("Read config"); return; } config_register &= ~0b11111100; config_register |= 0b101 << 5; // 1000 ms standby time config_register |= (this->iir_filter_ & 0b111) << 2; if (!this->write_byte(BME280_REGISTER_CONFIG, config_register)) { - this->mark_failed(); + this->mark_failed("Write config"); return; } } @@ -183,7 +183,7 @@ void BME280Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); break; case WRONG_CHIP_ID: - ESP_LOGE(TAG, "BME280 has wrong chip ID! Is it a BME280?"); + ESP_LOGE(TAG, BME280_ERROR_WRONG_CHIP_ID); break; case NONE: default: @@ -223,21 +223,21 @@ void BME280Component::update() { this->set_timeout("data", uint32_t(ceilf(meas_time)), [this]() { uint8_t data[8]; if (!this->read_bytes(BME280_REGISTER_MEASUREMENTS, data, 8)) { - ESP_LOGW(TAG, "Error reading registers."); + ESP_LOGW(TAG, "Error reading registers"); this->status_set_warning(); return; } int32_t t_fine = 0; float const temperature = this->read_temperature_(data, &t_fine); if (std::isnan(temperature)) { - ESP_LOGW(TAG, "Invalid temperature, cannot read pressure & humidity values."); + ESP_LOGW(TAG, "Invalid temperature"); this->status_set_warning(); return; } float const pressure = this->read_pressure_(data, t_fine); float const humidity = this->read_humidity_(data, t_fine); - ESP_LOGV(TAG, "Got temperature=%.1f°C pressure=%.1fhPa humidity=%.1f%%", temperature, pressure, humidity); + ESP_LOGV(TAG, "Temperature=%.1f°C Pressure=%.1fhPa Humidity=%.1f%%", temperature, pressure, humidity); if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(temperature); if (this->pressure_sensor_ != nullptr) diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 6b5f98b9ce..e3cc2d9a57 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -2,6 +2,8 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" +#define BMP280_ERROR_WRONG_CHIP_ID "Wrong chip ID" + namespace esphome { namespace bmp280_base { @@ -63,23 +65,23 @@ void BMP280Component::setup() { // https://community.st.com/t5/stm32-mcus-products/issue-with-reading-bmp280-chip-id-using-spi/td-p/691855 if (!this->read_byte(0xD0, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); return; } if (!this->read_byte(0xD0, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); return; } if (chip_id != 0x58) { this->error_code_ = WRONG_CHIP_ID; - this->mark_failed(); + this->mark_failed(BMP280_ERROR_WRONG_CHIP_ID); return; } // Send a soft reset. if (!this->write_byte(BMP280_REGISTER_RESET, BMP280_SOFT_RESET)) { - this->mark_failed(); + this->mark_failed("Reset failed"); return; } // Wait until the NVM data has finished loading. @@ -88,14 +90,12 @@ void BMP280Component::setup() { do { delay(2); if (!this->read_byte(BMP280_REGISTER_STATUS, &status)) { - ESP_LOGW(TAG, "Error reading status register."); - this->mark_failed(); + this->mark_failed("Error reading status register"); return; } } while ((status & BMP280_STATUS_IM_UPDATE) && (--retry)); if (status & BMP280_STATUS_IM_UPDATE) { - ESP_LOGW(TAG, "Timeout loading NVM."); - this->mark_failed(); + this->mark_failed("Timeout loading NVM"); return; } @@ -116,14 +116,14 @@ void BMP280Component::setup() { uint8_t config_register = 0; if (!this->read_byte(BMP280_REGISTER_CONFIG, &config_register)) { - this->mark_failed(); + this->mark_failed("Read config"); return; } config_register &= ~0b11111100; config_register |= 0b000 << 5; // 0.5 ms standby time config_register |= (this->iir_filter_ & 0b111) << 2; if (!this->write_byte(BMP280_REGISTER_CONFIG, config_register)) { - this->mark_failed(); + this->mark_failed("Write config"); return; } } @@ -134,7 +134,7 @@ void BMP280Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); break; case WRONG_CHIP_ID: - ESP_LOGE(TAG, "BMP280 has wrong chip ID! Is it a BME280?"); + ESP_LOGE(TAG, BMP280_ERROR_WRONG_CHIP_ID); break; case NONE: default: @@ -172,13 +172,13 @@ void BMP280Component::update() { int32_t t_fine = 0; float temperature = this->read_temperature_(&t_fine); if (std::isnan(temperature)) { - ESP_LOGW(TAG, "Invalid temperature, cannot read pressure values."); + ESP_LOGW(TAG, "Invalid temperature"); this->status_set_warning(); return; } float pressure = this->read_pressure_(t_fine); - ESP_LOGD(TAG, "Got temperature=%.1f°C pressure=%.1fhPa", temperature, pressure); + ESP_LOGV(TAG, "Temperature=%.1f°C Pressure=%.1fhPa", temperature, pressure); if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(temperature); if (this->pressure_sensor_ != nullptr) From ed2b76050ba2a2a44a8003fdc3cc166301f7df7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 21:18:53 -0500 Subject: [PATCH 0502/1626] [bluetooth_proxy] Remove ESPBTUUID dependency to save 296 bytes of flash (#10213) --- esphome/components/ble_client/__init__.py | 1 + .../bluetooth_proxy/bluetooth_connection.cpp | 32 +++++++++++++------ esphome/components/esp32_ble/ble_uuid.cpp | 4 ++- esphome/components/esp32_ble/ble_uuid.h | 5 ++- .../components/esp32_ble_beacon/__init__.py | 2 ++ .../components/esp32_ble_server/__init__.py | 1 + .../components/esp32_ble_tracker/__init__.py | 1 + .../esp32_ble_tracker/esp32_ble_tracker.h | 2 ++ esphome/core/defines.h | 1 + 9 files changed, 38 insertions(+), 11 deletions(-) diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 0f3869c23b..5f4ea8afd1 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -286,6 +286,7 @@ async def remove_bond_to_code(config, action_id, template_arg, args): async def to_code(config): # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) + cg.add_define("USE_ESP32_BLE_UUID") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index b16b894188..347f60c28f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -12,16 +12,30 @@ namespace esphome::bluetooth_proxy { static const char *const TAG = "bluetooth_proxy.connection"; +// This function is allocation-free and directly packs UUIDs into the output array +// using precalculated constants for the Bluetooth base UUID static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t uuid_source) { - esp_bt_uuid_t uuid = espbt::ESPBTUUID::from_uuid(uuid_source).as_128bit().get_uuid(); - out[0] = ((uint64_t) uuid.uuid.uuid128[15] << 56) | ((uint64_t) uuid.uuid.uuid128[14] << 48) | - ((uint64_t) uuid.uuid.uuid128[13] << 40) | ((uint64_t) uuid.uuid.uuid128[12] << 32) | - ((uint64_t) uuid.uuid.uuid128[11] << 24) | ((uint64_t) uuid.uuid.uuid128[10] << 16) | - ((uint64_t) uuid.uuid.uuid128[9] << 8) | ((uint64_t) uuid.uuid.uuid128[8]); - out[1] = ((uint64_t) uuid.uuid.uuid128[7] << 56) | ((uint64_t) uuid.uuid.uuid128[6] << 48) | - ((uint64_t) uuid.uuid.uuid128[5] << 40) | ((uint64_t) uuid.uuid.uuid128[4] << 32) | - ((uint64_t) uuid.uuid.uuid128[3] << 24) | ((uint64_t) uuid.uuid.uuid128[2] << 16) | - ((uint64_t) uuid.uuid.uuid128[1] << 8) | ((uint64_t) uuid.uuid.uuid128[0]); + // Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB + // out[0] = bytes 8-15 (big-endian) + // - For 128-bit UUIDs: use bytes 8-15 as-is + // - For 16/32-bit UUIDs: insert into bytes 12-15, use 0x00001000 for bytes 8-11 + out[0] = uuid_source.len == ESP_UUID_LEN_128 + ? (((uint64_t) uuid_source.uuid.uuid128[15] << 56) | ((uint64_t) uuid_source.uuid.uuid128[14] << 48) | + ((uint64_t) uuid_source.uuid.uuid128[13] << 40) | ((uint64_t) uuid_source.uuid.uuid128[12] << 32) | + ((uint64_t) uuid_source.uuid.uuid128[11] << 24) | ((uint64_t) uuid_source.uuid.uuid128[10] << 16) | + ((uint64_t) uuid_source.uuid.uuid128[9] << 8) | ((uint64_t) uuid_source.uuid.uuid128[8])) + : (((uint64_t) (uuid_source.len == ESP_UUID_LEN_16 ? uuid_source.uuid.uuid16 : uuid_source.uuid.uuid32) + << 32) | + 0x00001000ULL); // Base UUID bytes 8-11 + // out[1] = bytes 0-7 (big-endian) + // - For 128-bit UUIDs: use bytes 0-7 as-is + // - For 16/32-bit UUIDs: use precalculated base UUID constant + out[1] = uuid_source.len == ESP_UUID_LEN_128 + ? ((uint64_t) uuid_source.uuid.uuid128[7] << 56) | ((uint64_t) uuid_source.uuid.uuid128[6] << 48) | + ((uint64_t) uuid_source.uuid.uuid128[5] << 40) | ((uint64_t) uuid_source.uuid.uuid128[4] << 32) | + ((uint64_t) uuid_source.uuid.uuid128[3] << 24) | ((uint64_t) uuid_source.uuid.uuid128[2] << 16) | + ((uint64_t) uuid_source.uuid.uuid128[1] << 8) | ((uint64_t) uuid_source.uuid.uuid128[0]) + : 0x800000805F9B34FBULL; // Base UUID bytes 0-7: 80-00-00-80-5F-9B-34-FB } // Helper to fill UUID in the appropriate format based on client support and UUID type diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index fc6981acd3..be9c6945d7 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -1,6 +1,7 @@ #include "ble_uuid.h" #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_UUID #include #include @@ -190,4 +191,5 @@ std::string ESPBTUUID::to_string() const { } // namespace esphome::esp32_ble -#endif +#endif // USE_ESP32_BLE_UUID +#endif // USE_ESP32 diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 150ca359d3..4cf2d10abd 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -1,9 +1,11 @@ #pragma once +#include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #ifdef USE_ESP32 +#ifdef USE_ESP32_BLE_UUID #include #include @@ -42,4 +44,5 @@ class ESPBTUUID { } // namespace esphome::esp32_ble -#endif +#endif // USE_ESP32_BLE_UUID +#endif // USE_ESP32 diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 7ee0926eea..8fc4fe941d 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -65,6 +65,8 @@ FINAL_VALIDATE_SCHEMA = esp32_ble.validate_variant async def to_code(config): + cg.add_define("USE_ESP32_BLE_UUID") + uuid = config[CONF_UUID].hex uuid_arr = [ cg.RawExpression(f"0x{uuid[i : i + 2]}") for i in range(0, len(uuid), 2) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index feeb055600..8ddb15a7f8 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -529,6 +529,7 @@ async def to_code_characteristic(service_var, char_conf): async def to_code(config): # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) + cg.add_define("USE_ESP32_BLE_UUID") var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index d03e968e2d..9ad2f3b25f 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -373,6 +373,7 @@ async def _add_ble_features(): # Add feature-specific defines based on what's needed if BLEFeatures.ESP_BT_DEVICE in _required_features: cg.add_define("USE_ESP32_BLE_DEVICE") + cg.add_define("USE_ESP32_BLE_UUID") ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index bf99026810..3022eb25d2 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -33,10 +33,12 @@ enum AdvertisementParserType { RAW_ADVERTISEMENTS, }; +#ifdef USE_ESP32_BLE_UUID struct ServiceData { ESPBTUUID uuid; adv_data_t data; }; +#endif #ifdef USE_ESP32_BLE_DEVICE class ESPBLEiBeacon { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 01f6811e05..5df3bcf475 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -154,6 +154,7 @@ #define USE_ESP32_BLE_CLIENT #define USE_ESP32_BLE_DEVICE #define USE_ESP32_BLE_SERVER +#define USE_ESP32_BLE_UUID #define USE_ESP32_BLE_ADVERTISING #define USE_I2C #define USE_IMPROV From 6b7ced19704a3d6b8f17b4f7963f7bbf436a57fb Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 13 Aug 2025 14:46:50 +1200 Subject: [PATCH 0503/1626] Bump version to 2025.8.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 1f5ac5aa1b..6566b56e4b 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.8.0-dev +PROJECT_NUMBER = 2025.8.0b1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 49997ca766..841630df5c 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.8.0-dev" +__version__ = "2025.8.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 40c0c36179aade554ec7d941a10945c7e57ef10c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 13 Aug 2025 14:46:51 +1200 Subject: [PATCH 0504/1626] Bump version to 2025.9.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 1f5ac5aa1b..f312ca45e2 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.8.0-dev +PROJECT_NUMBER = 2025.9.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 49997ca766..f27adff9be 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.8.0-dev" +__version__ = "2025.9.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0dda3faed543c945402434787b5e2000fc8c3978 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 22:46:56 -0500 Subject: [PATCH 0505/1626] [CI] Fix CI job failures for PRs with >300 changed files (#10215) --- script/helpers.py | 19 +++++++++++-- tests/script/test_helpers.py | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/script/helpers.py b/script/helpers.py index b346f3a461..2c2f44a513 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -139,9 +139,24 @@ def _get_changed_files_github_actions() -> list[str] | None: if event_name == "pull_request": pr_number = _get_pr_number_from_github_env() if pr_number: - # Use GitHub CLI to get changed files directly + # Try gh pr diff first (faster for small PRs) cmd = ["gh", "pr", "diff", pr_number, "--name-only"] - return _get_changed_files_from_command(cmd) + try: + return _get_changed_files_from_command(cmd) + except Exception as e: + # If it fails due to the 300 file limit, use the API method + if "maximum" in str(e) and "files" in str(e): + cmd = [ + "gh", + "api", + f"repos/esphome/esphome/pulls/{pr_number}/files", + "--paginate", + "--jq", + ".[].filename", + ] + return _get_changed_files_from_command(cmd) + # Re-raise for other errors + raise # For pushes (including squash-and-merge) elif event_name == "push": diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 9730efd366..63f1f0e600 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -183,6 +183,61 @@ def test_get_changed_files_github_actions_pull_request( assert result == expected_files +def test_get_changed_files_github_actions_pull_request_large_pr( + monkeypatch: MonkeyPatch, +) -> None: + """Test _get_changed_files_github_actions fallback for PRs with >300 files.""" + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + + expected_files = ["file1.py", "file2.cpp"] + + with ( + patch("helpers._get_pr_number_from_github_env", return_value="10214"), + patch("helpers._get_changed_files_from_command") as mock_get, + ): + # First call fails with too many files error, second succeeds with API method + mock_get.side_effect = [ + Exception("Sorry, the diff exceeded the maximum number of files (300)"), + expected_files, + ] + + result = _get_changed_files_github_actions() + + assert mock_get.call_count == 2 + mock_get.assert_any_call(["gh", "pr", "diff", "10214", "--name-only"]) + mock_get.assert_any_call( + [ + "gh", + "api", + "repos/esphome/esphome/pulls/10214/files", + "--paginate", + "--jq", + ".[].filename", + ] + ) + assert result == expected_files + + +def test_get_changed_files_github_actions_pull_request_other_error( + monkeypatch: MonkeyPatch, +) -> None: + """Test _get_changed_files_github_actions re-raises non-file-limit errors.""" + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + + with ( + patch("helpers._get_pr_number_from_github_env", return_value="1234"), + patch("helpers._get_changed_files_from_command") as mock_get, + ): + # Error that is not about file limit + mock_get.side_effect = Exception("Command failed: authentication required") + + with pytest.raises(Exception, match="authentication required"): + _get_changed_files_github_actions() + + # Should only be called once (no retry with API) + mock_get.assert_called_once_with(["gh", "pr", "diff", "1234", "--name-only"]) + + def test_get_changed_files_github_actions_pull_request_no_pr_number( monkeypatch: MonkeyPatch, ) -> None: From 68ddd98f5f52dd38efbbb42c05324d18d1c50bba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Aug 2025 22:46:56 -0500 Subject: [PATCH 0506/1626] [CI] Fix CI job failures for PRs with >300 changed files (#10215) --- script/helpers.py | 19 +++++++++++-- tests/script/test_helpers.py | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/script/helpers.py b/script/helpers.py index b346f3a461..2c2f44a513 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -139,9 +139,24 @@ def _get_changed_files_github_actions() -> list[str] | None: if event_name == "pull_request": pr_number = _get_pr_number_from_github_env() if pr_number: - # Use GitHub CLI to get changed files directly + # Try gh pr diff first (faster for small PRs) cmd = ["gh", "pr", "diff", pr_number, "--name-only"] - return _get_changed_files_from_command(cmd) + try: + return _get_changed_files_from_command(cmd) + except Exception as e: + # If it fails due to the 300 file limit, use the API method + if "maximum" in str(e) and "files" in str(e): + cmd = [ + "gh", + "api", + f"repos/esphome/esphome/pulls/{pr_number}/files", + "--paginate", + "--jq", + ".[].filename", + ] + return _get_changed_files_from_command(cmd) + # Re-raise for other errors + raise # For pushes (including squash-and-merge) elif event_name == "push": diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 9730efd366..63f1f0e600 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -183,6 +183,61 @@ def test_get_changed_files_github_actions_pull_request( assert result == expected_files +def test_get_changed_files_github_actions_pull_request_large_pr( + monkeypatch: MonkeyPatch, +) -> None: + """Test _get_changed_files_github_actions fallback for PRs with >300 files.""" + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + + expected_files = ["file1.py", "file2.cpp"] + + with ( + patch("helpers._get_pr_number_from_github_env", return_value="10214"), + patch("helpers._get_changed_files_from_command") as mock_get, + ): + # First call fails with too many files error, second succeeds with API method + mock_get.side_effect = [ + Exception("Sorry, the diff exceeded the maximum number of files (300)"), + expected_files, + ] + + result = _get_changed_files_github_actions() + + assert mock_get.call_count == 2 + mock_get.assert_any_call(["gh", "pr", "diff", "10214", "--name-only"]) + mock_get.assert_any_call( + [ + "gh", + "api", + "repos/esphome/esphome/pulls/10214/files", + "--paginate", + "--jq", + ".[].filename", + ] + ) + assert result == expected_files + + +def test_get_changed_files_github_actions_pull_request_other_error( + monkeypatch: MonkeyPatch, +) -> None: + """Test _get_changed_files_github_actions re-raises non-file-limit errors.""" + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + + with ( + patch("helpers._get_pr_number_from_github_env", return_value="1234"), + patch("helpers._get_changed_files_from_command") as mock_get, + ): + # Error that is not about file limit + mock_get.side_effect = Exception("Command failed: authentication required") + + with pytest.raises(Exception, match="authentication required"): + _get_changed_files_github_actions() + + # Should only be called once (no retry with API) + mock_get.assert_called_once_with(["gh", "pr", "diff", "1234", "--name-only"]) + + def test_get_changed_files_github_actions_pull_request_no_pr_number( monkeypatch: MonkeyPatch, ) -> None: From 6a8722f33e9579fdbbc75f1b7c64330cb315daa6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 14 Aug 2025 09:42:11 +1200 Subject: [PATCH 0507/1626] [entity] Allow ``device_id`` to be blank on entities (#10217) --- esphome/config_validation.py | 5 +++- esphome/core/entity_helpers.py | 8 +++--- .../fixtures/areas_and_devices.yaml | 26 +++++++++++++++++++ tests/integration/test_areas_and_devices.py | 13 ++++++++++ tests/unit_tests/core/test_entity_helpers.py | 16 ++++++++++++ 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 9aaeb9f9e8..f811fbf7c2 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -393,10 +393,13 @@ def icon(value): ) -def sub_device_id(value: str | None) -> core.ID: +def sub_device_id(value: str | None) -> core.ID | None: # Lazy import to avoid circular imports from esphome.core.config import Device + if not value: + return None + return use_id(Device)(value) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 107b9fd739..1ccc3e2683 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -77,8 +77,8 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: """ # Get device info device_name: str | None = None - if CONF_DEVICE_ID in config: - device_id_obj: ID = config[CONF_DEVICE_ID] + device_id_obj: ID | None + if device_id_obj := config.get(CONF_DEVICE_ID): device: MockObj = await get_variable(device_id_obj) add(var.set_device(device)) # Get device name for object ID calculation @@ -199,8 +199,8 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Get device name if entity is on a sub-device device_name = None device_id = "" # Empty string for main device - if CONF_DEVICE_ID in config: - device_id_obj = config[CONF_DEVICE_ID] + device_id_obj: ID | None + if device_id_obj := config.get(CONF_DEVICE_ID): device_name = device_id_obj.id # Use the device ID string directly for uniqueness device_id = device_id_obj.id diff --git a/tests/integration/fixtures/areas_and_devices.yaml b/tests/integration/fixtures/areas_and_devices.yaml index 12ab070e55..08b02e6e1e 100644 --- a/tests/integration/fixtures/areas_and_devices.yaml +++ b/tests/integration/fixtures/areas_and_devices.yaml @@ -55,6 +55,12 @@ sensor: lambda: return 4.0; update_interval: 0.1s + - platform: template + name: Living Room Sensor + device_id: "" + lambda: return 5.0; + update_interval: 0.1s + # Switches with the same name on different devices to test device_id lookup switch: # Switch with no device_id (defaults to 0) @@ -96,3 +102,23 @@ switch: - logger.log: "Turning on Test Switch on Motion Detector" turn_off_action: - logger.log: "Turning off Test Switch on Motion Detector" + + - platform: template + name: Living Room Blank Switch + device_id: "" + id: test_switch_blank_living_room + optimistic: true + turn_on_action: + - logger.log: "Turning on Living Room Blank Switch" + turn_off_action: + - logger.log: "Turning off Living Room Blank Switch" + + - platform: template + name: Living Room None Switch + device_id: + id: test_switch_none_living_room + optimistic: true + turn_on_action: + - logger.log: "Turning on Living Room None Switch" + turn_off_action: + - logger.log: "Turning off Living Room None Switch" diff --git a/tests/integration/test_areas_and_devices.py b/tests/integration/test_areas_and_devices.py index 1af16c87e8..93326de0a9 100644 --- a/tests/integration/test_areas_and_devices.py +++ b/tests/integration/test_areas_and_devices.py @@ -132,6 +132,7 @@ async def test_areas_and_devices( "Temperature Sensor Reading": temp_sensor.device_id, "Motion Detector Status": motion_detector.device_id, "Smart Switch Power": smart_switch.device_id, + "Living Room Sensor": 0, # Main device } for entity in sensor_entities: @@ -160,6 +161,18 @@ async def test_areas_and_devices( "Should have a switch with device_id 0 (main device)" ) + # Verify extra switches with blank and none device_id are correctly available + extra_switches = [ + e for e in switch_entities if e.name.startswith("Living Room") + ] + assert len(extra_switches) == 2, ( + f"Expected 2 extra switches for Living Room, got {len(extra_switches)}" + ) + extra_switch_device_ids = [e.device_id for e in extra_switches] + assert all(d == 0 for d in extra_switch_device_ids), ( + "All extra switches should have device_id 0 (main device)" + ) + # Wait for initial states to be received for all switches await asyncio.wait_for(initial_states_future, timeout=2.0) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 2157bc20a9..db99243a1a 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -689,3 +689,19 @@ def test_entity_duplicate_validator_internal_entities() -> None: Invalid, match=r"Duplicate sensor entity with name 'Temperature' found" ): validator(config4) + + +def test_empty_or_null_device_id_on_entity() -> None: + """Test that empty or null device IDs are handled correctly.""" + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # Entity with empty device_id should pass + config1 = {CONF_NAME: "Battery", CONF_DEVICE_ID: ""} + validated1 = validator(config1) + assert validated1 == config1 + + # Entity with None device_id should pass + config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: None} + validated2 = validator(config2) + assert validated2 == config2 From 0d1949a61becc1cc99316306f3bffe98595dbcc5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 14 Aug 2025 10:30:28 +1200 Subject: [PATCH 0508/1626] [espnow] Set state to enabled before adding initial peers (#10225) --- esphome/components/espnow/espnow_component.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 82f8e3230e..b0d5938dba 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -208,11 +208,11 @@ void ESPNowComponent::enable_() { esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL); #endif + this->state_ = ESPNOW_STATE_ENABLED; + for (auto peer : this->peers_) { this->add_peer(peer.address); } - - this->state_ = ESPNOW_STATE_ENABLED; } void ESPNowComponent::disable() { @@ -407,7 +407,7 @@ esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { } if (memcmp(peer, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { - this->mark_failed(); + this->status_momentary_warning("peer-add-failed"); return ESP_ERR_INVALID_MAC; } From c3f15964987bfb1cfb3464f4bcc18317f62b448b Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Wed, 13 Aug 2025 15:40:12 -0700 Subject: [PATCH 0509/1626] [psram] allow disabling (#10224) Co-authored-by: Samuel Sieb --- esphome/components/psram/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index fd7e70a055..b5c87ae5a8 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -16,6 +16,7 @@ from esphome.components.esp32.const import ( import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, + CONF_DISABLED, CONF_FRAMEWORK, CONF_ID, CONF_MODE, @@ -102,6 +103,7 @@ def get_config_schema(config): cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True), cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean, cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True), + cv.Optional(CONF_DISABLED, default=False): cv.boolean, } )(config) @@ -112,6 +114,8 @@ FINAL_VALIDATE_SCHEMA = validate_psram_mode async def to_code(config): + if config[CONF_DISABLED]: + return if CORE.using_arduino: cg.add_build_flag("-DBOARD_HAS_PSRAM") if config[CONF_MODE] == TYPE_OCTAL: From 7c4a54de909820d208386598116e442f3f925c55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:42:54 -0500 Subject: [PATCH 0510/1626] Bump aioesphomeapi from 38.2.1 to 39.0.0 (#10222) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9793336cf3..fc63c62f64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==38.2.1 +aioesphomeapi==39.0.0 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 46d433775b14b08c048aa4f758a35c9e906da709 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 12:40:20 +1200 Subject: [PATCH 0511/1626] Bump esphome-dashboard from 20250514.0 to 20250814.0 (#10227) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fc63c62f64..0675115c02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyserial==3.5 platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 -esphome-dashboard==20250514.0 +esphome-dashboard==20250814.0 aioesphomeapi==39.0.0 zeroconf==0.147.0 puremagic==1.30 From 7922462bcf9354cb67776b61d1ed4b52c05efcb6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 14 Aug 2025 09:42:11 +1200 Subject: [PATCH 0512/1626] [entity] Allow ``device_id`` to be blank on entities (#10217) --- esphome/config_validation.py | 5 +++- esphome/core/entity_helpers.py | 8 +++--- .../fixtures/areas_and_devices.yaml | 26 +++++++++++++++++++ tests/integration/test_areas_and_devices.py | 13 ++++++++++ tests/unit_tests/core/test_entity_helpers.py | 16 ++++++++++++ 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 9aaeb9f9e8..f811fbf7c2 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -393,10 +393,13 @@ def icon(value): ) -def sub_device_id(value: str | None) -> core.ID: +def sub_device_id(value: str | None) -> core.ID | None: # Lazy import to avoid circular imports from esphome.core.config import Device + if not value: + return None + return use_id(Device)(value) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 107b9fd739..1ccc3e2683 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -77,8 +77,8 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: """ # Get device info device_name: str | None = None - if CONF_DEVICE_ID in config: - device_id_obj: ID = config[CONF_DEVICE_ID] + device_id_obj: ID | None + if device_id_obj := config.get(CONF_DEVICE_ID): device: MockObj = await get_variable(device_id_obj) add(var.set_device(device)) # Get device name for object ID calculation @@ -199,8 +199,8 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Get device name if entity is on a sub-device device_name = None device_id = "" # Empty string for main device - if CONF_DEVICE_ID in config: - device_id_obj = config[CONF_DEVICE_ID] + device_id_obj: ID | None + if device_id_obj := config.get(CONF_DEVICE_ID): device_name = device_id_obj.id # Use the device ID string directly for uniqueness device_id = device_id_obj.id diff --git a/tests/integration/fixtures/areas_and_devices.yaml b/tests/integration/fixtures/areas_and_devices.yaml index 12ab070e55..08b02e6e1e 100644 --- a/tests/integration/fixtures/areas_and_devices.yaml +++ b/tests/integration/fixtures/areas_and_devices.yaml @@ -55,6 +55,12 @@ sensor: lambda: return 4.0; update_interval: 0.1s + - platform: template + name: Living Room Sensor + device_id: "" + lambda: return 5.0; + update_interval: 0.1s + # Switches with the same name on different devices to test device_id lookup switch: # Switch with no device_id (defaults to 0) @@ -96,3 +102,23 @@ switch: - logger.log: "Turning on Test Switch on Motion Detector" turn_off_action: - logger.log: "Turning off Test Switch on Motion Detector" + + - platform: template + name: Living Room Blank Switch + device_id: "" + id: test_switch_blank_living_room + optimistic: true + turn_on_action: + - logger.log: "Turning on Living Room Blank Switch" + turn_off_action: + - logger.log: "Turning off Living Room Blank Switch" + + - platform: template + name: Living Room None Switch + device_id: + id: test_switch_none_living_room + optimistic: true + turn_on_action: + - logger.log: "Turning on Living Room None Switch" + turn_off_action: + - logger.log: "Turning off Living Room None Switch" diff --git a/tests/integration/test_areas_and_devices.py b/tests/integration/test_areas_and_devices.py index 1af16c87e8..93326de0a9 100644 --- a/tests/integration/test_areas_and_devices.py +++ b/tests/integration/test_areas_and_devices.py @@ -132,6 +132,7 @@ async def test_areas_and_devices( "Temperature Sensor Reading": temp_sensor.device_id, "Motion Detector Status": motion_detector.device_id, "Smart Switch Power": smart_switch.device_id, + "Living Room Sensor": 0, # Main device } for entity in sensor_entities: @@ -160,6 +161,18 @@ async def test_areas_and_devices( "Should have a switch with device_id 0 (main device)" ) + # Verify extra switches with blank and none device_id are correctly available + extra_switches = [ + e for e in switch_entities if e.name.startswith("Living Room") + ] + assert len(extra_switches) == 2, ( + f"Expected 2 extra switches for Living Room, got {len(extra_switches)}" + ) + extra_switch_device_ids = [e.device_id for e in extra_switches] + assert all(d == 0 for d in extra_switch_device_ids), ( + "All extra switches should have device_id 0 (main device)" + ) + # Wait for initial states to be received for all switches await asyncio.wait_for(initial_states_future, timeout=2.0) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 2157bc20a9..db99243a1a 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -689,3 +689,19 @@ def test_entity_duplicate_validator_internal_entities() -> None: Invalid, match=r"Duplicate sensor entity with name 'Temperature' found" ): validator(config4) + + +def test_empty_or_null_device_id_on_entity() -> None: + """Test that empty or null device IDs are handled correctly.""" + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # Entity with empty device_id should pass + config1 = {CONF_NAME: "Battery", CONF_DEVICE_ID: ""} + validated1 = validator(config1) + assert validated1 == config1 + + # Entity with None device_id should pass + config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: None} + validated2 = validator(config2) + assert validated2 == config2 From b7afeafda91b25cec3dd3428ea064841747e51b4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 14 Aug 2025 10:30:28 +1200 Subject: [PATCH 0513/1626] [espnow] Set state to enabled before adding initial peers (#10225) --- esphome/components/espnow/espnow_component.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 82f8e3230e..b0d5938dba 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -208,11 +208,11 @@ void ESPNowComponent::enable_() { esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL); #endif + this->state_ = ESPNOW_STATE_ENABLED; + for (auto peer : this->peers_) { this->add_peer(peer.address); } - - this->state_ = ESPNOW_STATE_ENABLED; } void ESPNowComponent::disable() { @@ -407,7 +407,7 @@ esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { } if (memcmp(peer, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { - this->mark_failed(); + this->status_momentary_warning("peer-add-failed"); return ESP_ERR_INVALID_MAC; } From 95cd224e3ed6e68f0246a2a158505dca0c38fdc4 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Wed, 13 Aug 2025 15:40:12 -0700 Subject: [PATCH 0514/1626] [psram] allow disabling (#10224) Co-authored-by: Samuel Sieb --- esphome/components/psram/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index fd7e70a055..b5c87ae5a8 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -16,6 +16,7 @@ from esphome.components.esp32.const import ( import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, + CONF_DISABLED, CONF_FRAMEWORK, CONF_ID, CONF_MODE, @@ -102,6 +103,7 @@ def get_config_schema(config): cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True), cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean, cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True), + cv.Optional(CONF_DISABLED, default=False): cv.boolean, } )(config) @@ -112,6 +114,8 @@ FINAL_VALIDATE_SCHEMA = validate_psram_mode async def to_code(config): + if config[CONF_DISABLED]: + return if CORE.using_arduino: cg.add_build_flag("-DBOARD_HAS_PSRAM") if config[CONF_MODE] == TYPE_OCTAL: From 93f94751053a00280112d5e763f2bdc125c5d0c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:42:54 -0500 Subject: [PATCH 0515/1626] Bump aioesphomeapi from 38.2.1 to 39.0.0 (#10222) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9793336cf3..fc63c62f64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==38.2.1 +aioesphomeapi==39.0.0 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.14 # dashboard_import From 9c897993bb9555290a625de4e2eba6e20ba0414a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 12:40:20 +1200 Subject: [PATCH 0516/1626] Bump esphome-dashboard from 20250514.0 to 20250814.0 (#10227) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fc63c62f64..0675115c02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyserial==3.5 platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 -esphome-dashboard==20250514.0 +esphome-dashboard==20250814.0 aioesphomeapi==39.0.0 zeroconf==0.147.0 puremagic==1.30 From 8d61b1e8dfcc0e51577f51c36f7fa9e27456af2b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:00:27 +1200 Subject: [PATCH 0517/1626] Bump version to 2025.8.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 6566b56e4b..e2d892eb44 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.8.0b1 +PROJECT_NUMBER = 2025.8.0b2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 841630df5c..3b5365854d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.8.0b1" +__version__ = "2025.8.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 6b5e43ca72bb3a2f72abc6d33672c60ba55b54e4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:19:03 +1200 Subject: [PATCH 0518/1626] [qm6988] Clean up code (#10216) --- esphome/components/qmp6988/qmp6988.cpp | 205 ++++++++++--------------- esphome/components/qmp6988/qmp6988.h | 68 ++++---- 2 files changed, 111 insertions(+), 162 deletions(-) diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 6c22150f4f..61fde186d7 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -18,14 +18,6 @@ static const uint8_t QMP6988_TEMPERATURE_MSB_REG = 0xFA; /* Temperature MSB Reg static const uint8_t QMP6988_CALIBRATION_DATA_START = 0xA0; /* QMP6988 compensation coefficients */ static const uint8_t QMP6988_CALIBRATION_DATA_LENGTH = 25; -static const uint8_t SHIFT_RIGHT_4_POSITION = 4; -static const uint8_t SHIFT_LEFT_2_POSITION = 2; -static const uint8_t SHIFT_LEFT_4_POSITION = 4; -static const uint8_t SHIFT_LEFT_5_POSITION = 5; -static const uint8_t SHIFT_LEFT_8_POSITION = 8; -static const uint8_t SHIFT_LEFT_12_POSITION = 12; -static const uint8_t SHIFT_LEFT_16_POSITION = 16; - /* power mode */ static const uint8_t QMP6988_SLEEP_MODE = 0x00; static const uint8_t QMP6988_FORCED_MODE = 0x01; @@ -95,64 +87,45 @@ static const char *iir_filter_to_str(QMP6988IIRFilter filter) { } bool QMP6988Component::device_check_() { - uint8_t ret = 0; - - ret = this->read_register(QMP6988_CHIP_ID_REG, &(qmp6988_data_.chip_id), 1); - if (ret != i2c::ERROR_OK) { - ESP_LOGE(TAG, "%s: read chip ID (0xD1) failed", __func__); + if (this->read_register(QMP6988_CHIP_ID_REG, &(qmp6988_data_.chip_id), 1) != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Read chip ID (0xD1) failed"); + return false; } - ESP_LOGD(TAG, "qmp6988 read chip id = 0x%x", qmp6988_data_.chip_id); + ESP_LOGV(TAG, "Read chip ID = 0x%x", qmp6988_data_.chip_id); return qmp6988_data_.chip_id == QMP6988_CHIP_ID; } bool QMP6988Component::get_calibration_data_() { - uint8_t status = 0; // BITFIELDS temp_COE; uint8_t a_data_uint8_tr[QMP6988_CALIBRATION_DATA_LENGTH] = {0}; - int len; - for (len = 0; len < QMP6988_CALIBRATION_DATA_LENGTH; len += 1) { - status = this->read_register(QMP6988_CALIBRATION_DATA_START + len, &a_data_uint8_tr[len], 1); - if (status != i2c::ERROR_OK) { - ESP_LOGE(TAG, "qmp6988 read calibration data (0xA0) error!"); + for (uint8_t len = 0; len < QMP6988_CALIBRATION_DATA_LENGTH; len += 1) { + if (this->read_register(QMP6988_CALIBRATION_DATA_START + len, &a_data_uint8_tr[len], 1) != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Read calibration data (0xA0) error"); return false; } } qmp6988_data_.qmp6988_cali.COE_a0 = - (QMP6988_S32_t) (((a_data_uint8_tr[18] << SHIFT_LEFT_12_POSITION) | - (a_data_uint8_tr[19] << SHIFT_LEFT_4_POSITION) | (a_data_uint8_tr[24] & 0x0f)) - << 12); + (int32_t) encode_uint32(a_data_uint8_tr[18], a_data_uint8_tr[19], (a_data_uint8_tr[24] & 0x0f) << 4, 0); qmp6988_data_.qmp6988_cali.COE_a0 = qmp6988_data_.qmp6988_cali.COE_a0 >> 12; - qmp6988_data_.qmp6988_cali.COE_a1 = - (QMP6988_S16_t) (((a_data_uint8_tr[20]) << SHIFT_LEFT_8_POSITION) | a_data_uint8_tr[21]); - qmp6988_data_.qmp6988_cali.COE_a2 = - (QMP6988_S16_t) (((a_data_uint8_tr[22]) << SHIFT_LEFT_8_POSITION) | a_data_uint8_tr[23]); + qmp6988_data_.qmp6988_cali.COE_a1 = (int16_t) encode_uint16(a_data_uint8_tr[20], a_data_uint8_tr[21]); + qmp6988_data_.qmp6988_cali.COE_a2 = (int16_t) encode_uint16(a_data_uint8_tr[22], a_data_uint8_tr[23]); qmp6988_data_.qmp6988_cali.COE_b00 = - (QMP6988_S32_t) (((a_data_uint8_tr[0] << SHIFT_LEFT_12_POSITION) | (a_data_uint8_tr[1] << SHIFT_LEFT_4_POSITION) | - ((a_data_uint8_tr[24] & 0xf0) >> SHIFT_RIGHT_4_POSITION)) - << 12); + (int32_t) encode_uint32(a_data_uint8_tr[0], a_data_uint8_tr[1], a_data_uint8_tr[24] & 0xf0, 0); qmp6988_data_.qmp6988_cali.COE_b00 = qmp6988_data_.qmp6988_cali.COE_b00 >> 12; - qmp6988_data_.qmp6988_cali.COE_bt1 = - (QMP6988_S16_t) (((a_data_uint8_tr[2]) << SHIFT_LEFT_8_POSITION) | a_data_uint8_tr[3]); - qmp6988_data_.qmp6988_cali.COE_bt2 = - (QMP6988_S16_t) (((a_data_uint8_tr[4]) << SHIFT_LEFT_8_POSITION) | a_data_uint8_tr[5]); - qmp6988_data_.qmp6988_cali.COE_bp1 = - (QMP6988_S16_t) (((a_data_uint8_tr[6]) << SHIFT_LEFT_8_POSITION) | a_data_uint8_tr[7]); - qmp6988_data_.qmp6988_cali.COE_b11 = - (QMP6988_S16_t) (((a_data_uint8_tr[8]) << SHIFT_LEFT_8_POSITION) | a_data_uint8_tr[9]); - qmp6988_data_.qmp6988_cali.COE_bp2 = - (QMP6988_S16_t) (((a_data_uint8_tr[10]) << SHIFT_LEFT_8_POSITION) | a_data_uint8_tr[11]); - qmp6988_data_.qmp6988_cali.COE_b12 = - (QMP6988_S16_t) (((a_data_uint8_tr[12]) << SHIFT_LEFT_8_POSITION) | a_data_uint8_tr[13]); - qmp6988_data_.qmp6988_cali.COE_b21 = - (QMP6988_S16_t) (((a_data_uint8_tr[14]) << SHIFT_LEFT_8_POSITION) | a_data_uint8_tr[15]); - qmp6988_data_.qmp6988_cali.COE_bp3 = - (QMP6988_S16_t) (((a_data_uint8_tr[16]) << SHIFT_LEFT_8_POSITION) | a_data_uint8_tr[17]); + qmp6988_data_.qmp6988_cali.COE_bt1 = (int16_t) encode_uint16(a_data_uint8_tr[2], a_data_uint8_tr[3]); + qmp6988_data_.qmp6988_cali.COE_bt2 = (int16_t) encode_uint16(a_data_uint8_tr[4], a_data_uint8_tr[5]); + qmp6988_data_.qmp6988_cali.COE_bp1 = (int16_t) encode_uint16(a_data_uint8_tr[6], a_data_uint8_tr[7]); + qmp6988_data_.qmp6988_cali.COE_b11 = (int16_t) encode_uint16(a_data_uint8_tr[8], a_data_uint8_tr[9]); + qmp6988_data_.qmp6988_cali.COE_bp2 = (int16_t) encode_uint16(a_data_uint8_tr[10], a_data_uint8_tr[11]); + qmp6988_data_.qmp6988_cali.COE_b12 = (int16_t) encode_uint16(a_data_uint8_tr[12], a_data_uint8_tr[13]); + qmp6988_data_.qmp6988_cali.COE_b21 = (int16_t) encode_uint16(a_data_uint8_tr[14], a_data_uint8_tr[15]); + qmp6988_data_.qmp6988_cali.COE_bp3 = (int16_t) encode_uint16(a_data_uint8_tr[16], a_data_uint8_tr[17]); ESP_LOGV(TAG, "<-----------calibration data-------------->\r\n"); ESP_LOGV(TAG, "COE_a0[%d] COE_a1[%d] COE_a2[%d] COE_b00[%d]\r\n", qmp6988_data_.qmp6988_cali.COE_a0, @@ -166,17 +139,17 @@ bool QMP6988Component::get_calibration_data_() { qmp6988_data_.ik.a0 = qmp6988_data_.qmp6988_cali.COE_a0; // 20Q4 qmp6988_data_.ik.b00 = qmp6988_data_.qmp6988_cali.COE_b00; // 20Q4 - qmp6988_data_.ik.a1 = 3608L * (QMP6988_S32_t) qmp6988_data_.qmp6988_cali.COE_a1 - 1731677965L; // 31Q23 - qmp6988_data_.ik.a2 = 16889L * (QMP6988_S32_t) qmp6988_data_.qmp6988_cali.COE_a2 - 87619360L; // 30Q47 + qmp6988_data_.ik.a1 = 3608L * (int32_t) qmp6988_data_.qmp6988_cali.COE_a1 - 1731677965L; // 31Q23 + qmp6988_data_.ik.a2 = 16889L * (int32_t) qmp6988_data_.qmp6988_cali.COE_a2 - 87619360L; // 30Q47 - qmp6988_data_.ik.bt1 = 2982L * (QMP6988_S64_t) qmp6988_data_.qmp6988_cali.COE_bt1 + 107370906L; // 28Q15 - qmp6988_data_.ik.bt2 = 329854L * (QMP6988_S64_t) qmp6988_data_.qmp6988_cali.COE_bt2 + 108083093L; // 34Q38 - qmp6988_data_.ik.bp1 = 19923L * (QMP6988_S64_t) qmp6988_data_.qmp6988_cali.COE_bp1 + 1133836764L; // 31Q20 - qmp6988_data_.ik.b11 = 2406L * (QMP6988_S64_t) qmp6988_data_.qmp6988_cali.COE_b11 + 118215883L; // 28Q34 - qmp6988_data_.ik.bp2 = 3079L * (QMP6988_S64_t) qmp6988_data_.qmp6988_cali.COE_bp2 - 181579595L; // 29Q43 - qmp6988_data_.ik.b12 = 6846L * (QMP6988_S64_t) qmp6988_data_.qmp6988_cali.COE_b12 + 85590281L; // 29Q53 - qmp6988_data_.ik.b21 = 13836L * (QMP6988_S64_t) qmp6988_data_.qmp6988_cali.COE_b21 + 79333336L; // 29Q60 - qmp6988_data_.ik.bp3 = 2915L * (QMP6988_S64_t) qmp6988_data_.qmp6988_cali.COE_bp3 + 157155561L; // 28Q65 + qmp6988_data_.ik.bt1 = 2982L * (int64_t) qmp6988_data_.qmp6988_cali.COE_bt1 + 107370906L; // 28Q15 + qmp6988_data_.ik.bt2 = 329854L * (int64_t) qmp6988_data_.qmp6988_cali.COE_bt2 + 108083093L; // 34Q38 + qmp6988_data_.ik.bp1 = 19923L * (int64_t) qmp6988_data_.qmp6988_cali.COE_bp1 + 1133836764L; // 31Q20 + qmp6988_data_.ik.b11 = 2406L * (int64_t) qmp6988_data_.qmp6988_cali.COE_b11 + 118215883L; // 28Q34 + qmp6988_data_.ik.bp2 = 3079L * (int64_t) qmp6988_data_.qmp6988_cali.COE_bp2 - 181579595L; // 29Q43 + qmp6988_data_.ik.b12 = 6846L * (int64_t) qmp6988_data_.qmp6988_cali.COE_b12 + 85590281L; // 29Q53 + qmp6988_data_.ik.b21 = 13836L * (int64_t) qmp6988_data_.qmp6988_cali.COE_b21 + 79333336L; // 29Q60 + qmp6988_data_.ik.bp3 = 2915L * (int64_t) qmp6988_data_.qmp6988_cali.COE_bp3 + 157155561L; // 28Q65 ESP_LOGV(TAG, "<----------- int calibration data -------------->\r\n"); ESP_LOGV(TAG, "a0[%d] a1[%d] a2[%d] b00[%d]\r\n", qmp6988_data_.ik.a0, qmp6988_data_.ik.a1, qmp6988_data_.ik.a2, qmp6988_data_.ik.b00); @@ -188,55 +161,55 @@ bool QMP6988Component::get_calibration_data_() { return true; } -QMP6988_S16_t QMP6988Component::get_compensated_temperature_(qmp6988_ik_data_t *ik, QMP6988_S32_t dt) { - QMP6988_S16_t ret; - QMP6988_S64_t wk1, wk2; +int16_t QMP6988Component::get_compensated_temperature_(qmp6988_ik_data_t *ik, int32_t dt) { + int16_t ret; + int64_t wk1, wk2; // wk1: 60Q4 // bit size - wk1 = ((QMP6988_S64_t) ik->a1 * (QMP6988_S64_t) dt); // 31Q23+24-1=54 (54Q23) - wk2 = ((QMP6988_S64_t) ik->a2 * (QMP6988_S64_t) dt) >> 14; // 30Q47+24-1=53 (39Q33) - wk2 = (wk2 * (QMP6988_S64_t) dt) >> 10; // 39Q33+24-1=62 (52Q23) - wk2 = ((wk1 + wk2) / 32767) >> 19; // 54,52->55Q23 (20Q04) - ret = (QMP6988_S16_t) ((ik->a0 + wk2) >> 4); // 21Q4 -> 17Q0 + wk1 = ((int64_t) ik->a1 * (int64_t) dt); // 31Q23+24-1=54 (54Q23) + wk2 = ((int64_t) ik->a2 * (int64_t) dt) >> 14; // 30Q47+24-1=53 (39Q33) + wk2 = (wk2 * (int64_t) dt) >> 10; // 39Q33+24-1=62 (52Q23) + wk2 = ((wk1 + wk2) / 32767) >> 19; // 54,52->55Q23 (20Q04) + ret = (int16_t) ((ik->a0 + wk2) >> 4); // 21Q4 -> 17Q0 return ret; } -QMP6988_S32_t QMP6988Component::get_compensated_pressure_(qmp6988_ik_data_t *ik, QMP6988_S32_t dp, QMP6988_S16_t tx) { - QMP6988_S32_t ret; - QMP6988_S64_t wk1, wk2, wk3; +int32_t QMP6988Component::get_compensated_pressure_(qmp6988_ik_data_t *ik, int32_t dp, int16_t tx) { + int32_t ret; + int64_t wk1, wk2, wk3; // wk1 = 48Q16 // bit size - wk1 = ((QMP6988_S64_t) ik->bt1 * (QMP6988_S64_t) tx); // 28Q15+16-1=43 (43Q15) - wk2 = ((QMP6988_S64_t) ik->bp1 * (QMP6988_S64_t) dp) >> 5; // 31Q20+24-1=54 (49Q15) - wk1 += wk2; // 43,49->50Q15 - wk2 = ((QMP6988_S64_t) ik->bt2 * (QMP6988_S64_t) tx) >> 1; // 34Q38+16-1=49 (48Q37) - wk2 = (wk2 * (QMP6988_S64_t) tx) >> 8; // 48Q37+16-1=63 (55Q29) - wk3 = wk2; // 55Q29 - wk2 = ((QMP6988_S64_t) ik->b11 * (QMP6988_S64_t) tx) >> 4; // 28Q34+16-1=43 (39Q30) - wk2 = (wk2 * (QMP6988_S64_t) dp) >> 1; // 39Q30+24-1=62 (61Q29) - wk3 += wk2; // 55,61->62Q29 - wk2 = ((QMP6988_S64_t) ik->bp2 * (QMP6988_S64_t) dp) >> 13; // 29Q43+24-1=52 (39Q30) - wk2 = (wk2 * (QMP6988_S64_t) dp) >> 1; // 39Q30+24-1=62 (61Q29) - wk3 += wk2; // 62,61->63Q29 - wk1 += wk3 >> 14; // Q29 >> 14 -> Q15 - wk2 = ((QMP6988_S64_t) ik->b12 * (QMP6988_S64_t) tx); // 29Q53+16-1=45 (45Q53) - wk2 = (wk2 * (QMP6988_S64_t) tx) >> 22; // 45Q53+16-1=61 (39Q31) - wk2 = (wk2 * (QMP6988_S64_t) dp) >> 1; // 39Q31+24-1=62 (61Q30) - wk3 = wk2; // 61Q30 - wk2 = ((QMP6988_S64_t) ik->b21 * (QMP6988_S64_t) tx) >> 6; // 29Q60+16-1=45 (39Q54) - wk2 = (wk2 * (QMP6988_S64_t) dp) >> 23; // 39Q54+24-1=62 (39Q31) - wk2 = (wk2 * (QMP6988_S64_t) dp) >> 1; // 39Q31+24-1=62 (61Q20) - wk3 += wk2; // 61,61->62Q30 - wk2 = ((QMP6988_S64_t) ik->bp3 * (QMP6988_S64_t) dp) >> 12; // 28Q65+24-1=51 (39Q53) - wk2 = (wk2 * (QMP6988_S64_t) dp) >> 23; // 39Q53+24-1=62 (39Q30) - wk2 = (wk2 * (QMP6988_S64_t) dp); // 39Q30+24-1=62 (62Q30) - wk3 += wk2; // 62,62->63Q30 - wk1 += wk3 >> 15; // Q30 >> 15 = Q15 + wk1 = ((int64_t) ik->bt1 * (int64_t) tx); // 28Q15+16-1=43 (43Q15) + wk2 = ((int64_t) ik->bp1 * (int64_t) dp) >> 5; // 31Q20+24-1=54 (49Q15) + wk1 += wk2; // 43,49->50Q15 + wk2 = ((int64_t) ik->bt2 * (int64_t) tx) >> 1; // 34Q38+16-1=49 (48Q37) + wk2 = (wk2 * (int64_t) tx) >> 8; // 48Q37+16-1=63 (55Q29) + wk3 = wk2; // 55Q29 + wk2 = ((int64_t) ik->b11 * (int64_t) tx) >> 4; // 28Q34+16-1=43 (39Q30) + wk2 = (wk2 * (int64_t) dp) >> 1; // 39Q30+24-1=62 (61Q29) + wk3 += wk2; // 55,61->62Q29 + wk2 = ((int64_t) ik->bp2 * (int64_t) dp) >> 13; // 29Q43+24-1=52 (39Q30) + wk2 = (wk2 * (int64_t) dp) >> 1; // 39Q30+24-1=62 (61Q29) + wk3 += wk2; // 62,61->63Q29 + wk1 += wk3 >> 14; // Q29 >> 14 -> Q15 + wk2 = ((int64_t) ik->b12 * (int64_t) tx); // 29Q53+16-1=45 (45Q53) + wk2 = (wk2 * (int64_t) tx) >> 22; // 45Q53+16-1=61 (39Q31) + wk2 = (wk2 * (int64_t) dp) >> 1; // 39Q31+24-1=62 (61Q30) + wk3 = wk2; // 61Q30 + wk2 = ((int64_t) ik->b21 * (int64_t) tx) >> 6; // 29Q60+16-1=45 (39Q54) + wk2 = (wk2 * (int64_t) dp) >> 23; // 39Q54+24-1=62 (39Q31) + wk2 = (wk2 * (int64_t) dp) >> 1; // 39Q31+24-1=62 (61Q20) + wk3 += wk2; // 61,61->62Q30 + wk2 = ((int64_t) ik->bp3 * (int64_t) dp) >> 12; // 28Q65+24-1=51 (39Q53) + wk2 = (wk2 * (int64_t) dp) >> 23; // 39Q53+24-1=62 (39Q30) + wk2 = (wk2 * (int64_t) dp); // 39Q30+24-1=62 (62Q30) + wk3 += wk2; // 62,62->63Q30 + wk1 += wk3 >> 15; // Q30 >> 15 = Q15 wk1 /= 32767L; wk1 >>= 11; // Q15 >> 7 = Q4 wk1 += ik->b00; // Q4 + 20Q4 // wk1 >>= 4; // 28Q4 -> 24Q0 - ret = (QMP6988_S32_t) wk1; + ret = (int32_t) wk1; return ret; } @@ -274,7 +247,7 @@ void QMP6988Component::set_power_mode_(uint8_t power_mode) { delay(10); } -void QMP6988Component::write_filter_(unsigned char filter) { +void QMP6988Component::write_filter_(QMP6988IIRFilter filter) { uint8_t data; data = (filter & 0x03); @@ -282,7 +255,7 @@ void QMP6988Component::write_filter_(unsigned char filter) { delay(10); } -void QMP6988Component::write_oversampling_pressure_(unsigned char oversampling_p) { +void QMP6988Component::write_oversampling_pressure_(QMP6988Oversampling oversampling_p) { uint8_t data; this->read_register(QMP6988_CTRLMEAS_REG, &data, 1); @@ -292,7 +265,7 @@ void QMP6988Component::write_oversampling_pressure_(unsigned char oversampling_p delay(10); } -void QMP6988Component::write_oversampling_temperature_(unsigned char oversampling_t) { +void QMP6988Component::write_oversampling_temperature_(QMP6988Oversampling oversampling_t) { uint8_t data; this->read_register(QMP6988_CTRLMEAS_REG, &data, 1); @@ -302,16 +275,6 @@ void QMP6988Component::write_oversampling_temperature_(unsigned char oversamplin delay(10); } -void QMP6988Component::set_temperature_oversampling(QMP6988Oversampling oversampling_t) { - this->temperature_oversampling_ = oversampling_t; -} - -void QMP6988Component::set_pressure_oversampling(QMP6988Oversampling oversampling_p) { - this->pressure_oversampling_ = oversampling_p; -} - -void QMP6988Component::set_iir_filter(QMP6988IIRFilter iirfilter) { this->iir_filter_ = iirfilter; } - void QMP6988Component::calculate_altitude_(float pressure, float temp) { float altitude; altitude = (pow((101325 / pressure), 1 / 5.257) - 1) * (temp + 273.15) / 0.0065; @@ -320,10 +283,10 @@ void QMP6988Component::calculate_altitude_(float pressure, float temp) { void QMP6988Component::calculate_pressure_() { uint8_t err = 0; - QMP6988_U32_t p_read, t_read; - QMP6988_S32_t p_raw, t_raw; + uint32_t p_read, t_read; + int32_t p_raw, t_raw; uint8_t a_data_uint8_tr[6] = {0}; - QMP6988_S32_t t_int, p_int; + int32_t t_int, p_int; this->qmp6988_data_.temperature = 0; this->qmp6988_data_.pressure = 0; @@ -332,13 +295,11 @@ void QMP6988Component::calculate_pressure_() { ESP_LOGE(TAG, "Error reading raw pressure/temp values"); return; } - p_read = (QMP6988_U32_t) ((((QMP6988_U32_t) (a_data_uint8_tr[0])) << SHIFT_LEFT_16_POSITION) | - (((QMP6988_U16_t) (a_data_uint8_tr[1])) << SHIFT_LEFT_8_POSITION) | (a_data_uint8_tr[2])); - p_raw = (QMP6988_S32_t) (p_read - SUBTRACTOR); + p_read = encode_uint24(a_data_uint8_tr[0], a_data_uint8_tr[1], a_data_uint8_tr[2]); + p_raw = (int32_t) (p_read - SUBTRACTOR); - t_read = (QMP6988_U32_t) ((((QMP6988_U32_t) (a_data_uint8_tr[3])) << SHIFT_LEFT_16_POSITION) | - (((QMP6988_U16_t) (a_data_uint8_tr[4])) << SHIFT_LEFT_8_POSITION) | (a_data_uint8_tr[5])); - t_raw = (QMP6988_S32_t) (t_read - SUBTRACTOR); + t_read = encode_uint24(a_data_uint8_tr[3], a_data_uint8_tr[4], a_data_uint8_tr[5]); + t_raw = (int32_t) (t_read - SUBTRACTOR); t_int = this->get_compensated_temperature_(&(qmp6988_data_.ik), t_raw); p_int = this->get_compensated_pressure_(&(qmp6988_data_.ik), p_raw, t_int); @@ -348,10 +309,9 @@ void QMP6988Component::calculate_pressure_() { } void QMP6988Component::setup() { - bool ret; - ret = this->device_check_(); - if (!ret) { - ESP_LOGCONFIG(TAG, "Setup failed - device not found"); + if (!this->device_check_()) { + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + return; } this->software_reset_(); @@ -365,9 +325,6 @@ void QMP6988Component::setup() { void QMP6988Component::dump_config() { ESP_LOGCONFIG(TAG, "QMP6988:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); @@ -377,8 +334,6 @@ void QMP6988Component::dump_config() { ESP_LOGCONFIG(TAG, " IIR Filter: %s", iir_filter_to_str(this->iir_filter_)); } -float QMP6988Component::get_setup_priority() const { return setup_priority::DATA; } - void QMP6988Component::update() { this->calculate_pressure_(); float pressurehectopascals = this->qmp6988_data_.pressure / 100; diff --git a/esphome/components/qmp6988/qmp6988.h b/esphome/components/qmp6988/qmp6988.h index 61b46a4189..5b0f80c77e 100644 --- a/esphome/components/qmp6988/qmp6988.h +++ b/esphome/components/qmp6988/qmp6988.h @@ -1,24 +1,17 @@ #pragma once +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esphome/components/sensor/sensor.h" -#include "esphome/components/i2c/i2c.h" namespace esphome { namespace qmp6988 { -#define QMP6988_U16_t unsigned short -#define QMP6988_S16_t short -#define QMP6988_U32_t unsigned int -#define QMP6988_S32_t int -#define QMP6988_U64_t unsigned long long -#define QMP6988_S64_t long long - /* oversampling */ -enum QMP6988Oversampling { +enum QMP6988Oversampling : uint8_t { QMP6988_OVERSAMPLING_SKIPPED = 0x00, QMP6988_OVERSAMPLING_1X = 0x01, QMP6988_OVERSAMPLING_2X = 0x02, @@ -30,7 +23,7 @@ enum QMP6988Oversampling { }; /* filter */ -enum QMP6988IIRFilter { +enum QMP6988IIRFilter : uint8_t { QMP6988_IIR_FILTER_OFF = 0x00, QMP6988_IIR_FILTER_2X = 0x01, QMP6988_IIR_FILTER_4X = 0x02, @@ -40,18 +33,18 @@ enum QMP6988IIRFilter { }; using qmp6988_cali_data_t = struct Qmp6988CaliData { - QMP6988_S32_t COE_a0; - QMP6988_S16_t COE_a1; - QMP6988_S16_t COE_a2; - QMP6988_S32_t COE_b00; - QMP6988_S16_t COE_bt1; - QMP6988_S16_t COE_bt2; - QMP6988_S16_t COE_bp1; - QMP6988_S16_t COE_b11; - QMP6988_S16_t COE_bp2; - QMP6988_S16_t COE_b12; - QMP6988_S16_t COE_b21; - QMP6988_S16_t COE_bp3; + int32_t COE_a0; + int16_t COE_a1; + int16_t COE_a2; + int32_t COE_b00; + int16_t COE_bt1; + int16_t COE_bt2; + int16_t COE_bp1; + int16_t COE_b11; + int16_t COE_bp2; + int16_t COE_b12; + int16_t COE_b21; + int16_t COE_bp3; }; using qmp6988_fk_data_t = struct Qmp6988FkData { @@ -60,9 +53,9 @@ using qmp6988_fk_data_t = struct Qmp6988FkData { }; using qmp6988_ik_data_t = struct Qmp6988IkData { - QMP6988_S32_t a0, b00; - QMP6988_S32_t a1, a2; - QMP6988_S64_t bt1, bt2, bp1, b11, bp2, b12, b21, bp3; + int32_t a0, b00; + int32_t a1, a2; + int64_t bt1, bt2, bp1, b11, bp2, b12, b21, bp3; }; using qmp6988_data_t = struct Qmp6988Data { @@ -77,17 +70,18 @@ using qmp6988_data_t = struct Qmp6988Data { class QMP6988Component : public PollingComponent, public i2c::I2CDevice { public: - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } - void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } + void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } + void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } void setup() override; void dump_config() override; - float get_setup_priority() const override; void update() override; - void set_iir_filter(QMP6988IIRFilter iirfilter); - void set_temperature_oversampling(QMP6988Oversampling oversampling_t); - void set_pressure_oversampling(QMP6988Oversampling oversampling_p); + void set_iir_filter(QMP6988IIRFilter iirfilter) { this->iir_filter_ = iirfilter; } + void set_temperature_oversampling(QMP6988Oversampling oversampling_t) { + this->temperature_oversampling_ = oversampling_t; + } + void set_pressure_oversampling(QMP6988Oversampling oversampling_p) { this->pressure_oversampling_ = oversampling_p; } protected: qmp6988_data_t qmp6988_data_; @@ -102,14 +96,14 @@ class QMP6988Component : public PollingComponent, public i2c::I2CDevice { bool get_calibration_data_(); bool device_check_(); void set_power_mode_(uint8_t power_mode); - void write_oversampling_temperature_(unsigned char oversampling_t); - void write_oversampling_pressure_(unsigned char oversampling_p); - void write_filter_(unsigned char filter); + void write_oversampling_temperature_(QMP6988Oversampling oversampling_t); + void write_oversampling_pressure_(QMP6988Oversampling oversampling_p); + void write_filter_(QMP6988IIRFilter filter); void calculate_pressure_(); void calculate_altitude_(float pressure, float temp); - QMP6988_S32_t get_compensated_pressure_(qmp6988_ik_data_t *ik, QMP6988_S32_t dp, QMP6988_S16_t tx); - QMP6988_S16_t get_compensated_temperature_(qmp6988_ik_data_t *ik, QMP6988_S32_t dt); + int32_t get_compensated_pressure_(qmp6988_ik_data_t *ik, int32_t dp, int16_t tx); + int16_t get_compensated_temperature_(qmp6988_ik_data_t *ik, int32_t dt); }; } // namespace qmp6988 From b43ca2bbab22d03c15aca304ede3381c3c78753e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 22:10:15 -0500 Subject: [PATCH 0519/1626] [api] Optimize message buffer allocation and eliminate redundant methods --- esphome/components/api/api_connection.cpp | 50 +++++++++++------------ esphome/components/api/api_connection.h | 31 -------------- 2 files changed, 25 insertions(+), 56 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cdeabb5cac..6e2af9a783 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -281,34 +281,42 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess const uint8_t header_padding = conn->helper_->frame_header_padding(); const uint8_t footer_size = conn->helper_->frame_footer_size(); - // Calculate total size with padding for buffer allocation - size_t total_calculated_size = calculated_size + header_padding + footer_size; + // Calculate total size with padding + size_t total_size = calculated_size + header_padding + footer_size; // Check if it fits - if (total_calculated_size > remaining_size) { + if (total_size > remaining_size) { return 0; // Doesn't fit } - // Allocate buffer space - pass payload size, allocation functions add header/footer space - ProtoWriteBuffer buffer = is_single ? conn->allocate_single_message_buffer(calculated_size) - : conn->allocate_batch_message_buffer(calculated_size); - - // Get buffer size after allocation (which includes header padding) + // Get buffer and prepare it inline std::vector &shared_buf = conn->parent_->get_shared_buffer_ref(); - size_t size_before_encode = shared_buf.size(); + + if (is_single || conn->flags_.batch_first_message) { + // Single message or first batch message + shared_buf.clear(); + shared_buf.reserve(total_size); + shared_buf.resize(header_padding); + if (conn->flags_.batch_first_message) { + conn->flags_.batch_first_message = false; + } + } else { + // Batch message second or later + // Add padding for previous message footer + this message header + size_t current_size = shared_buf.size(); + shared_buf.reserve(current_size + total_size); + shared_buf.resize(current_size + footer_size + header_padding); + } // Encode directly into buffer + ProtoWriteBuffer buffer{&shared_buf}; + size_t size_before_encode = shared_buf.size(); msg.encode(buffer); - // Calculate actual encoded size (not including header that was already added) size_t actual_payload_size = shared_buf.size() - size_before_encode; - - // Return actual total size (header + actual payload + footer) - size_t actual_total_size = header_padding + actual_payload_size + footer_size; - - // Verify that calculate_size() returned the correct value assert(calculated_size == actual_payload_size); - return static_cast(actual_total_size); + + return static_cast(total_size); } #ifdef USE_BINARY_SENSOR @@ -1620,14 +1628,6 @@ bool APIConnection::schedule_batch_() { return true; } -ProtoWriteBuffer APIConnection::allocate_single_message_buffer(uint16_t size) { return this->create_buffer(size); } - -ProtoWriteBuffer APIConnection::allocate_batch_message_buffer(uint16_t size) { - ProtoWriteBuffer result = this->prepare_message_buffer(size, this->flags_.batch_first_message); - this->flags_.batch_first_message = false; - return result; -} - void APIConnection::process_batch_() { // Ensure PacketInfo remains trivially destructible for our placement new approach static_assert(std::is_trivially_destructible::value, @@ -1735,7 +1735,7 @@ void APIConnection::process_batch_() { } remaining_size -= payload_size; // Calculate where the next message's header padding will start - // Current buffer size + footer space (that prepare_message_buffer will add for this message) + // Current buffer size + footer space for this message current_offset = shared_buf.size() + footer_size; } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index f0f308c248..09b2f15e7a 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -265,42 +265,11 @@ class APIConnection : public APIServerConnection { return {&shared_buf}; } - // Prepare buffer for next message in batch - ProtoWriteBuffer prepare_message_buffer(uint16_t message_size, bool is_first_message) { - // Get reference to shared buffer (it maintains state between batch messages) - std::vector &shared_buf = this->parent_->get_shared_buffer_ref(); - - if (is_first_message) { - shared_buf.clear(); - } - - size_t current_size = shared_buf.size(); - - // Calculate padding to add: - // - First message: just header padding - // - Subsequent messages: footer for previous message + header padding for this message - size_t padding_to_add = is_first_message - ? this->helper_->frame_header_padding() - : this->helper_->frame_header_padding() + this->helper_->frame_footer_size(); - - // Reserve space for padding + message - shared_buf.reserve(current_size + padding_to_add + message_size); - - // Resize to add the padding bytes - shared_buf.resize(current_size + padding_to_add); - - return {&shared_buf}; - } - bool try_to_clear_buffer(bool log_out_of_space); bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override; std::string get_client_combined_info() const { return this->client_info_.get_combined_info(); } - // Buffer allocator methods for batch processing - ProtoWriteBuffer allocate_single_message_buffer(uint16_t size); - ProtoWriteBuffer allocate_batch_message_buffer(uint16_t size); - protected: // Helper function to handle authentication completion void complete_authentication_(); From 3346e09785bc943fd0144ef1bbdad1bb2d9c7196 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 22:23:47 -0500 Subject: [PATCH 0520/1626] preen --- esphome/components/api/api_connection.cpp | 4 +--- esphome/components/api/api_connection.h | 10 +++++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 6e2af9a783..9c0d47bca1 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -294,9 +294,7 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess if (is_single || conn->flags_.batch_first_message) { // Single message or first batch message - shared_buf.clear(); - shared_buf.reserve(total_size); - shared_buf.resize(header_padding); + conn->prepare_first_message_buffer(shared_buf, header_padding, total_size); if (conn->flags_.batch_first_message) { conn->flags_.batch_first_message = false; } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 09b2f15e7a..076dccfad7 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -252,17 +252,21 @@ class APIConnection : public APIServerConnection { // Get header padding size - used for both reserve and insert uint8_t header_padding = this->helper_->frame_header_padding(); - // Get shared buffer from parent server std::vector &shared_buf = this->parent_->get_shared_buffer_ref(); + this->prepare_first_message_buffer(shared_buf, header_padding, + reserve_size + header_padding + this->helper_->frame_footer_size()); + return {&shared_buf}; + } + + void prepare_first_message_buffer(std::vector &shared_buf, size_t header_padding, size_t total_size) { shared_buf.clear(); // Reserve space for header padding + message + footer // - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext) // - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext) - shared_buf.reserve(reserve_size + header_padding + this->helper_->frame_footer_size()); + shared_buf.reserve(total_size); // Resize to add header padding so message encoding starts at the correct position shared_buf.resize(header_padding); - return {&shared_buf}; } bool try_to_clear_buffer(bool log_out_of_space); From f0decc4716941139d3ee94220df2eba6847ca4ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 22:42:08 -0500 Subject: [PATCH 0521/1626] tweak --- esphome/components/api/api_connection.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 9c0d47bca1..64abcf2e26 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -311,10 +311,15 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess size_t size_before_encode = shared_buf.size(); msg.encode(buffer); + // Calculate actual encoded size (not including header that was already added) size_t actual_payload_size = shared_buf.size() - size_before_encode; - assert(calculated_size == actual_payload_size); - return static_cast(total_size); + // Return actual total size (header + actual payload + footer) + size_t actual_total_size = header_padding + actual_payload_size + footer_size; + + // Verify that calculate_size() returned the correct value + assert(calculated_size == actual_payload_size); + return static_cast(actual_total_size); } #ifdef USE_BINARY_SENSOR From d37390412cf18c4d86828ab7785a3b3e0b108022 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 22:52:51 -0500 Subject: [PATCH 0522/1626] preen --- esphome/components/api/api_connection.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 64abcf2e26..81cac8fa79 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -282,19 +282,20 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess const uint8_t footer_size = conn->helper_->frame_footer_size(); // Calculate total size with padding - size_t total_size = calculated_size + header_padding + footer_size; + size_t total_calculated_size = calculated_size + header_padding + footer_size; // Check if it fits - if (total_size > remaining_size) { + if (total_calculated_size > remaining_size) { return 0; // Doesn't fit } // Get buffer and prepare it inline std::vector &shared_buf = conn->parent_->get_shared_buffer_ref(); + size_t size_before_encode = shared_buf.size(); if (is_single || conn->flags_.batch_first_message) { // Single message or first batch message - conn->prepare_first_message_buffer(shared_buf, header_padding, total_size); + conn->prepare_first_message_buffer(shared_buf, header_padding, total_calculated_size); if (conn->flags_.batch_first_message) { conn->flags_.batch_first_message = false; } @@ -302,13 +303,12 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess // Batch message second or later // Add padding for previous message footer + this message header size_t current_size = shared_buf.size(); - shared_buf.reserve(current_size + total_size); + shared_buf.reserve(current_size + total_calculated_size); shared_buf.resize(current_size + footer_size + header_padding); } // Encode directly into buffer ProtoWriteBuffer buffer{&shared_buf}; - size_t size_before_encode = shared_buf.size(); msg.encode(buffer); // Calculate actual encoded size (not including header that was already added) From deff1c4bc7fe9f5a7ca7f6b7fbbda40e88f7c50b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 22:53:12 -0500 Subject: [PATCH 0523/1626] preen --- esphome/components/api/api_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 81cac8fa79..c8600048f7 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -281,7 +281,7 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess const uint8_t header_padding = conn->helper_->frame_header_padding(); const uint8_t footer_size = conn->helper_->frame_footer_size(); - // Calculate total size with padding + // Calculate total size with padding for buffer allocation size_t total_calculated_size = calculated_size + header_padding + footer_size; // Check if it fits From 97c405b57e4a83258a8524634a4221d6e788b73c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 22:53:33 -0500 Subject: [PATCH 0524/1626] preen --- esphome/components/api/api_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c8600048f7..c8e6480e1e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -289,7 +289,7 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess return 0; // Doesn't fit } - // Get buffer and prepare it inline + // Get buffer size after allocation (which includes header padding) std::vector &shared_buf = conn->parent_->get_shared_buffer_ref(); size_t size_before_encode = shared_buf.size(); From 51bf2c35116b6a6494a076a12f9149401253278a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 22:55:06 -0500 Subject: [PATCH 0525/1626] preen --- esphome/components/api/api_connection.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c8e6480e1e..fc4bf6b4f2 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -302,9 +302,8 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess } else { // Batch message second or later // Add padding for previous message footer + this message header - size_t current_size = shared_buf.size(); - shared_buf.reserve(current_size + total_calculated_size); - shared_buf.resize(current_size + footer_size + header_padding); + shared_buf.reserve(size_before_encode + total_calculated_size); + shared_buf.resize(size_before_encode + footer_size + header_padding); } // Encode directly into buffer From 0207444765f6bebc3daf654cf87bec09d54661b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 22:58:04 -0500 Subject: [PATCH 0526/1626] preen --- esphome/components/api/api_connection.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index fc4bf6b4f2..75c033cc76 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -307,8 +307,7 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess } // Encode directly into buffer - ProtoWriteBuffer buffer{&shared_buf}; - msg.encode(buffer); + msg.encode({&shared_buf}); // Calculate actual encoded size (not including header that was already added) size_t actual_payload_size = shared_buf.size() - size_before_encode; From d83ed9ebe1d41fffa08efbe36e9f7751a1d65efb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 23:04:38 -0500 Subject: [PATCH 0527/1626] Revert "preen" This reverts commit 0207444765f6bebc3daf654cf87bec09d54661b1. --- esphome/components/api/api_connection.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 75c033cc76..fc4bf6b4f2 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -307,7 +307,8 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess } // Encode directly into buffer - msg.encode({&shared_buf}); + ProtoWriteBuffer buffer{&shared_buf}; + msg.encode(buffer); // Calculate actual encoded size (not including header that was already added) size_t actual_payload_size = shared_buf.size() - size_before_encode; From 58074e03577432aea96821b18825a8a48e7e5034 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 23:05:12 -0500 Subject: [PATCH 0528/1626] Revert "preen" This reverts commit 51bf2c35116b6a6494a076a12f9149401253278a. --- esphome/components/api/api_connection.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index fc4bf6b4f2..c8e6480e1e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -302,8 +302,9 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess } else { // Batch message second or later // Add padding for previous message footer + this message header - shared_buf.reserve(size_before_encode + total_calculated_size); - shared_buf.resize(size_before_encode + footer_size + header_padding); + size_t current_size = shared_buf.size(); + shared_buf.reserve(current_size + total_calculated_size); + shared_buf.resize(current_size + footer_size + header_padding); } // Encode directly into buffer From 09fa349349caf7c495755f01971eb84d36824737 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 23:06:23 -0500 Subject: [PATCH 0529/1626] Revert "Revert "preen"" This reverts commit d83ed9ebe1d41fffa08efbe36e9f7751a1d65efb. --- esphome/components/api/api_connection.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c8e6480e1e..9557c9943b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -308,8 +308,7 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess } // Encode directly into buffer - ProtoWriteBuffer buffer{&shared_buf}; - msg.encode(buffer); + msg.encode({&shared_buf}); // Calculate actual encoded size (not including header that was already added) size_t actual_payload_size = shared_buf.size() - size_before_encode; From 9bcd6c7a8546d01ad1efd11b16e6209625a5a29d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Aug 2025 23:07:22 -0500 Subject: [PATCH 0530/1626] fix --- esphome/components/api/api_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 9557c9943b..ced0f489be 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -291,7 +291,6 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess // Get buffer size after allocation (which includes header padding) std::vector &shared_buf = conn->parent_->get_shared_buffer_ref(); - size_t size_before_encode = shared_buf.size(); if (is_single || conn->flags_.batch_first_message) { // Single message or first batch message @@ -308,6 +307,7 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess } // Encode directly into buffer + size_t size_before_encode = shared_buf.size(); msg.encode({&shared_buf}); // Calculate actual encoded size (not including header that was already added) From bd60dbb74608e1eaf89dbfd3fdc81f14cb4d30ad Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 15 Aug 2025 01:48:25 +1200 Subject: [PATCH 0531/1626] [quality] Remove period from audio related Invalid raises (#10229) --- esphome/components/i2s_audio/__init__.py | 2 +- esphome/components/i2s_audio/media_player/__init__.py | 2 +- esphome/components/i2s_audio/microphone/__init__.py | 2 +- esphome/components/i2s_audio/speaker/__init__.py | 2 +- esphome/components/micro_wake_word/__init__.py | 4 ++-- esphome/components/speaker/media_player/__init__.py | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index aa0a688fa0..bfa1b726f1 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -212,7 +212,7 @@ def validate_use_legacy(value): f"All i2s_audio components must set {CONF_USE_LEGACY} to the same value." ) if (not value[CONF_USE_LEGACY]) and (CORE.using_arduino): - raise cv.Invalid("Arduino supports only the legacy i2s driver.") + raise cv.Invalid("Arduino supports only the legacy i2s driver") _use_legacy_driver = value[CONF_USE_LEGACY] return value diff --git a/esphome/components/i2s_audio/media_player/__init__.py b/esphome/components/i2s_audio/media_player/__init__.py index ad6665a5f5..316ce7c48b 100644 --- a/esphome/components/i2s_audio/media_player/__init__.py +++ b/esphome/components/i2s_audio/media_player/__init__.py @@ -92,7 +92,7 @@ CONFIG_SCHEMA = cv.All( def _final_validate(_): if not use_legacy(): - raise cv.Invalid("I2S media player is only compatible with legacy i2s driver.") + raise cv.Invalid("I2S media player is only compatible with legacy i2s driver") FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 0f02ba6c3a..f919199c60 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -122,7 +122,7 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config): if not use_legacy() and config[CONF_ADC_TYPE] == "internal": - raise cv.Invalid("Internal ADC is only compatible with legacy i2s driver.") + raise cv.Invalid("Internal ADC is only compatible with legacy i2s driver") FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index cb7b876a40..98322d3a18 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -163,7 +163,7 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config): if not use_legacy(): if config[CONF_DAC_TYPE] == "internal": - raise cv.Invalid("Internal DAC is only compatible with legacy i2s driver.") + raise cv.Invalid("Internal DAC is only compatible with legacy i2s driver") if config[CONF_I2S_COMM_FMT] == "stand_max": raise cv.Invalid( "I2S standard max format only implemented with legacy i2s driver." diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index cde8752157..8cd7115368 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -201,7 +201,7 @@ def _validate_manifest_version(manifest_data): else: raise cv.Invalid("Invalid manifest version") else: - raise cv.Invalid("Invalid manifest file, missing 'version' key.") + raise cv.Invalid("Invalid manifest file, missing 'version' key") def _process_http_source(config): @@ -421,7 +421,7 @@ def _feature_step_size_validate(config): if features_step_size is None: features_step_size = model_step_size elif features_step_size != model_step_size: - raise cv.Invalid("Cannot load models with different features step sizes.") + raise cv.Invalid("Cannot load models with different features step sizes") FINAL_VALIDATE_SCHEMA = cv.All( diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 3ae7b980d3..69ea0a53c6 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -147,7 +147,7 @@ def _read_audio_file_and_type(file_config): elif file_source == TYPE_WEB: path = _compute_local_file_path(conf_file) else: - raise cv.Invalid("Unsupported file source.") + raise cv.Invalid("Unsupported file source") with open(path, "rb") as f: data = f.read() @@ -219,7 +219,7 @@ def _validate_supported_local_file(config): for file_config in config.get(CONF_FILES, []): _, media_file_type = _read_audio_file_and_type(file_config) if str(media_file_type) == str(audio.AUDIO_FILE_TYPE_ENUM["NONE"]): - raise cv.Invalid("Unsupported local media file.") + raise cv.Invalid("Unsupported local media file") if not config[CONF_CODEC_SUPPORT_ENABLED] and str(media_file_type) != str( audio.AUDIO_FILE_TYPE_ENUM["WAV"] ): From 71efaf097b4f86da7a752e12e2860329d842bfc1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 15 Aug 2025 01:49:14 +1200 Subject: [PATCH 0532/1626] [esp32_ble] Add ``USE_ESP32_BLE_UUID`` when advertising is desired (#10230) --- esphome/components/esp32_ble/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c758b3ef8f..2edd69c6c0 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -294,6 +294,7 @@ async def to_code(config): if config[CONF_ADVERTISING]: cg.add_define("USE_ESP32_BLE_ADVERTISING") + cg.add_define("USE_ESP32_BLE_UUID") @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) From ef07d3e0c882328044b68fa96fa4e2c44b61e422 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 09:34:41 -0500 Subject: [PATCH 0533/1626] [core] Trigger clean build when components are removed from configuration --- esphome/writer.py | 14 +++- tests/unit_tests/test_writer.py | 139 ++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/test_writer.py diff --git a/esphome/writer.py b/esphome/writer.py index b5c834722a..17fd3cfc48 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -86,7 +86,10 @@ def storage_should_clean(old: StorageJSON, new: StorageJSON) -> bool: if old.src_version != new.src_version: return True - return old.build_path != new.build_path + if old.build_path != new.build_path: + return True + # Check if any components have been removed + return bool(old.loaded_integrations - new.loaded_integrations) def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> bool: @@ -108,7 +111,14 @@ def update_storage_json(): return if storage_should_clean(old, new): - _LOGGER.info("Core config, version changed, cleaning build files...") + if old and old.loaded_integrations - new.loaded_integrations: + removed = old.loaded_integrations - new.loaded_integrations + _LOGGER.info( + "Components removed (%s), cleaning build files...", + ", ".join(sorted(removed)), + ) + else: + _LOGGER.info("Core config or version changed, cleaning build files...") clean_build() elif storage_should_update_cmake_cache(old, new): _LOGGER.info("Integrations changed, cleaning cmake cache...") diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py new file mode 100644 index 0000000000..7f9f97826f --- /dev/null +++ b/tests/unit_tests/test_writer.py @@ -0,0 +1,139 @@ +"""Test writer module functionality.""" + +from collections.abc import Callable +from typing import Any + +import pytest + +from esphome.storage_json import StorageJSON +from esphome.writer import storage_should_clean + + +@pytest.fixture +def create_storage() -> Callable[..., StorageJSON]: + """Factory fixture to create StorageJSON instances.""" + + def _create( + loaded_integrations: list[str] | None = None, **kwargs: Any + ) -> StorageJSON: + return StorageJSON( + storage_version=kwargs.get("storage_version", 1), + name=kwargs.get("name", "test"), + friendly_name=kwargs.get("friendly_name", "Test Device"), + comment=kwargs.get("comment"), + esphome_version=kwargs.get("esphome_version", "2025.1.0"), + src_version=kwargs.get("src_version", 1), + address=kwargs.get("address", "test.local"), + web_port=kwargs.get("web_port", 80), + target_platform=kwargs.get("target_platform", "ESP32"), + build_path=kwargs.get("build_path", "/build"), + firmware_bin_path=kwargs.get("firmware_bin_path", "/firmware.bin"), + loaded_integrations=set(loaded_integrations or []), + loaded_platforms=kwargs.get("loaded_platforms", set()), + no_mdns=kwargs.get("no_mdns", False), + framework=kwargs.get("framework", "arduino"), + core_platform=kwargs.get("core_platform", "esp32"), + ) + + return _create + + +def test_storage_should_clean_when_old_is_none( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when old storage is None.""" + new = create_storage(loaded_integrations=["api", "wifi"]) + assert storage_should_clean(None, new) is True + + +def test_storage_should_clean_when_src_version_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when src_version changes.""" + old = create_storage(loaded_integrations=["api", "wifi"], src_version=1) + new = create_storage(loaded_integrations=["api", "wifi"], src_version=2) + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_build_path_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when build_path changes.""" + old = create_storage(loaded_integrations=["api", "wifi"], build_path="/build1") + new = create_storage(loaded_integrations=["api", "wifi"], build_path="/build2") + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_component_removed( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when a component is removed.""" + old = create_storage( + loaded_integrations=["api", "wifi", "bluetooth_proxy", "esp32_ble_tracker"] + ) + new = create_storage(loaded_integrations=["api", "wifi", "esp32_ble_tracker"]) + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_multiple_components_removed( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when multiple components are removed.""" + old = create_storage( + loaded_integrations=["api", "wifi", "ota", "web_server", "logger"] + ) + new = create_storage(loaded_integrations=["api", "wifi", "logger"]) + assert storage_should_clean(old, new) is True + + +def test_storage_should_not_clean_when_nothing_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is not triggered when nothing changes.""" + old = create_storage(loaded_integrations=["api", "wifi", "logger"]) + new = create_storage(loaded_integrations=["api", "wifi", "logger"]) + assert storage_should_clean(old, new) is False + + +def test_storage_should_not_clean_when_component_added( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is not triggered when a component is only added.""" + old = create_storage(loaded_integrations=["api", "wifi"]) + new = create_storage(loaded_integrations=["api", "wifi", "ota"]) + assert storage_should_clean(old, new) is False + + +def test_storage_should_not_clean_when_other_fields_change( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is not triggered when non-relevant fields change.""" + old = create_storage( + loaded_integrations=["api", "wifi"], + friendly_name="Old Name", + esphome_version="2024.12.0", + ) + new = create_storage( + loaded_integrations=["api", "wifi"], + friendly_name="New Name", + esphome_version="2025.1.0", + ) + assert storage_should_clean(old, new) is False + + +def test_storage_edge_case_empty_integrations( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test edge case when old has integrations but new has none.""" + old = create_storage(loaded_integrations=["api", "wifi"]) + new = create_storage(loaded_integrations=[]) + assert storage_should_clean(old, new) is True + + +def test_storage_edge_case_from_empty_integrations( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test edge case when old has no integrations but new has some.""" + old = create_storage(loaded_integrations=[]) + new = create_storage(loaded_integrations=["api", "wifi"]) + assert storage_should_clean(old, new) is False From d42d9fa41e10bcd45c66ea67aa0da2081d121fec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 10:05:11 -0500 Subject: [PATCH 0534/1626] Update esphome/writer.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/writer.py b/esphome/writer.py index 17fd3cfc48..990602e91e 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -111,7 +111,7 @@ def update_storage_json(): return if storage_should_clean(old, new): - if old and old.loaded_integrations - new.loaded_integrations: + if old.loaded_integrations - new.loaded_integrations: removed = old.loaded_integrations - new.loaded_integrations _LOGGER.info( "Components removed (%s), cleaning build files...", From 7e7bfb00aa1a4a4fd5e61400e63173a6352c3ca6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 10:06:48 -0500 Subject: [PATCH 0535/1626] fix typing --- esphome/writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/writer.py b/esphome/writer.py index 990602e91e..974c5901e0 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -80,7 +80,7 @@ def replace_file_content(text, pattern, repl): return content_new, count -def storage_should_clean(old: StorageJSON, new: StorageJSON) -> bool: +def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: if old is None: return True From 6a9dcc7d76d426273a19e2620afc0e939e686597 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 14:20:55 -0500 Subject: [PATCH 0536/1626] merge --- .../components/captive_portal/captive_index.h | 174 ++++++++---------- 1 file changed, 77 insertions(+), 97 deletions(-) diff --git a/esphome/components/captive_portal/captive_index.h b/esphome/components/captive_portal/captive_index.h index 8835762fb3..407ab86a60 100644 --- a/esphome/components/captive_portal/captive_index.h +++ b/esphome/components/captive_portal/captive_index.h @@ -7,103 +7,83 @@ namespace esphome { namespace captive_portal { const uint8_t INDEX_GZ[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xdd, 0x58, 0x6d, 0x6f, 0xdb, 0x38, 0x12, 0xfe, 0xde, - 0x5f, 0x31, 0xa7, 0x36, 0x6b, 0x6b, 0x1b, 0x51, 0x22, 0xe5, 0xb7, 0xd8, 0x92, 0x16, 0x69, 0xae, 0x8b, 0x5d, 0xa0, - 0xdd, 0x2d, 0x90, 0x6c, 0xef, 0x43, 0x51, 0x20, 0xb4, 0x34, 0xb2, 0xd8, 0x48, 0xa4, 0x4e, 0xa4, 0x5f, 0x52, 0xc3, - 0xf7, 0xdb, 0x0f, 0x94, 0x6c, 0xc7, 0xe9, 0x35, 0x87, 0xeb, 0xe2, 0x0e, 0x87, 0xdd, 0x18, 0x21, 0x86, 0xe4, 0xcc, - 0x70, 0xe6, 0xf1, 0x0c, 0x67, 0xcc, 0xe8, 0x2f, 0x99, 0x4a, 0xcd, 0x7d, 0x8d, 0x50, 0x98, 0xaa, 0x4c, 0x22, 0x3b, - 0x42, 0xc9, 0xe5, 0x22, 0x46, 0x99, 0x44, 0x05, 0xf2, 0x2c, 0x89, 0x2a, 0x34, 0x1c, 0xd2, 0x82, 0x37, 0x1a, 0x4d, - 0xfc, 0xdb, 0xcd, 0x8f, 0xde, 0x04, 0xfc, 0x24, 0x2a, 0x85, 0xbc, 0x83, 0x06, 0xcb, 0x58, 0xa4, 0x4a, 0x42, 0xd1, - 0x60, 0x1e, 0x67, 0xdc, 0xf0, 0xa9, 0xa8, 0xf8, 0x02, 0x2d, 0x43, 0x2b, 0x26, 0x79, 0x85, 0xf1, 0x4a, 0xe0, 0xba, - 0x56, 0x8d, 0x81, 0x54, 0x49, 0x83, 0xd2, 0xc4, 0xce, 0x5a, 0x64, 0xa6, 0x88, 0x33, 0x5c, 0x89, 0x14, 0xbd, 0x76, - 0x72, 0x2e, 0xa4, 0x30, 0x82, 0x97, 0x9e, 0x4e, 0x79, 0x89, 0x31, 0x3d, 0x5f, 0x6a, 0x6c, 0xda, 0x09, 0x9f, 0x97, - 0x18, 0x4b, 0xe5, 0xf8, 0x49, 0xa4, 0xd3, 0x46, 0xd4, 0x06, 0xac, 0xbd, 0x71, 0xa5, 0xb2, 0x65, 0x89, 0x89, 0xef, - 0x73, 0xad, 0xd1, 0x68, 0x5f, 0xc8, 0x0c, 0x37, 0x64, 0x14, 0x86, 0x29, 0xe3, 0xe3, 0x9c, 0x7c, 0xd2, 0xcf, 0x32, - 0x95, 0x2e, 0x2b, 0x94, 0x86, 0x94, 0x2a, 0xe5, 0x46, 0x28, 0x49, 0x34, 0xf2, 0x26, 0x2d, 0xe2, 0x38, 0x76, 0x7e, - 0xd0, 0x7c, 0x85, 0xce, 0x77, 0xdf, 0xf5, 0x8f, 0x4c, 0x0b, 0x34, 0xaf, 0x4b, 0xb4, 0xa4, 0x7e, 0x75, 0x7f, 0xc3, - 0x17, 0xbf, 0xf0, 0x0a, 0xfb, 0x0e, 0xd7, 0x22, 0x43, 0xc7, 0xfd, 0x10, 0x7c, 0x24, 0xda, 0xdc, 0x97, 0x48, 0x32, - 0xa1, 0xeb, 0x92, 0xdf, 0xc7, 0xce, 0xbc, 0x54, 0xe9, 0x9d, 0xe3, 0xce, 0xf2, 0xa5, 0x4c, 0xad, 0x72, 0xd0, 0x7d, - 0x74, 0xb7, 0x25, 0x1a, 0x30, 0xf1, 0x5b, 0x6e, 0x0a, 0x52, 0xf1, 0x4d, 0xbf, 0x23, 0x84, 0xec, 0xb3, 0xef, 0xfb, - 0xf8, 0x92, 0x06, 0x81, 0x7b, 0xde, 0x0e, 0x81, 0xeb, 0xd3, 0x20, 0x98, 0x35, 0x68, 0x96, 0x8d, 0x04, 0xde, 0xbf, - 0x8d, 0x6a, 0x6e, 0x0a, 0xc8, 0x62, 0xa7, 0xa2, 0x8c, 0x04, 0xc1, 0x04, 0xe8, 0x05, 0x61, 0x43, 0x8f, 0x52, 0x12, - 0x7a, 0x74, 0x98, 0x8e, 0xbd, 0x21, 0xd0, 0x81, 0x37, 0x04, 0xc6, 0xc8, 0x10, 0x82, 0xcf, 0x0e, 0xe4, 0xa2, 0x2c, - 0x63, 0x47, 0x2a, 0x89, 0x0e, 0x68, 0xd3, 0xa8, 0x3b, 0x8c, 0x9d, 0x74, 0xd9, 0x34, 0x28, 0xcd, 0x95, 0x2a, 0x55, - 0xe3, 0xf8, 0xc9, 0x33, 0x78, 0xf4, 0xf7, 0xcd, 0x47, 0x98, 0x86, 0x4b, 0x9d, 0xab, 0xa6, 0x8a, 0x9d, 0xf6, 0x4b, - 0xe9, 0xbf, 0xd8, 0x9a, 0x1d, 0xd8, 0xc1, 0x3d, 0xd9, 0xf4, 0x54, 0x23, 0x16, 0x42, 0xc6, 0x0e, 0x65, 0x40, 0x27, - 0x8e, 0x9f, 0xdc, 0xba, 0xbb, 0x23, 0x26, 0xdc, 0x62, 0xb2, 0xf7, 0x52, 0xf5, 0x3f, 0xdc, 0x46, 0x7a, 0xb5, 0x80, - 0x4d, 0x55, 0x4a, 0x1d, 0x3b, 0x85, 0x31, 0xf5, 0xd4, 0xf7, 0xd7, 0xeb, 0x35, 0x59, 0x87, 0x44, 0x35, 0x0b, 0x9f, - 0x05, 0x41, 0xe0, 0xeb, 0xd5, 0xc2, 0x81, 0x2e, 0x3e, 0x1c, 0x36, 0x70, 0xa0, 0x40, 0xb1, 0x28, 0x4c, 0x4b, 0x27, - 0x2f, 0xb6, 0xb8, 0x8b, 0x2c, 0x47, 0x72, 0xfb, 0xf1, 0xe4, 0x14, 0x71, 0x72, 0x0a, 0xfe, 0x70, 0x82, 0x66, 0xef, - 0xad, 0x35, 0x6a, 0xcc, 0x19, 0x30, 0x08, 0xda, 0x0f, 0xf3, 0x2c, 0xbd, 0x9f, 0x79, 0x5f, 0xcc, 0xe0, 0x64, 0x06, - 0x0c, 0x9e, 0x01, 0xb0, 0x6a, 0xe4, 0x5d, 0x1c, 0xc5, 0xa9, 0xdd, 0x5e, 0xd1, 0xe0, 0x61, 0xc1, 0xca, 0xfc, 0x34, - 0x3a, 0x9d, 0x7b, 0xec, 0xbd, 0x65, 0xb0, 0xd8, 0x1f, 0x85, 0x3c, 0x56, 0xd0, 0xf7, 0x23, 0x3e, 0x84, 0xe1, 0x7e, - 0x65, 0xe8, 0x59, 0xfa, 0x38, 0xb3, 0x27, 0xc1, 0x70, 0xc5, 0x0a, 0x5a, 0x79, 0x23, 0x6f, 0xc8, 0x43, 0x08, 0xf7, - 0x26, 0x85, 0x10, 0xae, 0x58, 0x31, 0x7a, 0x3f, 0x3a, 0x5d, 0xf3, 0xc2, 0xcf, 0x3d, 0x0b, 0xf3, 0xd4, 0x71, 0x1e, - 0x30, 0x50, 0xa7, 0x18, 0x90, 0x4f, 0x4a, 0xc8, 0xbe, 0xe3, 0xb8, 0xbb, 0x1c, 0x4d, 0x5a, 0xf4, 0x1d, 0x3f, 0x55, - 0x32, 0x17, 0x0b, 0xf2, 0x49, 0x2b, 0xe9, 0xb8, 0xc4, 0x14, 0x28, 0xfb, 0x07, 0x51, 0x2b, 0x88, 0xed, 0x4e, 0xff, - 0xcb, 0x1d, 0xe3, 0x6e, 0x8f, 0xf9, 0x61, 0x84, 0x29, 0x31, 0x36, 0xc4, 0x66, 0xf4, 0xf9, 0x71, 0x75, 0xae, 0xb2, - 0xfb, 0x27, 0x52, 0xa7, 0xa0, 0x5d, 0xde, 0x08, 0x29, 0xb1, 0xb9, 0xc1, 0x8d, 0x89, 0x9d, 0xb7, 0x97, 0x57, 0x70, - 0x99, 0x65, 0x0d, 0x6a, 0x3d, 0x05, 0xe7, 0xa5, 0x21, 0x15, 0x4f, 0xff, 0x73, 0x5d, 0xf4, 0x91, 0xae, 0xbf, 0x89, - 0x1f, 0x05, 0xfc, 0x82, 0x66, 0xad, 0x9a, 0xbb, 0xbd, 0x36, 0x6b, 0xda, 0xcc, 0x66, 0x60, 0x13, 0x1b, 0xc2, 0x6b, - 0x4d, 0x74, 0x29, 0x52, 0xec, 0x53, 0x97, 0x54, 0xbc, 0x7e, 0xf0, 0x4a, 0x1e, 0x80, 0xba, 0x8d, 0x32, 0xb1, 0x82, - 0xb4, 0xe4, 0x5a, 0xc7, 0x8e, 0xec, 0x54, 0x39, 0xb0, 0x4f, 0x1b, 0x25, 0xd3, 0x52, 0xa4, 0x77, 0xb1, 0xf3, 0x95, - 0x1b, 0xe2, 0xd5, 0xfd, 0xcf, 0x59, 0xbf, 0xa7, 0xb5, 0xc8, 0x7a, 0x2e, 0x59, 0xf1, 0x72, 0x89, 0x10, 0x83, 0x29, - 0x84, 0x7e, 0x30, 0x70, 0xf6, 0xa4, 0x58, 0xad, 0xef, 0x7a, 0x2e, 0xc9, 0x55, 0xba, 0xd4, 0x7d, 0xd7, 0x39, 0x64, - 0x69, 0xc4, 0xbb, 0x3b, 0xd4, 0x79, 0xee, 0x7c, 0x61, 0x91, 0x57, 0x62, 0x6e, 0x9c, 0x87, 0x6c, 0x7e, 0xb1, 0xd5, - 0x7d, 0x49, 0x1a, 0xad, 0x85, 0xbb, 0x3b, 0x2e, 0x46, 0xba, 0xe6, 0xf2, 0x4b, 0x41, 0x6b, 0xa0, 0x4d, 0x1a, 0x49, - 0x2c, 0x65, 0x33, 0xa7, 0xe6, 0xf2, 0x78, 0xa0, 0xcf, 0x0f, 0xe4, 0x8b, 0xad, 0xe8, 0x4b, 0x7b, 0x4b, 0xde, 0x1d, - 0x35, 0x46, 0x7e, 0x26, 0x56, 0xc9, 0xed, 0xce, 0x7d, 0xf0, 0xe3, 0xef, 0x4b, 0x6c, 0xee, 0xaf, 0xb1, 0xc4, 0xd4, - 0xa8, 0xa6, 0xef, 0x3c, 0x97, 0x68, 0x1c, 0xb7, 0x73, 0xf8, 0xa7, 0x9b, 0xb7, 0x6f, 0x62, 0xd5, 0x6f, 0xdc, 0xf3, - 0xa7, 0xb8, 0x6d, 0xb5, 0xf8, 0xd0, 0x60, 0xf9, 0x8f, 0xb8, 0x67, 0xeb, 0x45, 0xef, 0xa3, 0xe3, 0x92, 0xd6, 0xdf, - 0xdb, 0x87, 0xa2, 0x61, 0x13, 0xfb, 0xe5, 0xa6, 0x2a, 0xcf, 0xad, 0x87, 0xde, 0x68, 0xe8, 0xee, 0x6e, 0x77, 0xee, - 0xce, 0x9d, 0x45, 0x7e, 0x77, 0xef, 0x27, 0x51, 0x7b, 0x05, 0x27, 0xdf, 0x6f, 0xe7, 0x6a, 0xe3, 0x69, 0xf1, 0x59, - 0xc8, 0xc5, 0x54, 0xc8, 0x02, 0x1b, 0x61, 0x76, 0x99, 0x58, 0x9d, 0x0b, 0x59, 0x2f, 0xcd, 0xb6, 0xe6, 0x59, 0x66, - 0x77, 0x86, 0xf5, 0x66, 0x96, 0x2b, 0x69, 0x2c, 0x27, 0x4e, 0x29, 0x56, 0xbb, 0x6e, 0xbf, 0xbd, 0x5b, 0xa6, 0x17, - 0xc3, 0xb3, 0x9d, 0x0d, 0xb8, 0xad, 0xc1, 0x8d, 0xf1, 0x78, 0x29, 0x16, 0x72, 0x9a, 0xa2, 0x34, 0xd8, 0x74, 0x42, - 0x39, 0xaf, 0x44, 0x79, 0x3f, 0xd5, 0x5c, 0x6a, 0x4f, 0x63, 0x23, 0xf2, 0xdd, 0x7c, 0x69, 0x8c, 0x92, 0xdb, 0xb9, - 0x6a, 0x32, 0x6c, 0xa6, 0xc1, 0xac, 0x23, 0xbc, 0x86, 0x67, 0x62, 0xa9, 0xa7, 0x24, 0x6c, 0xb0, 0x9a, 0xcd, 0x79, - 0x7a, 0xb7, 0x68, 0xd4, 0x52, 0x66, 0x5e, 0x6a, 0x6f, 0xe1, 0xe9, 0x73, 0x9a, 0xf3, 0x10, 0xd3, 0xd9, 0x7e, 0x96, - 0xe7, 0xf9, 0xac, 0x14, 0x12, 0xbd, 0xee, 0x56, 0x9b, 0x32, 0x32, 0xb0, 0x62, 0x27, 0x66, 0x12, 0x66, 0x17, 0x3a, - 0x1b, 0x69, 0x10, 0x9c, 0xcd, 0x0e, 0xee, 0x04, 0xb3, 0x74, 0xd9, 0x68, 0xd5, 0x4c, 0x6b, 0x25, 0xac, 0x99, 0xbb, - 0x8a, 0x0b, 0x79, 0x6a, 0xbd, 0x0d, 0x93, 0xd9, 0xbe, 0x3c, 0x4d, 0x85, 0x6c, 0x8f, 0x69, 0x8b, 0xd4, 0xac, 0x12, - 0xb2, 0x2b, 0xb2, 0x53, 0x36, 0x0a, 0xea, 0xcd, 0x8e, 0xec, 0x03, 0x64, 0x7b, 0xe0, 0xce, 0x4b, 0xdc, 0xcc, 0x3e, - 0x2d, 0xb5, 0x11, 0xf9, 0xbd, 0xb7, 0x2f, 0xd2, 0x53, 0x5d, 0xf3, 0x14, 0xbd, 0x39, 0x9a, 0x35, 0xa2, 0x9c, 0xb5, - 0x67, 0x78, 0xc2, 0x60, 0xa5, 0xf7, 0x38, 0x1d, 0xd5, 0xb4, 0x01, 0xfa, 0x58, 0xd7, 0xbf, 0xe3, 0xb6, 0xb1, 0xb8, - 0xad, 0x78, 0xb3, 0x10, 0xd2, 0x9b, 0x2b, 0x63, 0x54, 0x35, 0xf5, 0xc6, 0xf5, 0x66, 0xb6, 0x5f, 0xb2, 0xca, 0xa6, - 0xd4, 0x9a, 0xd9, 0xd6, 0xde, 0x03, 0xde, 0xb4, 0xde, 0x80, 0x56, 0xa5, 0xc8, 0xf6, 0x7c, 0x2d, 0x0b, 0x04, 0x47, - 0x78, 0xe8, 0xb0, 0xde, 0x80, 0x5d, 0x3b, 0x40, 0x3d, 0xc8, 0x27, 0x9c, 0x06, 0x5f, 0xf9, 0x46, 0xb2, 0x3c, 0x67, - 0xf3, 0xfc, 0x88, 0x94, 0x2d, 0xa1, 0x3b, 0xb1, 0x8f, 0x0a, 0x36, 0xa8, 0x37, 0xb3, 0xc3, 0x77, 0x33, 0xa8, 0x37, - 0x3b, 0xd1, 0xa6, 0xc5, 0xf6, 0x44, 0x4b, 0x1b, 0xaa, 0xd3, 0x65, 0x53, 0xf6, 0x9d, 0xaf, 0x84, 0xee, 0x59, 0x78, - 0xf5, 0x50, 0xe2, 0x7a, 0x4f, 0x97, 0xb8, 0x1e, 0xd8, 0xa6, 0xe8, 0x95, 0xda, 0xc4, 0xbd, 0xb6, 0xd8, 0x0c, 0x80, - 0x0d, 0x7a, 0x67, 0xe1, 0xeb, 0xb3, 0xf0, 0xea, 0xbf, 0x52, 0xbb, 0x7e, 0x77, 0xe1, 0xfa, 0x86, 0xaa, 0xf5, 0x8d, - 0x15, 0xab, 0xf3, 0xce, 0x3a, 0x7f, 0x16, 0xbe, 0x76, 0xdc, 0x9d, 0x20, 0x5a, 0x2c, 0xe8, 0xff, 0x02, 0xda, 0x7f, - 0xc5, 0x31, 0xbc, 0xa4, 0x13, 0x72, 0x01, 0xed, 0xd0, 0x41, 0x44, 0xc2, 0x09, 0x8c, 0xaf, 0x06, 0x64, 0x40, 0xc1, - 0xb6, 0x43, 0x23, 0x18, 0x93, 0xc9, 0x05, 0xd0, 0x11, 0x09, 0xc7, 0x40, 0x19, 0x30, 0x4a, 0x86, 0x6f, 0x58, 0x48, - 0x46, 0x43, 0x18, 0x5f, 0xb1, 0x80, 0x84, 0x0c, 0x3a, 0xde, 0x11, 0x61, 0x0c, 0x42, 0xcb, 0x12, 0x56, 0x01, 0xb0, - 0x34, 0x24, 0xc1, 0x18, 0x02, 0x18, 0x91, 0xe0, 0x82, 0x4c, 0x46, 0x30, 0x21, 0x63, 0x0a, 0x8c, 0x0c, 0x86, 0xa5, - 0x37, 0x24, 0x14, 0x46, 0x24, 0x1c, 0xf1, 0x09, 0x19, 0x84, 0xd0, 0x0e, 0x1d, 0x1c, 0x63, 0xc2, 0x98, 0x47, 0x02, - 0xfa, 0x26, 0x24, 0x6c, 0x0c, 0x63, 0x32, 0x18, 0x5c, 0xd2, 0x11, 0xb9, 0x18, 0x40, 0x37, 0x76, 0xf0, 0x52, 0x06, - 0xc3, 0xa7, 0x40, 0x63, 0x7f, 0x5e, 0xd0, 0x42, 0xc2, 0x28, 0x84, 0xe4, 0x62, 0xc2, 0x6d, 0x5f, 0xca, 0xa0, 0x1b, - 0x3b, 0xdc, 0x28, 0x85, 0xe0, 0x77, 0x63, 0x16, 0xfe, 0x79, 0x31, 0xa3, 0x16, 0x01, 0x46, 0x06, 0xe1, 0x25, 0x0d, - 0xc9, 0x08, 0xda, 0xa1, 0x3b, 0x9b, 0x32, 0x98, 0x5c, 0x5d, 0xc0, 0x04, 0x46, 0x64, 0x34, 0x81, 0x0b, 0x18, 0x5a, - 0x74, 0x2f, 0xc8, 0x64, 0xd0, 0x09, 0x79, 0x8c, 0x7c, 0x2b, 0x8c, 0x83, 0x3f, 0x30, 0x8c, 0x4f, 0xf9, 0xf4, 0x07, - 0x76, 0xe9, 0xff, 0x71, 0x05, 0x45, 0x7e, 0xd7, 0x86, 0x45, 0x7e, 0xf7, 0x3c, 0x60, 0xbb, 0xa8, 0x24, 0xb2, 0xdd, - 0x48, 0x12, 0x15, 0x14, 0x44, 0x16, 0x57, 0x3c, 0x4d, 0x4e, 0x5a, 0xfd, 0xc8, 0x2f, 0xe8, 0x61, 0xab, 0xa0, 0xc9, - 0xa3, 0xc6, 0xbd, 0xdb, 0x6b, 0x2b, 0x7d, 0x72, 0x53, 0x20, 0xbc, 0xbe, 0x7e, 0x07, 0x6b, 0x51, 0x96, 0x20, 0xd5, - 0x1a, 0x4c, 0x73, 0x0f, 0x46, 0xd9, 0x57, 0x03, 0x89, 0xa9, 0xb1, 0xa4, 0x29, 0x10, 0xf6, 0x7d, 0x04, 0x21, 0x24, - 0x9a, 0x37, 0xc9, 0xbb, 0x12, 0xb9, 0x46, 0x58, 0x88, 0x15, 0x82, 0x30, 0xa0, 0x55, 0x85, 0x60, 0x84, 0x1d, 0x8e, - 0x82, 0x2d, 0x5f, 0xe4, 0x77, 0x87, 0x74, 0x8d, 0xb2, 0xc8, 0x62, 0x89, 0x26, 0xd9, 0x77, 0xc4, 0x51, 0x11, 0x76, - 0x56, 0x5d, 0xa3, 0x31, 0x42, 0x2e, 0xac, 0x55, 0x61, 0x12, 0xd9, 0x5f, 0xb7, 0xc0, 0xdb, 0xdf, 0x0c, 0xb1, 0xbf, - 0x16, 0xb9, 0xb0, 0x6f, 0x06, 0x49, 0xd4, 0x76, 0x91, 0x56, 0x83, 0x6d, 0x64, 0xba, 0x07, 0x8e, 0x96, 0x2a, 0x51, - 0x2e, 0x4c, 0x11, 0x87, 0x0c, 0xea, 0x92, 0xa7, 0x58, 0xa8, 0x32, 0xc3, 0x26, 0xbe, 0xbe, 0xfe, 0xf9, 0xaf, 0xf6, - 0x35, 0xc4, 0x9a, 0x70, 0x94, 0xac, 0xf5, 0x5d, 0x27, 0x68, 0x89, 0xbd, 0xdc, 0x68, 0xd0, 0xbd, 0x6b, 0xd4, 0x5c, - 0xeb, 0xb5, 0x6a, 0xb2, 0x47, 0x5a, 0xde, 0x1d, 0x16, 0xf7, 0x9a, 0xda, 0xff, 0xb6, 0x1f, 0xed, 0x84, 0xf4, 0x72, - 0x5e, 0x09, 0x93, 0x5c, 0xf3, 0x15, 0x46, 0x7e, 0xb7, 0x91, 0x44, 0xbe, 0x75, 0xa0, 0xe3, 0x2d, 0xf6, 0x32, 0x05, - 0x4d, 0x7e, 0xbd, 0xb9, 0x84, 0xdf, 0xea, 0x8c, 0x1b, 0xec, 0xb0, 0x6f, 0xbd, 0xac, 0xd0, 0x14, 0x2a, 0x8b, 0xdf, - 0xfd, 0x7a, 0x7d, 0x73, 0xf4, 0x78, 0xd9, 0x32, 0x01, 0xca, 0xb4, 0x7b, 0x6f, 0x59, 0x96, 0x46, 0xd4, 0xbc, 0x31, - 0xad, 0x5a, 0xcf, 0x66, 0xc7, 0xc1, 0xa3, 0x76, 0x3f, 0x17, 0x25, 0x76, 0x4e, 0xed, 0x05, 0xfd, 0x04, 0xbe, 0x66, - 0xe3, 0xe1, 0xec, 0x2f, 0xac, 0xf4, 0xbb, 0x00, 0xf2, 0xbb, 0x68, 0xf2, 0xdb, 0xd7, 0xa8, 0x7f, 0x02, 0x14, 0xee, - 0xbc, 0x64, 0x9d, 0x12, 0x00, 0x00}; + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x13, 0x95, 0x56, 0xeb, 0x6f, 0xd4, 0x38, 0x10, 0xff, 0xce, + 0x5f, 0xe1, 0x33, 0x8f, 0x26, 0xd0, 0x3c, 0xb7, 0xdb, 0x96, 0x6c, 0x12, 0x04, 0xdc, 0x21, 0x90, 0x28, 0x20, 0xb5, + 0x70, 0x1f, 0x10, 0x52, 0xbd, 0xc9, 0x64, 0x63, 0x9a, 0x38, 0x39, 0xdb, 0xfb, 0x62, 0xb5, 0xf7, 0xb7, 0xdf, 0x38, + 0xc9, 0x6e, 0xb7, 0x15, 0x9c, 0xee, 0x5a, 0x35, 0x1d, 0xdb, 0xf3, 0xf8, 0xcd, 0x78, 0x1e, 0x8e, 0x7f, 0xcb, 0x9b, + 0x4c, 0xaf, 0x5b, 0x20, 0xa5, 0xae, 0xab, 0x34, 0x36, 0x5f, 0x52, 0x31, 0x31, 0x4b, 0x40, 0xe0, 0x0a, 0x58, 0x9e, + 0xc6, 0x35, 0x68, 0x46, 0xb2, 0x92, 0x49, 0x05, 0x3a, 0xf9, 0x7c, 0xf5, 0xc6, 0x39, 0x4f, 0xe3, 0x8a, 0x8b, 0x1b, + 0x22, 0xa1, 0x4a, 0x78, 0xd6, 0x08, 0x52, 0x4a, 0x28, 0x92, 0x9c, 0x69, 0x16, 0xf1, 0x9a, 0xcd, 0x60, 0x10, 0x11, + 0xac, 0x86, 0x64, 0xc1, 0x61, 0xd9, 0x36, 0x52, 0x13, 0xe4, 0xd3, 0x20, 0x74, 0x42, 0x97, 0x3c, 0xd7, 0x65, 0x92, + 0xc3, 0x82, 0x67, 0xe0, 0x74, 0x8b, 0x63, 0x2e, 0xb8, 0xe6, 0xac, 0x72, 0x54, 0xc6, 0x2a, 0x48, 0x82, 0xe3, 0xb9, + 0x02, 0xd9, 0x2d, 0xd8, 0x14, 0xd7, 0xa2, 0xa1, 0x69, 0xac, 0x32, 0xc9, 0x5b, 0x4d, 0x0c, 0xd4, 0xa4, 0x6e, 0xf2, + 0x79, 0x05, 0xa9, 0xe7, 0x31, 0x85, 0x90, 0x94, 0xc7, 0x45, 0x0e, 0x2b, 0x77, 0xea, 0x67, 0x99, 0x0f, 0xe7, 0xe7, + 0xee, 0x77, 0xf5, 0x00, 0x9d, 0x9a, 0xd7, 0x68, 0xcd, 0xad, 0x9a, 0x8c, 0x69, 0xde, 0x08, 0x57, 0x01, 0x93, 0x59, + 0x99, 0x24, 0x09, 0x7d, 0xa1, 0xd8, 0x02, 0xe8, 0x93, 0x27, 0xd6, 0x9e, 0x69, 0x06, 0xfa, 0x8f, 0x0a, 0x0c, 0xa9, + 0x5e, 0xad, 0xaf, 0xd8, 0xec, 0x03, 0x02, 0xb7, 0x28, 0x53, 0x3c, 0x07, 0x6a, 0x7f, 0xf5, 0xbf, 0xb9, 0x4a, 0xaf, + 0x2b, 0x70, 0x73, 0xae, 0xda, 0x8a, 0xad, 0x13, 0x3a, 0x45, 0xad, 0x37, 0xd4, 0x9e, 0x14, 0x73, 0x91, 0x19, 0xe5, + 0x44, 0x59, 0x60, 0x6f, 0x2a, 0x40, 0x78, 0xc9, 0x05, 0xd3, 0xa5, 0x5b, 0xb3, 0x95, 0xd5, 0x13, 0x5c, 0x58, 0xe1, + 0x53, 0x0b, 0x9e, 0x05, 0xbe, 0x6f, 0x1f, 0x77, 0x1f, 0xdf, 0xf6, 0xf0, 0xff, 0x44, 0x82, 0x9e, 0x4b, 0x41, 0x98, + 0x75, 0x1d, 0xb7, 0xc8, 0x49, 0xf2, 0x84, 0x5e, 0x04, 0x21, 0x09, 0x9e, 0xbb, 0xe1, 0xf8, 0xbd, 0x7b, 0x46, 0x4e, + 0xf0, 0x7f, 0x76, 0xe6, 0x8c, 0x49, 0x70, 0x82, 0x9f, 0x30, 0x74, 0xc7, 0xc4, 0xff, 0x41, 0x49, 0xc1, 0xab, 0x2a, + 0xa1, 0xa2, 0x11, 0x40, 0x89, 0xd2, 0xb2, 0xb9, 0x81, 0x84, 0x66, 0x73, 0x29, 0x11, 0xfb, 0xeb, 0xa6, 0x6a, 0x24, + 0xf5, 0xd2, 0x07, 0xff, 0x4b, 0xa1, 0x96, 0x4c, 0xa8, 0xa2, 0x91, 0x75, 0x42, 0xbb, 0xe8, 0x5b, 0x8f, 0x36, 0x7a, + 0x4b, 0xcc, 0xc7, 0x3e, 0x38, 0x74, 0x1a, 0xc9, 0x67, 0x5c, 0x24, 0xd4, 0x68, 0x3c, 0x47, 0x23, 0xd7, 0xf6, 0x76, + 0xef, 0x3d, 0x33, 0xde, 0x0f, 0xfe, 0x34, 0xd6, 0xd7, 0xeb, 0x58, 0x2d, 0x66, 0x64, 0x55, 0x57, 0x42, 0x25, 0xb4, + 0xd4, 0xba, 0x8d, 0x3c, 0x6f, 0xb9, 0x5c, 0xba, 0xcb, 0x91, 0xdb, 0xc8, 0x99, 0x17, 0xfa, 0xbe, 0xef, 0x21, 0x07, + 0x25, 0x7d, 0x22, 0xd0, 0xf0, 0x84, 0x92, 0x12, 0xf8, 0xac, 0xd4, 0x1d, 0x9d, 0x3e, 0xda, 0xc0, 0x36, 0x36, 0x1c, + 0xe9, 0xf5, 0xb7, 0x03, 0x2b, 0xfc, 0xc0, 0x0a, 0xbc, 0x60, 0x16, 0xdd, 0xb9, 0x79, 0xd4, 0xb9, 0x79, 0xc6, 0x42, + 0x12, 0x12, 0xbf, 0xfb, 0x0d, 0x1d, 0x43, 0x0f, 0x2b, 0xe7, 0xde, 0x8a, 0x1c, 0xac, 0x0c, 0x55, 0x9f, 0x3a, 0xcf, + 0xf7, 0xb2, 0x81, 0xd9, 0x59, 0x04, 0xfe, 0xed, 0x86, 0x11, 0x78, 0x7b, 0x7a, 0xb8, 0x76, 0xc2, 0x2f, 0x87, 0x0c, + 0xc6, 0x5a, 0x19, 0x7c, 0x39, 0x65, 0x63, 0x32, 0x1e, 0x76, 0xc6, 0x8e, 0xa1, 0xf7, 0x2b, 0x32, 0x5e, 0x20, 0x47, + 0xed, 0x9c, 0x3a, 0x63, 0x36, 0x22, 0xa3, 0x01, 0x08, 0x52, 0xb8, 0x7d, 0x8a, 0x82, 0x07, 0x7b, 0xce, 0xe8, 0xc7, + 0x91, 0x97, 0x52, 0x3b, 0xa2, 0xf4, 0xd6, 0xf3, 0xe6, 0xd0, 0x73, 0xf7, 0x7b, 0x83, 0x39, 0x45, 0x29, 0x46, 0x06, + 0x74, 0x56, 0x5a, 0xd4, 0xc3, 0xc2, 0x2a, 0xf8, 0x0c, 0xb3, 0xbe, 0x11, 0xd4, 0x76, 0x75, 0x09, 0xc2, 0xda, 0x89, + 0x1a, 0x41, 0xe8, 0x4e, 0xac, 0xfb, 0x27, 0xda, 0xde, 0xec, 0xf3, 0x5f, 0x73, 0x8d, 0x65, 0xa6, 0x5d, 0x53, 0xb0, + 0xc7, 0xfb, 0xdd, 0x69, 0x93, 0xaf, 0x7f, 0x51, 0x1a, 0x65, 0xd0, 0xd7, 0x05, 0x17, 0x02, 0xe4, 0x15, 0xac, 0xf0, + 0xe6, 0x2e, 0x5e, 0xbe, 0x26, 0x2f, 0xf3, 0x5c, 0x82, 0x52, 0x11, 0xa1, 0xcf, 0x34, 0xd6, 0x40, 0xf6, 0xdf, 0x75, + 0x05, 0x77, 0x74, 0xfd, 0xc9, 0xdf, 0x70, 0xf2, 0x01, 0xf4, 0xb2, 0x91, 0x37, 0x83, 0x36, 0x03, 0x6d, 0x62, 0x2a, + 0x4c, 0x22, 0x4e, 0xd6, 0x2a, 0x57, 0x55, 0xd8, 0x3e, 0xac, 0xc0, 0x46, 0x3b, 0xed, 0xad, 0x57, 0x62, 0x17, 0xa8, + 0xeb, 0x38, 0xe7, 0x0b, 0x92, 0x55, 0xd8, 0x21, 0xb0, 0x5c, 0x7a, 0x55, 0x94, 0x3c, 0x20, 0xdd, 0x4f, 0x23, 0x32, + 0x94, 0xbe, 0x49, 0xe8, 0x4f, 0x3a, 0xc0, 0xab, 0xf5, 0xbb, 0xdc, 0x3a, 0x52, 0x58, 0xfb, 0x47, 0xb6, 0xbb, 0x60, + 0xd5, 0x1c, 0x48, 0x42, 0x74, 0xc9, 0xd5, 0x2d, 0xc0, 0xc9, 0x2f, 0xc5, 0x5a, 0x75, 0x83, 0x52, 0x05, 0x1e, 0x2b, + 0xcb, 0xa6, 0xe9, 0x60, 0x2e, 0x66, 0x7d, 0x83, 0xa4, 0x0f, 0xe9, 0x3d, 0x44, 0x4e, 0x05, 0x85, 0xde, 0xf3, 0x11, + 0x2c, 0x3b, 0x65, 0x09, 0x57, 0xa2, 0x75, 0x7b, 0xbb, 0xdf, 0x8c, 0x55, 0xcb, 0xc4, 0x7d, 0x41, 0x03, 0xd0, 0x94, + 0x0a, 0x36, 0x36, 0xa4, 0x4c, 0xbd, 0x20, 0xd3, 0xde, 0xa0, 0xc7, 0x76, 0xe4, 0xa3, 0x0d, 0x47, 0x8d, 0xa6, 0x5f, + 0xed, 0x35, 0xc6, 0x1e, 0x86, 0x26, 0xbd, 0xde, 0xda, 0xb7, 0x7e, 0xfc, 0x35, 0x07, 0xb9, 0xbe, 0x84, 0x0a, 0x32, + 0xdd, 0x48, 0x8b, 0x3e, 0x44, 0x2b, 0x98, 0x4a, 0x9d, 0xc3, 0x6f, 0xaf, 0x2e, 0xde, 0x27, 0x8d, 0x25, 0xed, 0xe3, + 0x5f, 0x71, 0x9b, 0x51, 0xf0, 0x15, 0x47, 0xc1, 0xdf, 0xc9, 0x91, 0x19, 0x06, 0x47, 0xdf, 0x50, 0xb4, 0xf3, 0xf7, + 0xfa, 0x76, 0x22, 0x98, 0x72, 0x7e, 0x86, 0x2d, 0xe1, 0xd8, 0x78, 0xe8, 0x9c, 0x8e, 0xed, 0x2d, 0xda, 0x47, 0x04, + 0x88, 0xbb, 0xeb, 0xeb, 0xd8, 0xdf, 0x4d, 0x8b, 0x4d, 0x9f, 0x6e, 0xa6, 0xcd, 0xca, 0x51, 0xfc, 0x07, 0x17, 0xb3, + 0x88, 0x8b, 0x12, 0x24, 0xd7, 0x5b, 0x84, 0x8b, 0x13, 0xa2, 0x9d, 0xeb, 0x4d, 0xcb, 0xf2, 0xdc, 0x9c, 0x8c, 0xdb, + 0xd5, 0xa4, 0xc0, 0x79, 0x62, 0x38, 0x21, 0x0a, 0xa0, 0xde, 0xf6, 0xe7, 0x5d, 0x47, 0x89, 0x9e, 0x8f, 0x1f, 0x6f, + 0x4d, 0xc2, 0x6d, 0x34, 0x5e, 0x96, 0xc3, 0x2a, 0x3e, 0x13, 0x51, 0x86, 0xc0, 0x41, 0xf6, 0x42, 0x05, 0xab, 0x79, + 0xb5, 0x8e, 0x14, 0xf6, 0x36, 0x07, 0x07, 0x0d, 0x2f, 0xb6, 0xd3, 0xb9, 0xd6, 0x8d, 0x40, 0xdb, 0x32, 0x07, 0x19, + 0xf9, 0x93, 0x9e, 0x70, 0x24, 0xcb, 0xf9, 0x5c, 0x45, 0xee, 0x48, 0x42, 0x3d, 0x99, 0xb2, 0xec, 0x66, 0x26, 0x9b, + 0xb9, 0xc8, 0x9d, 0xcc, 0x74, 0xda, 0xe8, 0x61, 0x50, 0xb0, 0x11, 0x64, 0x93, 0x61, 0x55, 0x14, 0xc5, 0x04, 0x43, + 0x01, 0x4e, 0xdf, 0xcb, 0xa2, 0xd0, 0x3d, 0x31, 0x62, 0x07, 0x30, 0xdd, 0xd0, 0x6c, 0xf4, 0x18, 0x71, 0x04, 0x3c, + 0x9e, 0xec, 0xdc, 0xf1, 0x27, 0xd8, 0xc2, 0x15, 0x2a, 0x69, 0xb1, 0xb6, 0x11, 0xe6, 0xb6, 0x66, 0x5c, 0x1c, 0xa2, + 0x37, 0x69, 0x32, 0x19, 0xc6, 0x0f, 0x86, 0xa5, 0x33, 0xd3, 0x0d, 0xa1, 0x09, 0x0e, 0x98, 0x7e, 0x86, 0x46, 0xe1, + 0xa9, 0xdf, 0xae, 0xb6, 0xee, 0x90, 0x20, 0x9b, 0x1d, 0x77, 0x51, 0xc1, 0x6a, 0xf2, 0x7d, 0xae, 0x34, 0x2f, 0xd6, + 0xce, 0x30, 0x83, 0x23, 0x4c, 0x16, 0x9c, 0xbd, 0x53, 0x64, 0x05, 0x10, 0x93, 0xce, 0x86, 0xc3, 0x35, 0xd4, 0x6a, + 0x88, 0xd3, 0x5e, 0x4d, 0x97, 0xa0, 0x77, 0x75, 0xfd, 0x1b, 0xb7, 0xc9, 0xc5, 0x4d, 0xcd, 0x24, 0x8e, 0x0a, 0x67, + 0xda, 0x60, 0x4c, 0xeb, 0xc8, 0x39, 0xc3, 0xbb, 0x1a, 0xb6, 0x8c, 0x32, 0xf4, 0x1c, 0x61, 0x76, 0xb3, 0x75, 0x17, + 0xef, 0xa0, 0x5d, 0x11, 0xd5, 0x54, 0x3c, 0x1f, 0xf8, 0x3a, 0x16, 0xe2, 0xef, 0xc3, 0x13, 0xe0, 0x75, 0x13, 0xb3, + 0xb7, 0x0b, 0xf5, 0x49, 0x71, 0xce, 0x02, 0xff, 0x27, 0x37, 0x92, 0x17, 0x45, 0x38, 0x2d, 0xf6, 0x91, 0x32, 0x63, + 0xd2, 0x94, 0x46, 0x97, 0x5a, 0xb1, 0xd7, 0xbf, 0x66, 0x4c, 0x66, 0xe0, 0x03, 0x05, 0x23, 0x8c, 0xef, 0x9b, 0x80, + 0xf0, 0x3c, 0xc1, 0x4e, 0x95, 0x1e, 0xb4, 0x2f, 0x64, 0x0c, 0x76, 0x47, 0x48, 0xdd, 0x69, 0x46, 0xfd, 0x59, 0x87, + 0x3e, 0x7d, 0xdd, 0x60, 0x7d, 0x60, 0xdb, 0x11, 0x33, 0xa2, 0x1b, 0x32, 0x84, 0xc0, 0x75, 0xdd, 0x78, 0x2a, 0xd3, + 0x4f, 0x15, 0x30, 0x05, 0x64, 0xc9, 0xb8, 0x76, 0xb1, 0x1a, 0x3b, 0xfe, 0xbe, 0x8e, 0x51, 0x29, 0xb2, 0xa6, 0x43, + 0xc1, 0xc6, 0xe5, 0xa8, 0x37, 0x70, 0x09, 0xda, 0x68, 0x32, 0x06, 0x46, 0x69, 0x6c, 0x46, 0x2e, 0x61, 0x5d, 0x4b, + 0x4b, 0xbc, 0x25, 0x2f, 0xb8, 0x79, 0xb2, 0xa4, 0x71, 0x97, 0xe4, 0x46, 0x83, 0x89, 0x73, 0xff, 0xbc, 0xea, 0xa8, + 0x0a, 0xc4, 0x0c, 0x27, 0xe9, 0x28, 0x24, 0xe8, 0x76, 0x06, 0x65, 0x53, 0x61, 0x58, 0x93, 0xcb, 0xcb, 0x77, 0xbf, + 0xa7, 0x06, 0xcc, 0xad, 0x1c, 0xf6, 0xa7, 0x5e, 0xcc, 0x10, 0x83, 0xd4, 0xe9, 0x49, 0xff, 0xa8, 0x6a, 0xb1, 0xbf, + 0xa0, 0x07, 0xf9, 0x1d, 0x1d, 0x9f, 0x86, 0xcd, 0x5e, 0x4f, 0xf7, 0xd7, 0x95, 0x4a, 0x7a, 0x89, 0x80, 0x62, 0x6f, + 0x58, 0xc4, 0x9e, 0x01, 0xdc, 0x9f, 0x97, 0x03, 0x1f, 0xc6, 0xe9, 0xe3, 0xd5, 0x4b, 0xf2, 0xb9, 0xc5, 0x26, 0x00, + 0x7d, 0xd8, 0x3a, 0xaf, 0xf0, 0x65, 0x58, 0x36, 0x79, 0xf2, 0xe9, 0xe3, 0xe5, 0xd5, 0xde, 0xc3, 0x79, 0xc7, 0x44, + 0x40, 0x64, 0xfd, 0xf3, 0x6e, 0x5e, 0x69, 0xde, 0x32, 0xa9, 0x3b, 0xb5, 0x8e, 0xe9, 0x22, 0x3b, 0x1f, 0xba, 0x73, + 0x7c, 0x03, 0x41, 0xef, 0x46, 0x2f, 0x98, 0x92, 0x1d, 0xaa, 0x9d, 0xb5, 0x7b, 0xb8, 0xbc, 0xfe, 0xb6, 0xbd, 0xfe, + 0xea, 0xbd, 0xee, 0xa5, 0xfb, 0x0f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00}; } // namespace captive_portal } // namespace esphome From 882237120ec6ff6c900553bc949c1fb314de1d2a Mon Sep 17 00:00:00 2001 From: Katherine Whitlock Date: Thu, 14 Aug 2025 16:14:53 -0400 Subject: [PATCH 0537/1626] Improve error reporting for add_library (#10226) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 9df5da1c78..8a9630735e 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -803,6 +803,10 @@ class EsphomeCore: raise TypeError( f"Library {library} must be instance of Library, not {type(library)}" ) + + if not library.name: + raise ValueError(f"The library for {library.repository} must have a name") + short_name = ( library.name if "/" not in library.name else library.name.split("/")[-1] ) From da73cb06cc14c50ad6e9d8ab29e692169a7412a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 15:59:36 -0500 Subject: [PATCH 0538/1626] cover other case --- esphome/writer.py | 4 +- tests/unit_tests/test_writer.py | 83 ++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index 974c5901e0..4b25a25f7e 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -103,7 +103,7 @@ def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> boo return False -def update_storage_json(): +def update_storage_json() -> None: path = storage_path() old = StorageJSON.load(path) new = StorageJSON.from_esphome_core(CORE, old) @@ -111,7 +111,7 @@ def update_storage_json(): return if storage_should_clean(old, new): - if old.loaded_integrations - new.loaded_integrations: + if old is not None and old.loaded_integrations - new.loaded_integrations: removed = old.loaded_integrations - new.loaded_integrations _LOGGER.info( "Components removed (%s), cleaning build files...", diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 7f9f97826f..93ebdf28e9 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -2,11 +2,12 @@ from collections.abc import Callable from typing import Any +from unittest.mock import MagicMock, patch import pytest from esphome.storage_json import StorageJSON -from esphome.writer import storage_should_clean +from esphome.writer import storage_should_clean, update_storage_json @pytest.fixture @@ -137,3 +138,83 @@ def test_storage_edge_case_from_empty_integrations( old = create_storage(loaded_integrations=[]) new = create_storage(loaded_integrations=["api", "wifi"]) assert storage_should_clean(old, new) is False + + +@patch("esphome.writer.clean_build") +@patch("esphome.writer.StorageJSON") +@patch("esphome.writer.storage_path") +@patch("esphome.writer.CORE") +@patch("esphome.writer._LOGGER") +def test_update_storage_json_logging_when_old_is_none( + mock_logger: MagicMock, + mock_core: MagicMock, + mock_storage_path: MagicMock, + mock_storage_json_class: MagicMock, + mock_clean_build: MagicMock, + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that update_storage_json doesn't crash when old storage is None. + + This is a regression test for the AttributeError that occurred when + old was None and we tried to access old.loaded_integrations. + """ + # Setup mocks + mock_storage_path.return_value = "/test/path" + mock_storage_json_class.load.return_value = None # Old storage is None + + new_storage = create_storage(loaded_integrations=["api", "wifi"]) + new_storage.save = MagicMock() # Mock the save method + mock_storage_json_class.from_esphome_core.return_value = new_storage + + # Call the function - should not raise AttributeError + update_storage_json() + + # Verify clean_build was called + mock_clean_build.assert_called_once() + + # Verify the correct log message was used (not the component removal message) + mock_logger.info.assert_called_with( + "Core config or version changed, cleaning build files..." + ) + + # Verify save was called + new_storage.save.assert_called_once_with("/test/path") + + +@patch("esphome.writer.clean_build") +@patch("esphome.writer.StorageJSON") +@patch("esphome.writer.storage_path") +@patch("esphome.writer.CORE") +@patch("esphome.writer._LOGGER") +def test_update_storage_json_logging_components_removed( + mock_logger: MagicMock, + mock_core: MagicMock, + mock_storage_path: MagicMock, + mock_storage_json_class: MagicMock, + mock_clean_build: MagicMock, + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that update_storage_json logs removed components correctly.""" + # Setup mocks + mock_storage_path.return_value = "/test/path" + + old_storage = create_storage(loaded_integrations=["api", "wifi", "bluetooth_proxy"]) + new_storage = create_storage(loaded_integrations=["api", "wifi"]) + new_storage.save = MagicMock() # Mock the save method + + mock_storage_json_class.load.return_value = old_storage + mock_storage_json_class.from_esphome_core.return_value = new_storage + + # Call the function + update_storage_json() + + # Verify clean_build was called + mock_clean_build.assert_called_once() + + # Verify the correct log message was used with component names + mock_logger.info.assert_called_with( + "Components removed (%s), cleaning build files...", "bluetooth_proxy" + ) + + # Verify save was called + new_storage.save.assert_called_once_with("/test/path") From 9af016e2ce6a7bb95a00560f296890cd36689f2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 16:03:44 -0500 Subject: [PATCH 0539/1626] preen --- tests/unit_tests/test_writer.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 93ebdf28e9..f47947ff37 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -144,14 +144,13 @@ def test_storage_edge_case_from_empty_integrations( @patch("esphome.writer.StorageJSON") @patch("esphome.writer.storage_path") @patch("esphome.writer.CORE") -@patch("esphome.writer._LOGGER") def test_update_storage_json_logging_when_old_is_none( - mock_logger: MagicMock, mock_core: MagicMock, mock_storage_path: MagicMock, mock_storage_json_class: MagicMock, mock_clean_build: MagicMock, create_storage: Callable[..., StorageJSON], + caplog: pytest.LogCaptureFixture, ) -> None: """Test that update_storage_json doesn't crash when old storage is None. @@ -167,15 +166,15 @@ def test_update_storage_json_logging_when_old_is_none( mock_storage_json_class.from_esphome_core.return_value = new_storage # Call the function - should not raise AttributeError - update_storage_json() + with caplog.at_level("INFO"): + update_storage_json() # Verify clean_build was called mock_clean_build.assert_called_once() # Verify the correct log message was used (not the component removal message) - mock_logger.info.assert_called_with( - "Core config or version changed, cleaning build files..." - ) + assert "Core config or version changed, cleaning build files..." in caplog.text + assert "Components removed" not in caplog.text # Verify save was called new_storage.save.assert_called_once_with("/test/path") @@ -185,14 +184,13 @@ def test_update_storage_json_logging_when_old_is_none( @patch("esphome.writer.StorageJSON") @patch("esphome.writer.storage_path") @patch("esphome.writer.CORE") -@patch("esphome.writer._LOGGER") def test_update_storage_json_logging_components_removed( - mock_logger: MagicMock, mock_core: MagicMock, mock_storage_path: MagicMock, mock_storage_json_class: MagicMock, mock_clean_build: MagicMock, create_storage: Callable[..., StorageJSON], + caplog: pytest.LogCaptureFixture, ) -> None: """Test that update_storage_json logs removed components correctly.""" # Setup mocks @@ -206,15 +204,17 @@ def test_update_storage_json_logging_components_removed( mock_storage_json_class.from_esphome_core.return_value = new_storage # Call the function - update_storage_json() + with caplog.at_level("INFO"): + update_storage_json() # Verify clean_build was called mock_clean_build.assert_called_once() # Verify the correct log message was used with component names - mock_logger.info.assert_called_with( - "Components removed (%s), cleaning build files...", "bluetooth_proxy" + assert ( + "Components removed (bluetooth_proxy), cleaning build files..." in caplog.text ) + assert "Core config or version changed" not in caplog.text # Verify save was called new_storage.save.assert_called_once_with("/test/path") From b7e0627b21a78540c9fee30d28b1488a0d0470d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 16:29:53 -0500 Subject: [PATCH 0540/1626] [wifi] Automatically disable Enterprise WiFi support when EAP is not configured --- esphome/components/wifi/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index ac002eac53..4013e8f400 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -375,11 +375,16 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + # Track if any network uses Enterprise authentication + has_eap = False + def add_sta(ap, network): ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP)) cg.add(var.add_sta(wifi_network(network, ap, ip_config))) for network in config.get(CONF_NETWORKS, []): + if CONF_EAP in network: + has_eap = True cg.with_local_variable(network[CONF_ID], WiFiAP(), add_sta, network) if CONF_AP in config: @@ -396,6 +401,10 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + # Disable Enterprise WiFi support if no EAP is configured + if CORE.is_esp32 and CORE.using_esp_idf and not has_eap: + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", False) + cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) cg.add(var.set_fast_connect(config[CONF_FAST_CONNECT])) From 4f29b3c7aa4f77b0f10a2147730899f082409f96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 17:43:45 -0500 Subject: [PATCH 0541/1626] [wifi] Automatically disable Enterprise WiFi support when EAP is not configured (#10242) --- esphome/components/wifi/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index ac002eac53..4013e8f400 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -375,11 +375,16 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + # Track if any network uses Enterprise authentication + has_eap = False + def add_sta(ap, network): ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP)) cg.add(var.add_sta(wifi_network(network, ap, ip_config))) for network in config.get(CONF_NETWORKS, []): + if CONF_EAP in network: + has_eap = True cg.with_local_variable(network[CONF_ID], WiFiAP(), add_sta, network) if CONF_AP in config: @@ -396,6 +401,10 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + # Disable Enterprise WiFi support if no EAP is configured + if CORE.is_esp32 and CORE.using_esp_idf and not has_eap: + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", False) + cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) cg.add(var.set_fast_connect(config[CONF_FAST_CONNECT])) From 8a9d30c8d31a4df29c9147c998bd17cc5c2817f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 17:44:15 -0500 Subject: [PATCH 0542/1626] [esp32_ble_client] Add log helper functions to reduce flash usage by 120 bytes --- .../esp32_ble_client/ble_client_base.cpp | 42 ++++++++++--------- .../esp32_ble_client/ble_client_base.h | 4 ++ 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 2d84436d84..e23be2e0c1 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -159,8 +159,7 @@ void BLEClientBase::disconnect() { return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { - ESP_LOGW(TAG, "[%d] [%s] Disconnecting before connected, disconnect scheduled.", this->connection_index_, - this->address_str_.c_str()); + this->log_warning_("Disconnect before connected, disconnect scheduled."); this->want_disconnect_ = true; return; } @@ -172,13 +171,11 @@ void BLEClientBase::unconditional_disconnect() { ESP_LOGI(TAG, "[%d] [%s] Disconnecting (conn_id: %d).", this->connection_index_, this->address_str_.c_str(), this->conn_id_); if (this->state_ == espbt::ClientState::DISCONNECTING) { - ESP_LOGE(TAG, "[%d] [%s] Tried to disconnect while already disconnecting.", this->connection_index_, - this->address_str_.c_str()); + this->log_error_("Already disconnecting"); return; } if (this->conn_id_ == UNSET_CONN_ID) { - ESP_LOGE(TAG, "[%d] [%s] No connection ID set, cannot disconnect.", this->connection_index_, - this->address_str_.c_str()); + this->log_error_("conn id unset, cannot disconnect"); return; } auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_); @@ -234,6 +231,18 @@ void BLEClientBase::log_connection_params_(const char *param_type) { ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); } +void BLEClientBase::log_error_(const char *message) { + ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); +} + +void BLEClientBase::log_error_(const char *message, int code) { + ESP_LOGE(TAG, "[%d] [%s] %s=%d", this->connection_index_, this->address_str_.c_str(), message, code); +} + +void BLEClientBase::log_warning_(const char *message) { + ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); +} + void BLEClientBase::restore_medium_conn_params_() { // Restore to medium connection parameters after initial connection phase // This balances performance with bandwidth usage for normal operation @@ -264,8 +273,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->app_id); this->gattc_if_ = esp_gattc_if; } else { - ESP_LOGE(TAG, "[%d] [%s] gattc app registration failed id=%d code=%d", this->connection_index_, - this->address_str_.c_str(), param->reg.app_id, param->reg.status); + this->log_error_("gattc app registration failed status", param->reg.status); this->status_ = param->reg.status; this->mark_failed(); } @@ -281,8 +289,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // This should not happen but lets log it in case it does // because it means we have a bad assumption about how the // ESP BT stack works. - ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while in %s state, status=%d", this->connection_index_, - this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status); + this->log_error_("ESP_GATTC_OPEN_EVT wrong state status", param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); @@ -307,7 +314,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->state_ = espbt::ClientState::ESTABLISHED; break; } - ESP_LOGD(TAG, "[%d] [%s] Searching for services", this->connection_index_, this->address_str_.c_str()); + this->log_event_("Searching for services"); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); break; } @@ -332,8 +339,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // Check if we were disconnected while waiting for service discovery if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state_ == espbt::ClientState::CONNECTED) { - ESP_LOGW(TAG, "[%d] [%s] Disconnected by remote during service discovery", this->connection_index_, - this->address_str_.c_str()); + this->log_warning_("Remote closed during discovery"); } else { ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, this->address_str_.c_str(), param->disconnect.reason); @@ -506,16 +512,14 @@ void BLEClientBase::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_ return; esp_bd_addr_t bd_addr; memcpy(bd_addr, param->ble_security.auth_cmpl.bd_addr, sizeof(esp_bd_addr_t)); - ESP_LOGI(TAG, "[%d] [%s] auth complete. remote BD_ADDR: %s", this->connection_index_, this->address_str_.c_str(), + ESP_LOGI(TAG, "[%d] [%s] auth complete addr: %s", this->connection_index_, this->address_str_.c_str(), format_hex(bd_addr, 6).c_str()); if (!param->ble_security.auth_cmpl.success) { - ESP_LOGE(TAG, "[%d] [%s] auth fail reason = 0x%x", this->connection_index_, this->address_str_.c_str(), - param->ble_security.auth_cmpl.fail_reason); + this->log_error_("auth fail reason", param->ble_security.auth_cmpl.fail_reason); } else { this->paired_ = true; - ESP_LOGD(TAG, "[%d] [%s] auth success. address type = %d auth mode = %d", this->connection_index_, - this->address_str_.c_str(), param->ble_security.auth_cmpl.addr_type, - param->ble_security.auth_cmpl.auth_mode); + ESP_LOGD(TAG, "[%d] [%s] auth success type = %d mode = %d", this->connection_index_, this->address_str_.c_str(), + param->ble_security.auth_cmpl.addr_type, param->ble_security.auth_cmpl.auth_mode); } break; diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 2d00688dbd..1850b2c5b3 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -137,6 +137,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); + // Compact error logging helpers to reduce flash usage + void log_error_(const char *message); + void log_error_(const char *message, int code); + void log_warning_(const char *message); }; } // namespace esphome::esp32_ble_client From 8ea1a3ed648f8718abb1eb5e3910690ab4cb28b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 17:50:03 -0500 Subject: [PATCH 0543/1626] [core] Trigger clean build when components are removed from configuration (#10235) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/writer.py | 18 ++- tests/unit_tests/test_writer.py | 220 ++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 tests/unit_tests/test_writer.py diff --git a/esphome/writer.py b/esphome/writer.py index b5c834722a..4b25a25f7e 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -80,13 +80,16 @@ def replace_file_content(text, pattern, repl): return content_new, count -def storage_should_clean(old: StorageJSON, new: StorageJSON) -> bool: +def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: if old is None: return True if old.src_version != new.src_version: return True - return old.build_path != new.build_path + if old.build_path != new.build_path: + return True + # Check if any components have been removed + return bool(old.loaded_integrations - new.loaded_integrations) def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> bool: @@ -100,7 +103,7 @@ def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> boo return False -def update_storage_json(): +def update_storage_json() -> None: path = storage_path() old = StorageJSON.load(path) new = StorageJSON.from_esphome_core(CORE, old) @@ -108,7 +111,14 @@ def update_storage_json(): return if storage_should_clean(old, new): - _LOGGER.info("Core config, version changed, cleaning build files...") + if old is not None and old.loaded_integrations - new.loaded_integrations: + removed = old.loaded_integrations - new.loaded_integrations + _LOGGER.info( + "Components removed (%s), cleaning build files...", + ", ".join(sorted(removed)), + ) + else: + _LOGGER.info("Core config or version changed, cleaning build files...") clean_build() elif storage_should_update_cmake_cache(old, new): _LOGGER.info("Integrations changed, cleaning cmake cache...") diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py new file mode 100644 index 0000000000..f47947ff37 --- /dev/null +++ b/tests/unit_tests/test_writer.py @@ -0,0 +1,220 @@ +"""Test writer module functionality.""" + +from collections.abc import Callable +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.storage_json import StorageJSON +from esphome.writer import storage_should_clean, update_storage_json + + +@pytest.fixture +def create_storage() -> Callable[..., StorageJSON]: + """Factory fixture to create StorageJSON instances.""" + + def _create( + loaded_integrations: list[str] | None = None, **kwargs: Any + ) -> StorageJSON: + return StorageJSON( + storage_version=kwargs.get("storage_version", 1), + name=kwargs.get("name", "test"), + friendly_name=kwargs.get("friendly_name", "Test Device"), + comment=kwargs.get("comment"), + esphome_version=kwargs.get("esphome_version", "2025.1.0"), + src_version=kwargs.get("src_version", 1), + address=kwargs.get("address", "test.local"), + web_port=kwargs.get("web_port", 80), + target_platform=kwargs.get("target_platform", "ESP32"), + build_path=kwargs.get("build_path", "/build"), + firmware_bin_path=kwargs.get("firmware_bin_path", "/firmware.bin"), + loaded_integrations=set(loaded_integrations or []), + loaded_platforms=kwargs.get("loaded_platforms", set()), + no_mdns=kwargs.get("no_mdns", False), + framework=kwargs.get("framework", "arduino"), + core_platform=kwargs.get("core_platform", "esp32"), + ) + + return _create + + +def test_storage_should_clean_when_old_is_none( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when old storage is None.""" + new = create_storage(loaded_integrations=["api", "wifi"]) + assert storage_should_clean(None, new) is True + + +def test_storage_should_clean_when_src_version_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when src_version changes.""" + old = create_storage(loaded_integrations=["api", "wifi"], src_version=1) + new = create_storage(loaded_integrations=["api", "wifi"], src_version=2) + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_build_path_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when build_path changes.""" + old = create_storage(loaded_integrations=["api", "wifi"], build_path="/build1") + new = create_storage(loaded_integrations=["api", "wifi"], build_path="/build2") + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_component_removed( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when a component is removed.""" + old = create_storage( + loaded_integrations=["api", "wifi", "bluetooth_proxy", "esp32_ble_tracker"] + ) + new = create_storage(loaded_integrations=["api", "wifi", "esp32_ble_tracker"]) + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_multiple_components_removed( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when multiple components are removed.""" + old = create_storage( + loaded_integrations=["api", "wifi", "ota", "web_server", "logger"] + ) + new = create_storage(loaded_integrations=["api", "wifi", "logger"]) + assert storage_should_clean(old, new) is True + + +def test_storage_should_not_clean_when_nothing_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is not triggered when nothing changes.""" + old = create_storage(loaded_integrations=["api", "wifi", "logger"]) + new = create_storage(loaded_integrations=["api", "wifi", "logger"]) + assert storage_should_clean(old, new) is False + + +def test_storage_should_not_clean_when_component_added( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is not triggered when a component is only added.""" + old = create_storage(loaded_integrations=["api", "wifi"]) + new = create_storage(loaded_integrations=["api", "wifi", "ota"]) + assert storage_should_clean(old, new) is False + + +def test_storage_should_not_clean_when_other_fields_change( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is not triggered when non-relevant fields change.""" + old = create_storage( + loaded_integrations=["api", "wifi"], + friendly_name="Old Name", + esphome_version="2024.12.0", + ) + new = create_storage( + loaded_integrations=["api", "wifi"], + friendly_name="New Name", + esphome_version="2025.1.0", + ) + assert storage_should_clean(old, new) is False + + +def test_storage_edge_case_empty_integrations( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test edge case when old has integrations but new has none.""" + old = create_storage(loaded_integrations=["api", "wifi"]) + new = create_storage(loaded_integrations=[]) + assert storage_should_clean(old, new) is True + + +def test_storage_edge_case_from_empty_integrations( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test edge case when old has no integrations but new has some.""" + old = create_storage(loaded_integrations=[]) + new = create_storage(loaded_integrations=["api", "wifi"]) + assert storage_should_clean(old, new) is False + + +@patch("esphome.writer.clean_build") +@patch("esphome.writer.StorageJSON") +@patch("esphome.writer.storage_path") +@patch("esphome.writer.CORE") +def test_update_storage_json_logging_when_old_is_none( + mock_core: MagicMock, + mock_storage_path: MagicMock, + mock_storage_json_class: MagicMock, + mock_clean_build: MagicMock, + create_storage: Callable[..., StorageJSON], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that update_storage_json doesn't crash when old storage is None. + + This is a regression test for the AttributeError that occurred when + old was None and we tried to access old.loaded_integrations. + """ + # Setup mocks + mock_storage_path.return_value = "/test/path" + mock_storage_json_class.load.return_value = None # Old storage is None + + new_storage = create_storage(loaded_integrations=["api", "wifi"]) + new_storage.save = MagicMock() # Mock the save method + mock_storage_json_class.from_esphome_core.return_value = new_storage + + # Call the function - should not raise AttributeError + with caplog.at_level("INFO"): + update_storage_json() + + # Verify clean_build was called + mock_clean_build.assert_called_once() + + # Verify the correct log message was used (not the component removal message) + assert "Core config or version changed, cleaning build files..." in caplog.text + assert "Components removed" not in caplog.text + + # Verify save was called + new_storage.save.assert_called_once_with("/test/path") + + +@patch("esphome.writer.clean_build") +@patch("esphome.writer.StorageJSON") +@patch("esphome.writer.storage_path") +@patch("esphome.writer.CORE") +def test_update_storage_json_logging_components_removed( + mock_core: MagicMock, + mock_storage_path: MagicMock, + mock_storage_json_class: MagicMock, + mock_clean_build: MagicMock, + create_storage: Callable[..., StorageJSON], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that update_storage_json logs removed components correctly.""" + # Setup mocks + mock_storage_path.return_value = "/test/path" + + old_storage = create_storage(loaded_integrations=["api", "wifi", "bluetooth_proxy"]) + new_storage = create_storage(loaded_integrations=["api", "wifi"]) + new_storage.save = MagicMock() # Mock the save method + + mock_storage_json_class.load.return_value = old_storage + mock_storage_json_class.from_esphome_core.return_value = new_storage + + # Call the function + with caplog.at_level("INFO"): + update_storage_json() + + # Verify clean_build was called + mock_clean_build.assert_called_once() + + # Verify the correct log message was used with component names + assert ( + "Components removed (bluetooth_proxy), cleaning build files..." in caplog.text + ) + assert "Core config or version changed" not in caplog.text + + # Verify save was called + new_storage.save.assert_called_once_with("/test/path") From 117cffd2b0db96f466ddec2f0ff79108001fd55f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 17:51:15 -0500 Subject: [PATCH 0544/1626] [bluetooth_proxy] Remove redundant connection type check after V1 removal (#10208) --- .../components/bluetooth_proxy/bluetooth_connection.cpp | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 347f60c28f..d2cbdeb984 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -133,7 +133,7 @@ void BluetoothConnection::loop() { // Check if we should disable the loop // - For V3_WITH_CACHE: Services are never sent, disable after INIT state - // - For other connections: Disable only after service discovery is complete + // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) if (this->state_ != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || this->send_service_ == DONE_SENDING_SERVICES)) { @@ -160,10 +160,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (this->send_service_ >= this->service_count_) { this->send_service_ = DONE_SENDING_SERVICES; this->proxy_->send_gatt_services_done(this->address_); - if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { - this->release_services(); - } + this->release_services(); return; } From 5d18afcd99addd56b4ef783afc4c712d49d81500 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 22:54:35 +0000 Subject: [PATCH 0545/1626] Bump ruff from 0.12.8 to 0.12.9 (#10239) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1830e7881c..5540733131 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.8 + rev: v0.12.9 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 9ad4591a04..f0a16fd7f3 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.8 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.12.8 # also change in .pre-commit-config.yaml when updating +ruff==0.12.9 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From 5fa84439c2816e94fece2bcd7d5ee36be24fb962 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 20:26:09 -0500 Subject: [PATCH 0546/1626] [api] Optimize message buffer allocation and eliminate redundant methods (#10231) --- esphome/components/api/api_connection.cpp | 32 +++++++++--------- esphome/components/api/api_connection.h | 41 ++++------------------- 2 files changed, 24 insertions(+), 49 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cdeabb5cac..ced0f489be 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -289,16 +289,26 @@ uint16_t APIConnection::encode_message_to_buffer(ProtoMessage &msg, uint8_t mess return 0; // Doesn't fit } - // Allocate buffer space - pass payload size, allocation functions add header/footer space - ProtoWriteBuffer buffer = is_single ? conn->allocate_single_message_buffer(calculated_size) - : conn->allocate_batch_message_buffer(calculated_size); - // Get buffer size after allocation (which includes header padding) std::vector &shared_buf = conn->parent_->get_shared_buffer_ref(); - size_t size_before_encode = shared_buf.size(); + + if (is_single || conn->flags_.batch_first_message) { + // Single message or first batch message + conn->prepare_first_message_buffer(shared_buf, header_padding, total_calculated_size); + if (conn->flags_.batch_first_message) { + conn->flags_.batch_first_message = false; + } + } else { + // Batch message second or later + // Add padding for previous message footer + this message header + size_t current_size = shared_buf.size(); + shared_buf.reserve(current_size + total_calculated_size); + shared_buf.resize(current_size + footer_size + header_padding); + } // Encode directly into buffer - msg.encode(buffer); + size_t size_before_encode = shared_buf.size(); + msg.encode({&shared_buf}); // Calculate actual encoded size (not including header that was already added) size_t actual_payload_size = shared_buf.size() - size_before_encode; @@ -1620,14 +1630,6 @@ bool APIConnection::schedule_batch_() { return true; } -ProtoWriteBuffer APIConnection::allocate_single_message_buffer(uint16_t size) { return this->create_buffer(size); } - -ProtoWriteBuffer APIConnection::allocate_batch_message_buffer(uint16_t size) { - ProtoWriteBuffer result = this->prepare_message_buffer(size, this->flags_.batch_first_message); - this->flags_.batch_first_message = false; - return result; -} - void APIConnection::process_batch_() { // Ensure PacketInfo remains trivially destructible for our placement new approach static_assert(std::is_trivially_destructible::value, @@ -1735,7 +1737,7 @@ void APIConnection::process_batch_() { } remaining_size -= payload_size; // Calculate where the next message's header padding will start - // Current buffer size + footer space (that prepare_message_buffer will add for this message) + // Current buffer size + footer space for this message current_offset = shared_buf.size() + footer_size; } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index f0f308c248..076dccfad7 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -252,44 +252,21 @@ class APIConnection : public APIServerConnection { // Get header padding size - used for both reserve and insert uint8_t header_padding = this->helper_->frame_header_padding(); - // Get shared buffer from parent server std::vector &shared_buf = this->parent_->get_shared_buffer_ref(); + this->prepare_first_message_buffer(shared_buf, header_padding, + reserve_size + header_padding + this->helper_->frame_footer_size()); + return {&shared_buf}; + } + + void prepare_first_message_buffer(std::vector &shared_buf, size_t header_padding, size_t total_size) { shared_buf.clear(); // Reserve space for header padding + message + footer // - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext) // - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext) - shared_buf.reserve(reserve_size + header_padding + this->helper_->frame_footer_size()); + shared_buf.reserve(total_size); // Resize to add header padding so message encoding starts at the correct position shared_buf.resize(header_padding); - return {&shared_buf}; - } - - // Prepare buffer for next message in batch - ProtoWriteBuffer prepare_message_buffer(uint16_t message_size, bool is_first_message) { - // Get reference to shared buffer (it maintains state between batch messages) - std::vector &shared_buf = this->parent_->get_shared_buffer_ref(); - - if (is_first_message) { - shared_buf.clear(); - } - - size_t current_size = shared_buf.size(); - - // Calculate padding to add: - // - First message: just header padding - // - Subsequent messages: footer for previous message + header padding for this message - size_t padding_to_add = is_first_message - ? this->helper_->frame_header_padding() - : this->helper_->frame_header_padding() + this->helper_->frame_footer_size(); - - // Reserve space for padding + message - shared_buf.reserve(current_size + padding_to_add + message_size); - - // Resize to add the padding bytes - shared_buf.resize(current_size + padding_to_add); - - return {&shared_buf}; } bool try_to_clear_buffer(bool log_out_of_space); @@ -297,10 +274,6 @@ class APIConnection : public APIServerConnection { std::string get_client_combined_info() const { return this->client_info_.get_combined_info(); } - // Buffer allocator methods for batch processing - ProtoWriteBuffer allocate_single_message_buffer(uint16_t size); - ProtoWriteBuffer allocate_batch_message_buffer(uint16_t size); - protected: // Helper function to handle authentication completion void complete_authentication_(); From df73d81acac621666ea43d30ff20ab8f413ed3de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 21:03:51 -0500 Subject: [PATCH 0547/1626] [esp8266] Replace std::vector with std::unique_ptr in preferences to save flash --- esphome/components/esp8266/preferences.cpp | 41 ++++++++++------------ 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index efd226e8f8..bb7e436bea 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -12,7 +12,7 @@ extern "C" { #include "preferences.h" #include -#include +#include namespace esphome { namespace esp8266 { @@ -67,6 +67,8 @@ static uint32_t get_esp8266_flash_sector() { } static uint32_t get_esp8266_flash_address() { return get_esp8266_flash_sector() * SPI_FLASH_SEC_SIZE; } +static inline size_t bytes_to_words(size_t bytes) { return (bytes + 3) / 4; } + template uint32_t calculate_crc(It first, It last, uint32_t type) { uint32_t crc = type; while (first != last) { @@ -123,41 +125,36 @@ class ESP8266PreferenceBackend : public ESPPreferenceBackend { size_t length_words = 0; bool save(const uint8_t *data, size_t len) override { - if ((len + 3) / 4 != length_words) { + if (bytes_to_words(len) != length_words) { return false; } - std::vector buffer; - buffer.resize(length_words + 1); - memcpy(buffer.data(), data, len); - buffer[buffer.size() - 1] = calculate_crc(buffer.begin(), buffer.end() - 1, type); + size_t buffer_size = length_words + 1; + std::unique_ptr buffer(new uint32_t[buffer_size]()); // Note the () for zero-initialization + memcpy(buffer.get(), data, len); + buffer[length_words] = calculate_crc(buffer.get(), buffer.get() + length_words, type); if (in_flash) { - return save_to_flash(offset, buffer.data(), buffer.size()); - } else { - return save_to_rtc(offset, buffer.data(), buffer.size()); + return save_to_flash(offset, buffer.get(), buffer_size); } + return save_to_rtc(offset, buffer.get(), buffer_size); } bool load(uint8_t *data, size_t len) override { - if ((len + 3) / 4 != length_words) { + if (bytes_to_words(len) != length_words) { return false; } - std::vector buffer; - buffer.resize(length_words + 1); - bool ret; - if (in_flash) { - ret = load_from_flash(offset, buffer.data(), buffer.size()); - } else { - ret = load_from_rtc(offset, buffer.data(), buffer.size()); - } + size_t buffer_size = length_words + 1; + std::unique_ptr buffer(new uint32_t[buffer_size]()); + bool ret = in_flash ? load_from_flash(offset, buffer.get(), buffer_size) + : load_from_rtc(offset, buffer.get(), buffer_size); if (!ret) return false; - uint32_t crc = calculate_crc(buffer.begin(), buffer.end() - 1, type); - if (buffer[buffer.size() - 1] != crc) { + uint32_t crc = calculate_crc(buffer.get(), buffer.get() + length_words, type); + if (buffer[length_words] != crc) { return false; } - memcpy(data, buffer.data(), len); + memcpy(data, buffer.get(), len); return true; } }; @@ -178,7 +175,7 @@ class ESP8266Preferences : public ESPPreferences { } ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) override { - uint32_t length_words = (length + 3) / 4; + uint32_t length_words = bytes_to_words(length); if (in_flash) { uint32_t start = current_flash_offset; uint32_t end = start + length_words + 1; From e9e94bcd4578ba7e73505d7936bda7fb5b791c8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 21:19:36 -0500 Subject: [PATCH 0548/1626] [esp32] Optimize preferences is_changed() by replacing temporary vector with unique_ptr --- esphome/components/esp32/preferences.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index e53cdd90d3..63757e5d69 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace esphome { namespace esp32 { @@ -156,20 +157,24 @@ class ESP32Preferences : public ESPPreferences { return failed == 0; } bool is_changed(const uint32_t nvs_handle, const NVSData &to_save) { - NVSData stored_data{}; size_t actual_len; esp_err_t err = nvs_get_blob(nvs_handle, to_save.key.c_str(), nullptr, &actual_len); if (err != 0) { ESP_LOGV(TAG, "nvs_get_blob('%s'): %s - the key might not be set yet", to_save.key.c_str(), esp_err_to_name(err)); return true; } - stored_data.data.resize(actual_len); - err = nvs_get_blob(nvs_handle, to_save.key.c_str(), stored_data.data.data(), &actual_len); + // Check size first before allocating memory + if (actual_len != to_save.data.size()) { + return true; + } + // Use unique_ptr to avoid vector overhead for temporary comparison + std::unique_ptr stored_data(new uint8_t[actual_len]); + err = nvs_get_blob(nvs_handle, to_save.key.c_str(), stored_data.get(), &actual_len); if (err != 0) { ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", to_save.key.c_str(), esp_err_to_name(err)); return true; } - return to_save.data != stored_data.data; + return memcmp(to_save.data.data(), stored_data.get(), actual_len) != 0; } bool reset() override { From 269786cac9544b8f8c08e5af05961bc211c4626f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 21:59:46 -0500 Subject: [PATCH 0549/1626] preen --- esphome/components/esp32_ble/ble_event.h | 60 ++++++++++++++++-------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index 884fc9ba65..d883d367b2 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -3,7 +3,9 @@ #ifdef USE_ESP32 #include // for offsetof +#include // for memcpy #include +#include // for std::unique_ptr #include #include @@ -145,16 +147,14 @@ class BLEEvent { } if (this->type_ == GATTC) { delete this->event_.gattc.gattc_param; - delete this->event_.gattc.data; this->event_.gattc.gattc_param = nullptr; - this->event_.gattc.data = nullptr; + this->reset_gattc_data_(); return; } if (this->type_ == GATTS) { delete this->event_.gatts.gatts_param; - delete this->event_.gatts.data; this->event_.gatts.gatts_param = nullptr; - this->event_.gatts.data = nullptr; + this->reset_gatts_data_(); } } @@ -209,17 +209,19 @@ class BLEEvent { esp_gattc_cb_event_t gattc_event; esp_gatt_if_t gattc_if; esp_ble_gattc_cb_param_t *gattc_param; // Heap-allocated - std::vector *data; // Heap-allocated - } gattc; // 16 bytes (pointers only) + uint8_t *data; // Heap-allocated raw buffer (manually managed) + uint16_t data_len; // Track size separately + } gattc; // NOLINTNEXTLINE(readability-identifier-naming) struct gatts_event { esp_gatts_cb_event_t gatts_event; esp_gatt_if_t gatts_if; esp_ble_gatts_cb_param_t *gatts_param; // Heap-allocated - std::vector *data; // Heap-allocated - } gatts; // 16 bytes (pointers only) - } event_; // 80 bytes + uint8_t *data; // Heap-allocated raw buffer (manually managed) + uint16_t data_len; // Track size separately + } gatts; + } event_; // 80 bytes ble_event_t type_; @@ -233,6 +235,20 @@ class BLEEvent { const esp_ble_sec_t &security() const { return event_.gap.security; } private: + // Helper to reset GATTC data + void reset_gattc_data_() { + delete[] this->event_.gattc.data; + this->event_.gattc.data = nullptr; + this->event_.gattc.data_len = 0; + } + + // Helper to reset GATTS data + void reset_gatts_data_() { + delete[] this->event_.gatts.data; + this->event_.gatts.data = nullptr; + this->event_.gatts.data_len = 0; + } + // Initialize GAP event data void init_gap_data_(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) { this->event_.gap.gap_event = e; @@ -318,7 +334,7 @@ class BLEEvent { if (p == nullptr) { this->event_.gattc.gattc_param = nullptr; - this->event_.gattc.data = nullptr; + this->reset_gattc_data_(); return; // Invalid event, but we can't log in header file } @@ -336,16 +352,20 @@ class BLEEvent { // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTC_NOTIFY_EVT: - this->event_.gattc.data = new std::vector(p->notify.value, p->notify.value + p->notify.value_len); - this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data->data(); + this->event_.gattc.data_len = p->notify.value_len; + this->event_.gattc.data = new uint8_t[p->notify.value_len]; + memcpy(this->event_.gattc.data, p->notify.value, p->notify.value_len); + this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data; break; case ESP_GATTC_READ_CHAR_EVT: case ESP_GATTC_READ_DESCR_EVT: - this->event_.gattc.data = new std::vector(p->read.value, p->read.value + p->read.value_len); - this->event_.gattc.gattc_param->read.value = this->event_.gattc.data->data(); + this->event_.gattc.data_len = p->read.value_len; + this->event_.gattc.data = new uint8_t[p->read.value_len]; + memcpy(this->event_.gattc.data, p->read.value, p->read.value_len); + this->event_.gattc.gattc_param->read.value = this->event_.gattc.data; break; default: - this->event_.gattc.data = nullptr; + this->reset_gattc_data_(); break; } } @@ -357,7 +377,7 @@ class BLEEvent { if (p == nullptr) { this->event_.gatts.gatts_param = nullptr; - this->event_.gatts.data = nullptr; + this->reset_gatts_data_(); return; // Invalid event, but we can't log in header file } @@ -375,11 +395,13 @@ class BLEEvent { // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTS_WRITE_EVT: - this->event_.gatts.data = new std::vector(p->write.value, p->write.value + p->write.len); - this->event_.gatts.gatts_param->write.value = this->event_.gatts.data->data(); + this->event_.gatts.data_len = p->write.len; + this->event_.gatts.data = new uint8_t[p->write.len]; + memcpy(this->event_.gatts.data, p->write.value, p->write.len); + this->event_.gatts.gatts_param->write.value = this->event_.gatts.data; break; default: - this->event_.gatts.data = nullptr; + this->reset_gatts_data_(); break; } } From 1fa33253f812df2108d8af3172c6d0d981c00165 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 22:04:10 -0500 Subject: [PATCH 0550/1626] wip --- esphome/components/esp32_ble/ble_event.h | 34 ++++++++++-------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index d883d367b2..ff9f1fcf3f 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -147,14 +147,18 @@ class BLEEvent { } if (this->type_ == GATTC) { delete this->event_.gattc.gattc_param; + delete[] this->event_.gattc.data; this->event_.gattc.gattc_param = nullptr; - this->reset_gattc_data_(); + this->event_.gattc.data = nullptr; + this->event_.gattc.data_len = 0; return; } if (this->type_ == GATTS) { delete this->event_.gatts.gatts_param; + delete[] this->event_.gatts.data; this->event_.gatts.gatts_param = nullptr; - this->reset_gatts_data_(); + this->event_.gatts.data = nullptr; + this->event_.gatts.data_len = 0; } } @@ -235,20 +239,6 @@ class BLEEvent { const esp_ble_sec_t &security() const { return event_.gap.security; } private: - // Helper to reset GATTC data - void reset_gattc_data_() { - delete[] this->event_.gattc.data; - this->event_.gattc.data = nullptr; - this->event_.gattc.data_len = 0; - } - - // Helper to reset GATTS data - void reset_gatts_data_() { - delete[] this->event_.gatts.data; - this->event_.gatts.data = nullptr; - this->event_.gatts.data_len = 0; - } - // Initialize GAP event data void init_gap_data_(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) { this->event_.gap.gap_event = e; @@ -334,7 +324,8 @@ class BLEEvent { if (p == nullptr) { this->event_.gattc.gattc_param = nullptr; - this->reset_gattc_data_(); + this->event_.gattc.data = nullptr; + this->event_.gattc.data_len = 0; return; // Invalid event, but we can't log in header file } @@ -365,7 +356,8 @@ class BLEEvent { this->event_.gattc.gattc_param->read.value = this->event_.gattc.data; break; default: - this->reset_gattc_data_(); + this->event_.gattc.data = nullptr; + this->event_.gattc.data_len = 0; break; } } @@ -377,7 +369,8 @@ class BLEEvent { if (p == nullptr) { this->event_.gatts.gatts_param = nullptr; - this->reset_gatts_data_(); + this->event_.gatts.data = nullptr; + this->event_.gatts.data_len = 0; return; // Invalid event, but we can't log in header file } @@ -401,7 +394,8 @@ class BLEEvent { this->event_.gatts.gatts_param->write.value = this->event_.gatts.data; break; default: - this->reset_gatts_data_(); + this->event_.gatts.data = nullptr; + this->event_.gatts.data_len = 0; break; } } From 0ee3155123c7c4876dcbd4fedbc44947beb2e5b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 22:20:08 -0500 Subject: [PATCH 0551/1626] fix --- esphome/components/esp32_ble/ble_event.h | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index ff9f1fcf3f..bb551bd867 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -344,15 +344,23 @@ class BLEEvent { switch (e) { case ESP_GATTC_NOTIFY_EVT: this->event_.gattc.data_len = p->notify.value_len; - this->event_.gattc.data = new uint8_t[p->notify.value_len]; - memcpy(this->event_.gattc.data, p->notify.value, p->notify.value_len); + if (p->notify.value_len > 0) { + this->event_.gattc.data = new uint8_t[p->notify.value_len]; + memcpy(this->event_.gattc.data, p->notify.value, p->notify.value_len); + } else { + this->event_.gattc.data = nullptr; + } this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data; break; case ESP_GATTC_READ_CHAR_EVT: case ESP_GATTC_READ_DESCR_EVT: this->event_.gattc.data_len = p->read.value_len; - this->event_.gattc.data = new uint8_t[p->read.value_len]; - memcpy(this->event_.gattc.data, p->read.value, p->read.value_len); + if (p->read.value_len > 0) { + this->event_.gattc.data = new uint8_t[p->read.value_len]; + memcpy(this->event_.gattc.data, p->read.value, p->read.value_len); + } else { + this->event_.gattc.data = nullptr; + } this->event_.gattc.gattc_param->read.value = this->event_.gattc.data; break; default: @@ -389,8 +397,12 @@ class BLEEvent { switch (e) { case ESP_GATTS_WRITE_EVT: this->event_.gatts.data_len = p->write.len; - this->event_.gatts.data = new uint8_t[p->write.len]; - memcpy(this->event_.gatts.data, p->write.value, p->write.len); + if (p->write.len > 0) { + this->event_.gatts.data = new uint8_t[p->write.len]; + memcpy(this->event_.gatts.data, p->write.value, p->write.len); + } else { + this->event_.gatts.data = nullptr; + } this->event_.gatts.gatts_param->write.value = this->event_.gatts.data; break; default: From 9f4d0d3f23d6276f2fbe857cfec00461a9d0f44b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 22:42:19 -0500 Subject: [PATCH 0552/1626] preen --- esphome/components/esp32/preferences.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 63757e5d69..1a5ac93a5e 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -168,7 +168,7 @@ class ESP32Preferences : public ESPPreferences { return true; } // Use unique_ptr to avoid vector overhead for temporary comparison - std::unique_ptr stored_data(new uint8_t[actual_len]); + auto stored_data = std::make_unique(actual_len); err = nvs_get_blob(nvs_handle, to_save.key.c_str(), stored_data.get(), &actual_len); if (err != 0) { ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", to_save.key.c_str(), esp_err_to_name(err)); From da5020354fe9e2d31d6e3efdbf897cf2a44824d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 22:59:37 -0500 Subject: [PATCH 0553/1626] preen --- esphome/components/esp32/preferences.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 1a5ac93a5e..cb18c6f5f1 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -167,7 +167,6 @@ class ESP32Preferences : public ESPPreferences { if (actual_len != to_save.data.size()) { return true; } - // Use unique_ptr to avoid vector overhead for temporary comparison auto stored_data = std::make_unique(actual_len); err = nvs_get_blob(nvs_handle, to_save.key.c_str(), stored_data.get(), &actual_len); if (err != 0) { From 14895adf479b53fb150e3245bc0ac0d26173520e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 23:38:25 -0500 Subject: [PATCH 0554/1626] Update esphome/components/esp32_ble/ble_event.h Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32_ble/ble_event.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index bb551bd867..fb3b315fe8 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -6,7 +6,6 @@ #include // for memcpy #include #include // for std::unique_ptr - #include #include #include From ce6d71e942dcc05963e05d9cae51d31d275248fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 23:38:54 -0500 Subject: [PATCH 0555/1626] Update esphome/components/esp32_ble/ble_event.h --- esphome/components/esp32_ble/ble_event.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index fb3b315fe8..20e334cd6d 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -5,7 +5,6 @@ #include // for offsetof #include // for memcpy #include -#include // for std::unique_ptr #include #include #include From c32584d48ee206b3d05562a9219e0481fb09bfec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 23:39:29 -0500 Subject: [PATCH 0556/1626] preen --- esphome/components/esp32_ble/ble_event.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index 20e334cd6d..2c0ab1d34e 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -4,7 +4,6 @@ #include // for offsetof #include // for memcpy -#include #include #include #include @@ -64,7 +63,7 @@ static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.remote_addr) == si // Received GAP, GATTC and GATTS events are only queued, and get processed in the main loop(). // This class stores each event with minimal memory usage. -// GAP events (99% of traffic) don't have the vector overhead. +// GAP events (99% of traffic) don't have the heap allocation overhead. // GATTC/GATTS events use heap allocation for their param and data. // // Event flow: From 04b0a829636f8e66de3356294728ff395f093041 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 23:49:05 -0500 Subject: [PATCH 0557/1626] Update esphome/components/esp32/preferences.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32/preferences.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index cb18c6f5f1..c5b07b497c 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -173,7 +173,7 @@ class ESP32Preferences : public ESPPreferences { ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", to_save.key.c_str(), esp_err_to_name(err)); return true; } - return memcmp(to_save.data.data(), stored_data.get(), actual_len) != 0; + return memcmp(to_save.data.data(), stored_data.get(), to_save.data.size()) != 0; } bool reset() override { From c2abb2c8ba6ce0144f7d72c7cf86d910e0b7c205 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 00:49:25 -0500 Subject: [PATCH 0558/1626] [esp32_ble] Use union space for inline GATTC/GATTS data storage to reduce heap allocations --- esphome/components/esp32_ble/ble_event.h | 163 ++++++++++++++--------- 1 file changed, 101 insertions(+), 62 deletions(-) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index 2c0ab1d34e..fb3dc54c1e 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -61,10 +61,14 @@ static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.rssi) == sizeof(es static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.remote_addr) == sizeof(esp_bt_status_t) + sizeof(int8_t), "remote_addr must follow rssi in read_rssi_cmpl"); +// Maximum size for inline storage of GATTC/GATTS data +// This value is chosen to fit within the 80-byte union while maximizing inline storage +static constexpr size_t INLINE_DATA_SIZE = 68; + // Received GAP, GATTC and GATTS events are only queued, and get processed in the main loop(). // This class stores each event with minimal memory usage. // GAP events (99% of traffic) don't have the heap allocation overhead. -// GATTC/GATTS events use heap allocation for their param and data. +// GATTC/GATTS events use heap allocation for their param and inline storage for small data. // // Event flow: // 1. ESP-IDF BLE stack calls our static handlers in the BLE task context @@ -135,27 +139,36 @@ class BLEEvent { ~BLEEvent() { this->release(); } // Default constructor for pre-allocation in pool - BLEEvent() : type_(GAP) {} + BLEEvent() : type_(GAP), event_{} {} // Invoked on return to EventPool - clean up any heap-allocated data void release() { - if (this->type_ == GAP) { - return; - } - if (this->type_ == GATTC) { - delete this->event_.gattc.gattc_param; - delete[] this->event_.gattc.data; - this->event_.gattc.gattc_param = nullptr; - this->event_.gattc.data = nullptr; - this->event_.gattc.data_len = 0; - return; - } - if (this->type_ == GATTS) { - delete this->event_.gatts.gatts_param; - delete[] this->event_.gatts.data; - this->event_.gatts.gatts_param = nullptr; - this->event_.gatts.data = nullptr; - this->event_.gatts.data_len = 0; + switch (this->type_) { + case GAP: + // GAP events don't have heap allocations + break; + case GATTC: + delete this->event_.gattc.gattc_param; + // Only delete heap data if it was heap-allocated + if (!this->event_.gattc.is_inline && this->event_.gattc.data.heap_data != nullptr) { + delete[] this->event_.gattc.data.heap_data; + } + // Clear critical fields to prevent issues if type changes + this->event_.gattc.gattc_param = nullptr; + this->event_.gattc.is_inline = false; + this->event_.gattc.data.heap_data = nullptr; + break; + case GATTS: + delete this->event_.gatts.gatts_param; + // Only delete heap data if it was heap-allocated + if (!this->event_.gatts.is_inline && this->event_.gatts.data.heap_data != nullptr) { + delete[] this->event_.gatts.data.heap_data; + } + // Clear critical fields to prevent issues if type changes + this->event_.gatts.gatts_param = nullptr; + this->event_.gatts.is_inline = false; + this->event_.gatts.data.heap_data = nullptr; + break; } } @@ -207,22 +220,30 @@ class BLEEvent { // NOLINTNEXTLINE(readability-identifier-naming) struct gattc_event { - esp_gattc_cb_event_t gattc_event; - esp_gatt_if_t gattc_if; - esp_ble_gattc_cb_param_t *gattc_param; // Heap-allocated - uint8_t *data; // Heap-allocated raw buffer (manually managed) - uint16_t data_len; // Track size separately - } gattc; + esp_ble_gattc_cb_param_t *gattc_param; // Heap-allocated (4 bytes) + esp_gattc_cb_event_t gattc_event; // 4 bytes + union { + uint8_t *heap_data; // 4 bytes when heap-allocated + uint8_t inline_data[INLINE_DATA_SIZE]; // INLINE_DATA_SIZE bytes when stored inline + } data; // INLINE_DATA_SIZE bytes total + uint16_t data_len; // 2 bytes + esp_gatt_if_t gattc_if; // 1 byte + bool is_inline; // 1 byte - true when data is stored inline + } gattc; // Total: 80 bytes // NOLINTNEXTLINE(readability-identifier-naming) struct gatts_event { - esp_gatts_cb_event_t gatts_event; - esp_gatt_if_t gatts_if; - esp_ble_gatts_cb_param_t *gatts_param; // Heap-allocated - uint8_t *data; // Heap-allocated raw buffer (manually managed) - uint16_t data_len; // Track size separately - } gatts; - } event_; // 80 bytes + esp_ble_gatts_cb_param_t *gatts_param; // Heap-allocated (4 bytes) + esp_gatts_cb_event_t gatts_event; // 4 bytes + union { + uint8_t *heap_data; // 4 bytes when heap-allocated + uint8_t inline_data[INLINE_DATA_SIZE]; // INLINE_DATA_SIZE bytes when stored inline + } data; // INLINE_DATA_SIZE bytes total + uint16_t data_len; // 2 bytes + esp_gatt_if_t gatts_if; // 1 byte + bool is_inline; // 1 byte - true when data is stored inline + } gatts; // Total: 80 bytes + } event_; // 80 bytes ble_event_t type_; @@ -236,6 +257,29 @@ class BLEEvent { const esp_ble_sec_t &security() const { return event_.gap.security; } private: + // Helper to copy data with inline storage optimization + template + void copy_data_with_inline_storage_(EventStruct &event, const uint8_t *src_data, uint16_t len, + uint8_t **param_value_ptr) { + event.data_len = len; + if (len > 0) { + if (len <= INLINE_DATA_SIZE) { + event.is_inline = true; + memcpy(event.data.inline_data, src_data, len); + *param_value_ptr = event.data.inline_data; + } else { + event.is_inline = false; + event.data.heap_data = new uint8_t[len]; + memcpy(event.data.heap_data, src_data, len); + *param_value_ptr = event.data.heap_data; + } + } else { + event.is_inline = false; + event.data.heap_data = nullptr; + *param_value_ptr = nullptr; + } + } + // Initialize GAP event data void init_gap_data_(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) { this->event_.gap.gap_event = e; @@ -321,12 +365,13 @@ class BLEEvent { if (p == nullptr) { this->event_.gattc.gattc_param = nullptr; - this->event_.gattc.data = nullptr; + this->event_.gattc.is_inline = false; + this->event_.gattc.data.heap_data = nullptr; this->event_.gattc.data_len = 0; return; // Invalid event, but we can't log in header file } - // Heap-allocate param and data + // Heap-allocate param // Heap allocation is used because GATTC/GATTS events are rare (<1% of events) // while GAP events (99%) are stored inline to minimize memory usage // IMPORTANT: This heap allocation provides clear ownership semantics: @@ -340,28 +385,17 @@ class BLEEvent { // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTC_NOTIFY_EVT: - this->event_.gattc.data_len = p->notify.value_len; - if (p->notify.value_len > 0) { - this->event_.gattc.data = new uint8_t[p->notify.value_len]; - memcpy(this->event_.gattc.data, p->notify.value, p->notify.value_len); - } else { - this->event_.gattc.data = nullptr; - } - this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data; + copy_data_with_inline_storage_(this->event_.gattc, p->notify.value, p->notify.value_len, + &this->event_.gattc.gattc_param->notify.value); break; case ESP_GATTC_READ_CHAR_EVT: case ESP_GATTC_READ_DESCR_EVT: - this->event_.gattc.data_len = p->read.value_len; - if (p->read.value_len > 0) { - this->event_.gattc.data = new uint8_t[p->read.value_len]; - memcpy(this->event_.gattc.data, p->read.value, p->read.value_len); - } else { - this->event_.gattc.data = nullptr; - } - this->event_.gattc.gattc_param->read.value = this->event_.gattc.data; + copy_data_with_inline_storage_(this->event_.gattc, p->read.value, p->read.value_len, + &this->event_.gattc.gattc_param->read.value); break; default: - this->event_.gattc.data = nullptr; + this->event_.gattc.is_inline = false; + this->event_.gattc.data.heap_data = nullptr; this->event_.gattc.data_len = 0; break; } @@ -374,12 +408,13 @@ class BLEEvent { if (p == nullptr) { this->event_.gatts.gatts_param = nullptr; - this->event_.gatts.data = nullptr; + this->event_.gatts.is_inline = false; + this->event_.gatts.data.heap_data = nullptr; this->event_.gatts.data_len = 0; return; // Invalid event, but we can't log in header file } - // Heap-allocate param and data + // Heap-allocate param // Heap allocation is used because GATTC/GATTS events are rare (<1% of events) // while GAP events (99%) are stored inline to minimize memory usage // IMPORTANT: This heap allocation provides clear ownership semantics: @@ -393,17 +428,12 @@ class BLEEvent { // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTS_WRITE_EVT: - this->event_.gatts.data_len = p->write.len; - if (p->write.len > 0) { - this->event_.gatts.data = new uint8_t[p->write.len]; - memcpy(this->event_.gatts.data, p->write.value, p->write.len); - } else { - this->event_.gatts.data = nullptr; - } - this->event_.gatts.gatts_param->write.value = this->event_.gatts.data; + copy_data_with_inline_storage_(this->event_.gatts, p->write.value, p->write.len, + &this->event_.gatts.gatts_param->write.value); break; default: - this->event_.gatts.data = nullptr; + this->event_.gatts.is_inline = false; + this->event_.gatts.data.heap_data = nullptr; this->event_.gatts.data_len = 0; break; } @@ -414,6 +444,15 @@ class BLEEvent { // The gap member in the union should be 80 bytes (including the gap_event enum) static_assert(sizeof(decltype(((BLEEvent *) nullptr)->event_.gap)) <= 80, "gap_event struct has grown beyond 80 bytes"); +// Verify GATTC and GATTS structs don't exceed GAP struct size +// This ensures the union size is determined by GAP (the most common event type) +static_assert(sizeof(decltype(((BLEEvent *) nullptr)->event_.gattc)) <= + sizeof(decltype(((BLEEvent *) nullptr)->event_.gap)), + "gattc_event struct exceeds gap_event size - union size would increase"); +static_assert(sizeof(decltype(((BLEEvent *) nullptr)->event_.gatts)) <= + sizeof(decltype(((BLEEvent *) nullptr)->event_.gap)), + "gatts_event struct exceeds gap_event size - union size would increase"); + // Verify esp_ble_sec_t fits within our union static_assert(sizeof(esp_ble_sec_t) <= 73, "esp_ble_sec_t is larger than BLEScanResult"); From 0d966ac11549555aaee07f0cd3b48ed494177c87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 00:58:52 -0500 Subject: [PATCH 0559/1626] preen --- esphome/components/esp32_ble/ble_event.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index fb3dc54c1e..bdac1d6458 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -139,7 +139,7 @@ class BLEEvent { ~BLEEvent() { this->release(); } // Default constructor for pre-allocation in pool - BLEEvent() : type_(GAP), event_{} {} + BLEEvent() : event_{}, type_(GAP) {} // Invoked on return to EventPool - clean up any heap-allocated data void release() { From af9ecf3429b410046162ba951b765ab8c3e256a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 02:38:27 -0500 Subject: [PATCH 0560/1626] [esp32_ble] Optimize BLE event memory usage by eliminating std::vector overhead (#10247) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32_ble/ble_event.h | 57 +++++++++++++++++------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index 884fc9ba65..2c0ab1d34e 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -3,8 +3,7 @@ #ifdef USE_ESP32 #include // for offsetof -#include - +#include // for memcpy #include #include #include @@ -64,7 +63,7 @@ static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.remote_addr) == si // Received GAP, GATTC and GATTS events are only queued, and get processed in the main loop(). // This class stores each event with minimal memory usage. -// GAP events (99% of traffic) don't have the vector overhead. +// GAP events (99% of traffic) don't have the heap allocation overhead. // GATTC/GATTS events use heap allocation for their param and data. // // Event flow: @@ -145,16 +144,18 @@ class BLEEvent { } if (this->type_ == GATTC) { delete this->event_.gattc.gattc_param; - delete this->event_.gattc.data; + delete[] this->event_.gattc.data; this->event_.gattc.gattc_param = nullptr; this->event_.gattc.data = nullptr; + this->event_.gattc.data_len = 0; return; } if (this->type_ == GATTS) { delete this->event_.gatts.gatts_param; - delete this->event_.gatts.data; + delete[] this->event_.gatts.data; this->event_.gatts.gatts_param = nullptr; this->event_.gatts.data = nullptr; + this->event_.gatts.data_len = 0; } } @@ -209,17 +210,19 @@ class BLEEvent { esp_gattc_cb_event_t gattc_event; esp_gatt_if_t gattc_if; esp_ble_gattc_cb_param_t *gattc_param; // Heap-allocated - std::vector *data; // Heap-allocated - } gattc; // 16 bytes (pointers only) + uint8_t *data; // Heap-allocated raw buffer (manually managed) + uint16_t data_len; // Track size separately + } gattc; // NOLINTNEXTLINE(readability-identifier-naming) struct gatts_event { esp_gatts_cb_event_t gatts_event; esp_gatt_if_t gatts_if; esp_ble_gatts_cb_param_t *gatts_param; // Heap-allocated - std::vector *data; // Heap-allocated - } gatts; // 16 bytes (pointers only) - } event_; // 80 bytes + uint8_t *data; // Heap-allocated raw buffer (manually managed) + uint16_t data_len; // Track size separately + } gatts; + } event_; // 80 bytes ble_event_t type_; @@ -319,6 +322,7 @@ class BLEEvent { if (p == nullptr) { this->event_.gattc.gattc_param = nullptr; this->event_.gattc.data = nullptr; + this->event_.gattc.data_len = 0; return; // Invalid event, but we can't log in header file } @@ -336,16 +340,29 @@ class BLEEvent { // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTC_NOTIFY_EVT: - this->event_.gattc.data = new std::vector(p->notify.value, p->notify.value + p->notify.value_len); - this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data->data(); + this->event_.gattc.data_len = p->notify.value_len; + if (p->notify.value_len > 0) { + this->event_.gattc.data = new uint8_t[p->notify.value_len]; + memcpy(this->event_.gattc.data, p->notify.value, p->notify.value_len); + } else { + this->event_.gattc.data = nullptr; + } + this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data; break; case ESP_GATTC_READ_CHAR_EVT: case ESP_GATTC_READ_DESCR_EVT: - this->event_.gattc.data = new std::vector(p->read.value, p->read.value + p->read.value_len); - this->event_.gattc.gattc_param->read.value = this->event_.gattc.data->data(); + this->event_.gattc.data_len = p->read.value_len; + if (p->read.value_len > 0) { + this->event_.gattc.data = new uint8_t[p->read.value_len]; + memcpy(this->event_.gattc.data, p->read.value, p->read.value_len); + } else { + this->event_.gattc.data = nullptr; + } + this->event_.gattc.gattc_param->read.value = this->event_.gattc.data; break; default: this->event_.gattc.data = nullptr; + this->event_.gattc.data_len = 0; break; } } @@ -358,6 +375,7 @@ class BLEEvent { if (p == nullptr) { this->event_.gatts.gatts_param = nullptr; this->event_.gatts.data = nullptr; + this->event_.gatts.data_len = 0; return; // Invalid event, but we can't log in header file } @@ -375,11 +393,18 @@ class BLEEvent { // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTS_WRITE_EVT: - this->event_.gatts.data = new std::vector(p->write.value, p->write.value + p->write.len); - this->event_.gatts.gatts_param->write.value = this->event_.gatts.data->data(); + this->event_.gatts.data_len = p->write.len; + if (p->write.len > 0) { + this->event_.gatts.data = new uint8_t[p->write.len]; + memcpy(this->event_.gatts.data, p->write.value, p->write.len); + } else { + this->event_.gatts.data = nullptr; + } + this->event_.gatts.gatts_param->write.value = this->event_.gatts.data; break; default: this->event_.gatts.data = nullptr; + this->event_.gatts.data_len = 0; break; } } From 7005da42bb4933768d8cf8485baca81a637c38a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 08:52:21 -0500 Subject: [PATCH 0561/1626] preen --- esphome/components/esp32_ble/ble_event.h | 132 ++++++++++++----------- 1 file changed, 68 insertions(+), 64 deletions(-) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index bdac1d6458..299fd7705f 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -61,9 +61,19 @@ static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.rssi) == sizeof(es static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.remote_addr) == sizeof(esp_bt_status_t) + sizeof(int8_t), "remote_addr must follow rssi in read_rssi_cmpl"); -// Maximum size for inline storage of GATTC/GATTS data -// This value is chosen to fit within the 80-byte union while maximizing inline storage -static constexpr size_t INLINE_DATA_SIZE = 68; +// Param struct sizes on ESP32 +static constexpr size_t GATTC_PARAM_SIZE = 28; +static constexpr size_t GATTS_PARAM_SIZE = 32; + +// Maximum size for inline storage of data +// GATTC: 80 - 28 (param) - 8 (other fields) = 44 bytes for data +// GATTS: 80 - 32 (param) - 8 (other fields) = 40 bytes for data +static constexpr size_t GATTC_INLINE_DATA_SIZE = 44; +static constexpr size_t GATTS_INLINE_DATA_SIZE = 40; + +// Verify param struct sizes +static_assert(sizeof(esp_ble_gattc_cb_param_t) == GATTC_PARAM_SIZE, "GATTC param size unexpected"); +static_assert(sizeof(esp_ble_gatts_cb_param_t) == GATTS_PARAM_SIZE, "GATTS param size unexpected"); // Received GAP, GATTC and GATTS events are only queued, and get processed in the main loop(). // This class stores each event with minimal memory usage. @@ -115,21 +125,21 @@ class BLEEvent { this->init_gap_data_(e, p); } - // Constructor for GATTC events - uses heap allocation - // IMPORTANT: The heap allocation is REQUIRED and must not be removed as an optimization. - // The param pointer from ESP-IDF is only valid during the callback execution. - // Since BLE events are processed asynchronously in the main loop, we must create - // our own copy to ensure the data remains valid until the event is processed. + // Constructor for GATTC events - param stored inline, data may use heap + // IMPORTANT: We MUST copy the param struct because the pointer from ESP-IDF + // is only valid during the callback execution. Since BLE events are processed + // asynchronously in the main loop, we store our own copy inline to ensure + // the data remains valid until the event is processed. BLEEvent(esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) { this->type_ = GATTC; this->init_gattc_data_(e, i, p); } - // Constructor for GATTS events - uses heap allocation - // IMPORTANT: The heap allocation is REQUIRED and must not be removed as an optimization. - // The param pointer from ESP-IDF is only valid during the callback execution. - // Since BLE events are processed asynchronously in the main loop, we must create - // our own copy to ensure the data remains valid until the event is processed. + // Constructor for GATTS events - param stored inline, data may use heap + // IMPORTANT: We MUST copy the param struct because the pointer from ESP-IDF + // is only valid during the callback execution. Since BLE events are processed + // asynchronously in the main loop, we store our own copy inline to ensure + // the data remains valid until the event is processed. BLEEvent(esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) { this->type_ = GATTS; this->init_gatts_data_(e, i, p); @@ -148,24 +158,20 @@ class BLEEvent { // GAP events don't have heap allocations break; case GATTC: - delete this->event_.gattc.gattc_param; - // Only delete heap data if it was heap-allocated + // Param is now stored inline, only delete heap data if it was heap-allocated if (!this->event_.gattc.is_inline && this->event_.gattc.data.heap_data != nullptr) { delete[] this->event_.gattc.data.heap_data; } // Clear critical fields to prevent issues if type changes - this->event_.gattc.gattc_param = nullptr; this->event_.gattc.is_inline = false; this->event_.gattc.data.heap_data = nullptr; break; case GATTS: - delete this->event_.gatts.gatts_param; - // Only delete heap data if it was heap-allocated + // Param is now stored inline, only delete heap data if it was heap-allocated if (!this->event_.gatts.is_inline && this->event_.gatts.data.heap_data != nullptr) { delete[] this->event_.gatts.data.heap_data; } // Clear critical fields to prevent issues if type changes - this->event_.gatts.gatts_param = nullptr; this->event_.gatts.is_inline = false; this->event_.gatts.data.heap_data = nullptr; break; @@ -220,30 +226,30 @@ class BLEEvent { // NOLINTNEXTLINE(readability-identifier-naming) struct gattc_event { - esp_ble_gattc_cb_param_t *gattc_param; // Heap-allocated (4 bytes) - esp_gattc_cb_event_t gattc_event; // 4 bytes + esp_ble_gattc_cb_param_t gattc_param; // Stored inline (28 bytes) + esp_gattc_cb_event_t gattc_event; // 4 bytes union { - uint8_t *heap_data; // 4 bytes when heap-allocated - uint8_t inline_data[INLINE_DATA_SIZE]; // INLINE_DATA_SIZE bytes when stored inline - } data; // INLINE_DATA_SIZE bytes total - uint16_t data_len; // 2 bytes - esp_gatt_if_t gattc_if; // 1 byte - bool is_inline; // 1 byte - true when data is stored inline - } gattc; // Total: 80 bytes + uint8_t *heap_data; // 4 bytes when heap-allocated + uint8_t inline_data[GATTC_INLINE_DATA_SIZE]; // 44 bytes when stored inline + } data; // 44 bytes total + uint16_t data_len; // 2 bytes + esp_gatt_if_t gattc_if; // 1 byte + bool is_inline; // 1 byte - true when data is stored inline + } gattc; // Total: 80 bytes // NOLINTNEXTLINE(readability-identifier-naming) struct gatts_event { - esp_ble_gatts_cb_param_t *gatts_param; // Heap-allocated (4 bytes) - esp_gatts_cb_event_t gatts_event; // 4 bytes + esp_ble_gatts_cb_param_t gatts_param; // Stored inline (32 bytes) + esp_gatts_cb_event_t gatts_event; // 4 bytes union { - uint8_t *heap_data; // 4 bytes when heap-allocated - uint8_t inline_data[INLINE_DATA_SIZE]; // INLINE_DATA_SIZE bytes when stored inline - } data; // INLINE_DATA_SIZE bytes total - uint16_t data_len; // 2 bytes - esp_gatt_if_t gatts_if; // 1 byte - bool is_inline; // 1 byte - true when data is stored inline - } gatts; // Total: 80 bytes - } event_; // 80 bytes + uint8_t *heap_data; // 4 bytes when heap-allocated + uint8_t inline_data[GATTS_INLINE_DATA_SIZE]; // 40 bytes when stored inline + } data; // 40 bytes total + uint16_t data_len; // 2 bytes + esp_gatt_if_t gatts_if; // 1 byte + bool is_inline; // 1 byte - true when data is stored inline + } gatts; // Total: 80 bytes + } event_; // 80 bytes ble_event_t type_; @@ -258,12 +264,12 @@ class BLEEvent { private: // Helper to copy data with inline storage optimization - template + template void copy_data_with_inline_storage_(EventStruct &event, const uint8_t *src_data, uint16_t len, uint8_t **param_value_ptr) { event.data_len = len; if (len > 0) { - if (len <= INLINE_DATA_SIZE) { + if (len <= InlineSize) { event.is_inline = true; memcpy(event.data.inline_data, src_data, len); *param_value_ptr = event.data.inline_data; @@ -364,34 +370,33 @@ class BLEEvent { this->event_.gattc.gattc_if = i; if (p == nullptr) { - this->event_.gattc.gattc_param = nullptr; + // Zero out the param struct when null + memset(&this->event_.gattc.gattc_param, 0, sizeof(this->event_.gattc.gattc_param)); this->event_.gattc.is_inline = false; this->event_.gattc.data.heap_data = nullptr; this->event_.gattc.data_len = 0; return; // Invalid event, but we can't log in header file } - // Heap-allocate param - // Heap allocation is used because GATTC/GATTS events are rare (<1% of events) - // while GAP events (99%) are stored inline to minimize memory usage - // IMPORTANT: This heap allocation provides clear ownership semantics: - // - The BLEEvent owns the allocated memory for its lifetime - // - The data remains valid from the BLE callback context until processed in the main loop - // - Without this copy, we'd have use-after-free bugs as ESP-IDF reuses the callback memory - this->event_.gattc.gattc_param = new esp_ble_gattc_cb_param_t(*p); + // Copy param struct inline (no heap allocation!) + // GATTC/GATTS events are rare (<1% of events) but we can still store them inline + // along with small data payloads, eliminating all heap allocations for typical BLE operations + // CRITICAL: This copy is REQUIRED for memory safety - the ESP-IDF param pointer + // is only valid during the callback and will be reused/freed after we return + this->event_.gattc.gattc_param = *p; // Copy data for events that need it // The param struct contains pointers (e.g., notify.value) that point to temporary buffers. // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTC_NOTIFY_EVT: - copy_data_with_inline_storage_(this->event_.gattc, p->notify.value, p->notify.value_len, - &this->event_.gattc.gattc_param->notify.value); + copy_data_with_inline_storage_event_.gattc), GATTC_INLINE_DATA_SIZE>( + this->event_.gattc, p->notify.value, p->notify.value_len, &this->event_.gattc.gattc_param.notify.value); break; case ESP_GATTC_READ_CHAR_EVT: case ESP_GATTC_READ_DESCR_EVT: - copy_data_with_inline_storage_(this->event_.gattc, p->read.value, p->read.value_len, - &this->event_.gattc.gattc_param->read.value); + copy_data_with_inline_storage_event_.gattc), GATTC_INLINE_DATA_SIZE>( + this->event_.gattc, p->read.value, p->read.value_len, &this->event_.gattc.gattc_param.read.value); break; default: this->event_.gattc.is_inline = false; @@ -407,29 +412,28 @@ class BLEEvent { this->event_.gatts.gatts_if = i; if (p == nullptr) { - this->event_.gatts.gatts_param = nullptr; + // Zero out the param struct when null + memset(&this->event_.gatts.gatts_param, 0, sizeof(this->event_.gatts.gatts_param)); this->event_.gatts.is_inline = false; this->event_.gatts.data.heap_data = nullptr; this->event_.gatts.data_len = 0; return; // Invalid event, but we can't log in header file } - // Heap-allocate param - // Heap allocation is used because GATTC/GATTS events are rare (<1% of events) - // while GAP events (99%) are stored inline to minimize memory usage - // IMPORTANT: This heap allocation provides clear ownership semantics: - // - The BLEEvent owns the allocated memory for its lifetime - // - The data remains valid from the BLE callback context until processed in the main loop - // - Without this copy, we'd have use-after-free bugs as ESP-IDF reuses the callback memory - this->event_.gatts.gatts_param = new esp_ble_gatts_cb_param_t(*p); + // Copy param struct inline (no heap allocation!) + // GATTC/GATTS events are rare (<1% of events) but we can still store them inline + // along with small data payloads, eliminating all heap allocations for typical BLE operations + // CRITICAL: This copy is REQUIRED for memory safety - the ESP-IDF param pointer + // is only valid during the callback and will be reused/freed after we return + this->event_.gatts.gatts_param = *p; // Copy data for events that need it // The param struct contains pointers (e.g., write.value) that point to temporary buffers. // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTS_WRITE_EVT: - copy_data_with_inline_storage_(this->event_.gatts, p->write.value, p->write.len, - &this->event_.gatts.gatts_param->write.value); + copy_data_with_inline_storage_event_.gatts), GATTS_INLINE_DATA_SIZE>( + this->event_.gatts, p->write.value, p->write.len, &this->event_.gatts.gatts_param.write.value); break; default: this->event_.gatts.is_inline = false; From 3aae84fadef602789d89e1537bab0460906e4cda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 08:54:17 -0500 Subject: [PATCH 0562/1626] preen --- esphome/components/esp32_ble/ble.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index d1ee7af4ea..a5ce83c2d8 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -306,7 +306,7 @@ void ESP32BLE::loop() { case BLEEvent::GATTS: { esp_gatts_cb_event_t event = ble_event->event_.gatts.gatts_event; esp_gatt_if_t gatts_if = ble_event->event_.gatts.gatts_if; - esp_ble_gatts_cb_param_t *param = ble_event->event_.gatts.gatts_param; + esp_ble_gatts_cb_param_t *param = &ble_event->event_.gatts.gatts_param; // Take address of inline struct ESP_LOGV(TAG, "gatts_event [esp_gatt_if: %d] - %d", gatts_if, event); for (auto *gatts_handler : this->gatts_event_handlers_) { gatts_handler->gatts_event_handler(event, gatts_if, param); @@ -316,7 +316,7 @@ void ESP32BLE::loop() { case BLEEvent::GATTC: { esp_gattc_cb_event_t event = ble_event->event_.gattc.gattc_event; esp_gatt_if_t gattc_if = ble_event->event_.gattc.gattc_if; - esp_ble_gattc_cb_param_t *param = ble_event->event_.gattc.gattc_param; + esp_ble_gattc_cb_param_t *param = &ble_event->event_.gattc.gattc_param; // Take address of inline struct ESP_LOGV(TAG, "gattc_event [esp_gatt_if: %d] - %d", gattc_if, event); for (auto *gattc_handler : this->gattc_event_handlers_) { gattc_handler->gattc_event_handler(event, gattc_if, param); From abecc0e8d8dca98bdb2978eb83c395b14554f7b6 Mon Sep 17 00:00:00 2001 From: RFDarter Date: Fri, 15 Aug 2025 16:44:24 +0200 Subject: [PATCH 0563/1626] [web_server] fix cover_all_json_generator wrong detail (#10252) --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 92c5961f87..399b8785ae 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -813,7 +813,7 @@ std::string WebServer::cover_state_json_generator(WebServer *web_server, void *s return web_server->cover_json((cover::Cover *) (source), DETAIL_STATE); } std::string WebServer::cover_all_json_generator(WebServer *web_server, void *source) { - return web_server->cover_json((cover::Cover *) (source), DETAIL_STATE); + return web_server->cover_json((cover::Cover *) (source), DETAIL_ALL); } std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) { return json::build_json([this, obj, start_config](JsonObject root) { From d78d2c87107767323745758b82bf99b4a3069d0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 09:53:07 -0500 Subject: [PATCH 0564/1626] Apply suggestions from code review --- esphome/components/esp32_ble/ble.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index a5ce83c2d8..e22d43c0cc 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -306,7 +306,7 @@ void ESP32BLE::loop() { case BLEEvent::GATTS: { esp_gatts_cb_event_t event = ble_event->event_.gatts.gatts_event; esp_gatt_if_t gatts_if = ble_event->event_.gatts.gatts_if; - esp_ble_gatts_cb_param_t *param = &ble_event->event_.gatts.gatts_param; // Take address of inline struct + esp_ble_gatts_cb_param_t *param = &ble_event->event_.gatts.gatts_param; ESP_LOGV(TAG, "gatts_event [esp_gatt_if: %d] - %d", gatts_if, event); for (auto *gatts_handler : this->gatts_event_handlers_) { gatts_handler->gatts_event_handler(event, gatts_if, param); @@ -316,7 +316,7 @@ void ESP32BLE::loop() { case BLEEvent::GATTC: { esp_gattc_cb_event_t event = ble_event->event_.gattc.gattc_event; esp_gatt_if_t gattc_if = ble_event->event_.gattc.gattc_if; - esp_ble_gattc_cb_param_t *param = &ble_event->event_.gattc.gattc_param; // Take address of inline struct + esp_ble_gattc_cb_param_t *param = &ble_event->event_.gattc.gattc_param; ESP_LOGV(TAG, "gattc_event [esp_gatt_if: %d] - %d", gattc_if, event); for (auto *gattc_handler : this->gattc_event_handlers_) { gattc_handler->gattc_event_handler(event, gattc_if, param); From 6c5632a0b3db4e1c0c23b4f9bb8cba70d1b904b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 10:11:49 -0500 Subject: [PATCH 0565/1626] [esp32] Optimize preferences is_changed() by replacing temporary vector with unique_ptr (#10246) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32/preferences.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index e53cdd90d3..c5b07b497c 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace esphome { namespace esp32 { @@ -156,20 +157,23 @@ class ESP32Preferences : public ESPPreferences { return failed == 0; } bool is_changed(const uint32_t nvs_handle, const NVSData &to_save) { - NVSData stored_data{}; size_t actual_len; esp_err_t err = nvs_get_blob(nvs_handle, to_save.key.c_str(), nullptr, &actual_len); if (err != 0) { ESP_LOGV(TAG, "nvs_get_blob('%s'): %s - the key might not be set yet", to_save.key.c_str(), esp_err_to_name(err)); return true; } - stored_data.data.resize(actual_len); - err = nvs_get_blob(nvs_handle, to_save.key.c_str(), stored_data.data.data(), &actual_len); + // Check size first before allocating memory + if (actual_len != to_save.data.size()) { + return true; + } + auto stored_data = std::make_unique(actual_len); + err = nvs_get_blob(nvs_handle, to_save.key.c_str(), stored_data.get(), &actual_len); if (err != 0) { ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", to_save.key.c_str(), esp_err_to_name(err)); return true; } - return to_save.data != stored_data.data; + return memcmp(to_save.data.data(), stored_data.get(), to_save.data.size()) != 0; } bool reset() override { From 2b887033c51fb90aaf1cdf8c72b47bae3f9c392c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 14:49:22 -0500 Subject: [PATCH 0566/1626] [core] Remove unnecessary FD_SETSIZE check on ESP32 and improve logging --- esphome/core/application.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 73bf13ab7c..d2d47fe171 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -475,11 +475,16 @@ bool Application::register_socket_fd(int fd) { if (fd < 0) return false; +#ifndef USE_ESP32 + // Only check on non-ESP32 platforms + // On ESP32 (both Arduino and ESP-IDF), CONFIG_LWIP_MAX_SOCKETS is always <= FD_SETSIZE by design + // (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS per lwipopts.h) + // Other platforms may not have this guarantee if (fd >= FD_SETSIZE) { - ESP_LOGE(TAG, "Cannot monitor socket fd %d: exceeds FD_SETSIZE (%d)", fd, FD_SETSIZE); - ESP_LOGE(TAG, "Socket will not be monitored for data - may cause performance issues!"); + ESP_LOGE(TAG, "fd %d exceeds FD_SETSIZE %d", fd, FD_SETSIZE); return false; } +#endif this->socket_fds_.push_back(fd); this->socket_fds_changed_ = true; From 405ebe90f5f9e40470872fb04940ff16c755532c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 15:39:33 -0500 Subject: [PATCH 0567/1626] teardown --- esphome/core/application.cpp | 63 +++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 73bf13ab7c..9a13a375e1 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -256,30 +256,63 @@ void Application::run_powerdown_hooks() { void Application::teardown_components(uint32_t timeout_ms) { uint32_t start_time = millis(); - // Copy all components in reverse order using reverse iterators + // Use a StaticVector instead of std::vector to avoid heap allocation + // since we know the maximum size at compile time + StaticVector pending_components; + + // Copy all components in reverse order // Reverse order matches the behavior of run_safe_shutdown_hooks() above and ensures // components are torn down in the opposite order of their setup_priority (which is // used to sort components during Application::setup()) - std::vector pending_components(this->components_.rbegin(), this->components_.rend()); + size_t num_components = this->components_.size(); + for (size_t i = 0; i < num_components; ++i) { + pending_components[i] = this->components_[num_components - 1 - i]; + } uint32_t now = start_time; - while (!pending_components.empty() && (now - start_time) < timeout_ms) { + size_t pending_count = pending_components.size(); + + // Compaction algorithm for teardown + // ================================== + // We repeatedly call teardown() on each component until it returns true. + // Components that are done are removed using array compaction: + // + // Initial state (all components pending): + // pending_components: [A, B, C, D, E, F] + // pending_count: 6 ^ + // + // After first iteration (B and D finish teardown): + // pending_components: [A, C, E, F | B, D] (B, D are still in memory but ignored) + // pending_count: 4 ^ + // + // After second iteration (A finishes): + // pending_components: [C, E, F | A, B, D] + // pending_count: 3 ^ + // + // The algorithm compacts remaining components to the front of the array, + // tracking only the count of pending components. This avoids expensive + // erase operations while maintaining O(n) complexity per iteration. + + while (pending_count > 0 && (now - start_time) < timeout_ms) { // Feed watchdog during teardown to prevent triggering this->feed_wdt(now); - // Use iterator to safely erase elements - for (auto it = pending_components.begin(); it != pending_components.end();) { - if ((*it)->teardown()) { - // Component finished teardown, erase it - it = pending_components.erase(it); - } else { - // Component still needs time - ++it; + // Process components and compact the array, keeping only those still pending + size_t still_pending = 0; + for (size_t i = 0; i < pending_count; ++i) { + if (!pending_components[i]->teardown()) { + // Component still needs time, keep it in the list + if (still_pending != i) { + pending_components[still_pending] = pending_components[i]; + } + ++still_pending; } + // Component finished teardown, skip it (don't increment still_pending) } + pending_count = still_pending; // Give some time for I/O operations if components are still pending - if (!pending_components.empty()) { + if (pending_count > 0) { this->yield_with_select_(1); } @@ -287,11 +320,11 @@ void Application::teardown_components(uint32_t timeout_ms) { now = millis(); } - if (!pending_components.empty()) { + if (pending_count > 0) { // Note: At this point, connections are either disconnected or in a bad state, // so this warning will only appear via serial rather than being transmitted to clients - for (auto *component : pending_components) { - ESP_LOGW(TAG, "%s did not complete teardown within %" PRIu32 " ms", component->get_component_source(), + for (size_t i = 0; i < pending_count; ++i) { + ESP_LOGW(TAG, "%s did not complete teardown within %" PRIu32 " ms", pending_components[i]->get_component_source(), timeout_ms); } } From 0a6661239940881b0bb4ef16dd5192f91d9634c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 15:40:19 -0500 Subject: [PATCH 0568/1626] teardown --- esphome/core/application.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 9a13a375e1..a204781db7 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -257,7 +257,7 @@ void Application::teardown_components(uint32_t timeout_ms) { uint32_t start_time = millis(); // Use a StaticVector instead of std::vector to avoid heap allocation - // since we know the maximum size at compile time + // since we know the actual size at compile time StaticVector pending_components; // Copy all components in reverse order From 59037458d6bdf6579d64e97f8d9a9dbab885ce68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 21:27:31 -0400 Subject: [PATCH 0569/1626] Update esphome/core/application.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/application.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index a204781db7..ff8f567c89 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -270,7 +270,7 @@ void Application::teardown_components(uint32_t timeout_ms) { } uint32_t now = start_time; - size_t pending_count = pending_components.size(); + size_t pending_count = num_components; // Compaction algorithm for teardown // ================================== From daf8ec36abd48bbc51484196a3faa3403f73c256 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 22:26:48 -0400 Subject: [PATCH 0570/1626] [core] Remove unnecessary FD_SETSIZE check on ESP32 and improve logging (#10255) --- esphome/core/application.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 73bf13ab7c..d2d47fe171 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -475,11 +475,16 @@ bool Application::register_socket_fd(int fd) { if (fd < 0) return false; +#ifndef USE_ESP32 + // Only check on non-ESP32 platforms + // On ESP32 (both Arduino and ESP-IDF), CONFIG_LWIP_MAX_SOCKETS is always <= FD_SETSIZE by design + // (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS per lwipopts.h) + // Other platforms may not have this guarantee if (fd >= FD_SETSIZE) { - ESP_LOGE(TAG, "Cannot monitor socket fd %d: exceeds FD_SETSIZE (%d)", fd, FD_SETSIZE); - ESP_LOGE(TAG, "Socket will not be monitored for data - may cause performance issues!"); + ESP_LOGE(TAG, "fd %d exceeds FD_SETSIZE %d", fd, FD_SETSIZE); return false; } +#endif this->socket_fds_.push_back(fd); this->socket_fds_changed_ = true; From 29daef230df7c6bdb8d37fee0633b8002bb4f6d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 23:51:24 -0400 Subject: [PATCH 0571/1626] [api] Add zero-copy StringRef methods for compilation_time and effect_name --- esphome/components/api/api_connection.cpp | 8 ++------ esphome/components/light/light_state.cpp | 14 ++++++++++++-- esphome/components/light/light_state.h | 3 +++ esphome/core/application.h | 3 +++ 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ced0f489be..4b3a3e2fc8 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -465,9 +465,7 @@ uint16_t APIConnection::try_send_light_state(EntityBase *entity, APIConnection * resp.cold_white = values.get_cold_white(); resp.warm_white = values.get_warm_white(); if (light->supports_effects()) { - // get_effect_name() returns temporary std::string - must store it - std::string effect_name = light->get_effect_name(); - resp.set_effect(StringRef(effect_name)); + resp.set_effect(light->get_effect_name_ref()); } return fill_and_encode_entity_state(light, resp, LightStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -1425,9 +1423,7 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); resp.set_esphome_version(ESPHOME_VERSION_REF); - // get_compilation_time() returns temporary std::string - must store it - std::string compilation_time = App.get_compilation_time(); - resp.set_compilation_time(StringRef(compilation_time)); + resp.set_compilation_time(App.get_compilation_time_ref()); // Compile-time StringRef constants for manufacturers #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 5b57707d6b..9e42b2f1e2 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -140,12 +140,22 @@ float LightState::get_setup_priority() const { return setup_priority::HARDWARE - void LightState::publish_state() { this->remote_values_callback_.call(); } LightOutput *LightState::get_output() const { return this->output_; } + +static constexpr const char *EFFECT_NONE = "None"; +static constexpr auto EFFECT_NONE_REF = StringRef::from_lit("None"); + std::string LightState::get_effect_name() { if (this->active_effect_index_ > 0) { return this->effects_[this->active_effect_index_ - 1]->get_name(); - } else { - return "None"; } + return EFFECT_NONE; +} + +StringRef LightState::get_effect_name_ref() { + if (this->active_effect_index_ > 0) { + return StringRef(this->effects_[this->active_effect_index_ - 1]->get_name()); + } + return EFFECT_NONE_REF; } void LightState::add_new_remote_values_callback(std::function &&send_callback) { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 72cb99223e..94b81dee61 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -4,6 +4,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/optional.h" #include "esphome/core/preferences.h" +#include "esphome/core/string_ref.h" #include "light_call.h" #include "light_color_values.h" #include "light_effect.h" @@ -116,6 +117,8 @@ class LightState : public EntityBase, public Component { /// Return the name of the current effect, or if no effect is active "None". std::string get_effect_name(); + /// Return the name of the current effect as StringRef (for API usage) + StringRef get_effect_name_ref(); /** * This lets front-end components subscribe to light change events. This callback is called once diff --git a/esphome/core/application.h b/esphome/core/application.h index 4120afff53..9cb2a4c638 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -10,6 +10,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include "esphome/core/scheduler.h" +#include "esphome/core/string_ref.h" #ifdef USE_DEVICES #include "esphome/core/device.h" @@ -248,6 +249,8 @@ class Application { bool is_name_add_mac_suffix_enabled() const { return this->name_add_mac_suffix_; } std::string get_compilation_time() const { return this->compilation_time_; } + /// Get the compilation time as StringRef (for API usage) + StringRef get_compilation_time_ref() const { return StringRef(this->compilation_time_); } /// Get the cached time in milliseconds from when the current component started its loop execution inline uint32_t IRAM_ATTR HOT get_loop_component_start_time() const { return this->loop_component_start_time_; } From 15fca7dea86061178d18a47fc3f69525c7f4d07b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 Aug 2025 09:35:12 -0400 Subject: [PATCH 0572/1626] Avoid object_id string allocations for all entity info API messages --- esphome/components/api/api_connection.h | 12 +++++++++--- esphome/core/entity_base.cpp | 10 ++++++++++ esphome/core/entity_base.h | 11 +++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 076dccfad7..7524d43299 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -301,9 +301,15 @@ class APIConnection : public APIServerConnection { APIConnection *conn, uint32_t remaining_size, bool is_single) { // Set common fields that are shared by all entity types msg.key = entity->get_object_id_hash(); - // IMPORTANT: get_object_id() may return a temporary std::string - std::string object_id = entity->get_object_id(); - msg.set_object_id(StringRef(object_id)); + // Try to use static reference first to avoid allocation + StringRef static_ref = entity->get_object_id_ref_for_api_(); + if (!static_ref.empty()) { + msg.set_object_id(static_ref); + } else { + // Dynamic case - need to allocate + std::string object_id = entity->get_object_id(); + msg.set_object_id(StringRef(object_id)); + } if (entity->has_own_name()) { msg.set_name(entity->get_name()); diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 2ea9c77a3e..97bf7147b5 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -1,6 +1,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" namespace esphome { @@ -58,6 +59,15 @@ std::string EntityBase::get_object_id() const { return this->object_id_c_str_; } } +StringRef EntityBase::get_object_id_ref_for_api_() const { + static constexpr auto EMPTY_STRING_REF = StringRef::from_lit(""); + // Return empty for dynamic case (MAC suffix) + if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { + return EMPTY_STRING_REF; + } + // For static case, return the string or empty if null + return this->object_id_c_str_ == nullptr ? EMPTY_STRING_REF : StringRef(this->object_id_c_str_); +} void EntityBase::set_object_id(const char *object_id) { this->object_id_c_str_ = object_id; this->calc_object_id_(); diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index e60e0728bc..68163ce8c3 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -12,6 +12,11 @@ namespace esphome { +// Forward declaration for friend access +namespace api { +class APIConnection; +} // namespace api + enum EntityCategory : uint8_t { ENTITY_CATEGORY_NONE = 0, ENTITY_CATEGORY_CONFIG = 1, @@ -81,6 +86,12 @@ class EntityBase { void set_has_state(bool state) { this->flags_.has_state = state; } protected: + friend class api::APIConnection; + + // Get object_id as StringRef when it's static (for API usage) + // Returns empty StringRef if object_id is dynamic (needs allocation) + StringRef get_object_id_ref_for_api_() const; + /// The hash_base() function has been deprecated. It is kept in this /// class for now, to prevent external components from not compiling. virtual uint32_t hash_base() { return 0L; } From 0b004a7d9b97afb56c01193649f1330b13d4214e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 Aug 2025 09:38:00 -0400 Subject: [PATCH 0573/1626] tweak --- esphome/core/entity_base.cpp | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 97bf7147b5..411a877bbf 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -51,22 +51,18 @@ std::string EntityBase::get_object_id() const { if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { // `App.get_friendly_name()` is dynamic. return str_sanitize(str_snake_case(App.get_friendly_name())); - } else { - // `App.get_friendly_name()` is constant. - if (this->object_id_c_str_ == nullptr) { - return ""; - } - return this->object_id_c_str_; } + // `App.get_friendly_name()` is constant. + return this->object_id_c_str_ == nullptr ? "" : this->object_id_c_str_; } StringRef EntityBase::get_object_id_ref_for_api_() const { - static constexpr auto EMPTY_STRING_REF = StringRef::from_lit(""); + static constexpr auto EMPTY_STRING = StringRef::from_lit(""); // Return empty for dynamic case (MAC suffix) if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { - return EMPTY_STRING_REF; + return EMPTY_STRING; } // For static case, return the string or empty if null - return this->object_id_c_str_ == nullptr ? EMPTY_STRING_REF : StringRef(this->object_id_c_str_); + return this->object_id_c_str_ == nullptr ? EMPTY_STRING : StringRef(this->object_id_c_str_); } void EntityBase::set_object_id(const char *object_id) { this->object_id_c_str_ = object_id; From 75f3adcd9537e158b9b0688e885190eb35349c3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Aug 2025 15:49:50 -0400 Subject: [PATCH 0574/1626] [esp32_ble] Store GATTC/GATTS param and small data inline to nearly eliminate heap allocations (#10249) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32_ble/ble.cpp | 4 +- esphome/components/esp32_ble/ble_event.h | 219 ++++++++++++++--------- 2 files changed, 133 insertions(+), 90 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index d1ee7af4ea..e22d43c0cc 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -306,7 +306,7 @@ void ESP32BLE::loop() { case BLEEvent::GATTS: { esp_gatts_cb_event_t event = ble_event->event_.gatts.gatts_event; esp_gatt_if_t gatts_if = ble_event->event_.gatts.gatts_if; - esp_ble_gatts_cb_param_t *param = ble_event->event_.gatts.gatts_param; + esp_ble_gatts_cb_param_t *param = &ble_event->event_.gatts.gatts_param; ESP_LOGV(TAG, "gatts_event [esp_gatt_if: %d] - %d", gatts_if, event); for (auto *gatts_handler : this->gatts_event_handlers_) { gatts_handler->gatts_event_handler(event, gatts_if, param); @@ -316,7 +316,7 @@ void ESP32BLE::loop() { case BLEEvent::GATTC: { esp_gattc_cb_event_t event = ble_event->event_.gattc.gattc_event; esp_gatt_if_t gattc_if = ble_event->event_.gattc.gattc_if; - esp_ble_gattc_cb_param_t *param = ble_event->event_.gattc.gattc_param; + esp_ble_gattc_cb_param_t *param = &ble_event->event_.gattc.gattc_param; ESP_LOGV(TAG, "gattc_event [esp_gatt_if: %d] - %d", gattc_if, event); for (auto *gattc_handler : this->gattc_event_handlers_) { gattc_handler->gattc_event_handler(event, gattc_if, param); diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index 2c0ab1d34e..299fd7705f 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -61,10 +61,24 @@ static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.rssi) == sizeof(es static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.remote_addr) == sizeof(esp_bt_status_t) + sizeof(int8_t), "remote_addr must follow rssi in read_rssi_cmpl"); +// Param struct sizes on ESP32 +static constexpr size_t GATTC_PARAM_SIZE = 28; +static constexpr size_t GATTS_PARAM_SIZE = 32; + +// Maximum size for inline storage of data +// GATTC: 80 - 28 (param) - 8 (other fields) = 44 bytes for data +// GATTS: 80 - 32 (param) - 8 (other fields) = 40 bytes for data +static constexpr size_t GATTC_INLINE_DATA_SIZE = 44; +static constexpr size_t GATTS_INLINE_DATA_SIZE = 40; + +// Verify param struct sizes +static_assert(sizeof(esp_ble_gattc_cb_param_t) == GATTC_PARAM_SIZE, "GATTC param size unexpected"); +static_assert(sizeof(esp_ble_gatts_cb_param_t) == GATTS_PARAM_SIZE, "GATTS param size unexpected"); + // Received GAP, GATTC and GATTS events are only queued, and get processed in the main loop(). // This class stores each event with minimal memory usage. // GAP events (99% of traffic) don't have the heap allocation overhead. -// GATTC/GATTS events use heap allocation for their param and data. +// GATTC/GATTS events use heap allocation for their param and inline storage for small data. // // Event flow: // 1. ESP-IDF BLE stack calls our static handlers in the BLE task context @@ -111,21 +125,21 @@ class BLEEvent { this->init_gap_data_(e, p); } - // Constructor for GATTC events - uses heap allocation - // IMPORTANT: The heap allocation is REQUIRED and must not be removed as an optimization. - // The param pointer from ESP-IDF is only valid during the callback execution. - // Since BLE events are processed asynchronously in the main loop, we must create - // our own copy to ensure the data remains valid until the event is processed. + // Constructor for GATTC events - param stored inline, data may use heap + // IMPORTANT: We MUST copy the param struct because the pointer from ESP-IDF + // is only valid during the callback execution. Since BLE events are processed + // asynchronously in the main loop, we store our own copy inline to ensure + // the data remains valid until the event is processed. BLEEvent(esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) { this->type_ = GATTC; this->init_gattc_data_(e, i, p); } - // Constructor for GATTS events - uses heap allocation - // IMPORTANT: The heap allocation is REQUIRED and must not be removed as an optimization. - // The param pointer from ESP-IDF is only valid during the callback execution. - // Since BLE events are processed asynchronously in the main loop, we must create - // our own copy to ensure the data remains valid until the event is processed. + // Constructor for GATTS events - param stored inline, data may use heap + // IMPORTANT: We MUST copy the param struct because the pointer from ESP-IDF + // is only valid during the callback execution. Since BLE events are processed + // asynchronously in the main loop, we store our own copy inline to ensure + // the data remains valid until the event is processed. BLEEvent(esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) { this->type_ = GATTS; this->init_gatts_data_(e, i, p); @@ -135,27 +149,32 @@ class BLEEvent { ~BLEEvent() { this->release(); } // Default constructor for pre-allocation in pool - BLEEvent() : type_(GAP) {} + BLEEvent() : event_{}, type_(GAP) {} // Invoked on return to EventPool - clean up any heap-allocated data void release() { - if (this->type_ == GAP) { - return; - } - if (this->type_ == GATTC) { - delete this->event_.gattc.gattc_param; - delete[] this->event_.gattc.data; - this->event_.gattc.gattc_param = nullptr; - this->event_.gattc.data = nullptr; - this->event_.gattc.data_len = 0; - return; - } - if (this->type_ == GATTS) { - delete this->event_.gatts.gatts_param; - delete[] this->event_.gatts.data; - this->event_.gatts.gatts_param = nullptr; - this->event_.gatts.data = nullptr; - this->event_.gatts.data_len = 0; + switch (this->type_) { + case GAP: + // GAP events don't have heap allocations + break; + case GATTC: + // Param is now stored inline, only delete heap data if it was heap-allocated + if (!this->event_.gattc.is_inline && this->event_.gattc.data.heap_data != nullptr) { + delete[] this->event_.gattc.data.heap_data; + } + // Clear critical fields to prevent issues if type changes + this->event_.gattc.is_inline = false; + this->event_.gattc.data.heap_data = nullptr; + break; + case GATTS: + // Param is now stored inline, only delete heap data if it was heap-allocated + if (!this->event_.gatts.is_inline && this->event_.gatts.data.heap_data != nullptr) { + delete[] this->event_.gatts.data.heap_data; + } + // Clear critical fields to prevent issues if type changes + this->event_.gatts.is_inline = false; + this->event_.gatts.data.heap_data = nullptr; + break; } } @@ -207,22 +226,30 @@ class BLEEvent { // NOLINTNEXTLINE(readability-identifier-naming) struct gattc_event { - esp_gattc_cb_event_t gattc_event; - esp_gatt_if_t gattc_if; - esp_ble_gattc_cb_param_t *gattc_param; // Heap-allocated - uint8_t *data; // Heap-allocated raw buffer (manually managed) - uint16_t data_len; // Track size separately - } gattc; + esp_ble_gattc_cb_param_t gattc_param; // Stored inline (28 bytes) + esp_gattc_cb_event_t gattc_event; // 4 bytes + union { + uint8_t *heap_data; // 4 bytes when heap-allocated + uint8_t inline_data[GATTC_INLINE_DATA_SIZE]; // 44 bytes when stored inline + } data; // 44 bytes total + uint16_t data_len; // 2 bytes + esp_gatt_if_t gattc_if; // 1 byte + bool is_inline; // 1 byte - true when data is stored inline + } gattc; // Total: 80 bytes // NOLINTNEXTLINE(readability-identifier-naming) struct gatts_event { - esp_gatts_cb_event_t gatts_event; - esp_gatt_if_t gatts_if; - esp_ble_gatts_cb_param_t *gatts_param; // Heap-allocated - uint8_t *data; // Heap-allocated raw buffer (manually managed) - uint16_t data_len; // Track size separately - } gatts; - } event_; // 80 bytes + esp_ble_gatts_cb_param_t gatts_param; // Stored inline (32 bytes) + esp_gatts_cb_event_t gatts_event; // 4 bytes + union { + uint8_t *heap_data; // 4 bytes when heap-allocated + uint8_t inline_data[GATTS_INLINE_DATA_SIZE]; // 40 bytes when stored inline + } data; // 40 bytes total + uint16_t data_len; // 2 bytes + esp_gatt_if_t gatts_if; // 1 byte + bool is_inline; // 1 byte - true when data is stored inline + } gatts; // Total: 80 bytes + } event_; // 80 bytes ble_event_t type_; @@ -236,6 +263,29 @@ class BLEEvent { const esp_ble_sec_t &security() const { return event_.gap.security; } private: + // Helper to copy data with inline storage optimization + template + void copy_data_with_inline_storage_(EventStruct &event, const uint8_t *src_data, uint16_t len, + uint8_t **param_value_ptr) { + event.data_len = len; + if (len > 0) { + if (len <= InlineSize) { + event.is_inline = true; + memcpy(event.data.inline_data, src_data, len); + *param_value_ptr = event.data.inline_data; + } else { + event.is_inline = false; + event.data.heap_data = new uint8_t[len]; + memcpy(event.data.heap_data, src_data, len); + *param_value_ptr = event.data.heap_data; + } + } else { + event.is_inline = false; + event.data.heap_data = nullptr; + *param_value_ptr = nullptr; + } + } + // Initialize GAP event data void init_gap_data_(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) { this->event_.gap.gap_event = e; @@ -320,48 +370,37 @@ class BLEEvent { this->event_.gattc.gattc_if = i; if (p == nullptr) { - this->event_.gattc.gattc_param = nullptr; - this->event_.gattc.data = nullptr; + // Zero out the param struct when null + memset(&this->event_.gattc.gattc_param, 0, sizeof(this->event_.gattc.gattc_param)); + this->event_.gattc.is_inline = false; + this->event_.gattc.data.heap_data = nullptr; this->event_.gattc.data_len = 0; return; // Invalid event, but we can't log in header file } - // Heap-allocate param and data - // Heap allocation is used because GATTC/GATTS events are rare (<1% of events) - // while GAP events (99%) are stored inline to minimize memory usage - // IMPORTANT: This heap allocation provides clear ownership semantics: - // - The BLEEvent owns the allocated memory for its lifetime - // - The data remains valid from the BLE callback context until processed in the main loop - // - Without this copy, we'd have use-after-free bugs as ESP-IDF reuses the callback memory - this->event_.gattc.gattc_param = new esp_ble_gattc_cb_param_t(*p); + // Copy param struct inline (no heap allocation!) + // GATTC/GATTS events are rare (<1% of events) but we can still store them inline + // along with small data payloads, eliminating all heap allocations for typical BLE operations + // CRITICAL: This copy is REQUIRED for memory safety - the ESP-IDF param pointer + // is only valid during the callback and will be reused/freed after we return + this->event_.gattc.gattc_param = *p; // Copy data for events that need it // The param struct contains pointers (e.g., notify.value) that point to temporary buffers. // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTC_NOTIFY_EVT: - this->event_.gattc.data_len = p->notify.value_len; - if (p->notify.value_len > 0) { - this->event_.gattc.data = new uint8_t[p->notify.value_len]; - memcpy(this->event_.gattc.data, p->notify.value, p->notify.value_len); - } else { - this->event_.gattc.data = nullptr; - } - this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data; + copy_data_with_inline_storage_event_.gattc), GATTC_INLINE_DATA_SIZE>( + this->event_.gattc, p->notify.value, p->notify.value_len, &this->event_.gattc.gattc_param.notify.value); break; case ESP_GATTC_READ_CHAR_EVT: case ESP_GATTC_READ_DESCR_EVT: - this->event_.gattc.data_len = p->read.value_len; - if (p->read.value_len > 0) { - this->event_.gattc.data = new uint8_t[p->read.value_len]; - memcpy(this->event_.gattc.data, p->read.value, p->read.value_len); - } else { - this->event_.gattc.data = nullptr; - } - this->event_.gattc.gattc_param->read.value = this->event_.gattc.data; + copy_data_with_inline_storage_event_.gattc), GATTC_INLINE_DATA_SIZE>( + this->event_.gattc, p->read.value, p->read.value_len, &this->event_.gattc.gattc_param.read.value); break; default: - this->event_.gattc.data = nullptr; + this->event_.gattc.is_inline = false; + this->event_.gattc.data.heap_data = nullptr; this->event_.gattc.data_len = 0; break; } @@ -373,37 +412,32 @@ class BLEEvent { this->event_.gatts.gatts_if = i; if (p == nullptr) { - this->event_.gatts.gatts_param = nullptr; - this->event_.gatts.data = nullptr; + // Zero out the param struct when null + memset(&this->event_.gatts.gatts_param, 0, sizeof(this->event_.gatts.gatts_param)); + this->event_.gatts.is_inline = false; + this->event_.gatts.data.heap_data = nullptr; this->event_.gatts.data_len = 0; return; // Invalid event, but we can't log in header file } - // Heap-allocate param and data - // Heap allocation is used because GATTC/GATTS events are rare (<1% of events) - // while GAP events (99%) are stored inline to minimize memory usage - // IMPORTANT: This heap allocation provides clear ownership semantics: - // - The BLEEvent owns the allocated memory for its lifetime - // - The data remains valid from the BLE callback context until processed in the main loop - // - Without this copy, we'd have use-after-free bugs as ESP-IDF reuses the callback memory - this->event_.gatts.gatts_param = new esp_ble_gatts_cb_param_t(*p); + // Copy param struct inline (no heap allocation!) + // GATTC/GATTS events are rare (<1% of events) but we can still store them inline + // along with small data payloads, eliminating all heap allocations for typical BLE operations + // CRITICAL: This copy is REQUIRED for memory safety - the ESP-IDF param pointer + // is only valid during the callback and will be reused/freed after we return + this->event_.gatts.gatts_param = *p; // Copy data for events that need it // The param struct contains pointers (e.g., write.value) that point to temporary buffers. // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTS_WRITE_EVT: - this->event_.gatts.data_len = p->write.len; - if (p->write.len > 0) { - this->event_.gatts.data = new uint8_t[p->write.len]; - memcpy(this->event_.gatts.data, p->write.value, p->write.len); - } else { - this->event_.gatts.data = nullptr; - } - this->event_.gatts.gatts_param->write.value = this->event_.gatts.data; + copy_data_with_inline_storage_event_.gatts), GATTS_INLINE_DATA_SIZE>( + this->event_.gatts, p->write.value, p->write.len, &this->event_.gatts.gatts_param.write.value); break; default: - this->event_.gatts.data = nullptr; + this->event_.gatts.is_inline = false; + this->event_.gatts.data.heap_data = nullptr; this->event_.gatts.data_len = 0; break; } @@ -414,6 +448,15 @@ class BLEEvent { // The gap member in the union should be 80 bytes (including the gap_event enum) static_assert(sizeof(decltype(((BLEEvent *) nullptr)->event_.gap)) <= 80, "gap_event struct has grown beyond 80 bytes"); +// Verify GATTC and GATTS structs don't exceed GAP struct size +// This ensures the union size is determined by GAP (the most common event type) +static_assert(sizeof(decltype(((BLEEvent *) nullptr)->event_.gattc)) <= + sizeof(decltype(((BLEEvent *) nullptr)->event_.gap)), + "gattc_event struct exceeds gap_event size - union size would increase"); +static_assert(sizeof(decltype(((BLEEvent *) nullptr)->event_.gatts)) <= + sizeof(decltype(((BLEEvent *) nullptr)->event_.gap)), + "gatts_event struct exceeds gap_event size - union size would increase"); + // Verify esp_ble_sec_t fits within our union static_assert(sizeof(esp_ble_sec_t) <= 73, "esp_ble_sec_t is larger than BLEScanResult"); From 5b674dc28c88e7839281039846257fc3ba044a38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Aug 2025 16:09:57 -0400 Subject: [PATCH 0575/1626] atomic remove --- esphome/core/scheduler.cpp | 37 +++++++++++++++++++++++--- esphome/core/scheduler.h | 53 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 6269a66543..c3ade260ac 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -82,7 +82,13 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type item->set_name(name_cstr, !is_static_string); item->type = type; item->callback = std::move(func); + // Initialize remove to false (though it should already be from constructor) + // Not using mark_item_removed_ helper since we're setting to false, not true +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + item->remove.store(false, std::memory_order_relaxed); +#else item->remove = false; +#endif item->is_retry = is_retry; #ifndef ESPHOME_THREAD_SINGLE @@ -398,6 +404,31 @@ void HOT Scheduler::call(uint32_t now) { this->pop_raw_(); continue; } + + // Check if item is marked for removal + // This handles two cases: + // 1. Item was marked for removal after cleanup_() but before we got here + // 2. Item is marked for removal but wasn't at the front of the heap during cleanup_() +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + // Multi-threaded platforms without atomics: must take lock to safely read remove flag + { + LockGuard guard{this->lock_}; + if (is_item_removed_(item.get())) { + this->pop_raw_(); + this->to_remove_--; + continue; + } + } +#else + // Single-threaded or multi-threaded with atomics: can check without lock + if (is_item_removed_(item.get())) { + LockGuard guard{this->lock_}; + this->pop_raw_(); + this->to_remove_--; + continue; + } +#endif + #ifdef ESPHOME_DEBUG_SCHEDULER const char *item_name = item->get_name(); ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")", @@ -518,7 +549,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c if (type == SchedulerItem::TIMEOUT) { for (auto &item : this->defer_queue_) { if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - item->remove = true; + this->mark_item_removed_(item.get()); total_cancelled++; } } @@ -528,7 +559,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Cancel items in the main heap for (auto &item : this->items_) { if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - item->remove = true; + this->mark_item_removed_(item.get()); total_cancelled++; this->to_remove_++; // Track removals for heap items } @@ -537,7 +568,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Cancel items in to_add_ for (auto &item : this->to_add_) { if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - item->remove = true; + this->mark_item_removed_(item.get()); total_cancelled++; // Don't track removals for to_add_ items } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index a6092e1b1e..f187549fb2 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -97,22 +97,42 @@ class Scheduler { std::function callback; - // Bit-packed fields to minimize padding +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Multi-threaded with atomics: use atomic for lock-free access + // Place atomic separately since it can't be packed with bit fields + std::atomic remove{false}; + + // Bit-packed fields (3 bits used, 5 bits padding in 1 byte) + enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; + bool name_is_dynamic : 1; // True if name was dynamically allocated (needs delete[]) + bool is_retry : 1; // True if this is a retry timeout + // 5 bits padding +#else + // Single-threaded or multi-threaded without atomics: can pack all fields together + // Bit-packed fields (4 bits used, 4 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; bool remove : 1; bool name_is_dynamic : 1; // True if name was dynamically allocated (needs delete[]) bool is_retry : 1; // True if this is a retry timeout - // 4 bits padding + // 4 bits padding +#endif // Constructor SchedulerItem() : component(nullptr), interval(0), next_execution_(0), +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // remove is initialized in the member declaration as std::atomic{false} + type(TIMEOUT), + name_is_dynamic(false), + is_retry(false) { +#else type(TIMEOUT), remove(false), name_is_dynamic(false), is_retry(false) { +#endif name_.static_name = nullptr; } @@ -219,6 +239,35 @@ class Scheduler { return item->remove || (item->component != nullptr && item->component->is_failed()); } + // Helper to check if item is marked for removal (platform-specific) + // Returns true if item should be skipped, handles platform-specific synchronization + // NOTE: For ESPHOME_THREAD_MULTI_NO_ATOMICS, caller must hold lock! + bool is_item_removed_(SchedulerItem *item) const { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Multi-threaded with atomics: use atomic load for lock-free access + return item->remove.load(std::memory_order_acquire); +#else + // Single-threaded (ESPHOME_THREAD_SINGLE) or + // multi-threaded without atomics (ESPHOME_THREAD_MULTI_NO_ATOMICS): direct read + // For ESPHOME_THREAD_MULTI_NO_ATOMICS, caller MUST hold lock! + return item->remove; +#endif + } + + // Helper to mark item for removal (platform-specific) + // NOTE: For ESPHOME_THREAD_MULTI_NO_ATOMICS, caller must hold lock! + void mark_item_removed_(SchedulerItem *item) { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Multi-threaded with atomics: use atomic store + item->remove.store(true, std::memory_order_release); +#else + // Single-threaded (ESPHOME_THREAD_SINGLE) or + // multi-threaded without atomics (ESPHOME_THREAD_MULTI_NO_ATOMICS): direct write + // For ESPHOME_THREAD_MULTI_NO_ATOMICS, caller MUST hold lock! + item->remove = true; +#endif + } + // Template helper to check if any item in a container matches our criteria template bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr, From e06dbffe9fbbe91359ea8d6653824839c8887620 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Aug 2025 16:12:53 -0400 Subject: [PATCH 0576/1626] fix --- esphome/core/scheduler.cpp | 3 + .../fixtures/scheduler_removed_item_race.yaml | 118 ++++++++++++++++++ .../test_scheduler_removed_item_race.py | 102 +++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 tests/integration/fixtures/scheduler_removed_item_race.yaml create mode 100644 tests/integration/test_scheduler_removed_item_race.py diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index c3ade260ac..4d8c4c67da 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -409,6 +409,8 @@ void HOT Scheduler::call(uint32_t now) { // This handles two cases: // 1. Item was marked for removal after cleanup_() but before we got here // 2. Item is marked for removal but wasn't at the front of the heap during cleanup_() + // TEMPORARILY DISABLED TO VERIFY TEST CATCHES THE BUG + /* #ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS // Multi-threaded platforms without atomics: must take lock to safely read remove flag { @@ -428,6 +430,7 @@ void HOT Scheduler::call(uint32_t now) { continue; } #endif + */ #ifdef ESPHOME_DEBUG_SCHEDULER const char *item_name = item->get_name(); diff --git a/tests/integration/fixtures/scheduler_removed_item_race.yaml b/tests/integration/fixtures/scheduler_removed_item_race.yaml new file mode 100644 index 0000000000..f47cf9a163 --- /dev/null +++ b/tests/integration/fixtures/scheduler_removed_item_race.yaml @@ -0,0 +1,118 @@ +esphome: + name: scheduler-removed-item-race + +host: + +api: + services: + - service: run_test + then: + - script.execute: run_test_script + +logger: + level: DEBUG + +globals: + - id: test_passed + type: bool + initial_value: 'true' + - id: removed_item_executed + type: int + initial_value: '0' + - id: normal_item_executed + type: int + initial_value: '0' + +sensor: + - platform: template + id: test_sensor + name: "Test Sensor" + update_interval: never + lambda: return 0.0; + +script: + - id: run_test_script + then: + - logger.log: "=== Starting Removed Item Race Test ===" + + # This test creates a scenario where: + # 1. Multiple timeouts are scheduled to execute at nearly the same time + # 2. One timeout in the middle of the heap gets cancelled + # 3. Without the fix, the cancelled timeout would still execute + + - lambda: |- + // Schedule multiple timeouts that will all be ready at the same time + // This ensures they're all in the heap together + + // First timeout - executes at 10ms + App.scheduler.set_timeout(id(test_sensor), "timeout1", 10, []() { + ESP_LOGD("test", "Timeout 1 executed (expected)"); + id(normal_item_executed)++; + }); + + // Second timeout - executes at 10ms (will be cancelled) + App.scheduler.set_timeout(id(test_sensor), "timeout2", 10, []() { + ESP_LOGE("test", "RACE: Timeout 2 executed after being cancelled!"); + id(removed_item_executed)++; + id(test_passed) = false; + }); + + // Third timeout - executes at 10ms + App.scheduler.set_timeout(id(test_sensor), "timeout3", 10, []() { + ESP_LOGD("test", "Timeout 3 executed (expected)"); + id(normal_item_executed)++; + }); + + // Fourth timeout - executes at 10ms + App.scheduler.set_timeout(id(test_sensor), "timeout4", 10, []() { + ESP_LOGD("test", "Timeout 4 executed (expected)"); + id(normal_item_executed)++; + }); + + // Now cancel timeout2 + // Since all timeouts have the same execution time, they're all in the heap + // timeout2 might not be at the front, so cleanup_() won't remove it + bool cancelled = App.scheduler.cancel_timeout(id(test_sensor), "timeout2"); + ESP_LOGD("test", "Cancelled timeout2: %s", cancelled ? "true" : "false"); + + // Also test with items at slightly different times + App.scheduler.set_timeout(id(test_sensor), "timeout5", 11, []() { + ESP_LOGD("test", "Timeout 5 executed (expected)"); + id(normal_item_executed)++; + }); + + App.scheduler.set_timeout(id(test_sensor), "timeout6", 12, []() { + ESP_LOGE("test", "RACE: Timeout 6 executed after being cancelled!"); + id(removed_item_executed)++; + id(test_passed) = false; + }); + + App.scheduler.set_timeout(id(test_sensor), "timeout7", 13, []() { + ESP_LOGD("test", "Timeout 7 executed (expected)"); + id(normal_item_executed)++; + }); + + // Cancel timeout6 + cancelled = App.scheduler.cancel_timeout(id(test_sensor), "timeout6"); + ESP_LOGD("test", "Cancelled timeout6: %s", cancelled ? "true" : "false"); + + # Wait for all timeouts to execute (or not) + - delay: 50ms + + # Check results + - lambda: |- + ESP_LOGI("test", "=== Test Results ==="); + ESP_LOGI("test", "Normal items executed: %d (expected 5)", id(normal_item_executed)); + ESP_LOGI("test", "Removed items executed: %d (expected 0)", id(removed_item_executed)); + + if (id(removed_item_executed) > 0) { + ESP_LOGE("test", "TEST FAILED: %d cancelled items were executed!", id(removed_item_executed)); + id(test_passed) = false; + } else if (id(normal_item_executed) != 5) { + ESP_LOGE("test", "TEST FAILED: Expected 5 normal items, got %d", id(normal_item_executed)); + id(test_passed) = false; + } else { + ESP_LOGI("test", "TEST PASSED: No cancelled items were executed"); + } + + ESP_LOGI("test", "=== Test Complete ==="); diff --git a/tests/integration/test_scheduler_removed_item_race.py b/tests/integration/test_scheduler_removed_item_race.py new file mode 100644 index 0000000000..c95a399ce3 --- /dev/null +++ b/tests/integration/test_scheduler_removed_item_race.py @@ -0,0 +1,102 @@ +"""Test for scheduler race condition where removed items still execute.""" + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_removed_item_race( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that items marked for removal don't execute. + + This test verifies the fix for a race condition where: + 1. cleanup_() only removes items from the front of the heap + 2. Items in the middle of the heap marked for removal still execute + 3. This causes cancelled timeouts to run when they shouldn't + """ + + loop = asyncio.get_running_loop() + test_complete_future: asyncio.Future[bool] = loop.create_future() + + # Track test results + test_passed = False + removed_executed = 0 + normal_executed = 0 + + # Patterns to match + race_pattern = re.compile(r"RACE: .* executed after being cancelled!") + passed_pattern = re.compile(r"TEST PASSED") + failed_pattern = re.compile(r"TEST FAILED") + complete_pattern = re.compile(r"=== Test Complete ===") + normal_count_pattern = re.compile(r"Normal items executed: (\d+)") + removed_count_pattern = re.compile(r"Removed items executed: (\d+)") + + def check_output(line: str) -> None: + """Check log output for test results.""" + nonlocal test_passed, removed_executed, normal_executed + + if race_pattern.search(line): + # Race condition detected - a cancelled item executed + test_passed = False + + if passed_pattern.search(line): + test_passed = True + elif failed_pattern.search(line): + test_passed = False + + normal_match = normal_count_pattern.search(line) + if normal_match: + normal_executed = int(normal_match.group(1)) + + removed_match = removed_count_pattern.search(line) + if removed_match: + removed_executed = int(removed_match.group(1)) + + if not test_complete_future.done() and complete_pattern.search(line): + test_complete_future.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify we can connect + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-removed-item-race" + + # List services + _, services = await asyncio.wait_for( + client.list_entities_services(), timeout=5.0 + ) + + # Find run_test service + run_test_service = next((s for s in services if s.name == "run_test"), None) + assert run_test_service is not None, "run_test service not found" + + # Execute the test + client.execute_service(run_test_service, {}) + + # Wait for test completion + try: + await asyncio.wait_for(test_complete_future, timeout=5.0) + except TimeoutError: + pytest.fail("Test did not complete within timeout") + + # Verify results + assert test_passed, ( + f"Test failed! Removed items executed: {removed_executed}, " + f"Normal items executed: {normal_executed}" + ) + assert removed_executed == 0, ( + f"Cancelled items should not execute, but {removed_executed} did" + ) + assert normal_executed == 5, ( + f"Expected 5 normal items to execute, got {normal_executed}" + ) From c56fd00a7c75096c411524577a395a178a93341a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Aug 2025 16:28:32 -0400 Subject: [PATCH 0577/1626] cleanup --- esphome/core/scheduler.cpp | 3 - .../fixtures/scheduler_removed_item_race.yaml | 113 +++++++++++------- .../test_scheduler_removed_item_race.py | 4 +- 3 files changed, 69 insertions(+), 51 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 4d8c4c67da..c3ade260ac 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -409,8 +409,6 @@ void HOT Scheduler::call(uint32_t now) { // This handles two cases: // 1. Item was marked for removal after cleanup_() but before we got here // 2. Item is marked for removal but wasn't at the front of the heap during cleanup_() - // TEMPORARILY DISABLED TO VERIFY TEST CATCHES THE BUG - /* #ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS // Multi-threaded platforms without atomics: must take lock to safely read remove flag { @@ -430,7 +428,6 @@ void HOT Scheduler::call(uint32_t now) { continue; } #endif - */ #ifdef ESPHOME_DEBUG_SCHEDULER const char *item_name = item->get_name(); diff --git a/tests/integration/fixtures/scheduler_removed_item_race.yaml b/tests/integration/fixtures/scheduler_removed_item_race.yaml index f47cf9a163..2f8a7fb987 100644 --- a/tests/integration/fixtures/scheduler_removed_item_race.yaml +++ b/tests/integration/fixtures/scheduler_removed_item_race.yaml @@ -36,80 +36,101 @@ script: - logger.log: "=== Starting Removed Item Race Test ===" # This test creates a scenario where: - # 1. Multiple timeouts are scheduled to execute at nearly the same time - # 2. One timeout in the middle of the heap gets cancelled - # 3. Without the fix, the cancelled timeout would still execute + # 1. First item in heap is NOT cancelled (cleanup stops immediately) + # 2. Items behind it ARE cancelled (remain in heap after cleanup) + # 3. All items execute at the same time, including cancelled ones - lambda: |- - // Schedule multiple timeouts that will all be ready at the same time - // This ensures they're all in the heap together + // The key to hitting the race: + // 1. Add items in a specific order to control heap structure + // 2. Cancel ONLY items that won't be at the front + // 3. Ensure the first item stays non-cancelled so cleanup_() stops immediately - // First timeout - executes at 10ms - App.scheduler.set_timeout(id(test_sensor), "timeout1", 10, []() { - ESP_LOGD("test", "Timeout 1 executed (expected)"); + // Schedule all items to execute at the SAME time (1ms from now) + // Using 1ms instead of 0 to avoid defer queue on multi-core platforms + // This ensures they'll all be ready together and go through the heap + const uint32_t exec_time = 1; + + // CRITICAL: Add a non-cancellable item FIRST + // This will be at the front of the heap and block cleanup_() + App.scheduler.set_timeout(id(test_sensor), "blocker", exec_time, []() { + ESP_LOGD("test", "Blocker timeout executed (expected) - was at front of heap"); id(normal_item_executed)++; }); - // Second timeout - executes at 10ms (will be cancelled) - App.scheduler.set_timeout(id(test_sensor), "timeout2", 10, []() { - ESP_LOGE("test", "RACE: Timeout 2 executed after being cancelled!"); + // Now add items that we WILL cancel + // These will be behind the blocker in the heap + App.scheduler.set_timeout(id(test_sensor), "cancel_1", exec_time, []() { + ESP_LOGE("test", "RACE: Cancelled timeout 1 executed after being cancelled!"); id(removed_item_executed)++; id(test_passed) = false; }); - // Third timeout - executes at 10ms - App.scheduler.set_timeout(id(test_sensor), "timeout3", 10, []() { - ESP_LOGD("test", "Timeout 3 executed (expected)"); - id(normal_item_executed)++; - }); - - // Fourth timeout - executes at 10ms - App.scheduler.set_timeout(id(test_sensor), "timeout4", 10, []() { - ESP_LOGD("test", "Timeout 4 executed (expected)"); - id(normal_item_executed)++; - }); - - // Now cancel timeout2 - // Since all timeouts have the same execution time, they're all in the heap - // timeout2 might not be at the front, so cleanup_() won't remove it - bool cancelled = App.scheduler.cancel_timeout(id(test_sensor), "timeout2"); - ESP_LOGD("test", "Cancelled timeout2: %s", cancelled ? "true" : "false"); - - // Also test with items at slightly different times - App.scheduler.set_timeout(id(test_sensor), "timeout5", 11, []() { - ESP_LOGD("test", "Timeout 5 executed (expected)"); - id(normal_item_executed)++; - }); - - App.scheduler.set_timeout(id(test_sensor), "timeout6", 12, []() { - ESP_LOGE("test", "RACE: Timeout 6 executed after being cancelled!"); + App.scheduler.set_timeout(id(test_sensor), "cancel_2", exec_time, []() { + ESP_LOGE("test", "RACE: Cancelled timeout 2 executed after being cancelled!"); id(removed_item_executed)++; id(test_passed) = false; }); - App.scheduler.set_timeout(id(test_sensor), "timeout7", 13, []() { - ESP_LOGD("test", "Timeout 7 executed (expected)"); + App.scheduler.set_timeout(id(test_sensor), "cancel_3", exec_time, []() { + ESP_LOGE("test", "RACE: Cancelled timeout 3 executed after being cancelled!"); + id(removed_item_executed)++; + id(test_passed) = false; + }); + + // Add some more normal items + App.scheduler.set_timeout(id(test_sensor), "normal_1", exec_time, []() { + ESP_LOGD("test", "Normal timeout 1 executed (expected)"); id(normal_item_executed)++; }); - // Cancel timeout6 - cancelled = App.scheduler.cancel_timeout(id(test_sensor), "timeout6"); - ESP_LOGD("test", "Cancelled timeout6: %s", cancelled ? "true" : "false"); + App.scheduler.set_timeout(id(test_sensor), "normal_2", exec_time, []() { + ESP_LOGD("test", "Normal timeout 2 executed (expected)"); + id(normal_item_executed)++; + }); + + App.scheduler.set_timeout(id(test_sensor), "normal_3", exec_time, []() { + ESP_LOGD("test", "Normal timeout 3 executed (expected)"); + id(normal_item_executed)++; + }); + + // Force items into the heap before cancelling + App.scheduler.process_to_add(); + + // NOW cancel the items - they're behind "blocker" in the heap + // When cleanup_() runs, it will see "blocker" (not removed) at the front + // and stop immediately, leaving cancel_1, cancel_2, cancel_3 in the heap + bool c1 = App.scheduler.cancel_timeout(id(test_sensor), "cancel_1"); + bool c2 = App.scheduler.cancel_timeout(id(test_sensor), "cancel_2"); + bool c3 = App.scheduler.cancel_timeout(id(test_sensor), "cancel_3"); + + ESP_LOGD("test", "Cancelled items (behind blocker): %s, %s, %s", + c1 ? "true" : "false", + c2 ? "true" : "false", + c3 ? "true" : "false"); + + // The heap now has: + // - "blocker" at front (not cancelled) + // - cancelled items behind it (marked remove=true but still in heap) + // - When all execute at once, cleanup_() stops at "blocker" + // - The loop then executes ALL ready items including cancelled ones + + ESP_LOGD("test", "Setup complete. Blocker at front prevents cleanup of cancelled items behind it"); # Wait for all timeouts to execute (or not) - - delay: 50ms + - delay: 20ms # Check results - lambda: |- ESP_LOGI("test", "=== Test Results ==="); - ESP_LOGI("test", "Normal items executed: %d (expected 5)", id(normal_item_executed)); + ESP_LOGI("test", "Normal items executed: %d (expected 4)", id(normal_item_executed)); ESP_LOGI("test", "Removed items executed: %d (expected 0)", id(removed_item_executed)); if (id(removed_item_executed) > 0) { ESP_LOGE("test", "TEST FAILED: %d cancelled items were executed!", id(removed_item_executed)); id(test_passed) = false; - } else if (id(normal_item_executed) != 5) { - ESP_LOGE("test", "TEST FAILED: Expected 5 normal items, got %d", id(normal_item_executed)); + } else if (id(normal_item_executed) != 4) { + ESP_LOGE("test", "TEST FAILED: Expected 4 normal items, got %d", id(normal_item_executed)); id(test_passed) = false; } else { ESP_LOGI("test", "TEST PASSED: No cancelled items were executed"); diff --git a/tests/integration/test_scheduler_removed_item_race.py b/tests/integration/test_scheduler_removed_item_race.py index c95a399ce3..3e72bacc0d 100644 --- a/tests/integration/test_scheduler_removed_item_race.py +++ b/tests/integration/test_scheduler_removed_item_race.py @@ -97,6 +97,6 @@ async def test_scheduler_removed_item_race( assert removed_executed == 0, ( f"Cancelled items should not execute, but {removed_executed} did" ) - assert normal_executed == 5, ( - f"Expected 5 normal items to execute, got {normal_executed}" + assert normal_executed == 4, ( + f"Expected 4 normal items to execute, got {normal_executed}" ) From 89732f30f471ccfd8418f76ae15d5c74cd25affe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Aug 2025 18:46:53 -0400 Subject: [PATCH 0578/1626] [libretiny] Optimize preferences is_changed() by replacing temporary vector with unique_ptr --- esphome/components/libretiny/preferences.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index ce4ed915c0..d0bd8d7cfa 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -5,6 +5,7 @@ #include "esphome/core/preferences.h" #include #include +#include #include #include @@ -139,21 +140,29 @@ class LibreTinyPreferences : public ESPPreferences { } bool is_changed(const fdb_kvdb_t db, const NVSData &to_save) { - NVSData stored_data{}; struct fdb_kv kv; fdb_kv_t kvp = fdb_kv_get_obj(db, to_save.key.c_str(), &kv); if (kvp == nullptr) { ESP_LOGV(TAG, "fdb_kv_get_obj('%s'): nullptr - the key might not be set yet", to_save.key.c_str()); return true; } - stored_data.data.resize(kv.value_len); - fdb_blob_make(&blob, stored_data.data.data(), kv.value_len); + + // Check size first - if different, data has changed + if (kv.value_len != to_save.data.size()) { + return true; + } + + // Allocate buffer on heap to avoid stack allocation for large data + auto stored_data = std::make_unique(kv.value_len); + fdb_blob_make(&blob, stored_data.get(), kv.value_len); size_t actual_len = fdb_kv_get_blob(db, to_save.key.c_str(), &blob); if (actual_len != kv.value_len) { ESP_LOGV(TAG, "fdb_kv_get_blob('%s') len mismatch: %u != %u", to_save.key.c_str(), actual_len, kv.value_len); return true; } - return to_save.data != stored_data.data; + + // Compare the actual data + return memcmp(to_save.data.data(), stored_data.get(), kv.value_len) != 0; } bool reset() override { From 2a3f80a82cc673036167d5371edc5abc169923a4 Mon Sep 17 00:00:00 2001 From: Ben Winslow Date: Sun, 17 Aug 2025 22:09:42 -0400 Subject: [PATCH 0579/1626] [senseair] Discard 0 ppm readings with "Out Of Range" bit set. (#10275) --- esphome/components/senseair/senseair.cpp | 10 +++++++--- esphome/components/senseair/senseair.h | 11 +++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index e58ee157f7..84520d407d 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -53,10 +53,14 @@ void SenseAirComponent::update() { this->status_clear_warning(); const uint8_t length = response[2]; - const uint16_t status = (uint16_t(response[3]) << 8) | response[4]; - const int16_t ppm = int16_t((response[length + 1] << 8) | response[length + 2]); + const uint16_t status = encode_uint16(response[3], response[4]); + const uint16_t ppm = encode_uint16(response[length + 1], response[length + 2]); - ESP_LOGD(TAG, "SenseAir Received CO₂=%dppm Status=0x%02X", ppm, status); + ESP_LOGD(TAG, "SenseAir Received CO₂=%uppm Status=0x%02X", ppm, status); + if (ppm == 0 && (status & SenseAirStatus::OUT_OF_RANGE_ERROR) != 0) { + ESP_LOGD(TAG, "Discarding 0 ppm reading with out-of-range status."); + return; + } if (this->co2_sensor_ != nullptr) this->co2_sensor_->publish_state(ppm); } diff --git a/esphome/components/senseair/senseair.h b/esphome/components/senseair/senseair.h index 9f939d5b07..5b66860f1a 100644 --- a/esphome/components/senseair/senseair.h +++ b/esphome/components/senseair/senseair.h @@ -8,6 +8,17 @@ namespace esphome { namespace senseair { +enum SenseAirStatus : uint8_t { + FATAL_ERROR = 1 << 0, + OFFSET_ERROR = 1 << 1, + ALGORITHM_ERROR = 1 << 2, + OUTPUT_ERROR = 1 << 3, + SELF_DIAGNOSTIC_ERROR = 1 << 4, + OUT_OF_RANGE_ERROR = 1 << 5, + MEMORY_ERROR = 1 << 6, + RESERVED = 1 << 7 +}; + class SenseAirComponent : public PollingComponent, public uart::UARTDevice { public: void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; } From 37e36a3d1dd09960768b49aec654928ecabca748 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Aug 2025 20:53:40 -0500 Subject: [PATCH 0580/1626] [api] Mark protobuf message classes as final to enable compiler optimizations --- esphome/components/api/api_pb2.h | 278 ++++++++++++++-------------- script/api_protobuf/api_protobuf.py | 4 +- 2 files changed, 141 insertions(+), 141 deletions(-) diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index edf839be55..abdf0e6121 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -321,7 +321,7 @@ class CommandProtoMessage : public ProtoDecodableMessage { protected: }; -class HelloRequest : public ProtoDecodableMessage { +class HelloRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 1; static constexpr uint8_t ESTIMATED_SIZE = 17; @@ -339,7 +339,7 @@ class HelloRequest : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class HelloResponse : public ProtoMessage { +class HelloResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 2; static constexpr uint8_t ESTIMATED_SIZE = 26; @@ -360,7 +360,7 @@ class HelloResponse : public ProtoMessage { protected: }; -class ConnectRequest : public ProtoDecodableMessage { +class ConnectRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 3; static constexpr uint8_t ESTIMATED_SIZE = 9; @@ -375,7 +375,7 @@ class ConnectRequest : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class ConnectResponse : public ProtoMessage { +class ConnectResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 4; static constexpr uint8_t ESTIMATED_SIZE = 2; @@ -391,7 +391,7 @@ class ConnectResponse : public ProtoMessage { protected: }; -class DisconnectRequest : public ProtoMessage { +class DisconnectRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 5; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -404,7 +404,7 @@ class DisconnectRequest : public ProtoMessage { protected: }; -class DisconnectResponse : public ProtoMessage { +class DisconnectResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 6; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -417,7 +417,7 @@ class DisconnectResponse : public ProtoMessage { protected: }; -class PingRequest : public ProtoMessage { +class PingRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 7; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -430,7 +430,7 @@ class PingRequest : public ProtoMessage { protected: }; -class PingResponse : public ProtoMessage { +class PingResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 8; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -443,7 +443,7 @@ class PingResponse : public ProtoMessage { protected: }; -class DeviceInfoRequest : public ProtoMessage { +class DeviceInfoRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 9; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -457,7 +457,7 @@ class DeviceInfoRequest : public ProtoMessage { protected: }; #ifdef USE_AREAS -class AreaInfo : public ProtoMessage { +class AreaInfo final : public ProtoMessage { public: uint32_t area_id{0}; StringRef name_ref_{}; @@ -472,7 +472,7 @@ class AreaInfo : public ProtoMessage { }; #endif #ifdef USE_DEVICES -class DeviceInfo : public ProtoMessage { +class DeviceInfo final : public ProtoMessage { public: uint32_t device_id{0}; StringRef name_ref_{}; @@ -487,7 +487,7 @@ class DeviceInfo : public ProtoMessage { protected: }; #endif -class DeviceInfoResponse : public ProtoMessage { +class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; static constexpr uint8_t ESTIMATED_SIZE = 247; @@ -559,7 +559,7 @@ class DeviceInfoResponse : public ProtoMessage { protected: }; -class ListEntitiesRequest : public ProtoMessage { +class ListEntitiesRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 11; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -572,7 +572,7 @@ class ListEntitiesRequest : public ProtoMessage { protected: }; -class ListEntitiesDoneResponse : public ProtoMessage { +class ListEntitiesDoneResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 19; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -585,7 +585,7 @@ class ListEntitiesDoneResponse : public ProtoMessage { protected: }; -class SubscribeStatesRequest : public ProtoMessage { +class SubscribeStatesRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 20; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -599,7 +599,7 @@ class SubscribeStatesRequest : public ProtoMessage { protected: }; #ifdef USE_BINARY_SENSOR -class ListEntitiesBinarySensorResponse : public InfoResponseProtoMessage { +class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 12; static constexpr uint8_t ESTIMATED_SIZE = 51; @@ -617,7 +617,7 @@ class ListEntitiesBinarySensorResponse : public InfoResponseProtoMessage { protected: }; -class BinarySensorStateResponse : public StateResponseProtoMessage { +class BinarySensorStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 21; static constexpr uint8_t ESTIMATED_SIZE = 13; @@ -636,7 +636,7 @@ class BinarySensorStateResponse : public StateResponseProtoMessage { }; #endif #ifdef USE_COVER -class ListEntitiesCoverResponse : public InfoResponseProtoMessage { +class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 13; static constexpr uint8_t ESTIMATED_SIZE = 57; @@ -657,7 +657,7 @@ class ListEntitiesCoverResponse : public InfoResponseProtoMessage { protected: }; -class CoverStateResponse : public StateResponseProtoMessage { +class CoverStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 22; static constexpr uint8_t ESTIMATED_SIZE = 21; @@ -675,7 +675,7 @@ class CoverStateResponse : public StateResponseProtoMessage { protected: }; -class CoverCommandRequest : public CommandProtoMessage { +class CoverCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 30; static constexpr uint8_t ESTIMATED_SIZE = 25; @@ -697,7 +697,7 @@ class CoverCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_FAN -class ListEntitiesFanResponse : public InfoResponseProtoMessage { +class ListEntitiesFanResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 14; static constexpr uint8_t ESTIMATED_SIZE = 68; @@ -717,7 +717,7 @@ class ListEntitiesFanResponse : public InfoResponseProtoMessage { protected: }; -class FanStateResponse : public StateResponseProtoMessage { +class FanStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 23; static constexpr uint8_t ESTIMATED_SIZE = 28; @@ -738,7 +738,7 @@ class FanStateResponse : public StateResponseProtoMessage { protected: }; -class FanCommandRequest : public CommandProtoMessage { +class FanCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 31; static constexpr uint8_t ESTIMATED_SIZE = 38; @@ -766,7 +766,7 @@ class FanCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_LIGHT -class ListEntitiesLightResponse : public InfoResponseProtoMessage { +class ListEntitiesLightResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 15; static constexpr uint8_t ESTIMATED_SIZE = 73; @@ -785,7 +785,7 @@ class ListEntitiesLightResponse : public InfoResponseProtoMessage { protected: }; -class LightStateResponse : public StateResponseProtoMessage { +class LightStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 24; static constexpr uint8_t ESTIMATED_SIZE = 67; @@ -813,7 +813,7 @@ class LightStateResponse : public StateResponseProtoMessage { protected: }; -class LightCommandRequest : public CommandProtoMessage { +class LightCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 32; static constexpr uint8_t ESTIMATED_SIZE = 112; @@ -857,7 +857,7 @@ class LightCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_SENSOR -class ListEntitiesSensorResponse : public InfoResponseProtoMessage { +class ListEntitiesSensorResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 16; static constexpr uint8_t ESTIMATED_SIZE = 66; @@ -879,7 +879,7 @@ class ListEntitiesSensorResponse : public InfoResponseProtoMessage { protected: }; -class SensorStateResponse : public StateResponseProtoMessage { +class SensorStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 25; static constexpr uint8_t ESTIMATED_SIZE = 16; @@ -898,7 +898,7 @@ class SensorStateResponse : public StateResponseProtoMessage { }; #endif #ifdef USE_SWITCH -class ListEntitiesSwitchResponse : public InfoResponseProtoMessage { +class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 17; static constexpr uint8_t ESTIMATED_SIZE = 51; @@ -916,7 +916,7 @@ class ListEntitiesSwitchResponse : public InfoResponseProtoMessage { protected: }; -class SwitchStateResponse : public StateResponseProtoMessage { +class SwitchStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 26; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -932,7 +932,7 @@ class SwitchStateResponse : public StateResponseProtoMessage { protected: }; -class SwitchCommandRequest : public CommandProtoMessage { +class SwitchCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 33; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -950,7 +950,7 @@ class SwitchCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_TEXT_SENSOR -class ListEntitiesTextSensorResponse : public InfoResponseProtoMessage { +class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 18; static constexpr uint8_t ESTIMATED_SIZE = 49; @@ -967,7 +967,7 @@ class ListEntitiesTextSensorResponse : public InfoResponseProtoMessage { protected: }; -class TextSensorStateResponse : public StateResponseProtoMessage { +class TextSensorStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 27; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -986,7 +986,7 @@ class TextSensorStateResponse : public StateResponseProtoMessage { protected: }; #endif -class SubscribeLogsRequest : public ProtoDecodableMessage { +class SubscribeLogsRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 28; static constexpr uint8_t ESTIMATED_SIZE = 4; @@ -1002,7 +1002,7 @@ class SubscribeLogsRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class SubscribeLogsResponse : public ProtoMessage { +class SubscribeLogsResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 29; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -1025,7 +1025,7 @@ class SubscribeLogsResponse : public ProtoMessage { protected: }; #ifdef USE_API_NOISE -class NoiseEncryptionSetKeyRequest : public ProtoDecodableMessage { +class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 124; static constexpr uint8_t ESTIMATED_SIZE = 9; @@ -1040,7 +1040,7 @@ class NoiseEncryptionSetKeyRequest : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class NoiseEncryptionSetKeyResponse : public ProtoMessage { +class NoiseEncryptionSetKeyResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 125; static constexpr uint8_t ESTIMATED_SIZE = 2; @@ -1058,7 +1058,7 @@ class NoiseEncryptionSetKeyResponse : public ProtoMessage { }; #endif #ifdef USE_API_HOMEASSISTANT_SERVICES -class SubscribeHomeassistantServicesRequest : public ProtoMessage { +class SubscribeHomeassistantServicesRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 34; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -1071,7 +1071,7 @@ class SubscribeHomeassistantServicesRequest : public ProtoMessage { protected: }; -class HomeassistantServiceMap : public ProtoMessage { +class HomeassistantServiceMap final : public ProtoMessage { public: StringRef key_ref_{}; void set_key(const StringRef &ref) { this->key_ref_ = ref; } @@ -1084,7 +1084,7 @@ class HomeassistantServiceMap : public ProtoMessage { protected: }; -class HomeassistantServiceResponse : public ProtoMessage { +class HomeassistantServiceResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 35; static constexpr uint8_t ESTIMATED_SIZE = 113; @@ -1107,7 +1107,7 @@ class HomeassistantServiceResponse : public ProtoMessage { }; #endif #ifdef USE_API_HOMEASSISTANT_STATES -class SubscribeHomeAssistantStatesRequest : public ProtoMessage { +class SubscribeHomeAssistantStatesRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 38; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -1120,7 +1120,7 @@ class SubscribeHomeAssistantStatesRequest : public ProtoMessage { protected: }; -class SubscribeHomeAssistantStateResponse : public ProtoMessage { +class SubscribeHomeAssistantStateResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 39; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -1140,7 +1140,7 @@ class SubscribeHomeAssistantStateResponse : public ProtoMessage { protected: }; -class HomeAssistantStateResponse : public ProtoDecodableMessage { +class HomeAssistantStateResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 40; static constexpr uint8_t ESTIMATED_SIZE = 27; @@ -1158,7 +1158,7 @@ class HomeAssistantStateResponse : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; #endif -class GetTimeRequest : public ProtoMessage { +class GetTimeRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 36; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -1171,7 +1171,7 @@ class GetTimeRequest : public ProtoMessage { protected: }; -class GetTimeResponse : public ProtoDecodableMessage { +class GetTimeResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 37; static constexpr uint8_t ESTIMATED_SIZE = 5; @@ -1189,7 +1189,7 @@ class GetTimeResponse : public ProtoDecodableMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; }; #ifdef USE_API_SERVICES -class ListEntitiesServicesArgument : public ProtoMessage { +class ListEntitiesServicesArgument final : public ProtoMessage { public: StringRef name_ref_{}; void set_name(const StringRef &ref) { this->name_ref_ = ref; } @@ -1202,7 +1202,7 @@ class ListEntitiesServicesArgument : public ProtoMessage { protected: }; -class ListEntitiesServicesResponse : public ProtoMessage { +class ListEntitiesServicesResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 41; static constexpr uint8_t ESTIMATED_SIZE = 48; @@ -1221,7 +1221,7 @@ class ListEntitiesServicesResponse : public ProtoMessage { protected: }; -class ExecuteServiceArgument : public ProtoDecodableMessage { +class ExecuteServiceArgument final : public ProtoDecodableMessage { public: bool bool_{false}; int32_t legacy_int{0}; @@ -1241,7 +1241,7 @@ class ExecuteServiceArgument : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ExecuteServiceRequest : public ProtoDecodableMessage { +class ExecuteServiceRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 42; static constexpr uint8_t ESTIMATED_SIZE = 39; @@ -1260,7 +1260,7 @@ class ExecuteServiceRequest : public ProtoDecodableMessage { }; #endif #ifdef USE_CAMERA -class ListEntitiesCameraResponse : public InfoResponseProtoMessage { +class ListEntitiesCameraResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 43; static constexpr uint8_t ESTIMATED_SIZE = 40; @@ -1275,7 +1275,7 @@ class ListEntitiesCameraResponse : public InfoResponseProtoMessage { protected: }; -class CameraImageResponse : public StateResponseProtoMessage { +class CameraImageResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 44; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -1297,7 +1297,7 @@ class CameraImageResponse : public StateResponseProtoMessage { protected: }; -class CameraImageRequest : public ProtoDecodableMessage { +class CameraImageRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 45; static constexpr uint8_t ESTIMATED_SIZE = 4; @@ -1315,7 +1315,7 @@ class CameraImageRequest : public ProtoDecodableMessage { }; #endif #ifdef USE_CLIMATE -class ListEntitiesClimateResponse : public InfoResponseProtoMessage { +class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 46; static constexpr uint8_t ESTIMATED_SIZE = 145; @@ -1347,7 +1347,7 @@ class ListEntitiesClimateResponse : public InfoResponseProtoMessage { protected: }; -class ClimateStateResponse : public StateResponseProtoMessage { +class ClimateStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 47; static constexpr uint8_t ESTIMATED_SIZE = 68; @@ -1377,7 +1377,7 @@ class ClimateStateResponse : public StateResponseProtoMessage { protected: }; -class ClimateCommandRequest : public CommandProtoMessage { +class ClimateCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 48; static constexpr uint8_t ESTIMATED_SIZE = 84; @@ -1415,7 +1415,7 @@ class ClimateCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_NUMBER -class ListEntitiesNumberResponse : public InfoResponseProtoMessage { +class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 49; static constexpr uint8_t ESTIMATED_SIZE = 75; @@ -1438,7 +1438,7 @@ class ListEntitiesNumberResponse : public InfoResponseProtoMessage { protected: }; -class NumberStateResponse : public StateResponseProtoMessage { +class NumberStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 50; static constexpr uint8_t ESTIMATED_SIZE = 16; @@ -1455,7 +1455,7 @@ class NumberStateResponse : public StateResponseProtoMessage { protected: }; -class NumberCommandRequest : public CommandProtoMessage { +class NumberCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 51; static constexpr uint8_t ESTIMATED_SIZE = 14; @@ -1473,7 +1473,7 @@ class NumberCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_SELECT -class ListEntitiesSelectResponse : public InfoResponseProtoMessage { +class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 52; static constexpr uint8_t ESTIMATED_SIZE = 58; @@ -1489,7 +1489,7 @@ class ListEntitiesSelectResponse : public InfoResponseProtoMessage { protected: }; -class SelectStateResponse : public StateResponseProtoMessage { +class SelectStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 53; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -1507,7 +1507,7 @@ class SelectStateResponse : public StateResponseProtoMessage { protected: }; -class SelectCommandRequest : public CommandProtoMessage { +class SelectCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 54; static constexpr uint8_t ESTIMATED_SIZE = 18; @@ -1526,7 +1526,7 @@ class SelectCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_SIREN -class ListEntitiesSirenResponse : public InfoResponseProtoMessage { +class ListEntitiesSirenResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 55; static constexpr uint8_t ESTIMATED_SIZE = 62; @@ -1544,7 +1544,7 @@ class ListEntitiesSirenResponse : public InfoResponseProtoMessage { protected: }; -class SirenStateResponse : public StateResponseProtoMessage { +class SirenStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 56; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -1560,7 +1560,7 @@ class SirenStateResponse : public StateResponseProtoMessage { protected: }; -class SirenCommandRequest : public CommandProtoMessage { +class SirenCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 57; static constexpr uint8_t ESTIMATED_SIZE = 37; @@ -1586,7 +1586,7 @@ class SirenCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_LOCK -class ListEntitiesLockResponse : public InfoResponseProtoMessage { +class ListEntitiesLockResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 58; static constexpr uint8_t ESTIMATED_SIZE = 55; @@ -1606,7 +1606,7 @@ class ListEntitiesLockResponse : public InfoResponseProtoMessage { protected: }; -class LockStateResponse : public StateResponseProtoMessage { +class LockStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 59; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -1622,7 +1622,7 @@ class LockStateResponse : public StateResponseProtoMessage { protected: }; -class LockCommandRequest : public CommandProtoMessage { +class LockCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 60; static constexpr uint8_t ESTIMATED_SIZE = 22; @@ -1643,7 +1643,7 @@ class LockCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_BUTTON -class ListEntitiesButtonResponse : public InfoResponseProtoMessage { +class ListEntitiesButtonResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 61; static constexpr uint8_t ESTIMATED_SIZE = 49; @@ -1660,7 +1660,7 @@ class ListEntitiesButtonResponse : public InfoResponseProtoMessage { protected: }; -class ButtonCommandRequest : public CommandProtoMessage { +class ButtonCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 62; static constexpr uint8_t ESTIMATED_SIZE = 9; @@ -1677,7 +1677,7 @@ class ButtonCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_MEDIA_PLAYER -class MediaPlayerSupportedFormat : public ProtoMessage { +class MediaPlayerSupportedFormat final : public ProtoMessage { public: StringRef format_ref_{}; void set_format(const StringRef &ref) { this->format_ref_ = ref; } @@ -1693,7 +1693,7 @@ class MediaPlayerSupportedFormat : public ProtoMessage { protected: }; -class ListEntitiesMediaPlayerResponse : public InfoResponseProtoMessage { +class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 63; static constexpr uint8_t ESTIMATED_SIZE = 80; @@ -1711,7 +1711,7 @@ class ListEntitiesMediaPlayerResponse : public InfoResponseProtoMessage { protected: }; -class MediaPlayerStateResponse : public StateResponseProtoMessage { +class MediaPlayerStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 64; static constexpr uint8_t ESTIMATED_SIZE = 18; @@ -1729,7 +1729,7 @@ class MediaPlayerStateResponse : public StateResponseProtoMessage { protected: }; -class MediaPlayerCommandRequest : public CommandProtoMessage { +class MediaPlayerCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 65; static constexpr uint8_t ESTIMATED_SIZE = 35; @@ -1755,7 +1755,7 @@ class MediaPlayerCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_BLUETOOTH_PROXY -class SubscribeBluetoothLEAdvertisementsRequest : public ProtoDecodableMessage { +class SubscribeBluetoothLEAdvertisementsRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 66; static constexpr uint8_t ESTIMATED_SIZE = 4; @@ -1770,7 +1770,7 @@ class SubscribeBluetoothLEAdvertisementsRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothLERawAdvertisement : public ProtoMessage { +class BluetoothLERawAdvertisement final : public ProtoMessage { public: uint64_t address{0}; int32_t rssi{0}; @@ -1785,7 +1785,7 @@ class BluetoothLERawAdvertisement : public ProtoMessage { protected: }; -class BluetoothLERawAdvertisementsResponse : public ProtoMessage { +class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 93; static constexpr uint8_t ESTIMATED_SIZE = 136; @@ -1802,7 +1802,7 @@ class BluetoothLERawAdvertisementsResponse : public ProtoMessage { protected: }; -class BluetoothDeviceRequest : public ProtoDecodableMessage { +class BluetoothDeviceRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 68; static constexpr uint8_t ESTIMATED_SIZE = 12; @@ -1820,7 +1820,7 @@ class BluetoothDeviceRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothDeviceConnectionResponse : public ProtoMessage { +class BluetoothDeviceConnectionResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 69; static constexpr uint8_t ESTIMATED_SIZE = 14; @@ -1839,7 +1839,7 @@ class BluetoothDeviceConnectionResponse : public ProtoMessage { protected: }; -class BluetoothGATTGetServicesRequest : public ProtoDecodableMessage { +class BluetoothGATTGetServicesRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 70; static constexpr uint8_t ESTIMATED_SIZE = 4; @@ -1854,7 +1854,7 @@ class BluetoothGATTGetServicesRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothGATTDescriptor : public ProtoMessage { +class BluetoothGATTDescriptor final : public ProtoMessage { public: std::array uuid{}; uint32_t handle{0}; @@ -1867,7 +1867,7 @@ class BluetoothGATTDescriptor : public ProtoMessage { protected: }; -class BluetoothGATTCharacteristic : public ProtoMessage { +class BluetoothGATTCharacteristic final : public ProtoMessage { public: std::array uuid{}; uint32_t handle{0}; @@ -1882,7 +1882,7 @@ class BluetoothGATTCharacteristic : public ProtoMessage { protected: }; -class BluetoothGATTService : public ProtoMessage { +class BluetoothGATTService final : public ProtoMessage { public: std::array uuid{}; uint32_t handle{0}; @@ -1896,7 +1896,7 @@ class BluetoothGATTService : public ProtoMessage { protected: }; -class BluetoothGATTGetServicesResponse : public ProtoMessage { +class BluetoothGATTGetServicesResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 71; static constexpr uint8_t ESTIMATED_SIZE = 38; @@ -1913,7 +1913,7 @@ class BluetoothGATTGetServicesResponse : public ProtoMessage { protected: }; -class BluetoothGATTGetServicesDoneResponse : public ProtoMessage { +class BluetoothGATTGetServicesDoneResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 72; static constexpr uint8_t ESTIMATED_SIZE = 4; @@ -1929,7 +1929,7 @@ class BluetoothGATTGetServicesDoneResponse : public ProtoMessage { protected: }; -class BluetoothGATTReadRequest : public ProtoDecodableMessage { +class BluetoothGATTReadRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 73; static constexpr uint8_t ESTIMATED_SIZE = 8; @@ -1945,7 +1945,7 @@ class BluetoothGATTReadRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothGATTReadResponse : public ProtoMessage { +class BluetoothGATTReadResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 74; static constexpr uint8_t ESTIMATED_SIZE = 17; @@ -1968,7 +1968,7 @@ class BluetoothGATTReadResponse : public ProtoMessage { protected: }; -class BluetoothGATTWriteRequest : public ProtoDecodableMessage { +class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 75; static constexpr uint8_t ESTIMATED_SIZE = 19; @@ -1987,7 +1987,7 @@ class BluetoothGATTWriteRequest : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothGATTReadDescriptorRequest : public ProtoDecodableMessage { +class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 76; static constexpr uint8_t ESTIMATED_SIZE = 8; @@ -2003,7 +2003,7 @@ class BluetoothGATTReadDescriptorRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothGATTWriteDescriptorRequest : public ProtoDecodableMessage { +class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 77; static constexpr uint8_t ESTIMATED_SIZE = 17; @@ -2021,7 +2021,7 @@ class BluetoothGATTWriteDescriptorRequest : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothGATTNotifyRequest : public ProtoDecodableMessage { +class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 78; static constexpr uint8_t ESTIMATED_SIZE = 10; @@ -2038,7 +2038,7 @@ class BluetoothGATTNotifyRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothGATTNotifyDataResponse : public ProtoMessage { +class BluetoothGATTNotifyDataResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 79; static constexpr uint8_t ESTIMATED_SIZE = 17; @@ -2061,7 +2061,7 @@ class BluetoothGATTNotifyDataResponse : public ProtoMessage { protected: }; -class SubscribeBluetoothConnectionsFreeRequest : public ProtoMessage { +class SubscribeBluetoothConnectionsFreeRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 80; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -2074,7 +2074,7 @@ class SubscribeBluetoothConnectionsFreeRequest : public ProtoMessage { protected: }; -class BluetoothConnectionsFreeResponse : public ProtoMessage { +class BluetoothConnectionsFreeResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 81; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -2092,7 +2092,7 @@ class BluetoothConnectionsFreeResponse : public ProtoMessage { protected: }; -class BluetoothGATTErrorResponse : public ProtoMessage { +class BluetoothGATTErrorResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 82; static constexpr uint8_t ESTIMATED_SIZE = 12; @@ -2110,7 +2110,7 @@ class BluetoothGATTErrorResponse : public ProtoMessage { protected: }; -class BluetoothGATTWriteResponse : public ProtoMessage { +class BluetoothGATTWriteResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 83; static constexpr uint8_t ESTIMATED_SIZE = 8; @@ -2127,7 +2127,7 @@ class BluetoothGATTWriteResponse : public ProtoMessage { protected: }; -class BluetoothGATTNotifyResponse : public ProtoMessage { +class BluetoothGATTNotifyResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 84; static constexpr uint8_t ESTIMATED_SIZE = 8; @@ -2144,7 +2144,7 @@ class BluetoothGATTNotifyResponse : public ProtoMessage { protected: }; -class BluetoothDevicePairingResponse : public ProtoMessage { +class BluetoothDevicePairingResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 85; static constexpr uint8_t ESTIMATED_SIZE = 10; @@ -2162,7 +2162,7 @@ class BluetoothDevicePairingResponse : public ProtoMessage { protected: }; -class BluetoothDeviceUnpairingResponse : public ProtoMessage { +class BluetoothDeviceUnpairingResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 86; static constexpr uint8_t ESTIMATED_SIZE = 10; @@ -2180,7 +2180,7 @@ class BluetoothDeviceUnpairingResponse : public ProtoMessage { protected: }; -class UnsubscribeBluetoothLEAdvertisementsRequest : public ProtoMessage { +class UnsubscribeBluetoothLEAdvertisementsRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 87; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -2193,7 +2193,7 @@ class UnsubscribeBluetoothLEAdvertisementsRequest : public ProtoMessage { protected: }; -class BluetoothDeviceClearCacheResponse : public ProtoMessage { +class BluetoothDeviceClearCacheResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 88; static constexpr uint8_t ESTIMATED_SIZE = 10; @@ -2211,7 +2211,7 @@ class BluetoothDeviceClearCacheResponse : public ProtoMessage { protected: }; -class BluetoothScannerStateResponse : public ProtoMessage { +class BluetoothScannerStateResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 126; static constexpr uint8_t ESTIMATED_SIZE = 4; @@ -2228,7 +2228,7 @@ class BluetoothScannerStateResponse : public ProtoMessage { protected: }; -class BluetoothScannerSetModeRequest : public ProtoDecodableMessage { +class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 127; static constexpr uint8_t ESTIMATED_SIZE = 2; @@ -2245,7 +2245,7 @@ class BluetoothScannerSetModeRequest : public ProtoDecodableMessage { }; #endif #ifdef USE_VOICE_ASSISTANT -class SubscribeVoiceAssistantRequest : public ProtoDecodableMessage { +class SubscribeVoiceAssistantRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 89; static constexpr uint8_t ESTIMATED_SIZE = 6; @@ -2261,7 +2261,7 @@ class SubscribeVoiceAssistantRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class VoiceAssistantAudioSettings : public ProtoMessage { +class VoiceAssistantAudioSettings final : public ProtoMessage { public: uint32_t noise_suppression_level{0}; uint32_t auto_gain{0}; @@ -2274,7 +2274,7 @@ class VoiceAssistantAudioSettings : public ProtoMessage { protected: }; -class VoiceAssistantRequest : public ProtoMessage { +class VoiceAssistantRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 90; static constexpr uint8_t ESTIMATED_SIZE = 41; @@ -2296,7 +2296,7 @@ class VoiceAssistantRequest : public ProtoMessage { protected: }; -class VoiceAssistantResponse : public ProtoDecodableMessage { +class VoiceAssistantResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 91; static constexpr uint8_t ESTIMATED_SIZE = 6; @@ -2312,7 +2312,7 @@ class VoiceAssistantResponse : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class VoiceAssistantEventData : public ProtoDecodableMessage { +class VoiceAssistantEventData final : public ProtoDecodableMessage { public: std::string name{}; std::string value{}; @@ -2323,7 +2323,7 @@ class VoiceAssistantEventData : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class VoiceAssistantEventResponse : public ProtoDecodableMessage { +class VoiceAssistantEventResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 92; static constexpr uint8_t ESTIMATED_SIZE = 36; @@ -2340,7 +2340,7 @@ class VoiceAssistantEventResponse : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class VoiceAssistantAudio : public ProtoDecodableMessage { +class VoiceAssistantAudio final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 106; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -2365,7 +2365,7 @@ class VoiceAssistantAudio : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class VoiceAssistantTimerEventResponse : public ProtoDecodableMessage { +class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 115; static constexpr uint8_t ESTIMATED_SIZE = 30; @@ -2386,7 +2386,7 @@ class VoiceAssistantTimerEventResponse : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class VoiceAssistantAnnounceRequest : public ProtoDecodableMessage { +class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 119; static constexpr uint8_t ESTIMATED_SIZE = 29; @@ -2405,7 +2405,7 @@ class VoiceAssistantAnnounceRequest : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class VoiceAssistantAnnounceFinished : public ProtoMessage { +class VoiceAssistantAnnounceFinished final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 120; static constexpr uint8_t ESTIMATED_SIZE = 2; @@ -2421,7 +2421,7 @@ class VoiceAssistantAnnounceFinished : public ProtoMessage { protected: }; -class VoiceAssistantWakeWord : public ProtoMessage { +class VoiceAssistantWakeWord final : public ProtoMessage { public: StringRef id_ref_{}; void set_id(const StringRef &ref) { this->id_ref_ = ref; } @@ -2436,7 +2436,7 @@ class VoiceAssistantWakeWord : public ProtoMessage { protected: }; -class VoiceAssistantConfigurationRequest : public ProtoMessage { +class VoiceAssistantConfigurationRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 121; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -2449,7 +2449,7 @@ class VoiceAssistantConfigurationRequest : public ProtoMessage { protected: }; -class VoiceAssistantConfigurationResponse : public ProtoMessage { +class VoiceAssistantConfigurationResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 122; static constexpr uint8_t ESTIMATED_SIZE = 56; @@ -2467,7 +2467,7 @@ class VoiceAssistantConfigurationResponse : public ProtoMessage { protected: }; -class VoiceAssistantSetConfiguration : public ProtoDecodableMessage { +class VoiceAssistantSetConfiguration final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 123; static constexpr uint8_t ESTIMATED_SIZE = 18; @@ -2484,7 +2484,7 @@ class VoiceAssistantSetConfiguration : public ProtoDecodableMessage { }; #endif #ifdef USE_ALARM_CONTROL_PANEL -class ListEntitiesAlarmControlPanelResponse : public InfoResponseProtoMessage { +class ListEntitiesAlarmControlPanelResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 94; static constexpr uint8_t ESTIMATED_SIZE = 48; @@ -2502,7 +2502,7 @@ class ListEntitiesAlarmControlPanelResponse : public InfoResponseProtoMessage { protected: }; -class AlarmControlPanelStateResponse : public StateResponseProtoMessage { +class AlarmControlPanelStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 95; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -2518,7 +2518,7 @@ class AlarmControlPanelStateResponse : public StateResponseProtoMessage { protected: }; -class AlarmControlPanelCommandRequest : public CommandProtoMessage { +class AlarmControlPanelCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 96; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -2538,7 +2538,7 @@ class AlarmControlPanelCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_TEXT -class ListEntitiesTextResponse : public InfoResponseProtoMessage { +class ListEntitiesTextResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 97; static constexpr uint8_t ESTIMATED_SIZE = 59; @@ -2558,7 +2558,7 @@ class ListEntitiesTextResponse : public InfoResponseProtoMessage { protected: }; -class TextStateResponse : public StateResponseProtoMessage { +class TextStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 98; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -2576,7 +2576,7 @@ class TextStateResponse : public StateResponseProtoMessage { protected: }; -class TextCommandRequest : public CommandProtoMessage { +class TextCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 99; static constexpr uint8_t ESTIMATED_SIZE = 18; @@ -2595,7 +2595,7 @@ class TextCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_DATETIME_DATE -class ListEntitiesDateResponse : public InfoResponseProtoMessage { +class ListEntitiesDateResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 100; static constexpr uint8_t ESTIMATED_SIZE = 40; @@ -2610,7 +2610,7 @@ class ListEntitiesDateResponse : public InfoResponseProtoMessage { protected: }; -class DateStateResponse : public StateResponseProtoMessage { +class DateStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 101; static constexpr uint8_t ESTIMATED_SIZE = 23; @@ -2629,7 +2629,7 @@ class DateStateResponse : public StateResponseProtoMessage { protected: }; -class DateCommandRequest : public CommandProtoMessage { +class DateCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 102; static constexpr uint8_t ESTIMATED_SIZE = 21; @@ -2649,7 +2649,7 @@ class DateCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_DATETIME_TIME -class ListEntitiesTimeResponse : public InfoResponseProtoMessage { +class ListEntitiesTimeResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 103; static constexpr uint8_t ESTIMATED_SIZE = 40; @@ -2664,7 +2664,7 @@ class ListEntitiesTimeResponse : public InfoResponseProtoMessage { protected: }; -class TimeStateResponse : public StateResponseProtoMessage { +class TimeStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 104; static constexpr uint8_t ESTIMATED_SIZE = 23; @@ -2683,7 +2683,7 @@ class TimeStateResponse : public StateResponseProtoMessage { protected: }; -class TimeCommandRequest : public CommandProtoMessage { +class TimeCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 105; static constexpr uint8_t ESTIMATED_SIZE = 21; @@ -2703,7 +2703,7 @@ class TimeCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_EVENT -class ListEntitiesEventResponse : public InfoResponseProtoMessage { +class ListEntitiesEventResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 107; static constexpr uint8_t ESTIMATED_SIZE = 67; @@ -2721,7 +2721,7 @@ class ListEntitiesEventResponse : public InfoResponseProtoMessage { protected: }; -class EventResponse : public StateResponseProtoMessage { +class EventResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 108; static constexpr uint8_t ESTIMATED_SIZE = 18; @@ -2740,7 +2740,7 @@ class EventResponse : public StateResponseProtoMessage { }; #endif #ifdef USE_VALVE -class ListEntitiesValveResponse : public InfoResponseProtoMessage { +class ListEntitiesValveResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 109; static constexpr uint8_t ESTIMATED_SIZE = 55; @@ -2760,7 +2760,7 @@ class ListEntitiesValveResponse : public InfoResponseProtoMessage { protected: }; -class ValveStateResponse : public StateResponseProtoMessage { +class ValveStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 110; static constexpr uint8_t ESTIMATED_SIZE = 16; @@ -2777,7 +2777,7 @@ class ValveStateResponse : public StateResponseProtoMessage { protected: }; -class ValveCommandRequest : public CommandProtoMessage { +class ValveCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 111; static constexpr uint8_t ESTIMATED_SIZE = 18; @@ -2797,7 +2797,7 @@ class ValveCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_DATETIME_DATETIME -class ListEntitiesDateTimeResponse : public InfoResponseProtoMessage { +class ListEntitiesDateTimeResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 112; static constexpr uint8_t ESTIMATED_SIZE = 40; @@ -2812,7 +2812,7 @@ class ListEntitiesDateTimeResponse : public InfoResponseProtoMessage { protected: }; -class DateTimeStateResponse : public StateResponseProtoMessage { +class DateTimeStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 113; static constexpr uint8_t ESTIMATED_SIZE = 16; @@ -2829,7 +2829,7 @@ class DateTimeStateResponse : public StateResponseProtoMessage { protected: }; -class DateTimeCommandRequest : public CommandProtoMessage { +class DateTimeCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 114; static constexpr uint8_t ESTIMATED_SIZE = 14; @@ -2847,7 +2847,7 @@ class DateTimeCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_UPDATE -class ListEntitiesUpdateResponse : public InfoResponseProtoMessage { +class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 116; static constexpr uint8_t ESTIMATED_SIZE = 49; @@ -2864,7 +2864,7 @@ class ListEntitiesUpdateResponse : public InfoResponseProtoMessage { protected: }; -class UpdateStateResponse : public StateResponseProtoMessage { +class UpdateStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 117; static constexpr uint8_t ESTIMATED_SIZE = 65; @@ -2893,7 +2893,7 @@ class UpdateStateResponse : public StateResponseProtoMessage { protected: }; -class UpdateCommandRequest : public CommandProtoMessage { +class UpdateCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 118; static constexpr uint8_t ESTIMATED_SIZE = 11; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 3396e5ad05..511d70d3ec 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1952,7 +1952,7 @@ def build_message_type( dump_impl += "}\n" if base_class: - out = f"class {desc.name} : public {base_class} {{\n" + out = f"class {desc.name} final : public {base_class} {{\n" else: # Check if message has any non-deprecated fields has_fields = any(not field.options.deprecated for field in desc.field) @@ -1961,7 +1961,7 @@ def build_message_type( base_class = "ProtoDecodableMessage" else: base_class = "ProtoMessage" - out = f"class {desc.name} : public {base_class} {{\n" + out = f"class {desc.name} final : public {base_class} {{\n" out += " public:\n" out += indent("\n".join(public_content)) + "\n" out += "\n" From c29f8d0187b65bd19848f1a2713d2719a1c48fa2 Mon Sep 17 00:00:00 2001 From: Ben Winslow Date: Sun, 17 Aug 2025 22:36:35 -0400 Subject: [PATCH 0581/1626] [core] Fix post-OTA logs display when using esphome run and MQTT (#10274) --- esphome/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 7cc8296e7e..8e8fc7d5d9 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -476,7 +476,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int from esphome.components.api.client import run_logs return run_logs(config, addresses_to_use) - if get_port_type(port) == "MQTT" and "mqtt" in config: + if get_port_type(port) in ("NETWORK", "MQTT") and "mqtt" in config: from esphome import mqtt return mqtt.show_logs( From a2ad2dd10e488d9498a05c29af0e17bf1e1272c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Aug 2025 22:21:51 -0500 Subject: [PATCH 0582/1626] [api] Optimize protobuf decode loop for better performance and maintainability --- esphome/components/api/proto.cpp | 70 +++++++++++++++----------------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index cb6c07ec3c..40ce293a61 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -8,74 +8,70 @@ namespace esphome::api { static const char *const TAG = "api.proto"; void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { - uint32_t i = 0; - bool error = false; - while (i < length) { + const uint8_t *ptr = buffer; + const uint8_t *end = buffer + length; + + while (ptr < end) { uint32_t consumed; - auto res = ProtoVarInt::parse(&buffer[i], length - i, &consumed); + + // Parse field header + auto res = ProtoVarInt::parse(ptr, end - ptr, &consumed); if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid field start at %" PRIu32, i); - break; + ESP_LOGV(TAG, "Invalid field start at offset %td", ptr - buffer); + return; } - uint32_t field_type = (res->as_uint32()) & 0b111; - uint32_t field_id = (res->as_uint32()) >> 3; - i += consumed; + uint32_t tag = res->as_uint32(); + uint32_t field_type = tag & 0b111; + uint32_t field_id = tag >> 3; + ptr += consumed; switch (field_type) { case 0: { // VarInt - res = ProtoVarInt::parse(&buffer[i], length - i, &consumed); + res = ProtoVarInt::parse(ptr, end - ptr, &consumed); if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid VarInt at %" PRIu32, i); - error = true; - break; + ESP_LOGV(TAG, "Invalid VarInt at offset %td", ptr - buffer); + return; } if (!this->decode_varint(field_id, *res)) { ESP_LOGV(TAG, "Cannot decode VarInt field %" PRIu32 " with value %" PRIu32 "!", field_id, res->as_uint32()); } - i += consumed; + ptr += consumed; break; } case 2: { // Length-delimited - res = ProtoVarInt::parse(&buffer[i], length - i, &consumed); + res = ProtoVarInt::parse(ptr, end - ptr, &consumed); if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid Length Delimited at %" PRIu32, i); - error = true; - break; + ESP_LOGV(TAG, "Invalid Length Delimited at offset %td", ptr - buffer); + return; } uint32_t field_length = res->as_uint32(); - i += consumed; - if (field_length > length - i) { - ESP_LOGV(TAG, "Out-of-bounds Length Delimited at %" PRIu32, i); - error = true; - break; + ptr += consumed; + if (ptr + field_length > end) { + ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %td", ptr - buffer); + return; } - if (!this->decode_length(field_id, ProtoLengthDelimited(&buffer[i], field_length))) { + if (!this->decode_length(field_id, ProtoLengthDelimited(ptr, field_length))) { ESP_LOGV(TAG, "Cannot decode Length Delimited field %" PRIu32 "!", field_id); } - i += field_length; + ptr += field_length; break; } case 5: { // 32-bit - if (length - i < 4) { - ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at %" PRIu32, i); - error = true; - break; + if (ptr + 4 > end) { + ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %td", ptr - buffer); + return; } - uint32_t val = encode_uint32(buffer[i + 3], buffer[i + 2], buffer[i + 1], buffer[i]); + uint32_t val = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]); if (!this->decode_32bit(field_id, Proto32Bit(val))) { ESP_LOGV(TAG, "Cannot decode 32-bit field %" PRIu32 " with value %" PRIu32 "!", field_id, val); } - i += 4; + ptr += 4; break; } default: - ESP_LOGV(TAG, "Invalid field type at %" PRIu32, i); - error = true; - break; - } - if (error) { - break; + ESP_LOGV(TAG, "Invalid field type %u at offset %td", field_type, ptr - buffer); + return; } } } From 134526e0ec5bd33d5622a5670b88a436676db477 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 00:56:19 -0500 Subject: [PATCH 0583/1626] [api] Optimize APIFrameHelper virtual methods and mark implementations as final --- esphome/components/api/api_frame_helper.h | 4 ++-- esphome/components/api/api_frame_helper_noise.h | 6 +----- esphome/components/api/api_frame_helper_plaintext.h | 5 +---- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 76dfe1366c..43e9d95fbe 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -104,9 +104,9 @@ class APIFrameHelper { // The buffer contains all messages with appropriate padding before each virtual APIError write_protobuf_packets(ProtoWriteBuffer buffer, std::span packets) = 0; // Get the frame header padding required by this protocol - virtual uint8_t frame_header_padding() = 0; + uint8_t frame_header_padding() const { return frame_header_padding_; } // Get the frame footer size required by this protocol - virtual uint8_t frame_footer_size() = 0; + uint8_t frame_footer_size() const { return frame_footer_size_; } // Check if socket has data ready to read bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index e82e5daadb..49bc6f8854 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -7,7 +7,7 @@ namespace esphome::api { -class APINoiseFrameHelper : public APIFrameHelper { +class APINoiseFrameHelper final : public APIFrameHelper { public: APINoiseFrameHelper(std::unique_ptr socket, std::shared_ptr ctx, const ClientInfo *client_info) @@ -25,10 +25,6 @@ class APINoiseFrameHelper : public APIFrameHelper { APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_packets(ProtoWriteBuffer buffer, std::span packets) override; - // Get the frame header padding required by this protocol - uint8_t frame_header_padding() override { return frame_header_padding_; } - // Get the frame footer size required by this protocol - uint8_t frame_footer_size() override { return frame_footer_size_; } protected: APIError state_action_(); diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index b50902dd75..55a6d0f744 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -5,7 +5,7 @@ namespace esphome::api { -class APIPlaintextFrameHelper : public APIFrameHelper { +class APIPlaintextFrameHelper final : public APIFrameHelper { public: APIPlaintextFrameHelper(std::unique_ptr socket, const ClientInfo *client_info) : APIFrameHelper(std::move(socket), client_info) { @@ -22,9 +22,6 @@ class APIPlaintextFrameHelper : public APIFrameHelper { APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_packets(ProtoWriteBuffer buffer, std::span packets) override; - uint8_t frame_header_padding() override { return frame_header_padding_; } - // Get the frame footer size required by this protocol - uint8_t frame_footer_size() override { return frame_footer_size_; } protected: APIError try_read_frame_(std::vector *frame); From af87e27382ae05236d645e9374793f84c58079eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 01:00:12 -0500 Subject: [PATCH 0584/1626] [api] Mark APIConnection as final for compiler optimizations --- esphome/components/api/api_connection.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 076dccfad7..f711502746 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -44,7 +44,7 @@ static constexpr size_t MAX_PACKETS_PER_BATCH = 64; // ESP32 has 8KB+ stack, HO static constexpr size_t MAX_PACKETS_PER_BATCH = 32; // ESP8266/RP2040/etc have smaller stacks #endif -class APIConnection : public APIServerConnection { +class APIConnection final : public APIServerConnection { public: friend class APIServer; friend class ListEntitiesIterator; From 5678621cd58928fcf437c6004b4082f838365533 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 01:06:47 -0500 Subject: [PATCH 0585/1626] [bluetooth_proxy] Mark BluetoothConnection as final for compiler optimizations --- esphome/components/bluetooth_proxy/bluetooth_connection.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index a975d25d91..e5d5ff2dd6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -8,7 +8,7 @@ namespace esphome::bluetooth_proxy { class BluetoothProxy; -class BluetoothConnection : public esp32_ble_client::BLEClientBase { +class BluetoothConnection final : public esp32_ble_client::BLEClientBase { public: void dump_config() override; void loop() override; From 0a774230731c2741c1feb0f52f8a75a64a99df78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 09:01:39 -0400 Subject: [PATCH 0586/1626] [esp8266] Replace std::vector with std::unique_ptr in preferences to save flash (#10245) --- esphome/components/esp8266/preferences.cpp | 41 ++++++++++------------ 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index efd226e8f8..bb7e436bea 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -12,7 +12,7 @@ extern "C" { #include "preferences.h" #include -#include +#include namespace esphome { namespace esp8266 { @@ -67,6 +67,8 @@ static uint32_t get_esp8266_flash_sector() { } static uint32_t get_esp8266_flash_address() { return get_esp8266_flash_sector() * SPI_FLASH_SEC_SIZE; } +static inline size_t bytes_to_words(size_t bytes) { return (bytes + 3) / 4; } + template uint32_t calculate_crc(It first, It last, uint32_t type) { uint32_t crc = type; while (first != last) { @@ -123,41 +125,36 @@ class ESP8266PreferenceBackend : public ESPPreferenceBackend { size_t length_words = 0; bool save(const uint8_t *data, size_t len) override { - if ((len + 3) / 4 != length_words) { + if (bytes_to_words(len) != length_words) { return false; } - std::vector buffer; - buffer.resize(length_words + 1); - memcpy(buffer.data(), data, len); - buffer[buffer.size() - 1] = calculate_crc(buffer.begin(), buffer.end() - 1, type); + size_t buffer_size = length_words + 1; + std::unique_ptr buffer(new uint32_t[buffer_size]()); // Note the () for zero-initialization + memcpy(buffer.get(), data, len); + buffer[length_words] = calculate_crc(buffer.get(), buffer.get() + length_words, type); if (in_flash) { - return save_to_flash(offset, buffer.data(), buffer.size()); - } else { - return save_to_rtc(offset, buffer.data(), buffer.size()); + return save_to_flash(offset, buffer.get(), buffer_size); } + return save_to_rtc(offset, buffer.get(), buffer_size); } bool load(uint8_t *data, size_t len) override { - if ((len + 3) / 4 != length_words) { + if (bytes_to_words(len) != length_words) { return false; } - std::vector buffer; - buffer.resize(length_words + 1); - bool ret; - if (in_flash) { - ret = load_from_flash(offset, buffer.data(), buffer.size()); - } else { - ret = load_from_rtc(offset, buffer.data(), buffer.size()); - } + size_t buffer_size = length_words + 1; + std::unique_ptr buffer(new uint32_t[buffer_size]()); + bool ret = in_flash ? load_from_flash(offset, buffer.get(), buffer_size) + : load_from_rtc(offset, buffer.get(), buffer_size); if (!ret) return false; - uint32_t crc = calculate_crc(buffer.begin(), buffer.end() - 1, type); - if (buffer[buffer.size() - 1] != crc) { + uint32_t crc = calculate_crc(buffer.get(), buffer.get() + length_words, type); + if (buffer[length_words] != crc) { return false; } - memcpy(data, buffer.data(), len); + memcpy(data, buffer.get(), len); return true; } }; @@ -178,7 +175,7 @@ class ESP8266Preferences : public ESPPreferences { } ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) override { - uint32_t length_words = (length + 3) / 4; + uint32_t length_words = bytes_to_words(length); if (in_flash) { uint32_t start = current_flash_offset; uint32_t end = start + length_words + 1; From a9227148f540017929bfc2d3deefff73e9d5b376 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 08:28:58 -0500 Subject: [PATCH 0587/1626] review comments --- esphome/components/api/proto.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index 40ce293a61..afda5d32ba 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -17,7 +17,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { // Parse field header auto res = ProtoVarInt::parse(ptr, end - ptr, &consumed); if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid field start at offset %td", ptr - buffer); + ESP_LOGV(TAG, "Invalid field start at offset %ld", (long) (ptr - buffer)); return; } @@ -30,7 +30,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { case 0: { // VarInt res = ProtoVarInt::parse(ptr, end - ptr, &consumed); if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid VarInt at offset %td", ptr - buffer); + ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer)); return; } if (!this->decode_varint(field_id, *res)) { @@ -42,13 +42,13 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { case 2: { // Length-delimited res = ProtoVarInt::parse(ptr, end - ptr, &consumed); if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid Length Delimited at offset %td", ptr - buffer); + ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer)); return; } uint32_t field_length = res->as_uint32(); ptr += consumed; if (ptr + field_length > end) { - ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %td", ptr - buffer); + ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %ld", (long) (ptr - buffer)); return; } if (!this->decode_length(field_id, ProtoLengthDelimited(ptr, field_length))) { @@ -59,7 +59,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { } case 5: { // 32-bit if (ptr + 4 > end) { - ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %td", ptr - buffer); + ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer)); return; } uint32_t val = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]); @@ -70,7 +70,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { break; } default: - ESP_LOGV(TAG, "Invalid field type %u at offset %td", field_type, ptr - buffer); + ESP_LOGV(TAG, "Invalid field type %u at offset %ld", field_type, (long) (ptr - buffer)); return; } } From 6818439109f33e1b782ca9fe28205c0276b1dc12 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 11:14:41 -0400 Subject: [PATCH 0588/1626] [core] Fix scheduler race condition where cancelled items still execute (#10268) --- esphome/core/scheduler.cpp | 37 ++++- esphome/core/scheduler.h | 55 ++++++- .../fixtures/scheduler_removed_item_race.yaml | 139 ++++++++++++++++++ .../test_scheduler_removed_item_race.py | 102 +++++++++++++ 4 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_removed_item_race.yaml create mode 100644 tests/integration/test_scheduler_removed_item_race.py diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 6269a66543..c3ade260ac 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -82,7 +82,13 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type item->set_name(name_cstr, !is_static_string); item->type = type; item->callback = std::move(func); + // Initialize remove to false (though it should already be from constructor) + // Not using mark_item_removed_ helper since we're setting to false, not true +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + item->remove.store(false, std::memory_order_relaxed); +#else item->remove = false; +#endif item->is_retry = is_retry; #ifndef ESPHOME_THREAD_SINGLE @@ -398,6 +404,31 @@ void HOT Scheduler::call(uint32_t now) { this->pop_raw_(); continue; } + + // Check if item is marked for removal + // This handles two cases: + // 1. Item was marked for removal after cleanup_() but before we got here + // 2. Item is marked for removal but wasn't at the front of the heap during cleanup_() +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + // Multi-threaded platforms without atomics: must take lock to safely read remove flag + { + LockGuard guard{this->lock_}; + if (is_item_removed_(item.get())) { + this->pop_raw_(); + this->to_remove_--; + continue; + } + } +#else + // Single-threaded or multi-threaded with atomics: can check without lock + if (is_item_removed_(item.get())) { + LockGuard guard{this->lock_}; + this->pop_raw_(); + this->to_remove_--; + continue; + } +#endif + #ifdef ESPHOME_DEBUG_SCHEDULER const char *item_name = item->get_name(); ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")", @@ -518,7 +549,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c if (type == SchedulerItem::TIMEOUT) { for (auto &item : this->defer_queue_) { if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - item->remove = true; + this->mark_item_removed_(item.get()); total_cancelled++; } } @@ -528,7 +559,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Cancel items in the main heap for (auto &item : this->items_) { if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - item->remove = true; + this->mark_item_removed_(item.get()); total_cancelled++; this->to_remove_++; // Track removals for heap items } @@ -537,7 +568,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Cancel items in to_add_ for (auto &item : this->to_add_) { if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - item->remove = true; + this->mark_item_removed_(item.get()); total_cancelled++; // Don't track removals for to_add_ items } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index a6092e1b1e..c73bd55d5d 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -97,22 +97,42 @@ class Scheduler { std::function callback; - // Bit-packed fields to minimize padding +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Multi-threaded with atomics: use atomic for lock-free access + // Place atomic separately since it can't be packed with bit fields + std::atomic remove{false}; + + // Bit-packed fields (3 bits used, 5 bits padding in 1 byte) + enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; + bool name_is_dynamic : 1; // True if name was dynamically allocated (needs delete[]) + bool is_retry : 1; // True if this is a retry timeout + // 5 bits padding +#else + // Single-threaded or multi-threaded without atomics: can pack all fields together + // Bit-packed fields (4 bits used, 4 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; bool remove : 1; bool name_is_dynamic : 1; // True if name was dynamically allocated (needs delete[]) bool is_retry : 1; // True if this is a retry timeout - // 4 bits padding + // 4 bits padding +#endif // Constructor SchedulerItem() : component(nullptr), interval(0), next_execution_(0), +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // remove is initialized in the member declaration as std::atomic{false} + type(TIMEOUT), + name_is_dynamic(false), + is_retry(false) { +#else type(TIMEOUT), remove(false), name_is_dynamic(false), is_retry(false) { +#endif name_.static_name = nullptr; } @@ -219,6 +239,37 @@ class Scheduler { return item->remove || (item->component != nullptr && item->component->is_failed()); } + // Helper to check if item is marked for removal (platform-specific) + // Returns true if item should be skipped, handles platform-specific synchronization + // For ESPHOME_THREAD_MULTI_NO_ATOMICS platforms, the caller must hold the scheduler lock before calling this + // function. + bool is_item_removed_(SchedulerItem *item) const { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Multi-threaded with atomics: use atomic load for lock-free access + return item->remove.load(std::memory_order_acquire); +#else + // Single-threaded (ESPHOME_THREAD_SINGLE) or + // multi-threaded without atomics (ESPHOME_THREAD_MULTI_NO_ATOMICS): direct read + // For ESPHOME_THREAD_MULTI_NO_ATOMICS, caller MUST hold lock! + return item->remove; +#endif + } + + // Helper to mark item for removal (platform-specific) + // For ESPHOME_THREAD_MULTI_NO_ATOMICS platforms, the caller must hold the scheduler lock before calling this + // function. + void mark_item_removed_(SchedulerItem *item) { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Multi-threaded with atomics: use atomic store + item->remove.store(true, std::memory_order_release); +#else + // Single-threaded (ESPHOME_THREAD_SINGLE) or + // multi-threaded without atomics (ESPHOME_THREAD_MULTI_NO_ATOMICS): direct write + // For ESPHOME_THREAD_MULTI_NO_ATOMICS, caller MUST hold lock! + item->remove = true; +#endif + } + // Template helper to check if any item in a container matches our criteria template bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr, diff --git a/tests/integration/fixtures/scheduler_removed_item_race.yaml b/tests/integration/fixtures/scheduler_removed_item_race.yaml new file mode 100644 index 0000000000..2f8a7fb987 --- /dev/null +++ b/tests/integration/fixtures/scheduler_removed_item_race.yaml @@ -0,0 +1,139 @@ +esphome: + name: scheduler-removed-item-race + +host: + +api: + services: + - service: run_test + then: + - script.execute: run_test_script + +logger: + level: DEBUG + +globals: + - id: test_passed + type: bool + initial_value: 'true' + - id: removed_item_executed + type: int + initial_value: '0' + - id: normal_item_executed + type: int + initial_value: '0' + +sensor: + - platform: template + id: test_sensor + name: "Test Sensor" + update_interval: never + lambda: return 0.0; + +script: + - id: run_test_script + then: + - logger.log: "=== Starting Removed Item Race Test ===" + + # This test creates a scenario where: + # 1. First item in heap is NOT cancelled (cleanup stops immediately) + # 2. Items behind it ARE cancelled (remain in heap after cleanup) + # 3. All items execute at the same time, including cancelled ones + + - lambda: |- + // The key to hitting the race: + // 1. Add items in a specific order to control heap structure + // 2. Cancel ONLY items that won't be at the front + // 3. Ensure the first item stays non-cancelled so cleanup_() stops immediately + + // Schedule all items to execute at the SAME time (1ms from now) + // Using 1ms instead of 0 to avoid defer queue on multi-core platforms + // This ensures they'll all be ready together and go through the heap + const uint32_t exec_time = 1; + + // CRITICAL: Add a non-cancellable item FIRST + // This will be at the front of the heap and block cleanup_() + App.scheduler.set_timeout(id(test_sensor), "blocker", exec_time, []() { + ESP_LOGD("test", "Blocker timeout executed (expected) - was at front of heap"); + id(normal_item_executed)++; + }); + + // Now add items that we WILL cancel + // These will be behind the blocker in the heap + App.scheduler.set_timeout(id(test_sensor), "cancel_1", exec_time, []() { + ESP_LOGE("test", "RACE: Cancelled timeout 1 executed after being cancelled!"); + id(removed_item_executed)++; + id(test_passed) = false; + }); + + App.scheduler.set_timeout(id(test_sensor), "cancel_2", exec_time, []() { + ESP_LOGE("test", "RACE: Cancelled timeout 2 executed after being cancelled!"); + id(removed_item_executed)++; + id(test_passed) = false; + }); + + App.scheduler.set_timeout(id(test_sensor), "cancel_3", exec_time, []() { + ESP_LOGE("test", "RACE: Cancelled timeout 3 executed after being cancelled!"); + id(removed_item_executed)++; + id(test_passed) = false; + }); + + // Add some more normal items + App.scheduler.set_timeout(id(test_sensor), "normal_1", exec_time, []() { + ESP_LOGD("test", "Normal timeout 1 executed (expected)"); + id(normal_item_executed)++; + }); + + App.scheduler.set_timeout(id(test_sensor), "normal_2", exec_time, []() { + ESP_LOGD("test", "Normal timeout 2 executed (expected)"); + id(normal_item_executed)++; + }); + + App.scheduler.set_timeout(id(test_sensor), "normal_3", exec_time, []() { + ESP_LOGD("test", "Normal timeout 3 executed (expected)"); + id(normal_item_executed)++; + }); + + // Force items into the heap before cancelling + App.scheduler.process_to_add(); + + // NOW cancel the items - they're behind "blocker" in the heap + // When cleanup_() runs, it will see "blocker" (not removed) at the front + // and stop immediately, leaving cancel_1, cancel_2, cancel_3 in the heap + bool c1 = App.scheduler.cancel_timeout(id(test_sensor), "cancel_1"); + bool c2 = App.scheduler.cancel_timeout(id(test_sensor), "cancel_2"); + bool c3 = App.scheduler.cancel_timeout(id(test_sensor), "cancel_3"); + + ESP_LOGD("test", "Cancelled items (behind blocker): %s, %s, %s", + c1 ? "true" : "false", + c2 ? "true" : "false", + c3 ? "true" : "false"); + + // The heap now has: + // - "blocker" at front (not cancelled) + // - cancelled items behind it (marked remove=true but still in heap) + // - When all execute at once, cleanup_() stops at "blocker" + // - The loop then executes ALL ready items including cancelled ones + + ESP_LOGD("test", "Setup complete. Blocker at front prevents cleanup of cancelled items behind it"); + + # Wait for all timeouts to execute (or not) + - delay: 20ms + + # Check results + - lambda: |- + ESP_LOGI("test", "=== Test Results ==="); + ESP_LOGI("test", "Normal items executed: %d (expected 4)", id(normal_item_executed)); + ESP_LOGI("test", "Removed items executed: %d (expected 0)", id(removed_item_executed)); + + if (id(removed_item_executed) > 0) { + ESP_LOGE("test", "TEST FAILED: %d cancelled items were executed!", id(removed_item_executed)); + id(test_passed) = false; + } else if (id(normal_item_executed) != 4) { + ESP_LOGE("test", "TEST FAILED: Expected 4 normal items, got %d", id(normal_item_executed)); + id(test_passed) = false; + } else { + ESP_LOGI("test", "TEST PASSED: No cancelled items were executed"); + } + + ESP_LOGI("test", "=== Test Complete ==="); diff --git a/tests/integration/test_scheduler_removed_item_race.py b/tests/integration/test_scheduler_removed_item_race.py new file mode 100644 index 0000000000..3e72bacc0d --- /dev/null +++ b/tests/integration/test_scheduler_removed_item_race.py @@ -0,0 +1,102 @@ +"""Test for scheduler race condition where removed items still execute.""" + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_removed_item_race( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that items marked for removal don't execute. + + This test verifies the fix for a race condition where: + 1. cleanup_() only removes items from the front of the heap + 2. Items in the middle of the heap marked for removal still execute + 3. This causes cancelled timeouts to run when they shouldn't + """ + + loop = asyncio.get_running_loop() + test_complete_future: asyncio.Future[bool] = loop.create_future() + + # Track test results + test_passed = False + removed_executed = 0 + normal_executed = 0 + + # Patterns to match + race_pattern = re.compile(r"RACE: .* executed after being cancelled!") + passed_pattern = re.compile(r"TEST PASSED") + failed_pattern = re.compile(r"TEST FAILED") + complete_pattern = re.compile(r"=== Test Complete ===") + normal_count_pattern = re.compile(r"Normal items executed: (\d+)") + removed_count_pattern = re.compile(r"Removed items executed: (\d+)") + + def check_output(line: str) -> None: + """Check log output for test results.""" + nonlocal test_passed, removed_executed, normal_executed + + if race_pattern.search(line): + # Race condition detected - a cancelled item executed + test_passed = False + + if passed_pattern.search(line): + test_passed = True + elif failed_pattern.search(line): + test_passed = False + + normal_match = normal_count_pattern.search(line) + if normal_match: + normal_executed = int(normal_match.group(1)) + + removed_match = removed_count_pattern.search(line) + if removed_match: + removed_executed = int(removed_match.group(1)) + + if not test_complete_future.done() and complete_pattern.search(line): + test_complete_future.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify we can connect + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-removed-item-race" + + # List services + _, services = await asyncio.wait_for( + client.list_entities_services(), timeout=5.0 + ) + + # Find run_test service + run_test_service = next((s for s in services if s.name == "run_test"), None) + assert run_test_service is not None, "run_test service not found" + + # Execute the test + client.execute_service(run_test_service, {}) + + # Wait for test completion + try: + await asyncio.wait_for(test_complete_future, timeout=5.0) + except TimeoutError: + pytest.fail("Test did not complete within timeout") + + # Verify results + assert test_passed, ( + f"Test failed! Removed items executed: {removed_executed}, " + f"Normal items executed: {normal_executed}" + ) + assert removed_executed == 0, ( + f"Cancelled items should not execute, but {removed_executed} did" + ) + assert normal_executed == 4, ( + f"Expected 4 normal items to execute, got {normal_executed}" + ) From fb3a01e84ef82b211b3609486c7b6a92d66ca1ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 11:05:09 -0500 Subject: [PATCH 0589/1626] might as well do both --- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index bc8d3ed762..c81c8c9532 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -50,7 +50,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; -class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, public Component { friend class BluetoothConnection; // Allow connection to update connections_free_response_ public: BluetoothProxy(); From a36942b76055866aba7baff6e372c95acda99743 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 12:37:44 -0500 Subject: [PATCH 0590/1626] [safe_mode] Reduce flash usage by 172 bytes through code optimization --- esphome/components/safe_mode/safe_mode.cpp | 70 +++++++++++----------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 5a62604269..22ac43c884 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -15,11 +15,11 @@ namespace safe_mode { static const char *const TAG = "safe_mode"; void SafeModeComponent::dump_config() { - ESP_LOGCONFIG(TAG, "Safe Mode:"); ESP_LOGCONFIG(TAG, - " Boot considered successful after %" PRIu32 " seconds\n" - " Invoke after %u boot attempts\n" - " Remain for %" PRIu32 " seconds", + "Safe Mode:\n" + " Successful after: %" PRIu32 "s\n" + " Attempts: %u\n" + " Duration: %" PRIu32 "s", this->safe_mode_boot_is_good_after_ / 1000, // because milliseconds this->safe_mode_num_attempts_, this->safe_mode_enable_time_ / 1000); // because milliseconds @@ -27,7 +27,7 @@ void SafeModeComponent::dump_config() { if (this->safe_mode_rtc_value_ > 1 && this->safe_mode_rtc_value_ != SafeModeComponent::ENTER_SAFE_MODE_MAGIC) { auto remaining_restarts = this->safe_mode_num_attempts_ - this->safe_mode_rtc_value_; if (remaining_restarts) { - ESP_LOGW(TAG, "Last reset occurred too quickly; will be invoked in %" PRIu32 " restarts", remaining_restarts); + ESP_LOGW(TAG, "Last reset too quick; safe mode in %" PRIu32 " restarts", remaining_restarts); } else { ESP_LOGW(TAG, "SAFE MODE IS ACTIVE"); } @@ -72,43 +72,45 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en this->safe_mode_boot_is_good_after_ = boot_is_good_after; this->safe_mode_num_attempts_ = num_attempts; this->rtc_ = global_preferences->make_preference(233825507UL, false); - this->safe_mode_rtc_value_ = this->read_rtc_(); - bool is_manual_safe_mode = this->safe_mode_rtc_value_ == SafeModeComponent::ENTER_SAFE_MODE_MAGIC; + uint32_t rtc_val = this->read_rtc_(); + this->safe_mode_rtc_value_ = rtc_val; - if (is_manual_safe_mode) { - ESP_LOGI(TAG, "Safe mode invoked manually"); + bool is_manual = rtc_val == SafeModeComponent::ENTER_SAFE_MODE_MAGIC; + + if (is_manual) { + ESP_LOGI(TAG, "Manual safe mode"); } else { - ESP_LOGCONFIG(TAG, "There have been %" PRIu32 " suspected unsuccessful boot attempts", this->safe_mode_rtc_value_); + ESP_LOGCONFIG(TAG, "Unsuccessful boot attempts: %" PRIu32, rtc_val); } - if (this->safe_mode_rtc_value_ >= num_attempts || is_manual_safe_mode) { - this->clean_rtc(); - - if (!is_manual_safe_mode) { - ESP_LOGE(TAG, "Boot loop detected. Proceeding"); - } - - this->status_set_error(); - this->set_timeout(enable_time, []() { - ESP_LOGW(TAG, "Safe mode enable time has elapsed -- restarting"); - App.reboot(); - }); - - // Delay here to allow power to stabilize before Wi-Fi/Ethernet is initialised - delay(300); // NOLINT - App.setup(); - - ESP_LOGW(TAG, "SAFE MODE IS ACTIVE"); - - this->safe_mode_callback_.call(); - - return true; - } else { + if (rtc_val < num_attempts && !is_manual) { // increment counter - this->write_rtc_(this->safe_mode_rtc_value_ + 1); + this->write_rtc_(rtc_val + 1); return false; } + + this->clean_rtc(); + + if (!is_manual) { + ESP_LOGE(TAG, "Boot loop detected"); + } + + this->status_set_error(); + this->set_timeout(enable_time, []() { + ESP_LOGW(TAG, "Safe mode timeout - restarting"); + App.reboot(); + }); + + // Delay here to allow power to stabilize before Wi-Fi/Ethernet is initialised + delay(300); // NOLINT + App.setup(); + + ESP_LOGW(TAG, "SAFE MODE IS ACTIVE"); + + this->safe_mode_callback_.call(); + + return true; } void SafeModeComponent::write_rtc_(uint32_t val) { From be2a680e8f15995660949b98d177278a92caccec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 12:39:33 -0500 Subject: [PATCH 0591/1626] [safe_mode] Reduce flash usage by 172 bytes through code optimization --- esphome/components/safe_mode/safe_mode.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 22ac43c884..a6b6a7804b 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -27,7 +27,7 @@ void SafeModeComponent::dump_config() { if (this->safe_mode_rtc_value_ > 1 && this->safe_mode_rtc_value_ != SafeModeComponent::ENTER_SAFE_MODE_MAGIC) { auto remaining_restarts = this->safe_mode_num_attempts_ - this->safe_mode_rtc_value_; if (remaining_restarts) { - ESP_LOGW(TAG, "Last reset too quick; safe mode in %" PRIu32 " restarts", remaining_restarts); + ESP_LOGW(TAG, "Last reset too quick; invoke in %" PRIu32 " restarts", remaining_restarts); } else { ESP_LOGW(TAG, "SAFE MODE IS ACTIVE"); } From 7d3a87c603159149a9e249541661bbf1d1b6266b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 12:40:24 -0500 Subject: [PATCH 0592/1626] [safe_mode] Reduce flash usage by 172 bytes through code optimization --- esphome/components/safe_mode/safe_mode.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index a6b6a7804b..993808ed5a 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -98,7 +98,7 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en this->status_set_error(); this->set_timeout(enable_time, []() { - ESP_LOGW(TAG, "Safe mode timeout - restarting"); + ESP_LOGW(TAG, "Timeout, restarting"); App.reboot(); }); From 571e6be404004f48a2930f44680c5a1cdd8e3d8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 12:41:35 -0500 Subject: [PATCH 0593/1626] [safe_mode] Reduce flash usage by 172 bytes through code optimization --- esphome/components/safe_mode/safe_mode.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 993808ed5a..97c5393502 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -79,7 +79,7 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en bool is_manual = rtc_val == SafeModeComponent::ENTER_SAFE_MODE_MAGIC; if (is_manual) { - ESP_LOGI(TAG, "Manual safe mode"); + ESP_LOGI(TAG, "Manual mode"); } else { ESP_LOGCONFIG(TAG, "Unsuccessful boot attempts: %" PRIu32, rtc_val); } From 6248c3d729941990a82016d58f883d0bb97088f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 12:47:22 -0500 Subject: [PATCH 0594/1626] preen --- esphome/components/safe_mode/safe_mode.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 97c5393502..62bbca4fb1 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -18,7 +18,7 @@ void SafeModeComponent::dump_config() { ESP_LOGCONFIG(TAG, "Safe Mode:\n" " Successful after: %" PRIu32 "s\n" - " Attempts: %u\n" + " Invoke after: %u attempts\n" " Duration: %" PRIu32 "s", this->safe_mode_boot_is_good_after_ / 1000, // because milliseconds this->safe_mode_num_attempts_, From fd6002e33427cd2a8c44178143fa923a970928eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 14:36:41 -0500 Subject: [PATCH 0595/1626] [mdns] Reduce flash usage and prevent RAM over-allocation in service compilation --- esphome/components/mdns/mdns_component.cpp | 157 +++++++++++++-------- 1 file changed, 99 insertions(+), 58 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 640750720d..316a10596f 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -24,100 +24,139 @@ static const char *const TAG = "mdns"; void MDNSComponent::compile_records_() { this->hostname_ = App.get_name(); - this->services_.clear(); + // Calculate exact capacity needed for services vector + size_t services_count = 0; #ifdef USE_API if (api::global_api_server != nullptr) { - MDNSService service{}; + services_count++; + } +#endif +#ifdef USE_PROMETHEUS + services_count++; +#endif +#ifdef USE_WEBSERVER + services_count++; +#endif +#ifdef USE_MDNS_EXTRA_SERVICES + services_count += this->services_extra_.size(); +#endif + // Reserve for fallback service if needed + if (services_count == 0) { + services_count = 1; + } + this->services_.reserve(services_count); + +#ifdef USE_API + if (api::global_api_server != nullptr) { + this->services_.emplace_back(); + auto &service = this->services_.back(); service.service_type = "_esphomelib"; service.proto = "_tcp"; service.port = api::global_api_server->get_port(); - if (!App.get_friendly_name().empty()) { - service.txt_records.push_back({"friendly_name", App.get_friendly_name()}); - } - service.txt_records.push_back({"version", ESPHOME_VERSION}); - service.txt_records.push_back({"mac", get_mac_address()}); - const char *platform = nullptr; -#ifdef USE_ESP8266 - platform = "ESP8266"; -#endif -#ifdef USE_ESP32 - platform = "ESP32"; -#endif -#ifdef USE_RP2040 - platform = "RP2040"; -#endif -#ifdef USE_LIBRETINY - platform = lt_cpu_get_model_name(); -#endif - if (platform != nullptr) { - service.txt_records.push_back({"platform", platform}); - } - service.txt_records.push_back({"board", ESPHOME_BOARD}); + const std::string &friendly_name = App.get_friendly_name(); + bool friendly_name_empty = friendly_name.empty(); + + // Calculate exact capacity for txt_records + size_t txt_count = 3; // version, mac, board (always present) + if (!friendly_name_empty) { + txt_count++; // friendly_name + } +#if defined(USE_ESP8266) || defined(USE_ESP32) || defined(USE_RP2040) || defined(USE_LIBRETINY) + txt_count++; // platform +#endif +#if defined(USE_WIFI) || defined(USE_ETHERNET) || defined(USE_OPENTHREAD) + txt_count++; // network +#endif +#ifdef USE_API_NOISE + txt_count++; // api_encryption or api_encryption_supported +#endif +#ifdef ESPHOME_PROJECT_NAME + txt_count += 2; // project_name and project_version +#endif +#ifdef USE_DASHBOARD_IMPORT + txt_count++; // package_import_url +#endif + + auto &txt_records = service.txt_records; + txt_records.reserve(txt_count); + + if (!friendly_name_empty) { + txt_records.emplace_back(MDNSTXTRecord{"friendly_name", friendly_name}); + } + txt_records.emplace_back(MDNSTXTRecord{"version", ESPHOME_VERSION}); + txt_records.emplace_back(MDNSTXTRecord{"mac", get_mac_address()}); + +#ifdef USE_ESP8266 + txt_records.emplace_back(MDNSTXTRecord{"platform", "ESP8266"}); +#elif defined(USE_ESP32) + txt_records.emplace_back(MDNSTXTRecord{"platform", "ESP32"}); +#elif defined(USE_RP2040) + txt_records.emplace_back(MDNSTXTRecord{"platform", "RP2040"}); +#elif defined(USE_LIBRETINY) + txt_records.emplace_back(MDNSTXTRecord{"platform", lt_cpu_get_model_name()}); +#endif + + txt_records.emplace_back(MDNSTXTRecord{"board", ESPHOME_BOARD}); #if defined(USE_WIFI) - service.txt_records.push_back({"network", "wifi"}); + txt_records.emplace_back(MDNSTXTRecord{"network", "wifi"}); #elif defined(USE_ETHERNET) - service.txt_records.push_back({"network", "ethernet"}); + txt_records.emplace_back(MDNSTXTRecord{"network", "ethernet"}); #elif defined(USE_OPENTHREAD) - service.txt_records.push_back({"network", "thread"}); + txt_records.emplace_back(MDNSTXTRecord{"network", "thread"}); #endif #ifdef USE_API_NOISE + static constexpr const char *NOISE_ENCRYPTION = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"; if (api::global_api_server->get_noise_ctx()->has_psk()) { - service.txt_records.push_back({"api_encryption", "Noise_NNpsk0_25519_ChaChaPoly_SHA256"}); + txt_records.emplace_back(MDNSTXTRecord{"api_encryption", NOISE_ENCRYPTION}); } else { - service.txt_records.push_back({"api_encryption_supported", "Noise_NNpsk0_25519_ChaChaPoly_SHA256"}); + txt_records.emplace_back(MDNSTXTRecord{"api_encryption_supported", NOISE_ENCRYPTION}); } #endif #ifdef ESPHOME_PROJECT_NAME - service.txt_records.push_back({"project_name", ESPHOME_PROJECT_NAME}); - service.txt_records.push_back({"project_version", ESPHOME_PROJECT_VERSION}); + txt_records.emplace_back(MDNSTXTRecord{"project_name", ESPHOME_PROJECT_NAME}); + txt_records.emplace_back(MDNSTXTRecord{"project_version", ESPHOME_PROJECT_VERSION}); #endif // ESPHOME_PROJECT_NAME #ifdef USE_DASHBOARD_IMPORT - service.txt_records.push_back({"package_import_url", dashboard_import::get_package_import_url()}); + txt_records.emplace_back(MDNSTXTRecord{"package_import_url", dashboard_import::get_package_import_url()}); #endif - - this->services_.push_back(service); } #endif // USE_API #ifdef USE_PROMETHEUS - { - MDNSService service{}; - service.service_type = "_prometheus-http"; - service.proto = "_tcp"; - service.port = USE_WEBSERVER_PORT; - this->services_.push_back(service); - } + this->services_.emplace_back(); + auto &prom_service = this->services_.back(); + prom_service.service_type = "_prometheus-http"; + prom_service.proto = "_tcp"; + prom_service.port = USE_WEBSERVER_PORT; #endif #ifdef USE_WEBSERVER - { - MDNSService service{}; - service.service_type = "_http"; - service.proto = "_tcp"; - service.port = USE_WEBSERVER_PORT; - this->services_.push_back(service); - } + this->services_.emplace_back(); + auto &web_service = this->services_.back(); + web_service.service_type = "_http"; + web_service.proto = "_tcp"; + web_service.port = USE_WEBSERVER_PORT; #endif #ifdef USE_MDNS_EXTRA_SERVICES this->services_.insert(this->services_.end(), this->services_extra_.begin(), this->services_extra_.end()); #endif - if (this->services_.empty()) { - // Publish "http" service if not using native API - // This is just to have *some* mDNS service so that .local resolution works - MDNSService service{}; - service.service_type = "_http"; - service.proto = "_tcp"; - service.port = USE_WEBSERVER_PORT; - service.txt_records.push_back({"version", ESPHOME_VERSION}); - this->services_.push_back(service); - } +#if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_WEBSERVER) && !defined(USE_MDNS_EXTRA_SERVICES) + // Publish "http" service if not using native API or any other services + // This is just to have *some* mDNS service so that .local resolution works + this->services_.emplace_back(); + auto &fallback_service = this->services_.back(); + fallback_service.service_type = "_http"; + fallback_service.proto = "_tcp"; + fallback_service.port = USE_WEBSERVER_PORT; + fallback_service.txt_records.emplace_back(MDNSTXTRecord{"version", ESPHOME_VERSION}); +#endif } void MDNSComponent::dump_config() { @@ -125,6 +164,7 @@ void MDNSComponent::dump_config() { "mDNS:\n" " Hostname: %s", this->hostname_.c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE ESP_LOGV(TAG, " Services:"); for (const auto &service : this->services_) { ESP_LOGV(TAG, " - %s, %s, %d", service.service_type.c_str(), service.proto.c_str(), @@ -134,6 +174,7 @@ void MDNSComponent::dump_config() { const_cast &>(record.value).value().c_str()); } } +#endif } std::vector MDNSComponent::get_services() { return this->services_; } From 1f5548689636385ce385c3ff27518c2d79d9ebca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 16:55:11 -0400 Subject: [PATCH 0596/1626] [api] Optimize APIFrameHelper virtual methods and mark implementations as final (#10278) --- esphome/components/api/api_frame_helper.h | 4 ++-- esphome/components/api/api_frame_helper_noise.h | 6 +----- esphome/components/api/api_frame_helper_plaintext.h | 5 +---- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 76dfe1366c..43e9d95fbe 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -104,9 +104,9 @@ class APIFrameHelper { // The buffer contains all messages with appropriate padding before each virtual APIError write_protobuf_packets(ProtoWriteBuffer buffer, std::span packets) = 0; // Get the frame header padding required by this protocol - virtual uint8_t frame_header_padding() = 0; + uint8_t frame_header_padding() const { return frame_header_padding_; } // Get the frame footer size required by this protocol - virtual uint8_t frame_footer_size() = 0; + uint8_t frame_footer_size() const { return frame_footer_size_; } // Check if socket has data ready to read bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index e82e5daadb..49bc6f8854 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -7,7 +7,7 @@ namespace esphome::api { -class APINoiseFrameHelper : public APIFrameHelper { +class APINoiseFrameHelper final : public APIFrameHelper { public: APINoiseFrameHelper(std::unique_ptr socket, std::shared_ptr ctx, const ClientInfo *client_info) @@ -25,10 +25,6 @@ class APINoiseFrameHelper : public APIFrameHelper { APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_packets(ProtoWriteBuffer buffer, std::span packets) override; - // Get the frame header padding required by this protocol - uint8_t frame_header_padding() override { return frame_header_padding_; } - // Get the frame footer size required by this protocol - uint8_t frame_footer_size() override { return frame_footer_size_; } protected: APIError state_action_(); diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index b50902dd75..55a6d0f744 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -5,7 +5,7 @@ namespace esphome::api { -class APIPlaintextFrameHelper : public APIFrameHelper { +class APIPlaintextFrameHelper final : public APIFrameHelper { public: APIPlaintextFrameHelper(std::unique_ptr socket, const ClientInfo *client_info) : APIFrameHelper(std::move(socket), client_info) { @@ -22,9 +22,6 @@ class APIPlaintextFrameHelper : public APIFrameHelper { APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_packets(ProtoWriteBuffer buffer, std::span packets) override; - uint8_t frame_header_padding() override { return frame_header_padding_; } - // Get the frame footer size required by this protocol - uint8_t frame_footer_size() override { return frame_footer_size_; } protected: APIError try_read_frame_(std::vector *frame); From 761c6c6685ed0c5a53cc671bc7315c3546876925 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 16:55:30 -0400 Subject: [PATCH 0597/1626] [api] Mark protobuf message classes as final to enable compiler optimizations (#10276) --- esphome/components/api/api_pb2.h | 278 ++++++++++++++-------------- script/api_protobuf/api_protobuf.py | 4 +- 2 files changed, 141 insertions(+), 141 deletions(-) diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index edf839be55..abdf0e6121 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -321,7 +321,7 @@ class CommandProtoMessage : public ProtoDecodableMessage { protected: }; -class HelloRequest : public ProtoDecodableMessage { +class HelloRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 1; static constexpr uint8_t ESTIMATED_SIZE = 17; @@ -339,7 +339,7 @@ class HelloRequest : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class HelloResponse : public ProtoMessage { +class HelloResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 2; static constexpr uint8_t ESTIMATED_SIZE = 26; @@ -360,7 +360,7 @@ class HelloResponse : public ProtoMessage { protected: }; -class ConnectRequest : public ProtoDecodableMessage { +class ConnectRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 3; static constexpr uint8_t ESTIMATED_SIZE = 9; @@ -375,7 +375,7 @@ class ConnectRequest : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class ConnectResponse : public ProtoMessage { +class ConnectResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 4; static constexpr uint8_t ESTIMATED_SIZE = 2; @@ -391,7 +391,7 @@ class ConnectResponse : public ProtoMessage { protected: }; -class DisconnectRequest : public ProtoMessage { +class DisconnectRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 5; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -404,7 +404,7 @@ class DisconnectRequest : public ProtoMessage { protected: }; -class DisconnectResponse : public ProtoMessage { +class DisconnectResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 6; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -417,7 +417,7 @@ class DisconnectResponse : public ProtoMessage { protected: }; -class PingRequest : public ProtoMessage { +class PingRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 7; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -430,7 +430,7 @@ class PingRequest : public ProtoMessage { protected: }; -class PingResponse : public ProtoMessage { +class PingResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 8; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -443,7 +443,7 @@ class PingResponse : public ProtoMessage { protected: }; -class DeviceInfoRequest : public ProtoMessage { +class DeviceInfoRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 9; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -457,7 +457,7 @@ class DeviceInfoRequest : public ProtoMessage { protected: }; #ifdef USE_AREAS -class AreaInfo : public ProtoMessage { +class AreaInfo final : public ProtoMessage { public: uint32_t area_id{0}; StringRef name_ref_{}; @@ -472,7 +472,7 @@ class AreaInfo : public ProtoMessage { }; #endif #ifdef USE_DEVICES -class DeviceInfo : public ProtoMessage { +class DeviceInfo final : public ProtoMessage { public: uint32_t device_id{0}; StringRef name_ref_{}; @@ -487,7 +487,7 @@ class DeviceInfo : public ProtoMessage { protected: }; #endif -class DeviceInfoResponse : public ProtoMessage { +class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; static constexpr uint8_t ESTIMATED_SIZE = 247; @@ -559,7 +559,7 @@ class DeviceInfoResponse : public ProtoMessage { protected: }; -class ListEntitiesRequest : public ProtoMessage { +class ListEntitiesRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 11; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -572,7 +572,7 @@ class ListEntitiesRequest : public ProtoMessage { protected: }; -class ListEntitiesDoneResponse : public ProtoMessage { +class ListEntitiesDoneResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 19; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -585,7 +585,7 @@ class ListEntitiesDoneResponse : public ProtoMessage { protected: }; -class SubscribeStatesRequest : public ProtoMessage { +class SubscribeStatesRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 20; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -599,7 +599,7 @@ class SubscribeStatesRequest : public ProtoMessage { protected: }; #ifdef USE_BINARY_SENSOR -class ListEntitiesBinarySensorResponse : public InfoResponseProtoMessage { +class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 12; static constexpr uint8_t ESTIMATED_SIZE = 51; @@ -617,7 +617,7 @@ class ListEntitiesBinarySensorResponse : public InfoResponseProtoMessage { protected: }; -class BinarySensorStateResponse : public StateResponseProtoMessage { +class BinarySensorStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 21; static constexpr uint8_t ESTIMATED_SIZE = 13; @@ -636,7 +636,7 @@ class BinarySensorStateResponse : public StateResponseProtoMessage { }; #endif #ifdef USE_COVER -class ListEntitiesCoverResponse : public InfoResponseProtoMessage { +class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 13; static constexpr uint8_t ESTIMATED_SIZE = 57; @@ -657,7 +657,7 @@ class ListEntitiesCoverResponse : public InfoResponseProtoMessage { protected: }; -class CoverStateResponse : public StateResponseProtoMessage { +class CoverStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 22; static constexpr uint8_t ESTIMATED_SIZE = 21; @@ -675,7 +675,7 @@ class CoverStateResponse : public StateResponseProtoMessage { protected: }; -class CoverCommandRequest : public CommandProtoMessage { +class CoverCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 30; static constexpr uint8_t ESTIMATED_SIZE = 25; @@ -697,7 +697,7 @@ class CoverCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_FAN -class ListEntitiesFanResponse : public InfoResponseProtoMessage { +class ListEntitiesFanResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 14; static constexpr uint8_t ESTIMATED_SIZE = 68; @@ -717,7 +717,7 @@ class ListEntitiesFanResponse : public InfoResponseProtoMessage { protected: }; -class FanStateResponse : public StateResponseProtoMessage { +class FanStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 23; static constexpr uint8_t ESTIMATED_SIZE = 28; @@ -738,7 +738,7 @@ class FanStateResponse : public StateResponseProtoMessage { protected: }; -class FanCommandRequest : public CommandProtoMessage { +class FanCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 31; static constexpr uint8_t ESTIMATED_SIZE = 38; @@ -766,7 +766,7 @@ class FanCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_LIGHT -class ListEntitiesLightResponse : public InfoResponseProtoMessage { +class ListEntitiesLightResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 15; static constexpr uint8_t ESTIMATED_SIZE = 73; @@ -785,7 +785,7 @@ class ListEntitiesLightResponse : public InfoResponseProtoMessage { protected: }; -class LightStateResponse : public StateResponseProtoMessage { +class LightStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 24; static constexpr uint8_t ESTIMATED_SIZE = 67; @@ -813,7 +813,7 @@ class LightStateResponse : public StateResponseProtoMessage { protected: }; -class LightCommandRequest : public CommandProtoMessage { +class LightCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 32; static constexpr uint8_t ESTIMATED_SIZE = 112; @@ -857,7 +857,7 @@ class LightCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_SENSOR -class ListEntitiesSensorResponse : public InfoResponseProtoMessage { +class ListEntitiesSensorResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 16; static constexpr uint8_t ESTIMATED_SIZE = 66; @@ -879,7 +879,7 @@ class ListEntitiesSensorResponse : public InfoResponseProtoMessage { protected: }; -class SensorStateResponse : public StateResponseProtoMessage { +class SensorStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 25; static constexpr uint8_t ESTIMATED_SIZE = 16; @@ -898,7 +898,7 @@ class SensorStateResponse : public StateResponseProtoMessage { }; #endif #ifdef USE_SWITCH -class ListEntitiesSwitchResponse : public InfoResponseProtoMessage { +class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 17; static constexpr uint8_t ESTIMATED_SIZE = 51; @@ -916,7 +916,7 @@ class ListEntitiesSwitchResponse : public InfoResponseProtoMessage { protected: }; -class SwitchStateResponse : public StateResponseProtoMessage { +class SwitchStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 26; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -932,7 +932,7 @@ class SwitchStateResponse : public StateResponseProtoMessage { protected: }; -class SwitchCommandRequest : public CommandProtoMessage { +class SwitchCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 33; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -950,7 +950,7 @@ class SwitchCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_TEXT_SENSOR -class ListEntitiesTextSensorResponse : public InfoResponseProtoMessage { +class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 18; static constexpr uint8_t ESTIMATED_SIZE = 49; @@ -967,7 +967,7 @@ class ListEntitiesTextSensorResponse : public InfoResponseProtoMessage { protected: }; -class TextSensorStateResponse : public StateResponseProtoMessage { +class TextSensorStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 27; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -986,7 +986,7 @@ class TextSensorStateResponse : public StateResponseProtoMessage { protected: }; #endif -class SubscribeLogsRequest : public ProtoDecodableMessage { +class SubscribeLogsRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 28; static constexpr uint8_t ESTIMATED_SIZE = 4; @@ -1002,7 +1002,7 @@ class SubscribeLogsRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class SubscribeLogsResponse : public ProtoMessage { +class SubscribeLogsResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 29; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -1025,7 +1025,7 @@ class SubscribeLogsResponse : public ProtoMessage { protected: }; #ifdef USE_API_NOISE -class NoiseEncryptionSetKeyRequest : public ProtoDecodableMessage { +class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 124; static constexpr uint8_t ESTIMATED_SIZE = 9; @@ -1040,7 +1040,7 @@ class NoiseEncryptionSetKeyRequest : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class NoiseEncryptionSetKeyResponse : public ProtoMessage { +class NoiseEncryptionSetKeyResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 125; static constexpr uint8_t ESTIMATED_SIZE = 2; @@ -1058,7 +1058,7 @@ class NoiseEncryptionSetKeyResponse : public ProtoMessage { }; #endif #ifdef USE_API_HOMEASSISTANT_SERVICES -class SubscribeHomeassistantServicesRequest : public ProtoMessage { +class SubscribeHomeassistantServicesRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 34; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -1071,7 +1071,7 @@ class SubscribeHomeassistantServicesRequest : public ProtoMessage { protected: }; -class HomeassistantServiceMap : public ProtoMessage { +class HomeassistantServiceMap final : public ProtoMessage { public: StringRef key_ref_{}; void set_key(const StringRef &ref) { this->key_ref_ = ref; } @@ -1084,7 +1084,7 @@ class HomeassistantServiceMap : public ProtoMessage { protected: }; -class HomeassistantServiceResponse : public ProtoMessage { +class HomeassistantServiceResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 35; static constexpr uint8_t ESTIMATED_SIZE = 113; @@ -1107,7 +1107,7 @@ class HomeassistantServiceResponse : public ProtoMessage { }; #endif #ifdef USE_API_HOMEASSISTANT_STATES -class SubscribeHomeAssistantStatesRequest : public ProtoMessage { +class SubscribeHomeAssistantStatesRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 38; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -1120,7 +1120,7 @@ class SubscribeHomeAssistantStatesRequest : public ProtoMessage { protected: }; -class SubscribeHomeAssistantStateResponse : public ProtoMessage { +class SubscribeHomeAssistantStateResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 39; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -1140,7 +1140,7 @@ class SubscribeHomeAssistantStateResponse : public ProtoMessage { protected: }; -class HomeAssistantStateResponse : public ProtoDecodableMessage { +class HomeAssistantStateResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 40; static constexpr uint8_t ESTIMATED_SIZE = 27; @@ -1158,7 +1158,7 @@ class HomeAssistantStateResponse : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; #endif -class GetTimeRequest : public ProtoMessage { +class GetTimeRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 36; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -1171,7 +1171,7 @@ class GetTimeRequest : public ProtoMessage { protected: }; -class GetTimeResponse : public ProtoDecodableMessage { +class GetTimeResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 37; static constexpr uint8_t ESTIMATED_SIZE = 5; @@ -1189,7 +1189,7 @@ class GetTimeResponse : public ProtoDecodableMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; }; #ifdef USE_API_SERVICES -class ListEntitiesServicesArgument : public ProtoMessage { +class ListEntitiesServicesArgument final : public ProtoMessage { public: StringRef name_ref_{}; void set_name(const StringRef &ref) { this->name_ref_ = ref; } @@ -1202,7 +1202,7 @@ class ListEntitiesServicesArgument : public ProtoMessage { protected: }; -class ListEntitiesServicesResponse : public ProtoMessage { +class ListEntitiesServicesResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 41; static constexpr uint8_t ESTIMATED_SIZE = 48; @@ -1221,7 +1221,7 @@ class ListEntitiesServicesResponse : public ProtoMessage { protected: }; -class ExecuteServiceArgument : public ProtoDecodableMessage { +class ExecuteServiceArgument final : public ProtoDecodableMessage { public: bool bool_{false}; int32_t legacy_int{0}; @@ -1241,7 +1241,7 @@ class ExecuteServiceArgument : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class ExecuteServiceRequest : public ProtoDecodableMessage { +class ExecuteServiceRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 42; static constexpr uint8_t ESTIMATED_SIZE = 39; @@ -1260,7 +1260,7 @@ class ExecuteServiceRequest : public ProtoDecodableMessage { }; #endif #ifdef USE_CAMERA -class ListEntitiesCameraResponse : public InfoResponseProtoMessage { +class ListEntitiesCameraResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 43; static constexpr uint8_t ESTIMATED_SIZE = 40; @@ -1275,7 +1275,7 @@ class ListEntitiesCameraResponse : public InfoResponseProtoMessage { protected: }; -class CameraImageResponse : public StateResponseProtoMessage { +class CameraImageResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 44; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -1297,7 +1297,7 @@ class CameraImageResponse : public StateResponseProtoMessage { protected: }; -class CameraImageRequest : public ProtoDecodableMessage { +class CameraImageRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 45; static constexpr uint8_t ESTIMATED_SIZE = 4; @@ -1315,7 +1315,7 @@ class CameraImageRequest : public ProtoDecodableMessage { }; #endif #ifdef USE_CLIMATE -class ListEntitiesClimateResponse : public InfoResponseProtoMessage { +class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 46; static constexpr uint8_t ESTIMATED_SIZE = 145; @@ -1347,7 +1347,7 @@ class ListEntitiesClimateResponse : public InfoResponseProtoMessage { protected: }; -class ClimateStateResponse : public StateResponseProtoMessage { +class ClimateStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 47; static constexpr uint8_t ESTIMATED_SIZE = 68; @@ -1377,7 +1377,7 @@ class ClimateStateResponse : public StateResponseProtoMessage { protected: }; -class ClimateCommandRequest : public CommandProtoMessage { +class ClimateCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 48; static constexpr uint8_t ESTIMATED_SIZE = 84; @@ -1415,7 +1415,7 @@ class ClimateCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_NUMBER -class ListEntitiesNumberResponse : public InfoResponseProtoMessage { +class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 49; static constexpr uint8_t ESTIMATED_SIZE = 75; @@ -1438,7 +1438,7 @@ class ListEntitiesNumberResponse : public InfoResponseProtoMessage { protected: }; -class NumberStateResponse : public StateResponseProtoMessage { +class NumberStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 50; static constexpr uint8_t ESTIMATED_SIZE = 16; @@ -1455,7 +1455,7 @@ class NumberStateResponse : public StateResponseProtoMessage { protected: }; -class NumberCommandRequest : public CommandProtoMessage { +class NumberCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 51; static constexpr uint8_t ESTIMATED_SIZE = 14; @@ -1473,7 +1473,7 @@ class NumberCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_SELECT -class ListEntitiesSelectResponse : public InfoResponseProtoMessage { +class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 52; static constexpr uint8_t ESTIMATED_SIZE = 58; @@ -1489,7 +1489,7 @@ class ListEntitiesSelectResponse : public InfoResponseProtoMessage { protected: }; -class SelectStateResponse : public StateResponseProtoMessage { +class SelectStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 53; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -1507,7 +1507,7 @@ class SelectStateResponse : public StateResponseProtoMessage { protected: }; -class SelectCommandRequest : public CommandProtoMessage { +class SelectCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 54; static constexpr uint8_t ESTIMATED_SIZE = 18; @@ -1526,7 +1526,7 @@ class SelectCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_SIREN -class ListEntitiesSirenResponse : public InfoResponseProtoMessage { +class ListEntitiesSirenResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 55; static constexpr uint8_t ESTIMATED_SIZE = 62; @@ -1544,7 +1544,7 @@ class ListEntitiesSirenResponse : public InfoResponseProtoMessage { protected: }; -class SirenStateResponse : public StateResponseProtoMessage { +class SirenStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 56; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -1560,7 +1560,7 @@ class SirenStateResponse : public StateResponseProtoMessage { protected: }; -class SirenCommandRequest : public CommandProtoMessage { +class SirenCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 57; static constexpr uint8_t ESTIMATED_SIZE = 37; @@ -1586,7 +1586,7 @@ class SirenCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_LOCK -class ListEntitiesLockResponse : public InfoResponseProtoMessage { +class ListEntitiesLockResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 58; static constexpr uint8_t ESTIMATED_SIZE = 55; @@ -1606,7 +1606,7 @@ class ListEntitiesLockResponse : public InfoResponseProtoMessage { protected: }; -class LockStateResponse : public StateResponseProtoMessage { +class LockStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 59; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -1622,7 +1622,7 @@ class LockStateResponse : public StateResponseProtoMessage { protected: }; -class LockCommandRequest : public CommandProtoMessage { +class LockCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 60; static constexpr uint8_t ESTIMATED_SIZE = 22; @@ -1643,7 +1643,7 @@ class LockCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_BUTTON -class ListEntitiesButtonResponse : public InfoResponseProtoMessage { +class ListEntitiesButtonResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 61; static constexpr uint8_t ESTIMATED_SIZE = 49; @@ -1660,7 +1660,7 @@ class ListEntitiesButtonResponse : public InfoResponseProtoMessage { protected: }; -class ButtonCommandRequest : public CommandProtoMessage { +class ButtonCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 62; static constexpr uint8_t ESTIMATED_SIZE = 9; @@ -1677,7 +1677,7 @@ class ButtonCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_MEDIA_PLAYER -class MediaPlayerSupportedFormat : public ProtoMessage { +class MediaPlayerSupportedFormat final : public ProtoMessage { public: StringRef format_ref_{}; void set_format(const StringRef &ref) { this->format_ref_ = ref; } @@ -1693,7 +1693,7 @@ class MediaPlayerSupportedFormat : public ProtoMessage { protected: }; -class ListEntitiesMediaPlayerResponse : public InfoResponseProtoMessage { +class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 63; static constexpr uint8_t ESTIMATED_SIZE = 80; @@ -1711,7 +1711,7 @@ class ListEntitiesMediaPlayerResponse : public InfoResponseProtoMessage { protected: }; -class MediaPlayerStateResponse : public StateResponseProtoMessage { +class MediaPlayerStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 64; static constexpr uint8_t ESTIMATED_SIZE = 18; @@ -1729,7 +1729,7 @@ class MediaPlayerStateResponse : public StateResponseProtoMessage { protected: }; -class MediaPlayerCommandRequest : public CommandProtoMessage { +class MediaPlayerCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 65; static constexpr uint8_t ESTIMATED_SIZE = 35; @@ -1755,7 +1755,7 @@ class MediaPlayerCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_BLUETOOTH_PROXY -class SubscribeBluetoothLEAdvertisementsRequest : public ProtoDecodableMessage { +class SubscribeBluetoothLEAdvertisementsRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 66; static constexpr uint8_t ESTIMATED_SIZE = 4; @@ -1770,7 +1770,7 @@ class SubscribeBluetoothLEAdvertisementsRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothLERawAdvertisement : public ProtoMessage { +class BluetoothLERawAdvertisement final : public ProtoMessage { public: uint64_t address{0}; int32_t rssi{0}; @@ -1785,7 +1785,7 @@ class BluetoothLERawAdvertisement : public ProtoMessage { protected: }; -class BluetoothLERawAdvertisementsResponse : public ProtoMessage { +class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 93; static constexpr uint8_t ESTIMATED_SIZE = 136; @@ -1802,7 +1802,7 @@ class BluetoothLERawAdvertisementsResponse : public ProtoMessage { protected: }; -class BluetoothDeviceRequest : public ProtoDecodableMessage { +class BluetoothDeviceRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 68; static constexpr uint8_t ESTIMATED_SIZE = 12; @@ -1820,7 +1820,7 @@ class BluetoothDeviceRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothDeviceConnectionResponse : public ProtoMessage { +class BluetoothDeviceConnectionResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 69; static constexpr uint8_t ESTIMATED_SIZE = 14; @@ -1839,7 +1839,7 @@ class BluetoothDeviceConnectionResponse : public ProtoMessage { protected: }; -class BluetoothGATTGetServicesRequest : public ProtoDecodableMessage { +class BluetoothGATTGetServicesRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 70; static constexpr uint8_t ESTIMATED_SIZE = 4; @@ -1854,7 +1854,7 @@ class BluetoothGATTGetServicesRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothGATTDescriptor : public ProtoMessage { +class BluetoothGATTDescriptor final : public ProtoMessage { public: std::array uuid{}; uint32_t handle{0}; @@ -1867,7 +1867,7 @@ class BluetoothGATTDescriptor : public ProtoMessage { protected: }; -class BluetoothGATTCharacteristic : public ProtoMessage { +class BluetoothGATTCharacteristic final : public ProtoMessage { public: std::array uuid{}; uint32_t handle{0}; @@ -1882,7 +1882,7 @@ class BluetoothGATTCharacteristic : public ProtoMessage { protected: }; -class BluetoothGATTService : public ProtoMessage { +class BluetoothGATTService final : public ProtoMessage { public: std::array uuid{}; uint32_t handle{0}; @@ -1896,7 +1896,7 @@ class BluetoothGATTService : public ProtoMessage { protected: }; -class BluetoothGATTGetServicesResponse : public ProtoMessage { +class BluetoothGATTGetServicesResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 71; static constexpr uint8_t ESTIMATED_SIZE = 38; @@ -1913,7 +1913,7 @@ class BluetoothGATTGetServicesResponse : public ProtoMessage { protected: }; -class BluetoothGATTGetServicesDoneResponse : public ProtoMessage { +class BluetoothGATTGetServicesDoneResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 72; static constexpr uint8_t ESTIMATED_SIZE = 4; @@ -1929,7 +1929,7 @@ class BluetoothGATTGetServicesDoneResponse : public ProtoMessage { protected: }; -class BluetoothGATTReadRequest : public ProtoDecodableMessage { +class BluetoothGATTReadRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 73; static constexpr uint8_t ESTIMATED_SIZE = 8; @@ -1945,7 +1945,7 @@ class BluetoothGATTReadRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothGATTReadResponse : public ProtoMessage { +class BluetoothGATTReadResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 74; static constexpr uint8_t ESTIMATED_SIZE = 17; @@ -1968,7 +1968,7 @@ class BluetoothGATTReadResponse : public ProtoMessage { protected: }; -class BluetoothGATTWriteRequest : public ProtoDecodableMessage { +class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 75; static constexpr uint8_t ESTIMATED_SIZE = 19; @@ -1987,7 +1987,7 @@ class BluetoothGATTWriteRequest : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothGATTReadDescriptorRequest : public ProtoDecodableMessage { +class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 76; static constexpr uint8_t ESTIMATED_SIZE = 8; @@ -2003,7 +2003,7 @@ class BluetoothGATTReadDescriptorRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothGATTWriteDescriptorRequest : public ProtoDecodableMessage { +class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 77; static constexpr uint8_t ESTIMATED_SIZE = 17; @@ -2021,7 +2021,7 @@ class BluetoothGATTWriteDescriptorRequest : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothGATTNotifyRequest : public ProtoDecodableMessage { +class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 78; static constexpr uint8_t ESTIMATED_SIZE = 10; @@ -2038,7 +2038,7 @@ class BluetoothGATTNotifyRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class BluetoothGATTNotifyDataResponse : public ProtoMessage { +class BluetoothGATTNotifyDataResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 79; static constexpr uint8_t ESTIMATED_SIZE = 17; @@ -2061,7 +2061,7 @@ class BluetoothGATTNotifyDataResponse : public ProtoMessage { protected: }; -class SubscribeBluetoothConnectionsFreeRequest : public ProtoMessage { +class SubscribeBluetoothConnectionsFreeRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 80; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -2074,7 +2074,7 @@ class SubscribeBluetoothConnectionsFreeRequest : public ProtoMessage { protected: }; -class BluetoothConnectionsFreeResponse : public ProtoMessage { +class BluetoothConnectionsFreeResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 81; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -2092,7 +2092,7 @@ class BluetoothConnectionsFreeResponse : public ProtoMessage { protected: }; -class BluetoothGATTErrorResponse : public ProtoMessage { +class BluetoothGATTErrorResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 82; static constexpr uint8_t ESTIMATED_SIZE = 12; @@ -2110,7 +2110,7 @@ class BluetoothGATTErrorResponse : public ProtoMessage { protected: }; -class BluetoothGATTWriteResponse : public ProtoMessage { +class BluetoothGATTWriteResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 83; static constexpr uint8_t ESTIMATED_SIZE = 8; @@ -2127,7 +2127,7 @@ class BluetoothGATTWriteResponse : public ProtoMessage { protected: }; -class BluetoothGATTNotifyResponse : public ProtoMessage { +class BluetoothGATTNotifyResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 84; static constexpr uint8_t ESTIMATED_SIZE = 8; @@ -2144,7 +2144,7 @@ class BluetoothGATTNotifyResponse : public ProtoMessage { protected: }; -class BluetoothDevicePairingResponse : public ProtoMessage { +class BluetoothDevicePairingResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 85; static constexpr uint8_t ESTIMATED_SIZE = 10; @@ -2162,7 +2162,7 @@ class BluetoothDevicePairingResponse : public ProtoMessage { protected: }; -class BluetoothDeviceUnpairingResponse : public ProtoMessage { +class BluetoothDeviceUnpairingResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 86; static constexpr uint8_t ESTIMATED_SIZE = 10; @@ -2180,7 +2180,7 @@ class BluetoothDeviceUnpairingResponse : public ProtoMessage { protected: }; -class UnsubscribeBluetoothLEAdvertisementsRequest : public ProtoMessage { +class UnsubscribeBluetoothLEAdvertisementsRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 87; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -2193,7 +2193,7 @@ class UnsubscribeBluetoothLEAdvertisementsRequest : public ProtoMessage { protected: }; -class BluetoothDeviceClearCacheResponse : public ProtoMessage { +class BluetoothDeviceClearCacheResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 88; static constexpr uint8_t ESTIMATED_SIZE = 10; @@ -2211,7 +2211,7 @@ class BluetoothDeviceClearCacheResponse : public ProtoMessage { protected: }; -class BluetoothScannerStateResponse : public ProtoMessage { +class BluetoothScannerStateResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 126; static constexpr uint8_t ESTIMATED_SIZE = 4; @@ -2228,7 +2228,7 @@ class BluetoothScannerStateResponse : public ProtoMessage { protected: }; -class BluetoothScannerSetModeRequest : public ProtoDecodableMessage { +class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 127; static constexpr uint8_t ESTIMATED_SIZE = 2; @@ -2245,7 +2245,7 @@ class BluetoothScannerSetModeRequest : public ProtoDecodableMessage { }; #endif #ifdef USE_VOICE_ASSISTANT -class SubscribeVoiceAssistantRequest : public ProtoDecodableMessage { +class SubscribeVoiceAssistantRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 89; static constexpr uint8_t ESTIMATED_SIZE = 6; @@ -2261,7 +2261,7 @@ class SubscribeVoiceAssistantRequest : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class VoiceAssistantAudioSettings : public ProtoMessage { +class VoiceAssistantAudioSettings final : public ProtoMessage { public: uint32_t noise_suppression_level{0}; uint32_t auto_gain{0}; @@ -2274,7 +2274,7 @@ class VoiceAssistantAudioSettings : public ProtoMessage { protected: }; -class VoiceAssistantRequest : public ProtoMessage { +class VoiceAssistantRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 90; static constexpr uint8_t ESTIMATED_SIZE = 41; @@ -2296,7 +2296,7 @@ class VoiceAssistantRequest : public ProtoMessage { protected: }; -class VoiceAssistantResponse : public ProtoDecodableMessage { +class VoiceAssistantResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 91; static constexpr uint8_t ESTIMATED_SIZE = 6; @@ -2312,7 +2312,7 @@ class VoiceAssistantResponse : public ProtoDecodableMessage { protected: bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class VoiceAssistantEventData : public ProtoDecodableMessage { +class VoiceAssistantEventData final : public ProtoDecodableMessage { public: std::string name{}; std::string value{}; @@ -2323,7 +2323,7 @@ class VoiceAssistantEventData : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class VoiceAssistantEventResponse : public ProtoDecodableMessage { +class VoiceAssistantEventResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 92; static constexpr uint8_t ESTIMATED_SIZE = 36; @@ -2340,7 +2340,7 @@ class VoiceAssistantEventResponse : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class VoiceAssistantAudio : public ProtoDecodableMessage { +class VoiceAssistantAudio final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 106; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -2365,7 +2365,7 @@ class VoiceAssistantAudio : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class VoiceAssistantTimerEventResponse : public ProtoDecodableMessage { +class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 115; static constexpr uint8_t ESTIMATED_SIZE = 30; @@ -2386,7 +2386,7 @@ class VoiceAssistantTimerEventResponse : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class VoiceAssistantAnnounceRequest : public ProtoDecodableMessage { +class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 119; static constexpr uint8_t ESTIMATED_SIZE = 29; @@ -2405,7 +2405,7 @@ class VoiceAssistantAnnounceRequest : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; -class VoiceAssistantAnnounceFinished : public ProtoMessage { +class VoiceAssistantAnnounceFinished final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 120; static constexpr uint8_t ESTIMATED_SIZE = 2; @@ -2421,7 +2421,7 @@ class VoiceAssistantAnnounceFinished : public ProtoMessage { protected: }; -class VoiceAssistantWakeWord : public ProtoMessage { +class VoiceAssistantWakeWord final : public ProtoMessage { public: StringRef id_ref_{}; void set_id(const StringRef &ref) { this->id_ref_ = ref; } @@ -2436,7 +2436,7 @@ class VoiceAssistantWakeWord : public ProtoMessage { protected: }; -class VoiceAssistantConfigurationRequest : public ProtoMessage { +class VoiceAssistantConfigurationRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 121; static constexpr uint8_t ESTIMATED_SIZE = 0; @@ -2449,7 +2449,7 @@ class VoiceAssistantConfigurationRequest : public ProtoMessage { protected: }; -class VoiceAssistantConfigurationResponse : public ProtoMessage { +class VoiceAssistantConfigurationResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 122; static constexpr uint8_t ESTIMATED_SIZE = 56; @@ -2467,7 +2467,7 @@ class VoiceAssistantConfigurationResponse : public ProtoMessage { protected: }; -class VoiceAssistantSetConfiguration : public ProtoDecodableMessage { +class VoiceAssistantSetConfiguration final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 123; static constexpr uint8_t ESTIMATED_SIZE = 18; @@ -2484,7 +2484,7 @@ class VoiceAssistantSetConfiguration : public ProtoDecodableMessage { }; #endif #ifdef USE_ALARM_CONTROL_PANEL -class ListEntitiesAlarmControlPanelResponse : public InfoResponseProtoMessage { +class ListEntitiesAlarmControlPanelResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 94; static constexpr uint8_t ESTIMATED_SIZE = 48; @@ -2502,7 +2502,7 @@ class ListEntitiesAlarmControlPanelResponse : public InfoResponseProtoMessage { protected: }; -class AlarmControlPanelStateResponse : public StateResponseProtoMessage { +class AlarmControlPanelStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 95; static constexpr uint8_t ESTIMATED_SIZE = 11; @@ -2518,7 +2518,7 @@ class AlarmControlPanelStateResponse : public StateResponseProtoMessage { protected: }; -class AlarmControlPanelCommandRequest : public CommandProtoMessage { +class AlarmControlPanelCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 96; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -2538,7 +2538,7 @@ class AlarmControlPanelCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_TEXT -class ListEntitiesTextResponse : public InfoResponseProtoMessage { +class ListEntitiesTextResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 97; static constexpr uint8_t ESTIMATED_SIZE = 59; @@ -2558,7 +2558,7 @@ class ListEntitiesTextResponse : public InfoResponseProtoMessage { protected: }; -class TextStateResponse : public StateResponseProtoMessage { +class TextStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 98; static constexpr uint8_t ESTIMATED_SIZE = 20; @@ -2576,7 +2576,7 @@ class TextStateResponse : public StateResponseProtoMessage { protected: }; -class TextCommandRequest : public CommandProtoMessage { +class TextCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 99; static constexpr uint8_t ESTIMATED_SIZE = 18; @@ -2595,7 +2595,7 @@ class TextCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_DATETIME_DATE -class ListEntitiesDateResponse : public InfoResponseProtoMessage { +class ListEntitiesDateResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 100; static constexpr uint8_t ESTIMATED_SIZE = 40; @@ -2610,7 +2610,7 @@ class ListEntitiesDateResponse : public InfoResponseProtoMessage { protected: }; -class DateStateResponse : public StateResponseProtoMessage { +class DateStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 101; static constexpr uint8_t ESTIMATED_SIZE = 23; @@ -2629,7 +2629,7 @@ class DateStateResponse : public StateResponseProtoMessage { protected: }; -class DateCommandRequest : public CommandProtoMessage { +class DateCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 102; static constexpr uint8_t ESTIMATED_SIZE = 21; @@ -2649,7 +2649,7 @@ class DateCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_DATETIME_TIME -class ListEntitiesTimeResponse : public InfoResponseProtoMessage { +class ListEntitiesTimeResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 103; static constexpr uint8_t ESTIMATED_SIZE = 40; @@ -2664,7 +2664,7 @@ class ListEntitiesTimeResponse : public InfoResponseProtoMessage { protected: }; -class TimeStateResponse : public StateResponseProtoMessage { +class TimeStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 104; static constexpr uint8_t ESTIMATED_SIZE = 23; @@ -2683,7 +2683,7 @@ class TimeStateResponse : public StateResponseProtoMessage { protected: }; -class TimeCommandRequest : public CommandProtoMessage { +class TimeCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 105; static constexpr uint8_t ESTIMATED_SIZE = 21; @@ -2703,7 +2703,7 @@ class TimeCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_EVENT -class ListEntitiesEventResponse : public InfoResponseProtoMessage { +class ListEntitiesEventResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 107; static constexpr uint8_t ESTIMATED_SIZE = 67; @@ -2721,7 +2721,7 @@ class ListEntitiesEventResponse : public InfoResponseProtoMessage { protected: }; -class EventResponse : public StateResponseProtoMessage { +class EventResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 108; static constexpr uint8_t ESTIMATED_SIZE = 18; @@ -2740,7 +2740,7 @@ class EventResponse : public StateResponseProtoMessage { }; #endif #ifdef USE_VALVE -class ListEntitiesValveResponse : public InfoResponseProtoMessage { +class ListEntitiesValveResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 109; static constexpr uint8_t ESTIMATED_SIZE = 55; @@ -2760,7 +2760,7 @@ class ListEntitiesValveResponse : public InfoResponseProtoMessage { protected: }; -class ValveStateResponse : public StateResponseProtoMessage { +class ValveStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 110; static constexpr uint8_t ESTIMATED_SIZE = 16; @@ -2777,7 +2777,7 @@ class ValveStateResponse : public StateResponseProtoMessage { protected: }; -class ValveCommandRequest : public CommandProtoMessage { +class ValveCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 111; static constexpr uint8_t ESTIMATED_SIZE = 18; @@ -2797,7 +2797,7 @@ class ValveCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_DATETIME_DATETIME -class ListEntitiesDateTimeResponse : public InfoResponseProtoMessage { +class ListEntitiesDateTimeResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 112; static constexpr uint8_t ESTIMATED_SIZE = 40; @@ -2812,7 +2812,7 @@ class ListEntitiesDateTimeResponse : public InfoResponseProtoMessage { protected: }; -class DateTimeStateResponse : public StateResponseProtoMessage { +class DateTimeStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 113; static constexpr uint8_t ESTIMATED_SIZE = 16; @@ -2829,7 +2829,7 @@ class DateTimeStateResponse : public StateResponseProtoMessage { protected: }; -class DateTimeCommandRequest : public CommandProtoMessage { +class DateTimeCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 114; static constexpr uint8_t ESTIMATED_SIZE = 14; @@ -2847,7 +2847,7 @@ class DateTimeCommandRequest : public CommandProtoMessage { }; #endif #ifdef USE_UPDATE -class ListEntitiesUpdateResponse : public InfoResponseProtoMessage { +class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 116; static constexpr uint8_t ESTIMATED_SIZE = 49; @@ -2864,7 +2864,7 @@ class ListEntitiesUpdateResponse : public InfoResponseProtoMessage { protected: }; -class UpdateStateResponse : public StateResponseProtoMessage { +class UpdateStateResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 117; static constexpr uint8_t ESTIMATED_SIZE = 65; @@ -2893,7 +2893,7 @@ class UpdateStateResponse : public StateResponseProtoMessage { protected: }; -class UpdateCommandRequest : public CommandProtoMessage { +class UpdateCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 118; static constexpr uint8_t ESTIMATED_SIZE = 11; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 3396e5ad05..511d70d3ec 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1952,7 +1952,7 @@ def build_message_type( dump_impl += "}\n" if base_class: - out = f"class {desc.name} : public {base_class} {{\n" + out = f"class {desc.name} final : public {base_class} {{\n" else: # Check if message has any non-deprecated fields has_fields = any(not field.options.deprecated for field in desc.field) @@ -1961,7 +1961,7 @@ def build_message_type( base_class = "ProtoDecodableMessage" else: base_class = "ProtoMessage" - out = f"class {desc.name} : public {base_class} {{\n" + out = f"class {desc.name} final : public {base_class} {{\n" out += " public:\n" out += indent("\n".join(public_content)) + "\n" out += "\n" From efaeb9180301b1fdf12a0f0d7d74458731626b22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 17:01:45 -0400 Subject: [PATCH 0598/1626] [api] Mark APIConnection as final for compiler optimizations (#10279) --- esphome/components/api/api_connection.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 076dccfad7..f711502746 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -44,7 +44,7 @@ static constexpr size_t MAX_PACKETS_PER_BATCH = 64; // ESP32 has 8KB+ stack, HO static constexpr size_t MAX_PACKETS_PER_BATCH = 32; // ESP8266/RP2040/etc have smaller stacks #endif -class APIConnection : public APIServerConnection { +class APIConnection final : public APIServerConnection { public: friend class APIServer; friend class ListEntitiesIterator; From 44bd8e5b54fcd4d13ee0976164203dc41246a584 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 17:14:20 -0400 Subject: [PATCH 0599/1626] [api] Optimize protobuf decode loop for better performance and maintainability (#10277) --- esphome/components/api/proto.cpp | 70 +++++++++++++++----------------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index cb6c07ec3c..afda5d32ba 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -8,74 +8,70 @@ namespace esphome::api { static const char *const TAG = "api.proto"; void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { - uint32_t i = 0; - bool error = false; - while (i < length) { + const uint8_t *ptr = buffer; + const uint8_t *end = buffer + length; + + while (ptr < end) { uint32_t consumed; - auto res = ProtoVarInt::parse(&buffer[i], length - i, &consumed); + + // Parse field header + auto res = ProtoVarInt::parse(ptr, end - ptr, &consumed); if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid field start at %" PRIu32, i); - break; + ESP_LOGV(TAG, "Invalid field start at offset %ld", (long) (ptr - buffer)); + return; } - uint32_t field_type = (res->as_uint32()) & 0b111; - uint32_t field_id = (res->as_uint32()) >> 3; - i += consumed; + uint32_t tag = res->as_uint32(); + uint32_t field_type = tag & 0b111; + uint32_t field_id = tag >> 3; + ptr += consumed; switch (field_type) { case 0: { // VarInt - res = ProtoVarInt::parse(&buffer[i], length - i, &consumed); + res = ProtoVarInt::parse(ptr, end - ptr, &consumed); if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid VarInt at %" PRIu32, i); - error = true; - break; + ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer)); + return; } if (!this->decode_varint(field_id, *res)) { ESP_LOGV(TAG, "Cannot decode VarInt field %" PRIu32 " with value %" PRIu32 "!", field_id, res->as_uint32()); } - i += consumed; + ptr += consumed; break; } case 2: { // Length-delimited - res = ProtoVarInt::parse(&buffer[i], length - i, &consumed); + res = ProtoVarInt::parse(ptr, end - ptr, &consumed); if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid Length Delimited at %" PRIu32, i); - error = true; - break; + ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer)); + return; } uint32_t field_length = res->as_uint32(); - i += consumed; - if (field_length > length - i) { - ESP_LOGV(TAG, "Out-of-bounds Length Delimited at %" PRIu32, i); - error = true; - break; + ptr += consumed; + if (ptr + field_length > end) { + ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %ld", (long) (ptr - buffer)); + return; } - if (!this->decode_length(field_id, ProtoLengthDelimited(&buffer[i], field_length))) { + if (!this->decode_length(field_id, ProtoLengthDelimited(ptr, field_length))) { ESP_LOGV(TAG, "Cannot decode Length Delimited field %" PRIu32 "!", field_id); } - i += field_length; + ptr += field_length; break; } case 5: { // 32-bit - if (length - i < 4) { - ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at %" PRIu32, i); - error = true; - break; + if (ptr + 4 > end) { + ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer)); + return; } - uint32_t val = encode_uint32(buffer[i + 3], buffer[i + 2], buffer[i + 1], buffer[i]); + uint32_t val = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]); if (!this->decode_32bit(field_id, Proto32Bit(val))) { ESP_LOGV(TAG, "Cannot decode 32-bit field %" PRIu32 " with value %" PRIu32 "!", field_id, val); } - i += 4; + ptr += 4; break; } default: - ESP_LOGV(TAG, "Invalid field type at %" PRIu32, i); - error = true; - break; - } - if (error) { - break; + ESP_LOGV(TAG, "Invalid field type %u at offset %ld", field_type, (long) (ptr - buffer)); + return; } } } From e7fadef15cc6566a7d1c64ffa1218addeeefba1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 18:00:46 -0500 Subject: [PATCH 0600/1626] [sensor] Convert LOG_SENSOR macro to function to reduce flash usage --- esphome/components/sensor/sensor.cpp | 27 +++++++++++++++++++++++++++ esphome/components/sensor/sensor.h | 25 +++++-------------------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 0a82677bc9..6df6347c18 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -6,6 +6,33 @@ namespace sensor { static const char *const TAG = "sensor"; +// Function implementation of LOG_SENSOR macro to reduce code size +void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *obj) { + if (obj == nullptr) { + return; + } + + ESP_LOGCONFIG(tag, + "%s%s '%s'\n" + "%s State Class: '%s'\n" + "%s Unit of Measurement: '%s'\n" + "%s Accuracy Decimals: %d", + prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()).c_str(), + prefix, obj->get_unit_of_measurement().c_str(), prefix, obj->get_accuracy_decimals()); + + if (!obj->get_device_class().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); + } + + if (!obj->get_icon().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + } + + if (obj->get_force_update()) { + ESP_LOGV(tag, "%s Force Update: YES", prefix); + } +} + std::string state_class_to_string(StateClass state_class) { switch (state_class) { case STATE_CLASS_MEASUREMENT: diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index c2ded0f2c3..ebfdb31ae7 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -12,26 +12,11 @@ namespace esphome { namespace sensor { -#define LOG_SENSOR(prefix, type, obj) \ - if ((obj) != nullptr) { \ - ESP_LOGCONFIG(TAG, \ - "%s%s '%s'\n" \ - "%s State Class: '%s'\n" \ - "%s Unit of Measurement: '%s'\n" \ - "%s Accuracy Decimals: %d", \ - prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str(), prefix, \ - state_class_to_string((obj)->get_state_class()).c_str(), prefix, \ - (obj)->get_unit_of_measurement().c_str(), prefix, (obj)->get_accuracy_decimals()); \ - if (!(obj)->get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ - } \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ - } \ - if ((obj)->get_force_update()) { \ - ESP_LOGV(TAG, "%s Force Update: YES", prefix); \ - } \ - } +// Forward declaration +void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *obj); + +// Macro that calls the function - kept for backward compatibility +#define LOG_SENSOR(prefix, type, obj) log_sensor(TAG, prefix, LOG_STR_LITERAL(type), obj) #define SUB_SENSOR(name) \ protected: \ From 8971e2e9a43c7294b6f3e4b1b8d62fda842cfd83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 18:14:55 -0500 Subject: [PATCH 0601/1626] [number] Convert LOG_NUMBER macro to function to reduce flash usage --- esphome/components/number/number.cpp | 21 +++++++++++++++++++++ esphome/components/number/number.h | 19 ++++++------------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index b6a845b19b..4769c1ed12 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -6,6 +6,27 @@ namespace number { static const char *const TAG = "number"; +// Function implementation of LOG_NUMBER macro to reduce code size +void log_number(const char *tag, const char *prefix, const char *type, Number *obj) { + if (obj == nullptr) { + return; + } + + ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); + + if (!obj->get_icon().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + } + + if (!obj->traits.get_unit_of_measurement().empty()) { + ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj->traits.get_unit_of_measurement().c_str()); + } + + if (!obj->traits.get_device_class().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->traits.get_device_class().c_str()); + } +} + void Number::publish_state(float state) { this->set_has_state(true); this->state = state; diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index 49bcbb857c..f38e84fd34 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -9,19 +9,12 @@ namespace esphome { namespace number { -#define LOG_NUMBER(prefix, type, obj) \ - if ((obj) != nullptr) { \ - ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ - } \ - if (!(obj)->traits.get_unit_of_measurement().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Unit of Measurement: '%s'", prefix, (obj)->traits.get_unit_of_measurement().c_str()); \ - } \ - if (!(obj)->traits.get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->traits.get_device_class().c_str()); \ - } \ - } +// Forward declaration +class Number; +void log_number(const char *tag, const char *prefix, const char *type, Number *obj); + +// Macro that calls the function - kept for backward compatibility +#define LOG_NUMBER(prefix, type, obj) log_number(TAG, prefix, LOG_STR_LITERAL(type), obj) #define SUB_NUMBER(name) \ protected: \ From a21ee3c483fcfd55ba97846d0175b00d0b16d89d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 18:19:53 -0500 Subject: [PATCH 0602/1626] [binary_sensor] Convert LOG_BINARY_SENSOR macro to function to reduce flash usage --- esphome/components/binary_sensor/binary_sensor.cpp | 13 +++++++++++++ esphome/components/binary_sensor/binary_sensor.h | 13 ++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 02b83af552..e652d302b6 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -7,6 +7,19 @@ namespace binary_sensor { static const char *const TAG = "binary_sensor"; +// Function implementation of LOG_BINARY_SENSOR macro to reduce code size +void log_binary_sensor(const char *tag, const char *prefix, const char *type, BinarySensor *obj) { + if (obj == nullptr) { + return; + } + + ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); + + if (!obj->get_device_class().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); + } +} + void BinarySensor::publish_state(bool new_state) { if (this->filter_list_ == nullptr) { this->send_state_internal(new_state); diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index d61be7a49b..82f3c8bad8 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -10,13 +10,12 @@ namespace esphome { namespace binary_sensor { -#define LOG_BINARY_SENSOR(prefix, type, obj) \ - if ((obj) != nullptr) { \ - ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ - } \ - } +// Forward declaration +class BinarySensor; +void log_binary_sensor(const char *tag, const char *prefix, const char *type, BinarySensor *obj); + +// Macro that calls the function - kept for backward compatibility +#define LOG_BINARY_SENSOR(prefix, type, obj) log_binary_sensor(TAG, prefix, LOG_STR_LITERAL(type), obj) #define SUB_BINARY_SENSOR(name) \ protected: \ From 9d25dd5dd2c8b875aff10bb42957eab715e1c496 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 18:29:13 -0500 Subject: [PATCH 0603/1626] [button] Convert LOG_BUTTON macro to function to reduce flash usage --- esphome/components/button/button.cpp | 13 +++++++++++++ esphome/components/button/button.h | 13 ++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/esphome/components/button/button.cpp b/esphome/components/button/button.cpp index 4c4cb7740c..63d71dcb8a 100644 --- a/esphome/components/button/button.cpp +++ b/esphome/components/button/button.cpp @@ -6,6 +6,19 @@ namespace button { static const char *const TAG = "button"; +// Function implementation of LOG_BUTTON macro to reduce code size +void log_button(const char *tag, const char *prefix, const char *type, Button *obj) { + if (obj == nullptr) { + return; + } + + ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); + + if (!obj->get_icon().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + } +} + void Button::press() { ESP_LOGD(TAG, "'%s' Pressed.", this->get_name().c_str()); this->press_action(); diff --git a/esphome/components/button/button.h b/esphome/components/button/button.h index 9488eca221..b89ed1985b 100644 --- a/esphome/components/button/button.h +++ b/esphome/components/button/button.h @@ -7,13 +7,12 @@ namespace esphome { namespace button { -#define LOG_BUTTON(prefix, type, obj) \ - if ((obj) != nullptr) { \ - ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ - } \ - } +// Forward declaration +class Button; +void log_button(const char *tag, const char *prefix, const char *type, Button *obj); + +// Macro that calls the function - kept for backward compatibility +#define LOG_BUTTON(prefix, type, obj) log_button(TAG, prefix, LOG_STR_LITERAL(type), obj) #define SUB_BUTTON(name) \ protected: \ From 59c93cf3f1a927d33c7f6d7153527afebb955746 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 18:32:50 -0500 Subject: [PATCH 0604/1626] preen --- esphome/components/ccs811/ccs811.cpp | 4 ++-- esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp | 8 ++++---- esphome/components/hlw8012/hlw8012.cpp | 8 ++++---- esphome/components/pulse_width/pulse_width.cpp | 2 +- esphome/components/ufire_ec/ufire_ec.cpp | 6 +++--- esphome/components/ufire_ise/ufire_ise.cpp | 6 +++--- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index cecb92b3df..2617d7577a 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -153,8 +153,8 @@ void CCS811Component::dump_config() { ESP_LOGCONFIG(TAG, "CCS811"); LOG_I2C_DEVICE(this) LOG_UPDATE_INTERVAL(this) - LOG_SENSOR(" ", "CO2 Sensor", this->co2_) - LOG_SENSOR(" ", "TVOC Sensor", this->tvoc_) + LOG_SENSOR(" ", "CO2 Sensor", this->co2_); + LOG_SENSOR(" ", "TVOC Sensor", this->tvoc_); LOG_TEXT_SENSOR(" ", "Firmware Version Sensor", this->version_) if (this->baseline_) { ESP_LOGCONFIG(TAG, " Baseline: %04X", *this->baseline_); diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp index 4842ee5d06..52ec8433a2 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp @@ -58,10 +58,10 @@ void GroveGasMultichannelV2Component::dump_config() { ESP_LOGCONFIG(TAG, "Grove Multichannel Gas Sensor V2"); LOG_I2C_DEVICE(this) LOG_UPDATE_INTERVAL(this) - LOG_SENSOR(" ", "Nitrogen Dioxide", this->nitrogen_dioxide_sensor_) - LOG_SENSOR(" ", "Ethanol", this->ethanol_sensor_) - LOG_SENSOR(" ", "Carbon Monoxide", this->carbon_monoxide_sensor_) - LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_) + LOG_SENSOR(" ", "Nitrogen Dioxide", this->nitrogen_dioxide_sensor_); + LOG_SENSOR(" ", "Ethanol", this->ethanol_sensor_); + LOG_SENSOR(" ", "Carbon Monoxide", this->carbon_monoxide_sensor_); + LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_); if (this->is_failed()) { switch (this->error_code_) { diff --git a/esphome/components/hlw8012/hlw8012.cpp b/esphome/components/hlw8012/hlw8012.cpp index a28678e630..f293185cce 100644 --- a/esphome/components/hlw8012/hlw8012.cpp +++ b/esphome/components/hlw8012/hlw8012.cpp @@ -43,10 +43,10 @@ void HLW8012Component::dump_config() { " Voltage Divider: %.1f", this->change_mode_every_, this->current_resistor_ * 1000.0f, this->voltage_divider_); LOG_UPDATE_INTERVAL(this) - LOG_SENSOR(" ", "Voltage", this->voltage_sensor_) - LOG_SENSOR(" ", "Current", this->current_sensor_) - LOG_SENSOR(" ", "Power", this->power_sensor_) - LOG_SENSOR(" ", "Energy", this->energy_sensor_) + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); + LOG_SENSOR(" ", "Power", this->power_sensor_); + LOG_SENSOR(" ", "Energy", this->energy_sensor_); } float HLW8012Component::get_setup_priority() const { return setup_priority::DATA; } void HLW8012Component::update() { diff --git a/esphome/components/pulse_width/pulse_width.cpp b/esphome/components/pulse_width/pulse_width.cpp index 8d66861049..c086ceaa23 100644 --- a/esphome/components/pulse_width/pulse_width.cpp +++ b/esphome/components/pulse_width/pulse_width.cpp @@ -17,7 +17,7 @@ void IRAM_ATTR PulseWidthSensorStore::gpio_intr(PulseWidthSensorStore *arg) { } void PulseWidthSensor::dump_config() { - LOG_SENSOR("", "Pulse Width", this) + LOG_SENSOR("", "Pulse Width", this); LOG_UPDATE_INTERVAL(this) LOG_PIN(" Pin: ", this->pin_); } diff --git a/esphome/components/ufire_ec/ufire_ec.cpp b/esphome/components/ufire_ec/ufire_ec.cpp index 364a133776..9e0055a2cc 100644 --- a/esphome/components/ufire_ec/ufire_ec.cpp +++ b/esphome/components/ufire_ec/ufire_ec.cpp @@ -105,9 +105,9 @@ void UFireECComponent::dump_config() { ESP_LOGCONFIG(TAG, "uFire-EC"); LOG_I2C_DEVICE(this) LOG_UPDATE_INTERVAL(this) - LOG_SENSOR(" ", "EC Sensor", this->ec_sensor_) - LOG_SENSOR(" ", "Temperature Sensor", this->temperature_sensor_) - LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_) + LOG_SENSOR(" ", "EC Sensor", this->ec_sensor_); + LOG_SENSOR(" ", "Temperature Sensor", this->temperature_sensor_); + LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_); ESP_LOGCONFIG(TAG, " Temperature Compensation: %f\n" " Temperature Coefficient: %f", diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index 503d993fb7..9e0e7e265d 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -142,9 +142,9 @@ void UFireISEComponent::dump_config() { ESP_LOGCONFIG(TAG, "uFire-ISE"); LOG_I2C_DEVICE(this) LOG_UPDATE_INTERVAL(this) - LOG_SENSOR(" ", "PH Sensor", this->ph_sensor_) - LOG_SENSOR(" ", "Temperature Sensor", this->temperature_sensor_) - LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_) + LOG_SENSOR(" ", "PH Sensor", this->ph_sensor_); + LOG_SENSOR(" ", "Temperature Sensor", this->temperature_sensor_); + LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_); } } // namespace ufire_ise From 8dc3958b0c9f6734127d36d3f22f69e8596d3156 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 19:17:19 -0500 Subject: [PATCH 0605/1626] Update esphome/components/sensor/sensor.h --- esphome/components/sensor/sensor.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index ebfdb31ae7..9f01e9d373 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -15,7 +15,6 @@ namespace sensor { // Forward declaration void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *obj); -// Macro that calls the function - kept for backward compatibility #define LOG_SENSOR(prefix, type, obj) log_sensor(TAG, prefix, LOG_STR_LITERAL(type), obj) #define SUB_SENSOR(name) \ From 8fd430e42328a3e525b0fc69cc16b0c66ef68556 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 19:17:26 -0500 Subject: [PATCH 0606/1626] Update esphome/components/sensor/sensor.h --- esphome/components/sensor/sensor.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index 9f01e9d373..b3206d8dab 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -12,7 +12,6 @@ namespace esphome { namespace sensor { -// Forward declaration void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *obj); #define LOG_SENSOR(prefix, type, obj) log_sensor(TAG, prefix, LOG_STR_LITERAL(type), obj) From 7118bea031b74b17beb765d041ef08f6581f2466 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:17:34 +1200 Subject: [PATCH 0607/1626] [esp32] Write variant to sdkconfig file (#10267) --- esphome/components/esp32/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index c219b8851a..ac236f4eb3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -824,8 +824,9 @@ async def to_code(config): cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) - cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{config[CONF_VARIANT]}") - cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[config[CONF_VARIANT]]) + variant = config[CONF_VARIANT] + cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") + cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant]) cg.add_define(ThreadModel.MULTI_ATOMICS) cg.add_platformio_option("lib_ldf_mode", "off") @@ -859,6 +860,7 @@ async def to_code(config): cg.add_platformio_option( "platform_packages", ["espressif/toolchain-esp32ulp@2.35.0-20220830"] ) + add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True) add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True ) From 3a6a66537c97189b99b2fddc79f8cf38274d52fe Mon Sep 17 00:00:00 2001 From: Ben Winslow Date: Mon, 18 Aug 2025 20:20:13 -0400 Subject: [PATCH 0608/1626] [nextion] Don't include terminating NUL in nextion text_sensor states (#10273) --- esphome/components/nextion/nextion.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 133bd2947c..b348bc9920 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -764,7 +764,8 @@ void Nextion::process_nextion_commands_() { variable_name = to_process.substr(0, index); ++index; - text_value = to_process.substr(index); + // Get variable value without terminating NUL byte. Length check above ensures substr len >= 0. + text_value = to_process.substr(index, to_process_length - index - 1); ESP_LOGN(TAG, "Text sensor: %s='%s'", variable_name.c_str(), text_value.c_str()); From 1786934242227a914b111fc694d82fb3e3f573d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 20:01:42 -0500 Subject: [PATCH 0609/1626] [web_server] Reduce flash usage by consolidating defer calls in switch and lock handlers --- esphome/components/web_server/web_server.cpp | 70 ++++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 399b8785ae..290992b096 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -507,14 +507,37 @@ void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlM auto detail = get_request_detail(request); std::string data = this->switch_json(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); - } else if (match.method_equals("toggle")) { - this->defer([obj]() { obj->toggle(); }); - request->send(200); + return; + } + + // Handle action methods with single defer and response + enum SwitchAction { NONE, TOGGLE, TURN_ON, TURN_OFF }; + SwitchAction action = NONE; + + if (match.method_equals("toggle")) { + action = TOGGLE; } else if (match.method_equals("turn_on")) { - this->defer([obj]() { obj->turn_on(); }); - request->send(200); + action = TURN_ON; } else if (match.method_equals("turn_off")) { - this->defer([obj]() { obj->turn_off(); }); + action = TURN_OFF; + } + + if (action != NONE) { + this->defer([obj, action]() { + switch (action) { + case TOGGLE: + obj->toggle(); + break; + case TURN_ON: + obj->turn_on(); + break; + case TURN_OFF: + obj->turn_off(); + break; + default: + break; + } + }); request->send(200); } else { request->send(404); @@ -1332,14 +1355,37 @@ void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMat auto detail = get_request_detail(request); std::string data = this->lock_json(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); - } else if (match.method_equals("lock")) { - this->defer([obj]() { obj->lock(); }); - request->send(200); + return; + } + + // Handle action methods with single defer and response + enum LockAction { NONE, LOCK, UNLOCK, OPEN }; + LockAction action = NONE; + + if (match.method_equals("lock")) { + action = LOCK; } else if (match.method_equals("unlock")) { - this->defer([obj]() { obj->unlock(); }); - request->send(200); + action = UNLOCK; } else if (match.method_equals("open")) { - this->defer([obj]() { obj->open(); }); + action = OPEN; + } + + if (action != NONE) { + this->defer([obj, action]() { + switch (action) { + case LOCK: + obj->lock(); + break; + case UNLOCK: + obj->unlock(); + break; + case OPEN: + obj->open(); + break; + default: + break; + } + }); request->send(200); } else { request->send(404); From 3c7865cd6f4ea2108551223a91dc6f2fbd6f50c1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 15 Aug 2025 01:49:14 +1200 Subject: [PATCH 0610/1626] [esp32_ble] Add ``USE_ESP32_BLE_UUID`` when advertising is desired (#10230) --- esphome/components/esp32_ble/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c758b3ef8f..2edd69c6c0 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -294,6 +294,7 @@ async def to_code(config): if config[CONF_ADVERTISING]: cg.add_define("USE_ESP32_BLE_ADVERTISING") + cg.add_define("USE_ESP32_BLE_UUID") @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) From 80970f972bac2f33f8dd847e63bf47f0f154949b Mon Sep 17 00:00:00 2001 From: Katherine Whitlock Date: Thu, 14 Aug 2025 16:14:53 -0400 Subject: [PATCH 0611/1626] Improve error reporting for add_library (#10226) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 9df5da1c78..8a9630735e 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -803,6 +803,10 @@ class EsphomeCore: raise TypeError( f"Library {library} must be instance of Library, not {type(library)}" ) + + if not library.name: + raise ValueError(f"The library for {library.repository} must have a name") + short_name = ( library.name if "/" not in library.name else library.name.split("/")[-1] ) From 7fdbd8528a6abb02208408c279ba572bb1586dec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 17:43:45 -0500 Subject: [PATCH 0612/1626] [wifi] Automatically disable Enterprise WiFi support when EAP is not configured (#10242) --- esphome/components/wifi/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index ac002eac53..4013e8f400 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -375,11 +375,16 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + # Track if any network uses Enterprise authentication + has_eap = False + def add_sta(ap, network): ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP)) cg.add(var.add_sta(wifi_network(network, ap, ip_config))) for network in config.get(CONF_NETWORKS, []): + if CONF_EAP in network: + has_eap = True cg.with_local_variable(network[CONF_ID], WiFiAP(), add_sta, network) if CONF_AP in config: @@ -396,6 +401,10 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + # Disable Enterprise WiFi support if no EAP is configured + if CORE.is_esp32 and CORE.using_esp_idf and not has_eap: + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", False) + cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) cg.add(var.set_fast_connect(config[CONF_FAST_CONNECT])) From ac8b48a53c34d4c730f26b1db214bee517de637c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 17:50:03 -0500 Subject: [PATCH 0613/1626] [core] Trigger clean build when components are removed from configuration (#10235) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/writer.py | 18 ++- tests/unit_tests/test_writer.py | 220 ++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 tests/unit_tests/test_writer.py diff --git a/esphome/writer.py b/esphome/writer.py index b5c834722a..4b25a25f7e 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -80,13 +80,16 @@ def replace_file_content(text, pattern, repl): return content_new, count -def storage_should_clean(old: StorageJSON, new: StorageJSON) -> bool: +def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: if old is None: return True if old.src_version != new.src_version: return True - return old.build_path != new.build_path + if old.build_path != new.build_path: + return True + # Check if any components have been removed + return bool(old.loaded_integrations - new.loaded_integrations) def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> bool: @@ -100,7 +103,7 @@ def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> boo return False -def update_storage_json(): +def update_storage_json() -> None: path = storage_path() old = StorageJSON.load(path) new = StorageJSON.from_esphome_core(CORE, old) @@ -108,7 +111,14 @@ def update_storage_json(): return if storage_should_clean(old, new): - _LOGGER.info("Core config, version changed, cleaning build files...") + if old is not None and old.loaded_integrations - new.loaded_integrations: + removed = old.loaded_integrations - new.loaded_integrations + _LOGGER.info( + "Components removed (%s), cleaning build files...", + ", ".join(sorted(removed)), + ) + else: + _LOGGER.info("Core config or version changed, cleaning build files...") clean_build() elif storage_should_update_cmake_cache(old, new): _LOGGER.info("Integrations changed, cleaning cmake cache...") diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py new file mode 100644 index 0000000000..f47947ff37 --- /dev/null +++ b/tests/unit_tests/test_writer.py @@ -0,0 +1,220 @@ +"""Test writer module functionality.""" + +from collections.abc import Callable +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.storage_json import StorageJSON +from esphome.writer import storage_should_clean, update_storage_json + + +@pytest.fixture +def create_storage() -> Callable[..., StorageJSON]: + """Factory fixture to create StorageJSON instances.""" + + def _create( + loaded_integrations: list[str] | None = None, **kwargs: Any + ) -> StorageJSON: + return StorageJSON( + storage_version=kwargs.get("storage_version", 1), + name=kwargs.get("name", "test"), + friendly_name=kwargs.get("friendly_name", "Test Device"), + comment=kwargs.get("comment"), + esphome_version=kwargs.get("esphome_version", "2025.1.0"), + src_version=kwargs.get("src_version", 1), + address=kwargs.get("address", "test.local"), + web_port=kwargs.get("web_port", 80), + target_platform=kwargs.get("target_platform", "ESP32"), + build_path=kwargs.get("build_path", "/build"), + firmware_bin_path=kwargs.get("firmware_bin_path", "/firmware.bin"), + loaded_integrations=set(loaded_integrations or []), + loaded_platforms=kwargs.get("loaded_platforms", set()), + no_mdns=kwargs.get("no_mdns", False), + framework=kwargs.get("framework", "arduino"), + core_platform=kwargs.get("core_platform", "esp32"), + ) + + return _create + + +def test_storage_should_clean_when_old_is_none( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when old storage is None.""" + new = create_storage(loaded_integrations=["api", "wifi"]) + assert storage_should_clean(None, new) is True + + +def test_storage_should_clean_when_src_version_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when src_version changes.""" + old = create_storage(loaded_integrations=["api", "wifi"], src_version=1) + new = create_storage(loaded_integrations=["api", "wifi"], src_version=2) + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_build_path_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when build_path changes.""" + old = create_storage(loaded_integrations=["api", "wifi"], build_path="/build1") + new = create_storage(loaded_integrations=["api", "wifi"], build_path="/build2") + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_component_removed( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when a component is removed.""" + old = create_storage( + loaded_integrations=["api", "wifi", "bluetooth_proxy", "esp32_ble_tracker"] + ) + new = create_storage(loaded_integrations=["api", "wifi", "esp32_ble_tracker"]) + assert storage_should_clean(old, new) is True + + +def test_storage_should_clean_when_multiple_components_removed( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when multiple components are removed.""" + old = create_storage( + loaded_integrations=["api", "wifi", "ota", "web_server", "logger"] + ) + new = create_storage(loaded_integrations=["api", "wifi", "logger"]) + assert storage_should_clean(old, new) is True + + +def test_storage_should_not_clean_when_nothing_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is not triggered when nothing changes.""" + old = create_storage(loaded_integrations=["api", "wifi", "logger"]) + new = create_storage(loaded_integrations=["api", "wifi", "logger"]) + assert storage_should_clean(old, new) is False + + +def test_storage_should_not_clean_when_component_added( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is not triggered when a component is only added.""" + old = create_storage(loaded_integrations=["api", "wifi"]) + new = create_storage(loaded_integrations=["api", "wifi", "ota"]) + assert storage_should_clean(old, new) is False + + +def test_storage_should_not_clean_when_other_fields_change( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is not triggered when non-relevant fields change.""" + old = create_storage( + loaded_integrations=["api", "wifi"], + friendly_name="Old Name", + esphome_version="2024.12.0", + ) + new = create_storage( + loaded_integrations=["api", "wifi"], + friendly_name="New Name", + esphome_version="2025.1.0", + ) + assert storage_should_clean(old, new) is False + + +def test_storage_edge_case_empty_integrations( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test edge case when old has integrations but new has none.""" + old = create_storage(loaded_integrations=["api", "wifi"]) + new = create_storage(loaded_integrations=[]) + assert storage_should_clean(old, new) is True + + +def test_storage_edge_case_from_empty_integrations( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test edge case when old has no integrations but new has some.""" + old = create_storage(loaded_integrations=[]) + new = create_storage(loaded_integrations=["api", "wifi"]) + assert storage_should_clean(old, new) is False + + +@patch("esphome.writer.clean_build") +@patch("esphome.writer.StorageJSON") +@patch("esphome.writer.storage_path") +@patch("esphome.writer.CORE") +def test_update_storage_json_logging_when_old_is_none( + mock_core: MagicMock, + mock_storage_path: MagicMock, + mock_storage_json_class: MagicMock, + mock_clean_build: MagicMock, + create_storage: Callable[..., StorageJSON], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that update_storage_json doesn't crash when old storage is None. + + This is a regression test for the AttributeError that occurred when + old was None and we tried to access old.loaded_integrations. + """ + # Setup mocks + mock_storage_path.return_value = "/test/path" + mock_storage_json_class.load.return_value = None # Old storage is None + + new_storage = create_storage(loaded_integrations=["api", "wifi"]) + new_storage.save = MagicMock() # Mock the save method + mock_storage_json_class.from_esphome_core.return_value = new_storage + + # Call the function - should not raise AttributeError + with caplog.at_level("INFO"): + update_storage_json() + + # Verify clean_build was called + mock_clean_build.assert_called_once() + + # Verify the correct log message was used (not the component removal message) + assert "Core config or version changed, cleaning build files..." in caplog.text + assert "Components removed" not in caplog.text + + # Verify save was called + new_storage.save.assert_called_once_with("/test/path") + + +@patch("esphome.writer.clean_build") +@patch("esphome.writer.StorageJSON") +@patch("esphome.writer.storage_path") +@patch("esphome.writer.CORE") +def test_update_storage_json_logging_components_removed( + mock_core: MagicMock, + mock_storage_path: MagicMock, + mock_storage_json_class: MagicMock, + mock_clean_build: MagicMock, + create_storage: Callable[..., StorageJSON], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that update_storage_json logs removed components correctly.""" + # Setup mocks + mock_storage_path.return_value = "/test/path" + + old_storage = create_storage(loaded_integrations=["api", "wifi", "bluetooth_proxy"]) + new_storage = create_storage(loaded_integrations=["api", "wifi"]) + new_storage.save = MagicMock() # Mock the save method + + mock_storage_json_class.load.return_value = old_storage + mock_storage_json_class.from_esphome_core.return_value = new_storage + + # Call the function + with caplog.at_level("INFO"): + update_storage_json() + + # Verify clean_build was called + mock_clean_build.assert_called_once() + + # Verify the correct log message was used with component names + assert ( + "Components removed (bluetooth_proxy), cleaning build files..." in caplog.text + ) + assert "Core config or version changed" not in caplog.text + + # Verify save was called + new_storage.save.assert_called_once_with("/test/path") From 46be8775940e48e66b175240abd7f968a8c9a7a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Aug 2025 17:51:15 -0500 Subject: [PATCH 0614/1626] [bluetooth_proxy] Remove redundant connection type check after V1 removal (#10208) --- .../components/bluetooth_proxy/bluetooth_connection.cpp | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 347f60c28f..d2cbdeb984 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -133,7 +133,7 @@ void BluetoothConnection::loop() { // Check if we should disable the loop // - For V3_WITH_CACHE: Services are never sent, disable after INIT state - // - For other connections: Disable only after service discovery is complete + // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) if (this->state_ != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || this->send_service_ == DONE_SENDING_SERVICES)) { @@ -160,10 +160,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (this->send_service_ >= this->service_count_) { this->send_service_ = DONE_SENDING_SERVICES; this->proxy_->send_gatt_services_done(this->address_); - if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { - this->release_services(); - } + this->release_services(); return; } From e80f6163662e31d2b2a541e1c75aa3ca6c0ce3b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Aug 2025 02:38:27 -0500 Subject: [PATCH 0615/1626] [esp32_ble] Optimize BLE event memory usage by eliminating std::vector overhead (#10247) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32_ble/ble_event.h | 57 +++++++++++++++++------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index 884fc9ba65..2c0ab1d34e 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -3,8 +3,7 @@ #ifdef USE_ESP32 #include // for offsetof -#include - +#include // for memcpy #include #include #include @@ -64,7 +63,7 @@ static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.remote_addr) == si // Received GAP, GATTC and GATTS events are only queued, and get processed in the main loop(). // This class stores each event with minimal memory usage. -// GAP events (99% of traffic) don't have the vector overhead. +// GAP events (99% of traffic) don't have the heap allocation overhead. // GATTC/GATTS events use heap allocation for their param and data. // // Event flow: @@ -145,16 +144,18 @@ class BLEEvent { } if (this->type_ == GATTC) { delete this->event_.gattc.gattc_param; - delete this->event_.gattc.data; + delete[] this->event_.gattc.data; this->event_.gattc.gattc_param = nullptr; this->event_.gattc.data = nullptr; + this->event_.gattc.data_len = 0; return; } if (this->type_ == GATTS) { delete this->event_.gatts.gatts_param; - delete this->event_.gatts.data; + delete[] this->event_.gatts.data; this->event_.gatts.gatts_param = nullptr; this->event_.gatts.data = nullptr; + this->event_.gatts.data_len = 0; } } @@ -209,17 +210,19 @@ class BLEEvent { esp_gattc_cb_event_t gattc_event; esp_gatt_if_t gattc_if; esp_ble_gattc_cb_param_t *gattc_param; // Heap-allocated - std::vector *data; // Heap-allocated - } gattc; // 16 bytes (pointers only) + uint8_t *data; // Heap-allocated raw buffer (manually managed) + uint16_t data_len; // Track size separately + } gattc; // NOLINTNEXTLINE(readability-identifier-naming) struct gatts_event { esp_gatts_cb_event_t gatts_event; esp_gatt_if_t gatts_if; esp_ble_gatts_cb_param_t *gatts_param; // Heap-allocated - std::vector *data; // Heap-allocated - } gatts; // 16 bytes (pointers only) - } event_; // 80 bytes + uint8_t *data; // Heap-allocated raw buffer (manually managed) + uint16_t data_len; // Track size separately + } gatts; + } event_; // 80 bytes ble_event_t type_; @@ -319,6 +322,7 @@ class BLEEvent { if (p == nullptr) { this->event_.gattc.gattc_param = nullptr; this->event_.gattc.data = nullptr; + this->event_.gattc.data_len = 0; return; // Invalid event, but we can't log in header file } @@ -336,16 +340,29 @@ class BLEEvent { // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTC_NOTIFY_EVT: - this->event_.gattc.data = new std::vector(p->notify.value, p->notify.value + p->notify.value_len); - this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data->data(); + this->event_.gattc.data_len = p->notify.value_len; + if (p->notify.value_len > 0) { + this->event_.gattc.data = new uint8_t[p->notify.value_len]; + memcpy(this->event_.gattc.data, p->notify.value, p->notify.value_len); + } else { + this->event_.gattc.data = nullptr; + } + this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data; break; case ESP_GATTC_READ_CHAR_EVT: case ESP_GATTC_READ_DESCR_EVT: - this->event_.gattc.data = new std::vector(p->read.value, p->read.value + p->read.value_len); - this->event_.gattc.gattc_param->read.value = this->event_.gattc.data->data(); + this->event_.gattc.data_len = p->read.value_len; + if (p->read.value_len > 0) { + this->event_.gattc.data = new uint8_t[p->read.value_len]; + memcpy(this->event_.gattc.data, p->read.value, p->read.value_len); + } else { + this->event_.gattc.data = nullptr; + } + this->event_.gattc.gattc_param->read.value = this->event_.gattc.data; break; default: this->event_.gattc.data = nullptr; + this->event_.gattc.data_len = 0; break; } } @@ -358,6 +375,7 @@ class BLEEvent { if (p == nullptr) { this->event_.gatts.gatts_param = nullptr; this->event_.gatts.data = nullptr; + this->event_.gatts.data_len = 0; return; // Invalid event, but we can't log in header file } @@ -375,11 +393,18 @@ class BLEEvent { // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTS_WRITE_EVT: - this->event_.gatts.data = new std::vector(p->write.value, p->write.value + p->write.len); - this->event_.gatts.gatts_param->write.value = this->event_.gatts.data->data(); + this->event_.gatts.data_len = p->write.len; + if (p->write.len > 0) { + this->event_.gatts.data = new uint8_t[p->write.len]; + memcpy(this->event_.gatts.data, p->write.value, p->write.len); + } else { + this->event_.gatts.data = nullptr; + } + this->event_.gatts.gatts_param->write.value = this->event_.gatts.data; break; default: this->event_.gatts.data = nullptr; + this->event_.gatts.data_len = 0; break; } } From 07a98d252578a49008ebace5ab47ffce9aa662e6 Mon Sep 17 00:00:00 2001 From: RFDarter Date: Fri, 15 Aug 2025 16:44:24 +0200 Subject: [PATCH 0616/1626] [web_server] fix cover_all_json_generator wrong detail (#10252) --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 92c5961f87..399b8785ae 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -813,7 +813,7 @@ std::string WebServer::cover_state_json_generator(WebServer *web_server, void *s return web_server->cover_json((cover::Cover *) (source), DETAIL_STATE); } std::string WebServer::cover_all_json_generator(WebServer *web_server, void *source) { - return web_server->cover_json((cover::Cover *) (source), DETAIL_STATE); + return web_server->cover_json((cover::Cover *) (source), DETAIL_ALL); } std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) { return json::build_json([this, obj, start_config](JsonObject root) { From cc6c8926784ca7a18d9949e9250ce870b29fc465 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 Aug 2025 15:49:50 -0400 Subject: [PATCH 0617/1626] [esp32_ble] Store GATTC/GATTS param and small data inline to nearly eliminate heap allocations (#10249) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32_ble/ble.cpp | 4 +- esphome/components/esp32_ble/ble_event.h | 219 ++++++++++++++--------- 2 files changed, 133 insertions(+), 90 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index d1ee7af4ea..e22d43c0cc 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -306,7 +306,7 @@ void ESP32BLE::loop() { case BLEEvent::GATTS: { esp_gatts_cb_event_t event = ble_event->event_.gatts.gatts_event; esp_gatt_if_t gatts_if = ble_event->event_.gatts.gatts_if; - esp_ble_gatts_cb_param_t *param = ble_event->event_.gatts.gatts_param; + esp_ble_gatts_cb_param_t *param = &ble_event->event_.gatts.gatts_param; ESP_LOGV(TAG, "gatts_event [esp_gatt_if: %d] - %d", gatts_if, event); for (auto *gatts_handler : this->gatts_event_handlers_) { gatts_handler->gatts_event_handler(event, gatts_if, param); @@ -316,7 +316,7 @@ void ESP32BLE::loop() { case BLEEvent::GATTC: { esp_gattc_cb_event_t event = ble_event->event_.gattc.gattc_event; esp_gatt_if_t gattc_if = ble_event->event_.gattc.gattc_if; - esp_ble_gattc_cb_param_t *param = ble_event->event_.gattc.gattc_param; + esp_ble_gattc_cb_param_t *param = &ble_event->event_.gattc.gattc_param; ESP_LOGV(TAG, "gattc_event [esp_gatt_if: %d] - %d", gattc_if, event); for (auto *gattc_handler : this->gattc_event_handlers_) { gattc_handler->gattc_event_handler(event, gattc_if, param); diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index 2c0ab1d34e..299fd7705f 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -61,10 +61,24 @@ static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.rssi) == sizeof(es static_assert(offsetof(esp_ble_gap_cb_param_t, read_rssi_cmpl.remote_addr) == sizeof(esp_bt_status_t) + sizeof(int8_t), "remote_addr must follow rssi in read_rssi_cmpl"); +// Param struct sizes on ESP32 +static constexpr size_t GATTC_PARAM_SIZE = 28; +static constexpr size_t GATTS_PARAM_SIZE = 32; + +// Maximum size for inline storage of data +// GATTC: 80 - 28 (param) - 8 (other fields) = 44 bytes for data +// GATTS: 80 - 32 (param) - 8 (other fields) = 40 bytes for data +static constexpr size_t GATTC_INLINE_DATA_SIZE = 44; +static constexpr size_t GATTS_INLINE_DATA_SIZE = 40; + +// Verify param struct sizes +static_assert(sizeof(esp_ble_gattc_cb_param_t) == GATTC_PARAM_SIZE, "GATTC param size unexpected"); +static_assert(sizeof(esp_ble_gatts_cb_param_t) == GATTS_PARAM_SIZE, "GATTS param size unexpected"); + // Received GAP, GATTC and GATTS events are only queued, and get processed in the main loop(). // This class stores each event with minimal memory usage. // GAP events (99% of traffic) don't have the heap allocation overhead. -// GATTC/GATTS events use heap allocation for their param and data. +// GATTC/GATTS events use heap allocation for their param and inline storage for small data. // // Event flow: // 1. ESP-IDF BLE stack calls our static handlers in the BLE task context @@ -111,21 +125,21 @@ class BLEEvent { this->init_gap_data_(e, p); } - // Constructor for GATTC events - uses heap allocation - // IMPORTANT: The heap allocation is REQUIRED and must not be removed as an optimization. - // The param pointer from ESP-IDF is only valid during the callback execution. - // Since BLE events are processed asynchronously in the main loop, we must create - // our own copy to ensure the data remains valid until the event is processed. + // Constructor for GATTC events - param stored inline, data may use heap + // IMPORTANT: We MUST copy the param struct because the pointer from ESP-IDF + // is only valid during the callback execution. Since BLE events are processed + // asynchronously in the main loop, we store our own copy inline to ensure + // the data remains valid until the event is processed. BLEEvent(esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) { this->type_ = GATTC; this->init_gattc_data_(e, i, p); } - // Constructor for GATTS events - uses heap allocation - // IMPORTANT: The heap allocation is REQUIRED and must not be removed as an optimization. - // The param pointer from ESP-IDF is only valid during the callback execution. - // Since BLE events are processed asynchronously in the main loop, we must create - // our own copy to ensure the data remains valid until the event is processed. + // Constructor for GATTS events - param stored inline, data may use heap + // IMPORTANT: We MUST copy the param struct because the pointer from ESP-IDF + // is only valid during the callback execution. Since BLE events are processed + // asynchronously in the main loop, we store our own copy inline to ensure + // the data remains valid until the event is processed. BLEEvent(esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) { this->type_ = GATTS; this->init_gatts_data_(e, i, p); @@ -135,27 +149,32 @@ class BLEEvent { ~BLEEvent() { this->release(); } // Default constructor for pre-allocation in pool - BLEEvent() : type_(GAP) {} + BLEEvent() : event_{}, type_(GAP) {} // Invoked on return to EventPool - clean up any heap-allocated data void release() { - if (this->type_ == GAP) { - return; - } - if (this->type_ == GATTC) { - delete this->event_.gattc.gattc_param; - delete[] this->event_.gattc.data; - this->event_.gattc.gattc_param = nullptr; - this->event_.gattc.data = nullptr; - this->event_.gattc.data_len = 0; - return; - } - if (this->type_ == GATTS) { - delete this->event_.gatts.gatts_param; - delete[] this->event_.gatts.data; - this->event_.gatts.gatts_param = nullptr; - this->event_.gatts.data = nullptr; - this->event_.gatts.data_len = 0; + switch (this->type_) { + case GAP: + // GAP events don't have heap allocations + break; + case GATTC: + // Param is now stored inline, only delete heap data if it was heap-allocated + if (!this->event_.gattc.is_inline && this->event_.gattc.data.heap_data != nullptr) { + delete[] this->event_.gattc.data.heap_data; + } + // Clear critical fields to prevent issues if type changes + this->event_.gattc.is_inline = false; + this->event_.gattc.data.heap_data = nullptr; + break; + case GATTS: + // Param is now stored inline, only delete heap data if it was heap-allocated + if (!this->event_.gatts.is_inline && this->event_.gatts.data.heap_data != nullptr) { + delete[] this->event_.gatts.data.heap_data; + } + // Clear critical fields to prevent issues if type changes + this->event_.gatts.is_inline = false; + this->event_.gatts.data.heap_data = nullptr; + break; } } @@ -207,22 +226,30 @@ class BLEEvent { // NOLINTNEXTLINE(readability-identifier-naming) struct gattc_event { - esp_gattc_cb_event_t gattc_event; - esp_gatt_if_t gattc_if; - esp_ble_gattc_cb_param_t *gattc_param; // Heap-allocated - uint8_t *data; // Heap-allocated raw buffer (manually managed) - uint16_t data_len; // Track size separately - } gattc; + esp_ble_gattc_cb_param_t gattc_param; // Stored inline (28 bytes) + esp_gattc_cb_event_t gattc_event; // 4 bytes + union { + uint8_t *heap_data; // 4 bytes when heap-allocated + uint8_t inline_data[GATTC_INLINE_DATA_SIZE]; // 44 bytes when stored inline + } data; // 44 bytes total + uint16_t data_len; // 2 bytes + esp_gatt_if_t gattc_if; // 1 byte + bool is_inline; // 1 byte - true when data is stored inline + } gattc; // Total: 80 bytes // NOLINTNEXTLINE(readability-identifier-naming) struct gatts_event { - esp_gatts_cb_event_t gatts_event; - esp_gatt_if_t gatts_if; - esp_ble_gatts_cb_param_t *gatts_param; // Heap-allocated - uint8_t *data; // Heap-allocated raw buffer (manually managed) - uint16_t data_len; // Track size separately - } gatts; - } event_; // 80 bytes + esp_ble_gatts_cb_param_t gatts_param; // Stored inline (32 bytes) + esp_gatts_cb_event_t gatts_event; // 4 bytes + union { + uint8_t *heap_data; // 4 bytes when heap-allocated + uint8_t inline_data[GATTS_INLINE_DATA_SIZE]; // 40 bytes when stored inline + } data; // 40 bytes total + uint16_t data_len; // 2 bytes + esp_gatt_if_t gatts_if; // 1 byte + bool is_inline; // 1 byte - true when data is stored inline + } gatts; // Total: 80 bytes + } event_; // 80 bytes ble_event_t type_; @@ -236,6 +263,29 @@ class BLEEvent { const esp_ble_sec_t &security() const { return event_.gap.security; } private: + // Helper to copy data with inline storage optimization + template + void copy_data_with_inline_storage_(EventStruct &event, const uint8_t *src_data, uint16_t len, + uint8_t **param_value_ptr) { + event.data_len = len; + if (len > 0) { + if (len <= InlineSize) { + event.is_inline = true; + memcpy(event.data.inline_data, src_data, len); + *param_value_ptr = event.data.inline_data; + } else { + event.is_inline = false; + event.data.heap_data = new uint8_t[len]; + memcpy(event.data.heap_data, src_data, len); + *param_value_ptr = event.data.heap_data; + } + } else { + event.is_inline = false; + event.data.heap_data = nullptr; + *param_value_ptr = nullptr; + } + } + // Initialize GAP event data void init_gap_data_(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) { this->event_.gap.gap_event = e; @@ -320,48 +370,37 @@ class BLEEvent { this->event_.gattc.gattc_if = i; if (p == nullptr) { - this->event_.gattc.gattc_param = nullptr; - this->event_.gattc.data = nullptr; + // Zero out the param struct when null + memset(&this->event_.gattc.gattc_param, 0, sizeof(this->event_.gattc.gattc_param)); + this->event_.gattc.is_inline = false; + this->event_.gattc.data.heap_data = nullptr; this->event_.gattc.data_len = 0; return; // Invalid event, but we can't log in header file } - // Heap-allocate param and data - // Heap allocation is used because GATTC/GATTS events are rare (<1% of events) - // while GAP events (99%) are stored inline to minimize memory usage - // IMPORTANT: This heap allocation provides clear ownership semantics: - // - The BLEEvent owns the allocated memory for its lifetime - // - The data remains valid from the BLE callback context until processed in the main loop - // - Without this copy, we'd have use-after-free bugs as ESP-IDF reuses the callback memory - this->event_.gattc.gattc_param = new esp_ble_gattc_cb_param_t(*p); + // Copy param struct inline (no heap allocation!) + // GATTC/GATTS events are rare (<1% of events) but we can still store them inline + // along with small data payloads, eliminating all heap allocations for typical BLE operations + // CRITICAL: This copy is REQUIRED for memory safety - the ESP-IDF param pointer + // is only valid during the callback and will be reused/freed after we return + this->event_.gattc.gattc_param = *p; // Copy data for events that need it // The param struct contains pointers (e.g., notify.value) that point to temporary buffers. // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTC_NOTIFY_EVT: - this->event_.gattc.data_len = p->notify.value_len; - if (p->notify.value_len > 0) { - this->event_.gattc.data = new uint8_t[p->notify.value_len]; - memcpy(this->event_.gattc.data, p->notify.value, p->notify.value_len); - } else { - this->event_.gattc.data = nullptr; - } - this->event_.gattc.gattc_param->notify.value = this->event_.gattc.data; + copy_data_with_inline_storage_event_.gattc), GATTC_INLINE_DATA_SIZE>( + this->event_.gattc, p->notify.value, p->notify.value_len, &this->event_.gattc.gattc_param.notify.value); break; case ESP_GATTC_READ_CHAR_EVT: case ESP_GATTC_READ_DESCR_EVT: - this->event_.gattc.data_len = p->read.value_len; - if (p->read.value_len > 0) { - this->event_.gattc.data = new uint8_t[p->read.value_len]; - memcpy(this->event_.gattc.data, p->read.value, p->read.value_len); - } else { - this->event_.gattc.data = nullptr; - } - this->event_.gattc.gattc_param->read.value = this->event_.gattc.data; + copy_data_with_inline_storage_event_.gattc), GATTC_INLINE_DATA_SIZE>( + this->event_.gattc, p->read.value, p->read.value_len, &this->event_.gattc.gattc_param.read.value); break; default: - this->event_.gattc.data = nullptr; + this->event_.gattc.is_inline = false; + this->event_.gattc.data.heap_data = nullptr; this->event_.gattc.data_len = 0; break; } @@ -373,37 +412,32 @@ class BLEEvent { this->event_.gatts.gatts_if = i; if (p == nullptr) { - this->event_.gatts.gatts_param = nullptr; - this->event_.gatts.data = nullptr; + // Zero out the param struct when null + memset(&this->event_.gatts.gatts_param, 0, sizeof(this->event_.gatts.gatts_param)); + this->event_.gatts.is_inline = false; + this->event_.gatts.data.heap_data = nullptr; this->event_.gatts.data_len = 0; return; // Invalid event, but we can't log in header file } - // Heap-allocate param and data - // Heap allocation is used because GATTC/GATTS events are rare (<1% of events) - // while GAP events (99%) are stored inline to minimize memory usage - // IMPORTANT: This heap allocation provides clear ownership semantics: - // - The BLEEvent owns the allocated memory for its lifetime - // - The data remains valid from the BLE callback context until processed in the main loop - // - Without this copy, we'd have use-after-free bugs as ESP-IDF reuses the callback memory - this->event_.gatts.gatts_param = new esp_ble_gatts_cb_param_t(*p); + // Copy param struct inline (no heap allocation!) + // GATTC/GATTS events are rare (<1% of events) but we can still store them inline + // along with small data payloads, eliminating all heap allocations for typical BLE operations + // CRITICAL: This copy is REQUIRED for memory safety - the ESP-IDF param pointer + // is only valid during the callback and will be reused/freed after we return + this->event_.gatts.gatts_param = *p; // Copy data for events that need it // The param struct contains pointers (e.g., write.value) that point to temporary buffers. // We must copy this data to ensure it remains valid when the event is processed later. switch (e) { case ESP_GATTS_WRITE_EVT: - this->event_.gatts.data_len = p->write.len; - if (p->write.len > 0) { - this->event_.gatts.data = new uint8_t[p->write.len]; - memcpy(this->event_.gatts.data, p->write.value, p->write.len); - } else { - this->event_.gatts.data = nullptr; - } - this->event_.gatts.gatts_param->write.value = this->event_.gatts.data; + copy_data_with_inline_storage_event_.gatts), GATTS_INLINE_DATA_SIZE>( + this->event_.gatts, p->write.value, p->write.len, &this->event_.gatts.gatts_param.write.value); break; default: - this->event_.gatts.data = nullptr; + this->event_.gatts.is_inline = false; + this->event_.gatts.data.heap_data = nullptr; this->event_.gatts.data_len = 0; break; } @@ -414,6 +448,15 @@ class BLEEvent { // The gap member in the union should be 80 bytes (including the gap_event enum) static_assert(sizeof(decltype(((BLEEvent *) nullptr)->event_.gap)) <= 80, "gap_event struct has grown beyond 80 bytes"); +// Verify GATTC and GATTS structs don't exceed GAP struct size +// This ensures the union size is determined by GAP (the most common event type) +static_assert(sizeof(decltype(((BLEEvent *) nullptr)->event_.gattc)) <= + sizeof(decltype(((BLEEvent *) nullptr)->event_.gap)), + "gattc_event struct exceeds gap_event size - union size would increase"); +static_assert(sizeof(decltype(((BLEEvent *) nullptr)->event_.gatts)) <= + sizeof(decltype(((BLEEvent *) nullptr)->event_.gap)), + "gatts_event struct exceeds gap_event size - union size would increase"); + // Verify esp_ble_sec_t fits within our union static_assert(sizeof(esp_ble_sec_t) <= 73, "esp_ble_sec_t is larger than BLEScanResult"); From 99f57ecb7398ee3e4cd384e405452075f65c3bd5 Mon Sep 17 00:00:00 2001 From: Ben Winslow Date: Sun, 17 Aug 2025 22:09:42 -0400 Subject: [PATCH 0618/1626] [senseair] Discard 0 ppm readings with "Out Of Range" bit set. (#10275) --- esphome/components/senseair/senseair.cpp | 10 +++++++--- esphome/components/senseair/senseair.h | 11 +++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index e58ee157f7..84520d407d 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -53,10 +53,14 @@ void SenseAirComponent::update() { this->status_clear_warning(); const uint8_t length = response[2]; - const uint16_t status = (uint16_t(response[3]) << 8) | response[4]; - const int16_t ppm = int16_t((response[length + 1] << 8) | response[length + 2]); + const uint16_t status = encode_uint16(response[3], response[4]); + const uint16_t ppm = encode_uint16(response[length + 1], response[length + 2]); - ESP_LOGD(TAG, "SenseAir Received CO₂=%dppm Status=0x%02X", ppm, status); + ESP_LOGD(TAG, "SenseAir Received CO₂=%uppm Status=0x%02X", ppm, status); + if (ppm == 0 && (status & SenseAirStatus::OUT_OF_RANGE_ERROR) != 0) { + ESP_LOGD(TAG, "Discarding 0 ppm reading with out-of-range status."); + return; + } if (this->co2_sensor_ != nullptr) this->co2_sensor_->publish_state(ppm); } diff --git a/esphome/components/senseair/senseair.h b/esphome/components/senseair/senseair.h index 9f939d5b07..5b66860f1a 100644 --- a/esphome/components/senseair/senseair.h +++ b/esphome/components/senseair/senseair.h @@ -8,6 +8,17 @@ namespace esphome { namespace senseair { +enum SenseAirStatus : uint8_t { + FATAL_ERROR = 1 << 0, + OFFSET_ERROR = 1 << 1, + ALGORITHM_ERROR = 1 << 2, + OUTPUT_ERROR = 1 << 3, + SELF_DIAGNOSTIC_ERROR = 1 << 4, + OUT_OF_RANGE_ERROR = 1 << 5, + MEMORY_ERROR = 1 << 6, + RESERVED = 1 << 7 +}; + class SenseAirComponent : public PollingComponent, public uart::UARTDevice { public: void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; } From c5858b7032d27ffba056a479eb391128c21983db Mon Sep 17 00:00:00 2001 From: Ben Winslow Date: Sun, 17 Aug 2025 22:36:35 -0400 Subject: [PATCH 0619/1626] [core] Fix post-OTA logs display when using esphome run and MQTT (#10274) --- esphome/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 7cc8296e7e..8e8fc7d5d9 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -476,7 +476,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int from esphome.components.api.client import run_logs return run_logs(config, addresses_to_use) - if get_port_type(port) == "MQTT" and "mqtt" in config: + if get_port_type(port) in ("NETWORK", "MQTT") and "mqtt" in config: from esphome import mqtt return mqtt.show_logs( From bb894c3e3200b1b51187fc37ceae6d2645668d81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 11:14:41 -0400 Subject: [PATCH 0620/1626] [core] Fix scheduler race condition where cancelled items still execute (#10268) --- esphome/core/scheduler.cpp | 37 ++++- esphome/core/scheduler.h | 55 ++++++- .../fixtures/scheduler_removed_item_race.yaml | 139 ++++++++++++++++++ .../test_scheduler_removed_item_race.py | 102 +++++++++++++ 4 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_removed_item_race.yaml create mode 100644 tests/integration/test_scheduler_removed_item_race.py diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 6269a66543..c3ade260ac 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -82,7 +82,13 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type item->set_name(name_cstr, !is_static_string); item->type = type; item->callback = std::move(func); + // Initialize remove to false (though it should already be from constructor) + // Not using mark_item_removed_ helper since we're setting to false, not true +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + item->remove.store(false, std::memory_order_relaxed); +#else item->remove = false; +#endif item->is_retry = is_retry; #ifndef ESPHOME_THREAD_SINGLE @@ -398,6 +404,31 @@ void HOT Scheduler::call(uint32_t now) { this->pop_raw_(); continue; } + + // Check if item is marked for removal + // This handles two cases: + // 1. Item was marked for removal after cleanup_() but before we got here + // 2. Item is marked for removal but wasn't at the front of the heap during cleanup_() +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + // Multi-threaded platforms without atomics: must take lock to safely read remove flag + { + LockGuard guard{this->lock_}; + if (is_item_removed_(item.get())) { + this->pop_raw_(); + this->to_remove_--; + continue; + } + } +#else + // Single-threaded or multi-threaded with atomics: can check without lock + if (is_item_removed_(item.get())) { + LockGuard guard{this->lock_}; + this->pop_raw_(); + this->to_remove_--; + continue; + } +#endif + #ifdef ESPHOME_DEBUG_SCHEDULER const char *item_name = item->get_name(); ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")", @@ -518,7 +549,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c if (type == SchedulerItem::TIMEOUT) { for (auto &item : this->defer_queue_) { if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - item->remove = true; + this->mark_item_removed_(item.get()); total_cancelled++; } } @@ -528,7 +559,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Cancel items in the main heap for (auto &item : this->items_) { if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - item->remove = true; + this->mark_item_removed_(item.get()); total_cancelled++; this->to_remove_++; // Track removals for heap items } @@ -537,7 +568,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Cancel items in to_add_ for (auto &item : this->to_add_) { if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - item->remove = true; + this->mark_item_removed_(item.get()); total_cancelled++; // Don't track removals for to_add_ items } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index a6092e1b1e..c73bd55d5d 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -97,22 +97,42 @@ class Scheduler { std::function callback; - // Bit-packed fields to minimize padding +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Multi-threaded with atomics: use atomic for lock-free access + // Place atomic separately since it can't be packed with bit fields + std::atomic remove{false}; + + // Bit-packed fields (3 bits used, 5 bits padding in 1 byte) + enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; + bool name_is_dynamic : 1; // True if name was dynamically allocated (needs delete[]) + bool is_retry : 1; // True if this is a retry timeout + // 5 bits padding +#else + // Single-threaded or multi-threaded without atomics: can pack all fields together + // Bit-packed fields (4 bits used, 4 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; bool remove : 1; bool name_is_dynamic : 1; // True if name was dynamically allocated (needs delete[]) bool is_retry : 1; // True if this is a retry timeout - // 4 bits padding + // 4 bits padding +#endif // Constructor SchedulerItem() : component(nullptr), interval(0), next_execution_(0), +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // remove is initialized in the member declaration as std::atomic{false} + type(TIMEOUT), + name_is_dynamic(false), + is_retry(false) { +#else type(TIMEOUT), remove(false), name_is_dynamic(false), is_retry(false) { +#endif name_.static_name = nullptr; } @@ -219,6 +239,37 @@ class Scheduler { return item->remove || (item->component != nullptr && item->component->is_failed()); } + // Helper to check if item is marked for removal (platform-specific) + // Returns true if item should be skipped, handles platform-specific synchronization + // For ESPHOME_THREAD_MULTI_NO_ATOMICS platforms, the caller must hold the scheduler lock before calling this + // function. + bool is_item_removed_(SchedulerItem *item) const { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Multi-threaded with atomics: use atomic load for lock-free access + return item->remove.load(std::memory_order_acquire); +#else + // Single-threaded (ESPHOME_THREAD_SINGLE) or + // multi-threaded without atomics (ESPHOME_THREAD_MULTI_NO_ATOMICS): direct read + // For ESPHOME_THREAD_MULTI_NO_ATOMICS, caller MUST hold lock! + return item->remove; +#endif + } + + // Helper to mark item for removal (platform-specific) + // For ESPHOME_THREAD_MULTI_NO_ATOMICS platforms, the caller must hold the scheduler lock before calling this + // function. + void mark_item_removed_(SchedulerItem *item) { +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + // Multi-threaded with atomics: use atomic store + item->remove.store(true, std::memory_order_release); +#else + // Single-threaded (ESPHOME_THREAD_SINGLE) or + // multi-threaded without atomics (ESPHOME_THREAD_MULTI_NO_ATOMICS): direct write + // For ESPHOME_THREAD_MULTI_NO_ATOMICS, caller MUST hold lock! + item->remove = true; +#endif + } + // Template helper to check if any item in a container matches our criteria template bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr, diff --git a/tests/integration/fixtures/scheduler_removed_item_race.yaml b/tests/integration/fixtures/scheduler_removed_item_race.yaml new file mode 100644 index 0000000000..2f8a7fb987 --- /dev/null +++ b/tests/integration/fixtures/scheduler_removed_item_race.yaml @@ -0,0 +1,139 @@ +esphome: + name: scheduler-removed-item-race + +host: + +api: + services: + - service: run_test + then: + - script.execute: run_test_script + +logger: + level: DEBUG + +globals: + - id: test_passed + type: bool + initial_value: 'true' + - id: removed_item_executed + type: int + initial_value: '0' + - id: normal_item_executed + type: int + initial_value: '0' + +sensor: + - platform: template + id: test_sensor + name: "Test Sensor" + update_interval: never + lambda: return 0.0; + +script: + - id: run_test_script + then: + - logger.log: "=== Starting Removed Item Race Test ===" + + # This test creates a scenario where: + # 1. First item in heap is NOT cancelled (cleanup stops immediately) + # 2. Items behind it ARE cancelled (remain in heap after cleanup) + # 3. All items execute at the same time, including cancelled ones + + - lambda: |- + // The key to hitting the race: + // 1. Add items in a specific order to control heap structure + // 2. Cancel ONLY items that won't be at the front + // 3. Ensure the first item stays non-cancelled so cleanup_() stops immediately + + // Schedule all items to execute at the SAME time (1ms from now) + // Using 1ms instead of 0 to avoid defer queue on multi-core platforms + // This ensures they'll all be ready together and go through the heap + const uint32_t exec_time = 1; + + // CRITICAL: Add a non-cancellable item FIRST + // This will be at the front of the heap and block cleanup_() + App.scheduler.set_timeout(id(test_sensor), "blocker", exec_time, []() { + ESP_LOGD("test", "Blocker timeout executed (expected) - was at front of heap"); + id(normal_item_executed)++; + }); + + // Now add items that we WILL cancel + // These will be behind the blocker in the heap + App.scheduler.set_timeout(id(test_sensor), "cancel_1", exec_time, []() { + ESP_LOGE("test", "RACE: Cancelled timeout 1 executed after being cancelled!"); + id(removed_item_executed)++; + id(test_passed) = false; + }); + + App.scheduler.set_timeout(id(test_sensor), "cancel_2", exec_time, []() { + ESP_LOGE("test", "RACE: Cancelled timeout 2 executed after being cancelled!"); + id(removed_item_executed)++; + id(test_passed) = false; + }); + + App.scheduler.set_timeout(id(test_sensor), "cancel_3", exec_time, []() { + ESP_LOGE("test", "RACE: Cancelled timeout 3 executed after being cancelled!"); + id(removed_item_executed)++; + id(test_passed) = false; + }); + + // Add some more normal items + App.scheduler.set_timeout(id(test_sensor), "normal_1", exec_time, []() { + ESP_LOGD("test", "Normal timeout 1 executed (expected)"); + id(normal_item_executed)++; + }); + + App.scheduler.set_timeout(id(test_sensor), "normal_2", exec_time, []() { + ESP_LOGD("test", "Normal timeout 2 executed (expected)"); + id(normal_item_executed)++; + }); + + App.scheduler.set_timeout(id(test_sensor), "normal_3", exec_time, []() { + ESP_LOGD("test", "Normal timeout 3 executed (expected)"); + id(normal_item_executed)++; + }); + + // Force items into the heap before cancelling + App.scheduler.process_to_add(); + + // NOW cancel the items - they're behind "blocker" in the heap + // When cleanup_() runs, it will see "blocker" (not removed) at the front + // and stop immediately, leaving cancel_1, cancel_2, cancel_3 in the heap + bool c1 = App.scheduler.cancel_timeout(id(test_sensor), "cancel_1"); + bool c2 = App.scheduler.cancel_timeout(id(test_sensor), "cancel_2"); + bool c3 = App.scheduler.cancel_timeout(id(test_sensor), "cancel_3"); + + ESP_LOGD("test", "Cancelled items (behind blocker): %s, %s, %s", + c1 ? "true" : "false", + c2 ? "true" : "false", + c3 ? "true" : "false"); + + // The heap now has: + // - "blocker" at front (not cancelled) + // - cancelled items behind it (marked remove=true but still in heap) + // - When all execute at once, cleanup_() stops at "blocker" + // - The loop then executes ALL ready items including cancelled ones + + ESP_LOGD("test", "Setup complete. Blocker at front prevents cleanup of cancelled items behind it"); + + # Wait for all timeouts to execute (or not) + - delay: 20ms + + # Check results + - lambda: |- + ESP_LOGI("test", "=== Test Results ==="); + ESP_LOGI("test", "Normal items executed: %d (expected 4)", id(normal_item_executed)); + ESP_LOGI("test", "Removed items executed: %d (expected 0)", id(removed_item_executed)); + + if (id(removed_item_executed) > 0) { + ESP_LOGE("test", "TEST FAILED: %d cancelled items were executed!", id(removed_item_executed)); + id(test_passed) = false; + } else if (id(normal_item_executed) != 4) { + ESP_LOGE("test", "TEST FAILED: Expected 4 normal items, got %d", id(normal_item_executed)); + id(test_passed) = false; + } else { + ESP_LOGI("test", "TEST PASSED: No cancelled items were executed"); + } + + ESP_LOGI("test", "=== Test Complete ==="); diff --git a/tests/integration/test_scheduler_removed_item_race.py b/tests/integration/test_scheduler_removed_item_race.py new file mode 100644 index 0000000000..3e72bacc0d --- /dev/null +++ b/tests/integration/test_scheduler_removed_item_race.py @@ -0,0 +1,102 @@ +"""Test for scheduler race condition where removed items still execute.""" + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_removed_item_race( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that items marked for removal don't execute. + + This test verifies the fix for a race condition where: + 1. cleanup_() only removes items from the front of the heap + 2. Items in the middle of the heap marked for removal still execute + 3. This causes cancelled timeouts to run when they shouldn't + """ + + loop = asyncio.get_running_loop() + test_complete_future: asyncio.Future[bool] = loop.create_future() + + # Track test results + test_passed = False + removed_executed = 0 + normal_executed = 0 + + # Patterns to match + race_pattern = re.compile(r"RACE: .* executed after being cancelled!") + passed_pattern = re.compile(r"TEST PASSED") + failed_pattern = re.compile(r"TEST FAILED") + complete_pattern = re.compile(r"=== Test Complete ===") + normal_count_pattern = re.compile(r"Normal items executed: (\d+)") + removed_count_pattern = re.compile(r"Removed items executed: (\d+)") + + def check_output(line: str) -> None: + """Check log output for test results.""" + nonlocal test_passed, removed_executed, normal_executed + + if race_pattern.search(line): + # Race condition detected - a cancelled item executed + test_passed = False + + if passed_pattern.search(line): + test_passed = True + elif failed_pattern.search(line): + test_passed = False + + normal_match = normal_count_pattern.search(line) + if normal_match: + normal_executed = int(normal_match.group(1)) + + removed_match = removed_count_pattern.search(line) + if removed_match: + removed_executed = int(removed_match.group(1)) + + if not test_complete_future.done() and complete_pattern.search(line): + test_complete_future.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify we can connect + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-removed-item-race" + + # List services + _, services = await asyncio.wait_for( + client.list_entities_services(), timeout=5.0 + ) + + # Find run_test service + run_test_service = next((s for s in services if s.name == "run_test"), None) + assert run_test_service is not None, "run_test service not found" + + # Execute the test + client.execute_service(run_test_service, {}) + + # Wait for test completion + try: + await asyncio.wait_for(test_complete_future, timeout=5.0) + except TimeoutError: + pytest.fail("Test did not complete within timeout") + + # Verify results + assert test_passed, ( + f"Test failed! Removed items executed: {removed_executed}, " + f"Normal items executed: {normal_executed}" + ) + assert removed_executed == 0, ( + f"Cancelled items should not execute, but {removed_executed} did" + ) + assert normal_executed == 4, ( + f"Expected 4 normal items to execute, got {normal_executed}" + ) From 6502ed70de4991290bca08ed0accaeeb36191280 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:17:34 +1200 Subject: [PATCH 0621/1626] [esp32] Write variant to sdkconfig file (#10267) --- esphome/components/esp32/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index c219b8851a..ac236f4eb3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -824,8 +824,9 @@ async def to_code(config): cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) - cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{config[CONF_VARIANT]}") - cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[config[CONF_VARIANT]]) + variant = config[CONF_VARIANT] + cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") + cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant]) cg.add_define(ThreadModel.MULTI_ATOMICS) cg.add_platformio_option("lib_ldf_mode", "off") @@ -859,6 +860,7 @@ async def to_code(config): cg.add_platformio_option( "platform_packages", ["espressif/toolchain-esp32ulp@2.35.0-20220830"] ) + add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True) add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True ) From 064385eac6e398680c0b5c616289d9042e181a3a Mon Sep 17 00:00:00 2001 From: Ben Winslow Date: Mon, 18 Aug 2025 20:20:13 -0400 Subject: [PATCH 0622/1626] [nextion] Don't include terminating NUL in nextion text_sensor states (#10273) --- esphome/components/nextion/nextion.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 133bd2947c..b348bc9920 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -764,7 +764,8 @@ void Nextion::process_nextion_commands_() { variable_name = to_process.substr(0, index); ++index; - text_value = to_process.substr(index); + // Get variable value without terminating NUL byte. Length check above ensures substr len >= 0. + text_value = to_process.substr(index, to_process_length - index - 1); ESP_LOGN(TAG, "Text sensor: %s='%s'", variable_name.c_str(), text_value.c_str()); From 23554cda06feed2fa4170a2038ba579d89bf4f9e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 19 Aug 2025 13:09:22 +1200 Subject: [PATCH 0623/1626] Bump version to 2025.8.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index e2d892eb44..dc785e3a8f 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.8.0b2 +PROJECT_NUMBER = 2025.8.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 3b5365854d..bf0f2eaf8a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.8.0b2" +__version__ = "2025.8.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 70eb45b5d318e10391e008a01f43923dbbd75de2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Aug 2025 21:06:24 -0500 Subject: [PATCH 0624/1626] lint --- esphome/components/ntc/ntc.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ntc/ntc.cpp b/esphome/components/ntc/ntc.cpp index 333dbc5a75..b08f84029b 100644 --- a/esphome/components/ntc/ntc.cpp +++ b/esphome/components/ntc/ntc.cpp @@ -11,7 +11,7 @@ void NTC::setup() { if (this->sensor_->has_state()) this->process_(this->sensor_->state); } -void NTC::dump_config() { LOG_SENSOR("", "NTC Sensor", this) } +void NTC::dump_config() { LOG_SENSOR("", "NTC Sensor", this); } float NTC::get_setup_priority() const { return setup_priority::DATA; } void NTC::process_(float value) { if (std::isnan(value)) { From 8f118232e43e5b1d03cbd7fc0c5a363bd260d162 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:35:48 +1200 Subject: [PATCH 0625/1626] [CI] Rename and expand needs-docs workflow (#10299) --- .github/workflows/needs-docs.yml | 24 ------------------ .github/workflows/status-check-labels.yml | 30 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 24 deletions(-) delete mode 100644 .github/workflows/needs-docs.yml create mode 100644 .github/workflows/status-check-labels.yml diff --git a/.github/workflows/needs-docs.yml b/.github/workflows/needs-docs.yml deleted file mode 100644 index 628b5cc5e3..0000000000 --- a/.github/workflows/needs-docs.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Needs Docs - -on: - pull_request: - types: [labeled, unlabeled] - -jobs: - check: - name: Check - runs-on: ubuntu-latest - steps: - - name: Check for needs-docs label - uses: actions/github-script@v7.0.1 - with: - script: | - const { data: labels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number - }); - const needsDocs = labels.find(label => label.name === 'needs-docs'); - if (needsDocs) { - core.setFailed('Pull request needs docs'); - } diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml new file mode 100644 index 0000000000..157f60f3a1 --- /dev/null +++ b/.github/workflows/status-check-labels.yml @@ -0,0 +1,30 @@ +name: Status check labels + +on: + pull_request: + types: [labeled, unlabeled] + +jobs: + check: + name: Check ${{ matrix.label }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + label: + - needs-docs + - merge-after-release + steps: + - name: Check for ${{ matrix.label }} label + uses: actions/github-script@v7.0.1 + with: + script: | + const { data: labels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + const hasLabel = labels.find(label => label.name === '${{ matrix.label }}'); + if (hasLabel) { + core.setFailed('Pull request cannot be merged, it is labeled as ${{ matrix.label }}'); + } From 7e23d865e6c4dfe857ba1f8cbe1ec7c9f9dcf61e Mon Sep 17 00:00:00 2001 From: Ben Winslow Date: Mon, 18 Aug 2025 23:45:30 -0400 Subject: [PATCH 0626/1626] [atm90e32] Only read 1 register per SPI transaction per datasheet. (#10258) --- esphome/components/atm90e32/atm90e32.cpp | 21 +++++---------------- esphome/components/atm90e32/atm90e32.h | 1 - 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index d5fee6ea04..634260b5e9 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -382,20 +382,15 @@ float ATM90E32Component::get_setup_priority() const { return setup_priority::IO; // R/C registers can conly be cleared after the LastSPIData register is updated (register 78H) // Peakdetect period: 05H. Bit 15:8 are PeakDet_period in ms. 7:0 are Sag_period // Default is 143FH (20ms, 63ms) -uint16_t ATM90E32Component::read16_transaction_(uint16_t a_register) { +uint16_t ATM90E32Component::read16_(uint16_t a_register) { + this->enable(); + delay_microseconds_safe(1); // min delay between CS low and first SCK is 200ns - 1us is plenty uint8_t addrh = (1 << 7) | ((a_register >> 8) & 0x03); uint8_t addrl = (a_register & 0xFF); uint8_t data[4] = {addrh, addrl, 0x00, 0x00}; this->transfer_array(data, 4); uint16_t output = encode_uint16(data[2], data[3]); ESP_LOGVV(TAG, "read16_ 0x%04" PRIX16 " output 0x%04" PRIX16, a_register, output); - return output; -} - -uint16_t ATM90E32Component::read16_(uint16_t a_register) { - this->enable(); - delay_microseconds_safe(1); // min delay between CS low and first SCK is 200ns - 1us is plenty - uint16_t output = this->read16_transaction_(a_register); delay_microseconds_safe(1); // allow the last clock to propagate before releasing CS this->disable(); delay_microseconds_safe(1); // meet minimum CS high time before next transaction @@ -403,14 +398,8 @@ uint16_t ATM90E32Component::read16_(uint16_t a_register) { } int ATM90E32Component::read32_(uint16_t addr_h, uint16_t addr_l) { - this->enable(); - delay_microseconds_safe(1); - const uint16_t val_h = this->read16_transaction_(addr_h); - delay_microseconds_safe(1); - const uint16_t val_l = this->read16_transaction_(addr_l); - delay_microseconds_safe(1); - this->disable(); - delay_microseconds_safe(1); + const uint16_t val_h = this->read16_(addr_h); + const uint16_t val_l = this->read16_(addr_l); const int32_t val = (val_h << 16) | val_l; ESP_LOGVV(TAG, diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index afbd9cf941..938ce512ce 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -140,7 +140,6 @@ class ATM90E32Component : public PollingComponent, number::Number *ref_currents_[3]{nullptr, nullptr, nullptr}; #endif uint16_t read16_(uint16_t a_register); - uint16_t read16_transaction_(uint16_t a_register); int read32_(uint16_t addr_h, uint16_t addr_l); void write16_(uint16_t a_register, uint16_t val, bool validate = true); float get_local_phase_voltage_(uint8_t phase); From 4dab9c44003d5d9afc51be347ab5456f80752300 Mon Sep 17 00:00:00 2001 From: Patrick Date: Tue, 19 Aug 2025 05:52:01 +0200 Subject: [PATCH 0627/1626] [pipsolar] fix faults_present, fix update interval (#10289) --- esphome/components/pipsolar/pipsolar.cpp | 96 +++++++++++++----------- esphome/components/pipsolar/pipsolar.h | 9 ++- 2 files changed, 59 insertions(+), 46 deletions(-) diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index 40405114a4..5751ad59f5 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -23,20 +23,18 @@ void Pipsolar::loop() { // Read message if (this->state_ == STATE_IDLE) { this->empty_uart_buffer_(); - switch (this->send_next_command_()) { - case 0: - // no command send (empty queue) time to poll - if (millis() - this->last_poll_ > this->update_interval_) { - this->send_next_poll_(); - this->last_poll_ = millis(); - } - return; - break; - case 1: - // command send - return; - break; + + if (this->send_next_command_()) { + // command sent + return; } + + if (this->send_next_poll_()) { + // poll sent + return; + } + + return; } if (this->state_ == STATE_COMMAND_COMPLETE) { if (this->check_incoming_length_(4)) { @@ -530,7 +528,7 @@ void Pipsolar::loop() { // '(00000000000000000000000000000000' // iterate over all available flag (as not all models have all flags, but at least in the same order) this->value_warnings_present_ = false; - this->value_faults_present_ = true; + this->value_faults_present_ = false; for (size_t i = 1; i < strlen(tmp); i++) { enabled = tmp[i] == '1'; @@ -708,6 +706,7 @@ void Pipsolar::loop() { return; } // crc ok + this->used_polling_commands_[this->last_polling_command_].needs_update = false; this->state_ = STATE_POLL_CHECKED; return; } else { @@ -788,7 +787,7 @@ uint8_t Pipsolar::check_incoming_crc_() { } // send next command used -uint8_t Pipsolar::send_next_command_() { +bool Pipsolar::send_next_command_() { uint16_t crc16; if (!this->command_queue_[this->command_queue_position_].empty()) { const char *command = this->command_queue_[this->command_queue_position_].c_str(); @@ -809,37 +808,43 @@ uint8_t Pipsolar::send_next_command_() { // end Byte this->write(0x0D); ESP_LOGD(TAG, "Sending command from queue: %s with length %d", command, length); - return 1; + return true; } - return 0; + return false; } -void Pipsolar::send_next_poll_() { +bool Pipsolar::send_next_poll_() { uint16_t crc16; - this->last_polling_command_ = (this->last_polling_command_ + 1) % 15; - if (this->used_polling_commands_[this->last_polling_command_].length == 0) { - this->last_polling_command_ = 0; + + for (uint8_t i = 0; i < POLLING_COMMANDS_MAX; i++) { + this->last_polling_command_ = (this->last_polling_command_ + 1) % POLLING_COMMANDS_MAX; + if (this->used_polling_commands_[this->last_polling_command_].length == 0) { + // not enabled + continue; + } + if (!this->used_polling_commands_[this->last_polling_command_].needs_update) { + // no update requested + continue; + } + this->state_ = STATE_POLL; + this->command_start_millis_ = millis(); + this->empty_uart_buffer_(); + this->read_pos_ = 0; + crc16 = this->pipsolar_crc_(this->used_polling_commands_[this->last_polling_command_].command, + this->used_polling_commands_[this->last_polling_command_].length); + this->write_array(this->used_polling_commands_[this->last_polling_command_].command, + this->used_polling_commands_[this->last_polling_command_].length); + // checksum + this->write(((uint8_t) ((crc16) >> 8))); // highbyte + this->write(((uint8_t) ((crc16) &0xff))); // lowbyte + // end Byte + this->write(0x0D); + ESP_LOGD(TAG, "Sending polling command : %s with length %d", + this->used_polling_commands_[this->last_polling_command_].command, + this->used_polling_commands_[this->last_polling_command_].length); + return true; } - if (this->used_polling_commands_[this->last_polling_command_].length == 0) { - // no command specified - return; - } - this->state_ = STATE_POLL; - this->command_start_millis_ = millis(); - this->empty_uart_buffer_(); - this->read_pos_ = 0; - crc16 = this->pipsolar_crc_(this->used_polling_commands_[this->last_polling_command_].command, - this->used_polling_commands_[this->last_polling_command_].length); - this->write_array(this->used_polling_commands_[this->last_polling_command_].command, - this->used_polling_commands_[this->last_polling_command_].length); - // checksum - this->write(((uint8_t) ((crc16) >> 8))); // highbyte - this->write(((uint8_t) ((crc16) &0xff))); // lowbyte - // end Byte - this->write(0x0D); - ESP_LOGD(TAG, "Sending polling command : %s with length %d", - this->used_polling_commands_[this->last_polling_command_].command, - this->used_polling_commands_[this->last_polling_command_].length); + return false; } void Pipsolar::queue_command_(const char *command, uint8_t length) { @@ -869,7 +874,13 @@ void Pipsolar::dump_config() { } } } -void Pipsolar::update() {} +void Pipsolar::update() { + for (auto &used_polling_command : this->used_polling_commands_) { + if (used_polling_command.length != 0) { + used_polling_command.needs_update = true; + } + } +} void Pipsolar::add_polling_command_(const char *command, ENUMPollingCommand polling_command) { for (auto &used_polling_command : this->used_polling_commands_) { @@ -891,6 +902,7 @@ void Pipsolar::add_polling_command_(const char *command, ENUMPollingCommand poll used_polling_command.errors = 0; used_polling_command.identifier = polling_command; used_polling_command.length = length - 1; + used_polling_command.needs_update = true; return; } } diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 373911b2d7..77b18badb9 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -25,6 +25,7 @@ struct PollingCommand { uint8_t length = 0; uint8_t errors; ENUMPollingCommand identifier; + bool needs_update; }; #define PIPSOLAR_VALUED_ENTITY_(type, name, polling_command, value_type) \ @@ -189,14 +190,14 @@ class Pipsolar : public uart::UARTDevice, public PollingComponent { static const size_t PIPSOLAR_READ_BUFFER_LENGTH = 110; // maximum supported answer length static const size_t COMMAND_QUEUE_LENGTH = 10; static const size_t COMMAND_TIMEOUT = 5000; - uint32_t last_poll_ = 0; + static const size_t POLLING_COMMANDS_MAX = 15; void add_polling_command_(const char *command, ENUMPollingCommand polling_command); void empty_uart_buffer_(); uint8_t check_incoming_crc_(); uint8_t check_incoming_length_(uint8_t length); uint16_t pipsolar_crc_(uint8_t *msg, uint8_t len); - uint8_t send_next_command_(); - void send_next_poll_(); + bool send_next_command_(); + bool send_next_poll_(); void queue_command_(const char *command, uint8_t length); std::string command_queue_[COMMAND_QUEUE_LENGTH]; uint8_t command_queue_position_ = 0; @@ -216,7 +217,7 @@ class Pipsolar : public uart::UARTDevice, public PollingComponent { }; uint8_t last_polling_command_ = 0; - PollingCommand used_polling_commands_[15]; + PollingCommand used_polling_commands_[POLLING_COMMANDS_MAX]; }; } // namespace pipsolar From 0c86241aed167e9d733b5b07c6780c9b894a2513 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 09:40:21 -0500 Subject: [PATCH 0628/1626] [bluetooth_proxy] Fix connection slot race by deferring slot release until GATT close --- .../bluetooth_proxy/bluetooth_connection.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index d2cbdeb984..540492f8c5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -375,10 +375,19 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga switch (event) { case ESP_GATTC_DISCONNECT_EVT: { - this->reset_connection_(param->disconnect.reason); + // Don't reset connection yet - wait for CLOSE_EVT to ensure controller has freed resources + // This prevents race condition where we mark slot as free before controller cleanup is complete + ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_.c_str(), + param->disconnect.reason); + // Send disconnection notification but don't free the slot yet + this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); break; } case ESP_GATTC_CLOSE_EVT: { + ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_.c_str(), + param->close.reason); + // Now the GATT connection is fully closed and controller resources are freed + // Safe to mark the connection slot as available this->reset_connection_(param->close.reason); break; } From 2aaf951357d6f5c82e1b8d737043a0225671f6b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 14:27:22 -0500 Subject: [PATCH 0629/1626] [bluetooth_proxy] Fix connection slot race by deferring slot release until GATT close (#10303) --- .../bluetooth_proxy/bluetooth_connection.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index d2cbdeb984..540492f8c5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -375,10 +375,19 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga switch (event) { case ESP_GATTC_DISCONNECT_EVT: { - this->reset_connection_(param->disconnect.reason); + // Don't reset connection yet - wait for CLOSE_EVT to ensure controller has freed resources + // This prevents race condition where we mark slot as free before controller cleanup is complete + ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_.c_str(), + param->disconnect.reason); + // Send disconnection notification but don't free the slot yet + this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); break; } case ESP_GATTC_CLOSE_EVT: { + ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_.c_str(), + param->close.reason); + // Now the GATT connection is fully closed and controller resources are freed + // Safe to mark the connection slot as available this->reset_connection_(param->close.reason); break; } From 225de226b0c3e8a57a765d5acf2711ed08a7e16e Mon Sep 17 00:00:00 2001 From: Ben Winslow Date: Mon, 18 Aug 2025 23:45:30 -0400 Subject: [PATCH 0630/1626] [atm90e32] Only read 1 register per SPI transaction per datasheet. (#10258) --- esphome/components/atm90e32/atm90e32.cpp | 21 +++++---------------- esphome/components/atm90e32/atm90e32.h | 1 - 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index d5fee6ea04..634260b5e9 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -382,20 +382,15 @@ float ATM90E32Component::get_setup_priority() const { return setup_priority::IO; // R/C registers can conly be cleared after the LastSPIData register is updated (register 78H) // Peakdetect period: 05H. Bit 15:8 are PeakDet_period in ms. 7:0 are Sag_period // Default is 143FH (20ms, 63ms) -uint16_t ATM90E32Component::read16_transaction_(uint16_t a_register) { +uint16_t ATM90E32Component::read16_(uint16_t a_register) { + this->enable(); + delay_microseconds_safe(1); // min delay between CS low and first SCK is 200ns - 1us is plenty uint8_t addrh = (1 << 7) | ((a_register >> 8) & 0x03); uint8_t addrl = (a_register & 0xFF); uint8_t data[4] = {addrh, addrl, 0x00, 0x00}; this->transfer_array(data, 4); uint16_t output = encode_uint16(data[2], data[3]); ESP_LOGVV(TAG, "read16_ 0x%04" PRIX16 " output 0x%04" PRIX16, a_register, output); - return output; -} - -uint16_t ATM90E32Component::read16_(uint16_t a_register) { - this->enable(); - delay_microseconds_safe(1); // min delay between CS low and first SCK is 200ns - 1us is plenty - uint16_t output = this->read16_transaction_(a_register); delay_microseconds_safe(1); // allow the last clock to propagate before releasing CS this->disable(); delay_microseconds_safe(1); // meet minimum CS high time before next transaction @@ -403,14 +398,8 @@ uint16_t ATM90E32Component::read16_(uint16_t a_register) { } int ATM90E32Component::read32_(uint16_t addr_h, uint16_t addr_l) { - this->enable(); - delay_microseconds_safe(1); - const uint16_t val_h = this->read16_transaction_(addr_h); - delay_microseconds_safe(1); - const uint16_t val_l = this->read16_transaction_(addr_l); - delay_microseconds_safe(1); - this->disable(); - delay_microseconds_safe(1); + const uint16_t val_h = this->read16_(addr_h); + const uint16_t val_l = this->read16_(addr_l); const int32_t val = (val_h << 16) | val_l; ESP_LOGVV(TAG, diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index afbd9cf941..938ce512ce 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -140,7 +140,6 @@ class ATM90E32Component : public PollingComponent, number::Number *ref_currents_[3]{nullptr, nullptr, nullptr}; #endif uint16_t read16_(uint16_t a_register); - uint16_t read16_transaction_(uint16_t a_register); int read32_(uint16_t addr_h, uint16_t addr_l); void write16_(uint16_t a_register, uint16_t val, bool validate = true); float get_local_phase_voltage_(uint8_t phase); From 8356f7fcd3e4f1720a33a55ed143fb289ef1f46b Mon Sep 17 00:00:00 2001 From: Patrick Date: Tue, 19 Aug 2025 05:52:01 +0200 Subject: [PATCH 0631/1626] [pipsolar] fix faults_present, fix update interval (#10289) --- esphome/components/pipsolar/pipsolar.cpp | 96 +++++++++++++----------- esphome/components/pipsolar/pipsolar.h | 9 ++- 2 files changed, 59 insertions(+), 46 deletions(-) diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index 40405114a4..5751ad59f5 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -23,20 +23,18 @@ void Pipsolar::loop() { // Read message if (this->state_ == STATE_IDLE) { this->empty_uart_buffer_(); - switch (this->send_next_command_()) { - case 0: - // no command send (empty queue) time to poll - if (millis() - this->last_poll_ > this->update_interval_) { - this->send_next_poll_(); - this->last_poll_ = millis(); - } - return; - break; - case 1: - // command send - return; - break; + + if (this->send_next_command_()) { + // command sent + return; } + + if (this->send_next_poll_()) { + // poll sent + return; + } + + return; } if (this->state_ == STATE_COMMAND_COMPLETE) { if (this->check_incoming_length_(4)) { @@ -530,7 +528,7 @@ void Pipsolar::loop() { // '(00000000000000000000000000000000' // iterate over all available flag (as not all models have all flags, but at least in the same order) this->value_warnings_present_ = false; - this->value_faults_present_ = true; + this->value_faults_present_ = false; for (size_t i = 1; i < strlen(tmp); i++) { enabled = tmp[i] == '1'; @@ -708,6 +706,7 @@ void Pipsolar::loop() { return; } // crc ok + this->used_polling_commands_[this->last_polling_command_].needs_update = false; this->state_ = STATE_POLL_CHECKED; return; } else { @@ -788,7 +787,7 @@ uint8_t Pipsolar::check_incoming_crc_() { } // send next command used -uint8_t Pipsolar::send_next_command_() { +bool Pipsolar::send_next_command_() { uint16_t crc16; if (!this->command_queue_[this->command_queue_position_].empty()) { const char *command = this->command_queue_[this->command_queue_position_].c_str(); @@ -809,37 +808,43 @@ uint8_t Pipsolar::send_next_command_() { // end Byte this->write(0x0D); ESP_LOGD(TAG, "Sending command from queue: %s with length %d", command, length); - return 1; + return true; } - return 0; + return false; } -void Pipsolar::send_next_poll_() { +bool Pipsolar::send_next_poll_() { uint16_t crc16; - this->last_polling_command_ = (this->last_polling_command_ + 1) % 15; - if (this->used_polling_commands_[this->last_polling_command_].length == 0) { - this->last_polling_command_ = 0; + + for (uint8_t i = 0; i < POLLING_COMMANDS_MAX; i++) { + this->last_polling_command_ = (this->last_polling_command_ + 1) % POLLING_COMMANDS_MAX; + if (this->used_polling_commands_[this->last_polling_command_].length == 0) { + // not enabled + continue; + } + if (!this->used_polling_commands_[this->last_polling_command_].needs_update) { + // no update requested + continue; + } + this->state_ = STATE_POLL; + this->command_start_millis_ = millis(); + this->empty_uart_buffer_(); + this->read_pos_ = 0; + crc16 = this->pipsolar_crc_(this->used_polling_commands_[this->last_polling_command_].command, + this->used_polling_commands_[this->last_polling_command_].length); + this->write_array(this->used_polling_commands_[this->last_polling_command_].command, + this->used_polling_commands_[this->last_polling_command_].length); + // checksum + this->write(((uint8_t) ((crc16) >> 8))); // highbyte + this->write(((uint8_t) ((crc16) &0xff))); // lowbyte + // end Byte + this->write(0x0D); + ESP_LOGD(TAG, "Sending polling command : %s with length %d", + this->used_polling_commands_[this->last_polling_command_].command, + this->used_polling_commands_[this->last_polling_command_].length); + return true; } - if (this->used_polling_commands_[this->last_polling_command_].length == 0) { - // no command specified - return; - } - this->state_ = STATE_POLL; - this->command_start_millis_ = millis(); - this->empty_uart_buffer_(); - this->read_pos_ = 0; - crc16 = this->pipsolar_crc_(this->used_polling_commands_[this->last_polling_command_].command, - this->used_polling_commands_[this->last_polling_command_].length); - this->write_array(this->used_polling_commands_[this->last_polling_command_].command, - this->used_polling_commands_[this->last_polling_command_].length); - // checksum - this->write(((uint8_t) ((crc16) >> 8))); // highbyte - this->write(((uint8_t) ((crc16) &0xff))); // lowbyte - // end Byte - this->write(0x0D); - ESP_LOGD(TAG, "Sending polling command : %s with length %d", - this->used_polling_commands_[this->last_polling_command_].command, - this->used_polling_commands_[this->last_polling_command_].length); + return false; } void Pipsolar::queue_command_(const char *command, uint8_t length) { @@ -869,7 +874,13 @@ void Pipsolar::dump_config() { } } } -void Pipsolar::update() {} +void Pipsolar::update() { + for (auto &used_polling_command : this->used_polling_commands_) { + if (used_polling_command.length != 0) { + used_polling_command.needs_update = true; + } + } +} void Pipsolar::add_polling_command_(const char *command, ENUMPollingCommand polling_command) { for (auto &used_polling_command : this->used_polling_commands_) { @@ -891,6 +902,7 @@ void Pipsolar::add_polling_command_(const char *command, ENUMPollingCommand poll used_polling_command.errors = 0; used_polling_command.identifier = polling_command; used_polling_command.length = length - 1; + used_polling_command.needs_update = true; return; } } diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 373911b2d7..77b18badb9 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -25,6 +25,7 @@ struct PollingCommand { uint8_t length = 0; uint8_t errors; ENUMPollingCommand identifier; + bool needs_update; }; #define PIPSOLAR_VALUED_ENTITY_(type, name, polling_command, value_type) \ @@ -189,14 +190,14 @@ class Pipsolar : public uart::UARTDevice, public PollingComponent { static const size_t PIPSOLAR_READ_BUFFER_LENGTH = 110; // maximum supported answer length static const size_t COMMAND_QUEUE_LENGTH = 10; static const size_t COMMAND_TIMEOUT = 5000; - uint32_t last_poll_ = 0; + static const size_t POLLING_COMMANDS_MAX = 15; void add_polling_command_(const char *command, ENUMPollingCommand polling_command); void empty_uart_buffer_(); uint8_t check_incoming_crc_(); uint8_t check_incoming_length_(uint8_t length); uint16_t pipsolar_crc_(uint8_t *msg, uint8_t len); - uint8_t send_next_command_(); - void send_next_poll_(); + bool send_next_command_(); + bool send_next_poll_(); void queue_command_(const char *command, uint8_t length); std::string command_queue_[COMMAND_QUEUE_LENGTH]; uint8_t command_queue_position_ = 0; @@ -216,7 +217,7 @@ class Pipsolar : public uart::UARTDevice, public PollingComponent { }; uint8_t last_polling_command_ = 0; - PollingCommand used_polling_commands_[15]; + PollingCommand used_polling_commands_[POLLING_COMMANDS_MAX]; }; } // namespace pipsolar From ad5f6f0cfe9bb76283353d1dfd1cc4009835002d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 14:27:22 -0500 Subject: [PATCH 0632/1626] [bluetooth_proxy] Fix connection slot race by deferring slot release until GATT close (#10303) --- .../bluetooth_proxy/bluetooth_connection.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index d2cbdeb984..540492f8c5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -375,10 +375,19 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga switch (event) { case ESP_GATTC_DISCONNECT_EVT: { - this->reset_connection_(param->disconnect.reason); + // Don't reset connection yet - wait for CLOSE_EVT to ensure controller has freed resources + // This prevents race condition where we mark slot as free before controller cleanup is complete + ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_.c_str(), + param->disconnect.reason); + // Send disconnection notification but don't free the slot yet + this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); break; } case ESP_GATTC_CLOSE_EVT: { + ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_.c_str(), + param->close.reason); + // Now the GATT connection is fully closed and controller resources are freed + // Safe to mark the connection slot as available this->reset_connection_(param->close.reason); break; } From 104906ca11d621d6a017839cb207b15f4fbc5cea Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 20 Aug 2025 09:40:19 +1200 Subject: [PATCH 0633/1626] Bump version to 2025.8.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index dc785e3a8f..bcaba5d42e 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.8.0b3 +PROJECT_NUMBER = 2025.8.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index bf0f2eaf8a..6cfff8801d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.8.0b3" +__version__ = "2025.8.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From a8775ba60b7292658aaba4d7ddaa01028fa9dd81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 16:57:24 -0500 Subject: [PATCH 0634/1626] [safe_mode] Reduce flash usage by 184 bytes through code optimization (#10284) --- esphome/components/safe_mode/safe_mode.cpp | 70 +++++++++++----------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 5a62604269..62bbca4fb1 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -15,11 +15,11 @@ namespace safe_mode { static const char *const TAG = "safe_mode"; void SafeModeComponent::dump_config() { - ESP_LOGCONFIG(TAG, "Safe Mode:"); ESP_LOGCONFIG(TAG, - " Boot considered successful after %" PRIu32 " seconds\n" - " Invoke after %u boot attempts\n" - " Remain for %" PRIu32 " seconds", + "Safe Mode:\n" + " Successful after: %" PRIu32 "s\n" + " Invoke after: %u attempts\n" + " Duration: %" PRIu32 "s", this->safe_mode_boot_is_good_after_ / 1000, // because milliseconds this->safe_mode_num_attempts_, this->safe_mode_enable_time_ / 1000); // because milliseconds @@ -27,7 +27,7 @@ void SafeModeComponent::dump_config() { if (this->safe_mode_rtc_value_ > 1 && this->safe_mode_rtc_value_ != SafeModeComponent::ENTER_SAFE_MODE_MAGIC) { auto remaining_restarts = this->safe_mode_num_attempts_ - this->safe_mode_rtc_value_; if (remaining_restarts) { - ESP_LOGW(TAG, "Last reset occurred too quickly; will be invoked in %" PRIu32 " restarts", remaining_restarts); + ESP_LOGW(TAG, "Last reset too quick; invoke in %" PRIu32 " restarts", remaining_restarts); } else { ESP_LOGW(TAG, "SAFE MODE IS ACTIVE"); } @@ -72,43 +72,45 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en this->safe_mode_boot_is_good_after_ = boot_is_good_after; this->safe_mode_num_attempts_ = num_attempts; this->rtc_ = global_preferences->make_preference(233825507UL, false); - this->safe_mode_rtc_value_ = this->read_rtc_(); - bool is_manual_safe_mode = this->safe_mode_rtc_value_ == SafeModeComponent::ENTER_SAFE_MODE_MAGIC; + uint32_t rtc_val = this->read_rtc_(); + this->safe_mode_rtc_value_ = rtc_val; - if (is_manual_safe_mode) { - ESP_LOGI(TAG, "Safe mode invoked manually"); + bool is_manual = rtc_val == SafeModeComponent::ENTER_SAFE_MODE_MAGIC; + + if (is_manual) { + ESP_LOGI(TAG, "Manual mode"); } else { - ESP_LOGCONFIG(TAG, "There have been %" PRIu32 " suspected unsuccessful boot attempts", this->safe_mode_rtc_value_); + ESP_LOGCONFIG(TAG, "Unsuccessful boot attempts: %" PRIu32, rtc_val); } - if (this->safe_mode_rtc_value_ >= num_attempts || is_manual_safe_mode) { - this->clean_rtc(); - - if (!is_manual_safe_mode) { - ESP_LOGE(TAG, "Boot loop detected. Proceeding"); - } - - this->status_set_error(); - this->set_timeout(enable_time, []() { - ESP_LOGW(TAG, "Safe mode enable time has elapsed -- restarting"); - App.reboot(); - }); - - // Delay here to allow power to stabilize before Wi-Fi/Ethernet is initialised - delay(300); // NOLINT - App.setup(); - - ESP_LOGW(TAG, "SAFE MODE IS ACTIVE"); - - this->safe_mode_callback_.call(); - - return true; - } else { + if (rtc_val < num_attempts && !is_manual) { // increment counter - this->write_rtc_(this->safe_mode_rtc_value_ + 1); + this->write_rtc_(rtc_val + 1); return false; } + + this->clean_rtc(); + + if (!is_manual) { + ESP_LOGE(TAG, "Boot loop detected"); + } + + this->status_set_error(); + this->set_timeout(enable_time, []() { + ESP_LOGW(TAG, "Timeout, restarting"); + App.reboot(); + }); + + // Delay here to allow power to stabilize before Wi-Fi/Ethernet is initialised + delay(300); // NOLINT + App.setup(); + + ESP_LOGW(TAG, "SAFE MODE IS ACTIVE"); + + this->safe_mode_callback_.call(); + + return true; } void SafeModeComponent::write_rtc_(uint32_t val) { From 8e67df8059c9514be619e470a794187eae58f2ec Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 20 Aug 2025 10:45:57 +1200 Subject: [PATCH 0635/1626] Bump version to 2025.8.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index bcaba5d42e..5ed1a4afc0 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.8.0b4 +PROJECT_NUMBER = 2025.8.0 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 6cfff8801d..69da0b9af0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.8.0b4" +__version__ = "2025.8.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0e31bc1a67f0ab57d8e89a6c05a16bb53683e776 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:26:53 -0500 Subject: [PATCH 0636/1626] [api] Add zero-copy StringRef methods for compilation_time and effect_name (#10257) --- esphome/components/api/api_connection.cpp | 8 ++------ esphome/components/light/light_state.cpp | 14 ++++++++++++-- esphome/components/light/light_state.h | 3 +++ esphome/core/application.h | 3 +++ 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ced0f489be..4b3a3e2fc8 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -465,9 +465,7 @@ uint16_t APIConnection::try_send_light_state(EntityBase *entity, APIConnection * resp.cold_white = values.get_cold_white(); resp.warm_white = values.get_warm_white(); if (light->supports_effects()) { - // get_effect_name() returns temporary std::string - must store it - std::string effect_name = light->get_effect_name(); - resp.set_effect(StringRef(effect_name)); + resp.set_effect(light->get_effect_name_ref()); } return fill_and_encode_entity_state(light, resp, LightStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -1425,9 +1423,7 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); resp.set_esphome_version(ESPHOME_VERSION_REF); - // get_compilation_time() returns temporary std::string - must store it - std::string compilation_time = App.get_compilation_time(); - resp.set_compilation_time(StringRef(compilation_time)); + resp.set_compilation_time(App.get_compilation_time_ref()); // Compile-time StringRef constants for manufacturers #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 5b57707d6b..9e42b2f1e2 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -140,12 +140,22 @@ float LightState::get_setup_priority() const { return setup_priority::HARDWARE - void LightState::publish_state() { this->remote_values_callback_.call(); } LightOutput *LightState::get_output() const { return this->output_; } + +static constexpr const char *EFFECT_NONE = "None"; +static constexpr auto EFFECT_NONE_REF = StringRef::from_lit("None"); + std::string LightState::get_effect_name() { if (this->active_effect_index_ > 0) { return this->effects_[this->active_effect_index_ - 1]->get_name(); - } else { - return "None"; } + return EFFECT_NONE; +} + +StringRef LightState::get_effect_name_ref() { + if (this->active_effect_index_ > 0) { + return StringRef(this->effects_[this->active_effect_index_ - 1]->get_name()); + } + return EFFECT_NONE_REF; } void LightState::add_new_remote_values_callback(std::function &&send_callback) { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 72cb99223e..94b81dee61 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -4,6 +4,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/optional.h" #include "esphome/core/preferences.h" +#include "esphome/core/string_ref.h" #include "light_call.h" #include "light_color_values.h" #include "light_effect.h" @@ -116,6 +117,8 @@ class LightState : public EntityBase, public Component { /// Return the name of the current effect, or if no effect is active "None". std::string get_effect_name(); + /// Return the name of the current effect as StringRef (for API usage) + StringRef get_effect_name_ref(); /** * This lets front-end components subscribe to light change events. This callback is called once diff --git a/esphome/core/application.h b/esphome/core/application.h index 4120afff53..9cb2a4c638 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -10,6 +10,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include "esphome/core/scheduler.h" +#include "esphome/core/string_ref.h" #ifdef USE_DEVICES #include "esphome/core/device.h" @@ -248,6 +249,8 @@ class Application { bool is_name_add_mac_suffix_enabled() const { return this->name_add_mac_suffix_; } std::string get_compilation_time() const { return this->compilation_time_; } + /// Get the compilation time as StringRef (for API usage) + StringRef get_compilation_time_ref() const { return StringRef(this->compilation_time_); } /// Get the cached time in milliseconds from when the current component started its loop execution inline uint32_t IRAM_ATTR HOT get_loop_component_start_time() const { return this->loop_component_start_time_; } From 0b50ef227b3164d8729f6c0011a00d047ef07373 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:27:08 +1200 Subject: [PATCH 0637/1626] [helper] Make crc8 function more flexible to avoid reimplementation in individual components (#10201) --- esphome/components/ags10/ags10.cpp | 21 +-- esphome/components/ags10/ags10.h | 14 +- esphome/components/am2315c/am2315c.cpp | 18 +- esphome/components/am2315c/am2315c.h | 5 +- esphome/components/hte501/hte501.cpp | 24 +-- esphome/components/hte501/hte501.h | 5 +- esphome/components/lc709203f/lc709203f.cpp | 21 +-- esphome/components/lc709203f/lc709203f.h | 5 +- esphome/components/mlx90614/mlx90614.cpp | 17 +- esphome/components/mlx90614/mlx90614.h | 1 - esphome/components/tee501/tee501.cpp | 23 +-- esphome/components/tee501/tee501.h | 6 +- esphome/core/helpers.cpp | 29 ++- esphome/core/helpers.h | 4 +- tests/integration/fixtures/crc8_helper.yaml | 17 ++ .../crc8_test_component/__init__.py | 17 ++ .../crc8_test_component.cpp | 170 ++++++++++++++++++ .../crc8_test_component/crc8_test_component.h | 29 +++ tests/integration/test_crc8_helper.py | 100 +++++++++++ 19 files changed, 379 insertions(+), 147 deletions(-) create mode 100644 tests/integration/fixtures/crc8_helper.yaml create mode 100644 tests/integration/fixtures/external_components/crc8_test_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.cpp create mode 100644 tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.h create mode 100644 tests/integration/test_crc8_helper.py diff --git a/esphome/components/ags10/ags10.cpp b/esphome/components/ags10/ags10.cpp index 9a29a979f3..fa7170114c 100644 --- a/esphome/components/ags10/ags10.cpp +++ b/esphome/components/ags10/ags10.cpp @@ -89,7 +89,7 @@ void AGS10Component::dump_config() { bool AGS10Component::new_i2c_address(uint8_t newaddress) { uint8_t rev_newaddress = ~newaddress; std::array data{newaddress, rev_newaddress, newaddress, rev_newaddress, 0}; - data[4] = calc_crc8_(data, 4); + data[4] = crc8(data.data(), 4, 0xFF, 0x31, true); if (!this->write_bytes(REG_ADDRESS, data)) { this->error_code_ = COMMUNICATION_FAILED; this->status_set_warning(); @@ -109,7 +109,7 @@ bool AGS10Component::set_zero_point_with_current_resistance() { return this->set bool AGS10Component::set_zero_point_with(uint16_t value) { std::array data{0x00, 0x0C, (uint8_t) ((value >> 8) & 0xFF), (uint8_t) (value & 0xFF), 0}; - data[4] = calc_crc8_(data, 4); + data[4] = crc8(data.data(), 4, 0xFF, 0x31, true); if (!this->write_bytes(REG_CALIBRATION, data)) { this->error_code_ = COMMUNICATION_FAILED; this->status_set_warning(); @@ -184,7 +184,7 @@ template optional> AGS10Component::read_and_che auto res = *data; auto crc_byte = res[len]; - if (crc_byte != calc_crc8_(res, len)) { + if (crc_byte != crc8(res.data(), len, 0xFF, 0x31, true)) { this->error_code_ = CRC_CHECK_FAILED; ESP_LOGE(TAG, "Reading AGS10 version failed: crc error!"); return optional>(); @@ -192,20 +192,5 @@ template optional> AGS10Component::read_and_che return data; } - -template uint8_t AGS10Component::calc_crc8_(std::array dat, uint8_t num) { - uint8_t i, byte1, crc = 0xFF; - for (byte1 = 0; byte1 < num; byte1++) { - crc ^= (dat[byte1]); - for (i = 0; i < 8; i++) { - if (crc & 0x80) { - crc = (crc << 1) ^ 0x31; - } else { - crc = (crc << 1); - } - } - } - return crc; -} } // namespace ags10 } // namespace esphome diff --git a/esphome/components/ags10/ags10.h b/esphome/components/ags10/ags10.h index 3e184ae176..e0975f14bc 100644 --- a/esphome/components/ags10/ags10.h +++ b/esphome/components/ags10/ags10.h @@ -1,9 +1,9 @@ #pragma once +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/components/sensor/sensor.h" -#include "esphome/components/i2c/i2c.h" namespace esphome { namespace ags10 { @@ -99,16 +99,6 @@ class AGS10Component : public PollingComponent, public i2c::I2CDevice { * Read, checks and returns data from the sensor. */ template optional> read_and_check_(uint8_t a_register); - - /** - * Calculates CRC8 value. - * - * CRC8 calculation, initial value: 0xFF, polynomial: 0x31 (x8+ x5+ x4+1) - * - * @param[in] dat the data buffer - * @param num number of bytes in the buffer - */ - template uint8_t calc_crc8_(std::array dat, uint8_t num); }; template class AGS10NewI2cAddressAction : public Action, public Parented { diff --git a/esphome/components/am2315c/am2315c.cpp b/esphome/components/am2315c/am2315c.cpp index 048c34d749..b20a8c6cbb 100644 --- a/esphome/components/am2315c/am2315c.cpp +++ b/esphome/components/am2315c/am2315c.cpp @@ -29,22 +29,6 @@ namespace am2315c { static const char *const TAG = "am2315c"; -uint8_t AM2315C::crc8_(uint8_t *data, uint8_t len) { - uint8_t crc = 0xFF; - while (len--) { - crc ^= *data++; - for (uint8_t i = 0; i < 8; i++) { - if (crc & 0x80) { - crc <<= 1; - crc ^= 0x31; - } else { - crc <<= 1; - } - } - } - return crc; -} - bool AM2315C::reset_register_(uint8_t reg) { // code based on demo code sent by www.aosong.com // no further documentation. @@ -86,7 +70,7 @@ bool AM2315C::convert_(uint8_t *data, float &humidity, float &temperature) { humidity = raw * 9.5367431640625e-5; raw = ((data[3] & 0x0F) << 16) | (data[4] << 8) | data[5]; temperature = raw * 1.9073486328125e-4 - 50; - return this->crc8_(data, 6) == data[6]; + return crc8(data, 6, 0xFF, 0x31, true) == data[6]; } void AM2315C::setup() { diff --git a/esphome/components/am2315c/am2315c.h b/esphome/components/am2315c/am2315c.h index 9cec40e4c2..c8d01beeaa 100644 --- a/esphome/components/am2315c/am2315c.h +++ b/esphome/components/am2315c/am2315c.h @@ -21,9 +21,9 @@ // SOFTWARE. #pragma once -#include "esphome/core/component.h" -#include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" namespace esphome { namespace am2315c { @@ -39,7 +39,6 @@ class AM2315C : public PollingComponent, public i2c::I2CDevice { void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } protected: - uint8_t crc8_(uint8_t *data, uint8_t len); bool convert_(uint8_t *data, float &humidity, float &temperature); bool reset_register_(uint8_t reg); diff --git a/esphome/components/hte501/hte501.cpp b/esphome/components/hte501/hte501.cpp index 75770ceffe..fa81640f50 100644 --- a/esphome/components/hte501/hte501.cpp +++ b/esphome/components/hte501/hte501.cpp @@ -12,7 +12,7 @@ void HTE501Component::setup() { this->write(address, 2, false); uint8_t identification[9]; this->read(identification, 9); - if (identification[8] != calc_crc8_(identification, 0, 7)) { + if (identification[8] != crc8(identification, 8, 0xFF, 0x31, true)) { this->error_code_ = CRC_CHECK_FAILED; this->mark_failed(); return; @@ -46,7 +46,8 @@ void HTE501Component::update() { this->set_timeout(50, [this]() { uint8_t i2c_response[6]; this->read(i2c_response, 6); - if (i2c_response[2] != calc_crc8_(i2c_response, 0, 1) && i2c_response[5] != calc_crc8_(i2c_response, 3, 4)) { + if (i2c_response[2] != crc8(i2c_response, 2, 0xFF, 0x31, true) && + i2c_response[5] != crc8(i2c_response + 3, 2, 0xFF, 0x31, true)) { this->error_code_ = CRC_CHECK_FAILED; this->status_set_warning(); return; @@ -67,24 +68,5 @@ void HTE501Component::update() { this->status_clear_warning(); }); } - -unsigned char HTE501Component::calc_crc8_(const unsigned char buf[], unsigned char from, unsigned char to) { - unsigned char crc_val = 0xFF; - unsigned char i = 0; - unsigned char j = 0; - for (i = from; i <= to; i++) { - int cur_val = buf[i]; - for (j = 0; j < 8; j++) { - if (((crc_val ^ cur_val) & 0x80) != 0) // If MSBs are not equal - { - crc_val = ((crc_val << 1) ^ 0x31); - } else { - crc_val = (crc_val << 1); - } - cur_val = cur_val << 1; - } - } - return crc_val; -} } // namespace hte501 } // namespace esphome diff --git a/esphome/components/hte501/hte501.h b/esphome/components/hte501/hte501.h index 0d2c952e81..a7072d5bdb 100644 --- a/esphome/components/hte501/hte501.h +++ b/esphome/components/hte501/hte501.h @@ -1,8 +1,8 @@ #pragma once -#include "esphome/core/component.h" -#include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" namespace esphome { namespace hte501 { @@ -19,7 +19,6 @@ class HTE501Component : public PollingComponent, public i2c::I2CDevice { void update() override; protected: - unsigned char calc_crc8_(const unsigned char buf[], unsigned char from, unsigned char to); sensor::Sensor *temperature_sensor_; sensor::Sensor *humidity_sensor_; diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index e5d12a75d4..f711cb4f0e 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -1,5 +1,6 @@ -#include "esphome/core/log.h" #include "lc709203f.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" namespace esphome { namespace lc709203f { @@ -189,7 +190,7 @@ uint8_t Lc709203f::get_register_(uint8_t register_to_read, uint16_t *register_va // Error on the i2c bus this->status_set_warning( str_sprintf("Error code %d when reading from register 0x%02X", return_code, register_to_read).c_str()); - } else if (this->crc8_(read_buffer, 5) != read_buffer[5]) { + } else if (crc8(read_buffer, 5, 0x00, 0x07, true) != read_buffer[5]) { // I2C indicated OK, but the CRC of the data does not matcth. this->status_set_warning(str_sprintf("CRC error reading from register 0x%02X", register_to_read).c_str()); } else { @@ -220,7 +221,7 @@ uint8_t Lc709203f::set_register_(uint8_t register_to_set, uint16_t value_to_set) write_buffer[1] = register_to_set; write_buffer[2] = value_to_set & 0xFF; // Low byte write_buffer[3] = (value_to_set >> 8) & 0xFF; // High byte - write_buffer[4] = this->crc8_(write_buffer, 4); + write_buffer[4] = crc8(write_buffer, 4, 0x00, 0x07, true); for (uint8_t i = 0; i <= LC709203F_I2C_RETRY_COUNT; i++) { // Note: we don't write the first byte of the write buffer to the device. @@ -239,20 +240,6 @@ uint8_t Lc709203f::set_register_(uint8_t register_to_set, uint16_t value_to_set) return return_code; } -uint8_t Lc709203f::crc8_(uint8_t *byte_buffer, uint8_t length_of_crc) { - uint8_t crc = 0x00; - const uint8_t polynomial(0x07); - - for (uint8_t j = length_of_crc; j; --j) { - crc ^= *byte_buffer++; - - for (uint8_t i = 8; i; --i) { - crc = (crc & 0x80) ? (crc << 1) ^ polynomial : (crc << 1); - } - } - return crc; -} - void Lc709203f::set_pack_size(uint16_t pack_size) { static const uint16_t PACK_SIZE_ARRAY[6] = {100, 200, 500, 1000, 2000, 3000}; static const uint16_t APA_ARRAY[6] = {0x08, 0x0B, 0x10, 0x19, 0x2D, 0x36}; diff --git a/esphome/components/lc709203f/lc709203f.h b/esphome/components/lc709203f/lc709203f.h index 3b5b04775f..59988a0079 100644 --- a/esphome/components/lc709203f/lc709203f.h +++ b/esphome/components/lc709203f/lc709203f.h @@ -1,8 +1,8 @@ #pragma once -#include "esphome/core/component.h" -#include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" namespace esphome { namespace lc709203f { @@ -38,7 +38,6 @@ class Lc709203f : public sensor::Sensor, public PollingComponent, public i2c::I2 private: uint8_t get_register_(uint8_t register_to_read, uint16_t *register_value); uint8_t set_register_(uint8_t register_to_set, uint16_t value_to_set); - uint8_t crc8_(uint8_t *byte_buffer, uint8_t length_of_crc); protected: sensor::Sensor *voltage_sensor_{nullptr}; diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index 2e711baf9a..24024df090 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -50,28 +50,13 @@ bool MLX90614Component::write_emissivity_() { return true; } -uint8_t MLX90614Component::crc8_pec_(const uint8_t *data, uint8_t len) { - uint8_t crc = 0; - for (uint8_t i = 0; i < len; i++) { - uint8_t in = data[i]; - for (uint8_t j = 0; j < 8; j++) { - bool carry = (crc ^ in) & 0x80; - crc <<= 1; - if (carry) - crc ^= 0x07; - in <<= 1; - } - } - return crc; -} - bool MLX90614Component::write_bytes_(uint8_t reg, uint16_t data) { uint8_t buf[5]; buf[0] = this->address_ << 1; buf[1] = reg; buf[2] = data & 0xFF; buf[3] = data >> 8; - buf[4] = this->crc8_pec_(buf, 4); + buf[4] = crc8(buf, 4, 0x00, 0x07, true); return this->write_bytes(reg, buf + 2, 3); } diff --git a/esphome/components/mlx90614/mlx90614.h b/esphome/components/mlx90614/mlx90614.h index b6bd44172d..fa6fb523bb 100644 --- a/esphome/components/mlx90614/mlx90614.h +++ b/esphome/components/mlx90614/mlx90614.h @@ -22,7 +22,6 @@ class MLX90614Component : public PollingComponent, public i2c::I2CDevice { protected: bool write_emissivity_(); - uint8_t crc8_pec_(const uint8_t *data, uint8_t len); bool write_bytes_(uint8_t reg, uint16_t data); sensor::Sensor *ambient_sensor_{nullptr}; diff --git a/esphome/components/tee501/tee501.cpp b/esphome/components/tee501/tee501.cpp index 460f446865..dc803be775 100644 --- a/esphome/components/tee501/tee501.cpp +++ b/esphome/components/tee501/tee501.cpp @@ -12,7 +12,7 @@ void TEE501Component::setup() { this->write(address, 2, false); uint8_t identification[9]; this->read(identification, 9); - if (identification[8] != calc_crc8_(identification, 0, 7)) { + if (identification[8] != crc8(identification, 8, 0xFF, 0x31, true)) { this->error_code_ = CRC_CHECK_FAILED; this->mark_failed(); return; @@ -45,7 +45,7 @@ void TEE501Component::update() { this->set_timeout(50, [this]() { uint8_t i2c_response[3]; this->read(i2c_response, 3); - if (i2c_response[2] != calc_crc8_(i2c_response, 0, 1)) { + if (i2c_response[2] != crc8(i2c_response, 2, 0xFF, 0x31, true)) { this->error_code_ = CRC_CHECK_FAILED; this->status_set_warning(); return; @@ -62,24 +62,5 @@ void TEE501Component::update() { }); } -unsigned char TEE501Component::calc_crc8_(const unsigned char buf[], unsigned char from, unsigned char to) { - unsigned char crc_val = 0xFF; - unsigned char i = 0; - unsigned char j = 0; - for (i = from; i <= to; i++) { - int cur_val = buf[i]; - for (j = 0; j < 8; j++) { - if (((crc_val ^ cur_val) & 0x80) != 0) // If MSBs are not equal - { - crc_val = ((crc_val << 1) ^ 0x31); - } else { - crc_val = (crc_val << 1); - } - cur_val = cur_val << 1; - } - } - return crc_val; -} - } // namespace tee501 } // namespace esphome diff --git a/esphome/components/tee501/tee501.h b/esphome/components/tee501/tee501.h index fc655e58c9..2437ac92eb 100644 --- a/esphome/components/tee501/tee501.h +++ b/esphome/components/tee501/tee501.h @@ -1,8 +1,8 @@ #pragma once -#include "esphome/core/component.h" -#include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" namespace esphome { namespace tee501 { @@ -16,8 +16,6 @@ class TEE501Component : public sensor::Sensor, public PollingComponent, public i void update() override; protected: - unsigned char calc_crc8_(const unsigned char buf[], unsigned char from, unsigned char to); - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, CRC_CHECK_FAILED } error_code_{NONE}; }; diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index e84f5a7317..44e9193994 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -41,17 +41,28 @@ static const uint16_t CRC16_1021_BE_LUT_H[] = {0x0000, 0x1231, 0x2462, 0x3653, 0 // Mathematics -uint8_t crc8(const uint8_t *data, uint8_t len) { - uint8_t crc = 0; - +uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc, uint8_t poly, bool msb_first) { while ((len--) != 0u) { uint8_t inbyte = *data++; - for (uint8_t i = 8; i != 0u; i--) { - bool mix = (crc ^ inbyte) & 0x01; - crc >>= 1; - if (mix) - crc ^= 0x8C; - inbyte >>= 1; + if (msb_first) { + // MSB first processing (for polynomials like 0x31, 0x07) + crc ^= inbyte; + for (uint8_t i = 8; i != 0u; i--) { + if (crc & 0x80) { + crc = (crc << 1) ^ poly; + } else { + crc <<= 1; + } + } + } else { + // LSB first processing (default for Dallas/Maxim 0x8C) + for (uint8_t i = 8; i != 0u; i--) { + bool mix = (crc ^ inbyte) & 0x01; + crc >>= 1; + if (mix) + crc ^= poly; + inbyte >>= 1; + } } } return crc; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b5fe59c4fd..53ec7a2a5a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -145,8 +145,8 @@ template T remap(U value, U min, U max, T min_out, T max return (value - min) * (max_out - min_out) / (max - min) + min_out; } -/// Calculate a CRC-8 checksum of \p data with size \p len using the CRC-8-Dallas/Maxim polynomial. -uint8_t crc8(const uint8_t *data, uint8_t len); +/// Calculate a CRC-8 checksum of \p data with size \p len. +uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc = 0x00, uint8_t poly = 0x8C, bool msb_first = false); /// Calculate a CRC-16 checksum of \p data with size \p len. uint16_t crc16(const uint8_t *data, uint16_t len, uint16_t crc = 0xffff, uint16_t reverse_poly = 0xa001, diff --git a/tests/integration/fixtures/crc8_helper.yaml b/tests/integration/fixtures/crc8_helper.yaml new file mode 100644 index 0000000000..e97e23eab0 --- /dev/null +++ b/tests/integration/fixtures/crc8_helper.yaml @@ -0,0 +1,17 @@ +esphome: + name: crc8-helper-test + +host: + +api: + +logger: + level: INFO + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [crc8_test_component] + +crc8_test_component: diff --git a/tests/integration/fixtures/external_components/crc8_test_component/__init__.py b/tests/integration/fixtures/external_components/crc8_test_component/__init__.py new file mode 100644 index 0000000000..6032b0861f --- /dev/null +++ b/tests/integration/fixtures/external_components/crc8_test_component/__init__.py @@ -0,0 +1,17 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +crc8_test_component_ns = cg.esphome_ns.namespace("crc8_test_component") +CRC8TestComponent = crc8_test_component_ns.class_("CRC8TestComponent", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(CRC8TestComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.cpp b/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.cpp new file mode 100644 index 0000000000..6c46af19fd --- /dev/null +++ b/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.cpp @@ -0,0 +1,170 @@ +#include "crc8_test_component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace crc8_test_component { + +static const char *const TAG = "crc8_test"; + +void CRC8TestComponent::setup() { + ESP_LOGI(TAG, "CRC8 Helper Function Integration Test Starting"); + + // Run all test suites + test_crc8_dallas_maxim(); + test_crc8_sensirion_style(); + test_crc8_pec_style(); + test_crc8_parameter_equivalence(); + test_crc8_edge_cases(); + test_component_compatibility(); + + ESP_LOGI(TAG, "CRC8 Integration Test Complete"); +} + +void CRC8TestComponent::test_crc8_dallas_maxim() { + ESP_LOGI(TAG, "Testing Dallas/Maxim CRC8 (default parameters)"); + + // Test vectors for Dallas/Maxim CRC8 (polynomial 0x8C, LSB-first, init 0x00) + const uint8_t test1[] = {0x01}; + const uint8_t test2[] = {0xFF}; + const uint8_t test3[] = {0x12, 0x34}; + const uint8_t test4[] = {0xAA, 0xBB, 0xCC}; + const uint8_t test5[] = {0x01, 0x02, 0x03, 0x04, 0x05}; + + bool all_passed = true; + all_passed &= verify_crc8("Dallas [0x01]", test1, sizeof(test1), 0x5E); + all_passed &= verify_crc8("Dallas [0xFF]", test2, sizeof(test2), 0x35); + all_passed &= verify_crc8("Dallas [0x12, 0x34]", test3, sizeof(test3), 0xA2); + all_passed &= verify_crc8("Dallas [0xAA, 0xBB, 0xCC]", test4, sizeof(test4), 0xD4); + all_passed &= verify_crc8("Dallas [0x01...0x05]", test5, sizeof(test5), 0x2A); + + log_test_result("Dallas/Maxim CRC8", all_passed); +} + +void CRC8TestComponent::test_crc8_sensirion_style() { + ESP_LOGI(TAG, "Testing Sensirion CRC8 (0x31 poly, MSB-first, init 0xFF)"); + + const uint8_t test1[] = {0x00}; + const uint8_t test2[] = {0x01}; + const uint8_t test3[] = {0xFF}; + const uint8_t test4[] = {0x12, 0x34}; + const uint8_t test5[] = {0xBE, 0xEF}; + + bool all_passed = true; + all_passed &= verify_crc8("Sensirion [0x00]", test1, sizeof(test1), 0xAC, 0xFF, 0x31, true); + all_passed &= verify_crc8("Sensirion [0x01]", test2, sizeof(test2), 0x9D, 0xFF, 0x31, true); + all_passed &= verify_crc8("Sensirion [0xFF]", test3, sizeof(test3), 0x00, 0xFF, 0x31, true); + all_passed &= verify_crc8("Sensirion [0x12, 0x34]", test4, sizeof(test4), 0x37, 0xFF, 0x31, true); + all_passed &= verify_crc8("Sensirion [0xBE, 0xEF]", test5, sizeof(test5), 0x92, 0xFF, 0x31, true); + + log_test_result("Sensirion CRC8", all_passed); +} + +void CRC8TestComponent::test_crc8_pec_style() { + ESP_LOGI(TAG, "Testing PEC CRC8 (0x07 poly, MSB-first, init 0x00)"); + + const uint8_t test1[] = {0x00}; + const uint8_t test2[] = {0x01}; + const uint8_t test3[] = {0xFF}; + const uint8_t test4[] = {0x12, 0x34}; + const uint8_t test5[] = {0xAA, 0xBB}; + + bool all_passed = true; + all_passed &= verify_crc8("PEC [0x00]", test1, sizeof(test1), 0x00, 0x00, 0x07, true); + all_passed &= verify_crc8("PEC [0x01]", test2, sizeof(test2), 0x07, 0x00, 0x07, true); + all_passed &= verify_crc8("PEC [0xFF]", test3, sizeof(test3), 0xF3, 0x00, 0x07, true); + all_passed &= verify_crc8("PEC [0x12, 0x34]", test4, sizeof(test4), 0xF1, 0x00, 0x07, true); + all_passed &= verify_crc8("PEC [0xAA, 0xBB]", test5, sizeof(test5), 0xB2, 0x00, 0x07, true); + + log_test_result("PEC CRC8", all_passed); +} + +void CRC8TestComponent::test_crc8_parameter_equivalence() { + ESP_LOGI(TAG, "Testing parameter equivalence"); + + const uint8_t test_data[] = {0x12, 0x34, 0x56, 0x78}; + + // Test that default parameters work as expected + uint8_t default_result = crc8(test_data, sizeof(test_data)); + uint8_t explicit_result = crc8(test_data, sizeof(test_data), 0x00, 0x8C, false); + + bool passed = (default_result == explicit_result); + if (!passed) { + ESP_LOGE(TAG, "Parameter equivalence FAILED: default=0x%02X, explicit=0x%02X", default_result, explicit_result); + } + + log_test_result("Parameter equivalence", passed); +} + +void CRC8TestComponent::test_crc8_edge_cases() { + ESP_LOGI(TAG, "Testing edge cases"); + + bool all_passed = true; + + // Empty array test + const uint8_t empty[] = {}; + uint8_t empty_result = crc8(empty, 0); + bool empty_passed = (empty_result == 0x00); // Should return init value + if (!empty_passed) { + ESP_LOGE(TAG, "Empty array test FAILED: expected 0x00, got 0x%02X", empty_result); + } + all_passed &= empty_passed; + + // Single byte tests + const uint8_t single_zero[] = {0x00}; + const uint8_t single_ff[] = {0xFF}; + all_passed &= verify_crc8("Single [0x00]", single_zero, 1, 0x00); + all_passed &= verify_crc8("Single [0xFF]", single_ff, 1, 0x35); + + log_test_result("Edge cases", all_passed); +} + +void CRC8TestComponent::test_component_compatibility() { + ESP_LOGI(TAG, "Testing component compatibility"); + + // Test specific component use cases + bool all_passed = true; + + // AGS10-style data (Sensirion CRC8) + const uint8_t ags10_data[] = {0x12, 0x34, 0x56}; + uint8_t ags10_result = crc8(ags10_data, sizeof(ags10_data), 0xFF, 0x31, true); + ESP_LOGI(TAG, "AGS10-style CRC8: 0x%02X", ags10_result); + + // LC709203F-style data (PEC CRC8) + const uint8_t lc_data[] = {0xAA, 0xBB}; + uint8_t lc_result = crc8(lc_data, sizeof(lc_data), 0x00, 0x07, true); + ESP_LOGI(TAG, "LC709203F-style CRC8: 0x%02X", lc_result); + + // DallasTemperature-style data (Dallas CRC8) + const uint8_t dallas_data[] = {0x28, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC}; + uint8_t dallas_result = crc8(dallas_data, sizeof(dallas_data)); + ESP_LOGI(TAG, "Dallas-style CRC8: 0x%02X", dallas_result); + + all_passed = true; // These are just demonstration tests + log_test_result("Component compatibility", all_passed); +} + +bool CRC8TestComponent::verify_crc8(const char *test_name, const uint8_t *data, uint8_t len, uint8_t expected, + uint8_t crc, uint8_t poly, bool msb_first) { + uint8_t result = esphome::crc8(data, len, crc, poly, msb_first); + bool passed = (result == expected); + + if (passed) { + ESP_LOGI(TAG, "%s: PASS (0x%02X)", test_name, result); + } else { + ESP_LOGE(TAG, "%s: FAIL - expected 0x%02X, got 0x%02X", test_name, expected, result); + } + + return passed; +} + +void CRC8TestComponent::log_test_result(const char *test_name, bool passed) { + if (passed) { + ESP_LOGI(TAG, "%s: ALL TESTS PASSED", test_name); + } else { + ESP_LOGE(TAG, "%s: SOME TESTS FAILED", test_name); + } +} + +} // namespace crc8_test_component +} // namespace esphome diff --git a/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.h b/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.h new file mode 100644 index 0000000000..3b8847259c --- /dev/null +++ b/tests/integration/fixtures/external_components/crc8_test_component/crc8_test_component.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace crc8_test_component { + +class CRC8TestComponent : public Component { + public: + void setup() override; + + private: + void test_crc8_dallas_maxim(); + void test_crc8_sensirion_style(); + void test_crc8_pec_style(); + void test_crc8_parameter_equivalence(); + void test_crc8_edge_cases(); + void test_component_compatibility(); + void test_old_vs_new_implementations(); + + void log_test_result(const char *test_name, bool passed); + bool verify_crc8(const char *test_name, const uint8_t *data, uint8_t len, uint8_t expected, uint8_t crc = 0x00, + uint8_t poly = 0x8C, bool msb_first = false); +}; + +} // namespace crc8_test_component +} // namespace esphome diff --git a/tests/integration/test_crc8_helper.py b/tests/integration/test_crc8_helper.py new file mode 100644 index 0000000000..ffe6244598 --- /dev/null +++ b/tests/integration/test_crc8_helper.py @@ -0,0 +1,100 @@ +"""Integration test for CRC8 helper function.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_crc8_helper( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the CRC8 helper function through integration testing.""" + # Get the path to the external components directory + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + + # Replace the placeholder in the YAML config with the actual path + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + # Track test completion with asyncio.Event + test_complete = asyncio.Event() + + # Track test results + test_results = { + "dallas_maxim": False, + "sensirion": False, + "pec": False, + "parameter_equivalence": False, + "edge_cases": False, + "component_compatibility": False, + "setup_started": False, + } + + def on_log_line(line): + """Process log lines to track test progress and results.""" + # Track test start + if "CRC8 Helper Function Integration Test Starting" in line: + test_results["setup_started"] = True + + # Track test completion + elif "CRC8 Integration Test Complete" in line: + test_complete.set() + + # Track individual test results + elif "ALL TESTS PASSED" in line: + if "Dallas/Maxim CRC8" in line: + test_results["dallas_maxim"] = True + elif "Sensirion CRC8" in line: + test_results["sensirion"] = True + elif "PEC CRC8" in line: + test_results["pec"] = True + elif "Parameter equivalence" in line: + test_results["parameter_equivalence"] = True + elif "Edge cases" in line: + test_results["edge_cases"] = True + elif "Component compatibility" in line: + test_results["component_compatibility"] = True + + # Log failures for debugging + elif "TEST FAILED:" in line or "SUBTEST FAILED:" in line: + print(f"CRC8 Test Failure: {line}") + + # Compile and run the test + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "crc8-helper-test" + + # Wait for tests to complete with timeout + try: + await asyncio.wait_for(test_complete.wait(), timeout=5.0) + except TimeoutError: + pytest.fail("CRC8 integration test timed out after 5 seconds") + + # Verify all tests passed + assert test_results["setup_started"], "CRC8 test setup never started" + assert test_results["dallas_maxim"], "Dallas/Maxim CRC8 test failed" + assert test_results["sensirion"], "Sensirion CRC8 test failed" + assert test_results["pec"], "PEC CRC8 test failed" + assert test_results["parameter_equivalence"], ( + "Parameter equivalence test failed" + ) + assert test_results["edge_cases"], "Edge cases test failed" + assert test_results["component_compatibility"], ( + "Component compatibility test failed" + ) From 5a1533bea9c7b65862f191ef9b68cb81fe8ed801 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:28:13 -0500 Subject: [PATCH 0638/1626] [api] Avoid object_id string allocations for all entity info messages (#10260) --- esphome/components/api/api_connection.h | 12 +++++++++--- esphome/core/entity_base.cpp | 18 ++++++++++++------ esphome/core/entity_base.h | 11 +++++++++++ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index f711502746..6254854238 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -301,9 +301,15 @@ class APIConnection final : public APIServerConnection { APIConnection *conn, uint32_t remaining_size, bool is_single) { // Set common fields that are shared by all entity types msg.key = entity->get_object_id_hash(); - // IMPORTANT: get_object_id() may return a temporary std::string - std::string object_id = entity->get_object_id(); - msg.set_object_id(StringRef(object_id)); + // Try to use static reference first to avoid allocation + StringRef static_ref = entity->get_object_id_ref_for_api_(); + if (!static_ref.empty()) { + msg.set_object_id(static_ref); + } else { + // Dynamic case - need to allocate + std::string object_id = entity->get_object_id(); + msg.set_object_id(StringRef(object_id)); + } if (entity->has_own_name()) { msg.set_name(entity->get_name()); diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 2ea9c77a3e..411a877bbf 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -1,6 +1,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" namespace esphome { @@ -50,13 +51,18 @@ std::string EntityBase::get_object_id() const { if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { // `App.get_friendly_name()` is dynamic. return str_sanitize(str_snake_case(App.get_friendly_name())); - } else { - // `App.get_friendly_name()` is constant. - if (this->object_id_c_str_ == nullptr) { - return ""; - } - return this->object_id_c_str_; } + // `App.get_friendly_name()` is constant. + return this->object_id_c_str_ == nullptr ? "" : this->object_id_c_str_; +} +StringRef EntityBase::get_object_id_ref_for_api_() const { + static constexpr auto EMPTY_STRING = StringRef::from_lit(""); + // Return empty for dynamic case (MAC suffix) + if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { + return EMPTY_STRING; + } + // For static case, return the string or empty if null + return this->object_id_c_str_ == nullptr ? EMPTY_STRING : StringRef(this->object_id_c_str_); } void EntityBase::set_object_id(const char *object_id) { this->object_id_c_str_ = object_id; diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index e60e0728bc..68163ce8c3 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -12,6 +12,11 @@ namespace esphome { +// Forward declaration for friend access +namespace api { +class APIConnection; +} // namespace api + enum EntityCategory : uint8_t { ENTITY_CATEGORY_NONE = 0, ENTITY_CATEGORY_CONFIG = 1, @@ -81,6 +86,12 @@ class EntityBase { void set_has_state(bool state) { this->flags_.has_state = state; } protected: + friend class api::APIConnection; + + // Get object_id as StringRef when it's static (for API usage) + // Returns empty StringRef if object_id is dynamic (needs allocation) + StringRef get_object_id_ref_for_api_() const; + /// The hash_base() function has been deprecated. It is kept in this /// class for now, to prevent external components from not compiling. virtual uint32_t hash_base() { return 0L; } From 9b1ebdb6da89a7b4caca6f70fb70b40623486f14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:34:34 -0500 Subject: [PATCH 0639/1626] [mdns] Reduce flash usage and prevent RAM over-allocation in service compilation (#10287) --- esphome/components/mdns/mdns_component.cpp | 157 +++++++++++++-------- 1 file changed, 99 insertions(+), 58 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 640750720d..316a10596f 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -24,100 +24,139 @@ static const char *const TAG = "mdns"; void MDNSComponent::compile_records_() { this->hostname_ = App.get_name(); - this->services_.clear(); + // Calculate exact capacity needed for services vector + size_t services_count = 0; #ifdef USE_API if (api::global_api_server != nullptr) { - MDNSService service{}; + services_count++; + } +#endif +#ifdef USE_PROMETHEUS + services_count++; +#endif +#ifdef USE_WEBSERVER + services_count++; +#endif +#ifdef USE_MDNS_EXTRA_SERVICES + services_count += this->services_extra_.size(); +#endif + // Reserve for fallback service if needed + if (services_count == 0) { + services_count = 1; + } + this->services_.reserve(services_count); + +#ifdef USE_API + if (api::global_api_server != nullptr) { + this->services_.emplace_back(); + auto &service = this->services_.back(); service.service_type = "_esphomelib"; service.proto = "_tcp"; service.port = api::global_api_server->get_port(); - if (!App.get_friendly_name().empty()) { - service.txt_records.push_back({"friendly_name", App.get_friendly_name()}); - } - service.txt_records.push_back({"version", ESPHOME_VERSION}); - service.txt_records.push_back({"mac", get_mac_address()}); - const char *platform = nullptr; -#ifdef USE_ESP8266 - platform = "ESP8266"; -#endif -#ifdef USE_ESP32 - platform = "ESP32"; -#endif -#ifdef USE_RP2040 - platform = "RP2040"; -#endif -#ifdef USE_LIBRETINY - platform = lt_cpu_get_model_name(); -#endif - if (platform != nullptr) { - service.txt_records.push_back({"platform", platform}); - } - service.txt_records.push_back({"board", ESPHOME_BOARD}); + const std::string &friendly_name = App.get_friendly_name(); + bool friendly_name_empty = friendly_name.empty(); + + // Calculate exact capacity for txt_records + size_t txt_count = 3; // version, mac, board (always present) + if (!friendly_name_empty) { + txt_count++; // friendly_name + } +#if defined(USE_ESP8266) || defined(USE_ESP32) || defined(USE_RP2040) || defined(USE_LIBRETINY) + txt_count++; // platform +#endif +#if defined(USE_WIFI) || defined(USE_ETHERNET) || defined(USE_OPENTHREAD) + txt_count++; // network +#endif +#ifdef USE_API_NOISE + txt_count++; // api_encryption or api_encryption_supported +#endif +#ifdef ESPHOME_PROJECT_NAME + txt_count += 2; // project_name and project_version +#endif +#ifdef USE_DASHBOARD_IMPORT + txt_count++; // package_import_url +#endif + + auto &txt_records = service.txt_records; + txt_records.reserve(txt_count); + + if (!friendly_name_empty) { + txt_records.emplace_back(MDNSTXTRecord{"friendly_name", friendly_name}); + } + txt_records.emplace_back(MDNSTXTRecord{"version", ESPHOME_VERSION}); + txt_records.emplace_back(MDNSTXTRecord{"mac", get_mac_address()}); + +#ifdef USE_ESP8266 + txt_records.emplace_back(MDNSTXTRecord{"platform", "ESP8266"}); +#elif defined(USE_ESP32) + txt_records.emplace_back(MDNSTXTRecord{"platform", "ESP32"}); +#elif defined(USE_RP2040) + txt_records.emplace_back(MDNSTXTRecord{"platform", "RP2040"}); +#elif defined(USE_LIBRETINY) + txt_records.emplace_back(MDNSTXTRecord{"platform", lt_cpu_get_model_name()}); +#endif + + txt_records.emplace_back(MDNSTXTRecord{"board", ESPHOME_BOARD}); #if defined(USE_WIFI) - service.txt_records.push_back({"network", "wifi"}); + txt_records.emplace_back(MDNSTXTRecord{"network", "wifi"}); #elif defined(USE_ETHERNET) - service.txt_records.push_back({"network", "ethernet"}); + txt_records.emplace_back(MDNSTXTRecord{"network", "ethernet"}); #elif defined(USE_OPENTHREAD) - service.txt_records.push_back({"network", "thread"}); + txt_records.emplace_back(MDNSTXTRecord{"network", "thread"}); #endif #ifdef USE_API_NOISE + static constexpr const char *NOISE_ENCRYPTION = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"; if (api::global_api_server->get_noise_ctx()->has_psk()) { - service.txt_records.push_back({"api_encryption", "Noise_NNpsk0_25519_ChaChaPoly_SHA256"}); + txt_records.emplace_back(MDNSTXTRecord{"api_encryption", NOISE_ENCRYPTION}); } else { - service.txt_records.push_back({"api_encryption_supported", "Noise_NNpsk0_25519_ChaChaPoly_SHA256"}); + txt_records.emplace_back(MDNSTXTRecord{"api_encryption_supported", NOISE_ENCRYPTION}); } #endif #ifdef ESPHOME_PROJECT_NAME - service.txt_records.push_back({"project_name", ESPHOME_PROJECT_NAME}); - service.txt_records.push_back({"project_version", ESPHOME_PROJECT_VERSION}); + txt_records.emplace_back(MDNSTXTRecord{"project_name", ESPHOME_PROJECT_NAME}); + txt_records.emplace_back(MDNSTXTRecord{"project_version", ESPHOME_PROJECT_VERSION}); #endif // ESPHOME_PROJECT_NAME #ifdef USE_DASHBOARD_IMPORT - service.txt_records.push_back({"package_import_url", dashboard_import::get_package_import_url()}); + txt_records.emplace_back(MDNSTXTRecord{"package_import_url", dashboard_import::get_package_import_url()}); #endif - - this->services_.push_back(service); } #endif // USE_API #ifdef USE_PROMETHEUS - { - MDNSService service{}; - service.service_type = "_prometheus-http"; - service.proto = "_tcp"; - service.port = USE_WEBSERVER_PORT; - this->services_.push_back(service); - } + this->services_.emplace_back(); + auto &prom_service = this->services_.back(); + prom_service.service_type = "_prometheus-http"; + prom_service.proto = "_tcp"; + prom_service.port = USE_WEBSERVER_PORT; #endif #ifdef USE_WEBSERVER - { - MDNSService service{}; - service.service_type = "_http"; - service.proto = "_tcp"; - service.port = USE_WEBSERVER_PORT; - this->services_.push_back(service); - } + this->services_.emplace_back(); + auto &web_service = this->services_.back(); + web_service.service_type = "_http"; + web_service.proto = "_tcp"; + web_service.port = USE_WEBSERVER_PORT; #endif #ifdef USE_MDNS_EXTRA_SERVICES this->services_.insert(this->services_.end(), this->services_extra_.begin(), this->services_extra_.end()); #endif - if (this->services_.empty()) { - // Publish "http" service if not using native API - // This is just to have *some* mDNS service so that .local resolution works - MDNSService service{}; - service.service_type = "_http"; - service.proto = "_tcp"; - service.port = USE_WEBSERVER_PORT; - service.txt_records.push_back({"version", ESPHOME_VERSION}); - this->services_.push_back(service); - } +#if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_WEBSERVER) && !defined(USE_MDNS_EXTRA_SERVICES) + // Publish "http" service if not using native API or any other services + // This is just to have *some* mDNS service so that .local resolution works + this->services_.emplace_back(); + auto &fallback_service = this->services_.back(); + fallback_service.service_type = "_http"; + fallback_service.proto = "_tcp"; + fallback_service.port = USE_WEBSERVER_PORT; + fallback_service.txt_records.emplace_back(MDNSTXTRecord{"version", ESPHOME_VERSION}); +#endif } void MDNSComponent::dump_config() { @@ -125,6 +164,7 @@ void MDNSComponent::dump_config() { "mDNS:\n" " Hostname: %s", this->hostname_.c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE ESP_LOGV(TAG, " Services:"); for (const auto &service : this->services_) { ESP_LOGV(TAG, " - %s, %s, %d", service.service_type.c_str(), service.proto.c_str(), @@ -134,6 +174,7 @@ void MDNSComponent::dump_config() { const_cast &>(record.value).value().c_str()); } } +#endif } std::vector MDNSComponent::get_services() { return this->services_; } From a45137434b78b5187dd8b954d160913c46177832 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:34:45 +1200 Subject: [PATCH 0640/1626] [quality] Convert remaining ``to_code`` to ``async`` (#10271) --- esphome/components/airthings_ble/__init__.py | 4 ++-- esphome/components/ble_client/output/__init__.py | 8 ++++---- esphome/components/cover/__init__.py | 6 +++--- esphome/components/pipsolar/__init__.py | 6 +++--- esphome/components/pipsolar/output/__init__.py | 8 ++++---- esphome/components/radon_eye_ble/__init__.py | 4 ++-- esphome/components/remote_base/__init__.py | 14 ++++++-------- esphome/components/shelly_dimmer/light.py | 14 +++++++------- esphome/components/valve/__init__.py | 6 +++--- 9 files changed, 34 insertions(+), 36 deletions(-) diff --git a/esphome/components/airthings_ble/__init__.py b/esphome/components/airthings_ble/__init__.py index eae400ab39..1545110798 100644 --- a/esphome/components/airthings_ble/__init__.py +++ b/esphome/components/airthings_ble/__init__.py @@ -18,6 +18,6 @@ CONFIG_SCHEMA = cv.Schema( ).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) -def to_code(config): +async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - yield esp32_ble_tracker.register_ble_device(var, config) + await esp32_ble_tracker.register_ble_device(var, config) diff --git a/esphome/components/ble_client/output/__init__.py b/esphome/components/ble_client/output/__init__.py index 729885eb8b..22a6b29442 100644 --- a/esphome/components/ble_client/output/__init__.py +++ b/esphome/components/ble_client/output/__init__.py @@ -27,7 +27,7 @@ CONFIG_SCHEMA = cv.All( ) -def to_code(config): +async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) if len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid16_format): cg.add( @@ -63,6 +63,6 @@ def to_code(config): ) cg.add(var.set_char_uuid128(uuid128)) cg.add(var.set_require_response(config[CONF_REQUIRE_RESPONSE])) - yield output.register_output(var, config) - yield ble_client.register_ble_node(var, config) - yield cg.register_component(var, config) + await output.register_output(var, config) + await ble_client.register_ble_node(var, config) + await cg.register_component(var, config) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 0e01eb336f..383cfaf8fb 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -228,9 +228,9 @@ async def cover_stop_to_code(config, action_id, template_arg, args): @automation.register_action("cover.toggle", ToggleAction, COVER_ACTION_SCHEMA) -def cover_toggle_to_code(config, action_id, template_arg, args): - paren = yield cg.get_variable(config[CONF_ID]) - yield cg.new_Pvariable(action_id, template_arg, paren) +async def cover_toggle_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, paren) COVER_CONTROL_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/pipsolar/__init__.py b/esphome/components/pipsolar/__init__.py index 1e4ea8492b..e3966aa2cc 100644 --- a/esphome/components/pipsolar/__init__.py +++ b/esphome/components/pipsolar/__init__.py @@ -26,7 +26,7 @@ CONFIG_SCHEMA = cv.All( ) -def to_code(config): +async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - yield cg.register_component(var, config) - yield uart.register_uart_device(var, config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/pipsolar/output/__init__.py b/esphome/components/pipsolar/output/__init__.py index 1eb7249119..829f8f7037 100644 --- a/esphome/components/pipsolar/output/__init__.py +++ b/esphome/components/pipsolar/output/__init__.py @@ -99,9 +99,9 @@ async def to_code(config): } ), ) -def output_pipsolar_set_level_to_code(config, action_id, template_arg, args): - paren = yield cg.get_variable(config[CONF_ID]) +async def output_pipsolar_set_level_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = yield cg.templatable(config[CONF_VALUE], args, float) + template_ = await cg.templatable(config[CONF_VALUE], args, float) cg.add(var.set_level(template_)) - yield var + return var diff --git a/esphome/components/radon_eye_ble/__init__.py b/esphome/components/radon_eye_ble/__init__.py index 01910c81a8..99daef30e5 100644 --- a/esphome/components/radon_eye_ble/__init__.py +++ b/esphome/components/radon_eye_ble/__init__.py @@ -18,6 +18,6 @@ CONFIG_SCHEMA = cv.Schema( ).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) -def to_code(config): +async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - yield esp32_ble_tracker.register_ble_device(var, config) + await esp32_ble_tracker.register_ble_device(var, config) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 8163661c65..42ebae77f7 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1782,14 +1782,12 @@ def nexa_dumper(var, config): @register_action("nexa", NexaAction, NEXA_SCHEMA) -def nexa_action(var, config, args): - cg.add(var.set_device((yield cg.templatable(config[CONF_DEVICE], args, cg.uint32)))) - cg.add(var.set_group((yield cg.templatable(config[CONF_GROUP], args, cg.uint8)))) - cg.add(var.set_state((yield cg.templatable(config[CONF_STATE], args, cg.uint8)))) - cg.add( - var.set_channel((yield cg.templatable(config[CONF_CHANNEL], args, cg.uint8))) - ) - cg.add(var.set_level((yield cg.templatable(config[CONF_LEVEL], args, cg.uint8)))) +async def nexa_action(var, config, args): + cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint32))) + cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.uint8))) + cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.uint8))) + cg.add(var.set_channel(await cg.templatable(config[CONF_CHANNEL], args, cg.uint8))) + cg.add(var.set_level(await cg.templatable(config[CONF_LEVEL], args, cg.uint8))) # Midea diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index bb2c3ceee8..c96bc380d7 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -183,7 +183,7 @@ CONFIG_SCHEMA = ( ) -def to_code(config): +async def to_code(config): fw_hex = get_firmware(config[CONF_FIRMWARE]) fw_major, fw_minor = parse_firmware_version(config[CONF_FIRMWARE][CONF_VERSION]) @@ -193,17 +193,17 @@ def to_code(config): cg.add_define("USE_SHD_FIRMWARE_MINOR_VERSION", fw_minor) var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) - yield cg.register_component(var, config) + await cg.register_component(var, config) config.pop( CONF_UPDATE_INTERVAL ) # drop UPDATE_INTERVAL as it does not apply to the light component - yield light.register_light(var, config) - yield uart.register_uart_device(var, config) + await light.register_light(var, config) + await uart.register_uart_device(var, config) - nrst_pin = yield cg.gpio_pin_expression(config[CONF_NRST_PIN]) + nrst_pin = await cg.gpio_pin_expression(config[CONF_NRST_PIN]) cg.add(var.set_nrst_pin(nrst_pin)) - boot0_pin = yield cg.gpio_pin_expression(config[CONF_BOOT0_PIN]) + boot0_pin = await cg.gpio_pin_expression(config[CONF_BOOT0_PIN]) cg.add(var.set_boot0_pin(boot0_pin)) cg.add(var.set_leading_edge(config[CONF_LEADING_EDGE])) @@ -217,5 +217,5 @@ def to_code(config): continue conf = config[key] - sens = yield sensor.new_sensor(conf) + sens = await sensor.new_sensor(conf) cg.add(getattr(var, f"set_{key}_sensor")(sens)) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 53254068af..8185bd6ea2 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -202,9 +202,9 @@ async def valve_stop_to_code(config, action_id, template_arg, args): @automation.register_action("valve.toggle", ToggleAction, VALVE_ACTION_SCHEMA) -def valve_toggle_to_code(config, action_id, template_arg, args): - paren = yield cg.get_variable(config[CONF_ID]) - yield cg.new_Pvariable(action_id, template_arg, paren) +async def valve_toggle_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, paren) VALVE_CONTROL_ACTION_SCHEMA = cv.Schema( From 3964f9794b613c8943f2927eaa6c3d661b92c2a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:35:09 -0500 Subject: [PATCH 0641/1626] [binary_sensor] Convert LOG_BINARY_SENSOR macro to function to reduce flash usage (#10294) --- esphome/components/binary_sensor/binary_sensor.cpp | 13 +++++++++++++ esphome/components/binary_sensor/binary_sensor.h | 11 ++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 02b83af552..e652d302b6 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -7,6 +7,19 @@ namespace binary_sensor { static const char *const TAG = "binary_sensor"; +// Function implementation of LOG_BINARY_SENSOR macro to reduce code size +void log_binary_sensor(const char *tag, const char *prefix, const char *type, BinarySensor *obj) { + if (obj == nullptr) { + return; + } + + ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); + + if (!obj->get_device_class().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); + } +} + void BinarySensor::publish_state(bool new_state) { if (this->filter_list_ == nullptr) { this->send_state_internal(new_state); diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index d61be7a49b..2bd17d97c9 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -10,13 +10,10 @@ namespace esphome { namespace binary_sensor { -#define LOG_BINARY_SENSOR(prefix, type, obj) \ - if ((obj) != nullptr) { \ - ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ - } \ - } +class BinarySensor; +void log_binary_sensor(const char *tag, const char *prefix, const char *type, BinarySensor *obj); + +#define LOG_BINARY_SENSOR(prefix, type, obj) log_binary_sensor(TAG, prefix, LOG_STR_LITERAL(type), obj) #define SUB_BINARY_SENSOR(name) \ protected: \ From 0eab908b0efefac0e9ab4f2695343c8bde8be51e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:35:45 -0500 Subject: [PATCH 0642/1626] [sensor] Convert LOG_SENSOR macro to function to reduce flash usage (#10290) --- esphome/components/ccs811/ccs811.cpp | 4 +-- .../grove_gas_mc_v2/grove_gas_mc_v2.cpp | 8 +++--- esphome/components/hlw8012/hlw8012.cpp | 8 +++--- esphome/components/ntc/ntc.cpp | 2 +- .../components/pulse_width/pulse_width.cpp | 2 +- esphome/components/sensor/sensor.cpp | 27 +++++++++++++++++++ esphome/components/sensor/sensor.h | 23 +++------------- esphome/components/ufire_ec/ufire_ec.cpp | 6 ++--- esphome/components/ufire_ise/ufire_ise.cpp | 6 ++--- 9 files changed, 48 insertions(+), 38 deletions(-) diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index cecb92b3df..2617d7577a 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -153,8 +153,8 @@ void CCS811Component::dump_config() { ESP_LOGCONFIG(TAG, "CCS811"); LOG_I2C_DEVICE(this) LOG_UPDATE_INTERVAL(this) - LOG_SENSOR(" ", "CO2 Sensor", this->co2_) - LOG_SENSOR(" ", "TVOC Sensor", this->tvoc_) + LOG_SENSOR(" ", "CO2 Sensor", this->co2_); + LOG_SENSOR(" ", "TVOC Sensor", this->tvoc_); LOG_TEXT_SENSOR(" ", "Firmware Version Sensor", this->version_) if (this->baseline_) { ESP_LOGCONFIG(TAG, " Baseline: %04X", *this->baseline_); diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp index 4842ee5d06..52ec8433a2 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp @@ -58,10 +58,10 @@ void GroveGasMultichannelV2Component::dump_config() { ESP_LOGCONFIG(TAG, "Grove Multichannel Gas Sensor V2"); LOG_I2C_DEVICE(this) LOG_UPDATE_INTERVAL(this) - LOG_SENSOR(" ", "Nitrogen Dioxide", this->nitrogen_dioxide_sensor_) - LOG_SENSOR(" ", "Ethanol", this->ethanol_sensor_) - LOG_SENSOR(" ", "Carbon Monoxide", this->carbon_monoxide_sensor_) - LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_) + LOG_SENSOR(" ", "Nitrogen Dioxide", this->nitrogen_dioxide_sensor_); + LOG_SENSOR(" ", "Ethanol", this->ethanol_sensor_); + LOG_SENSOR(" ", "Carbon Monoxide", this->carbon_monoxide_sensor_); + LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_); if (this->is_failed()) { switch (this->error_code_) { diff --git a/esphome/components/hlw8012/hlw8012.cpp b/esphome/components/hlw8012/hlw8012.cpp index a28678e630..f293185cce 100644 --- a/esphome/components/hlw8012/hlw8012.cpp +++ b/esphome/components/hlw8012/hlw8012.cpp @@ -43,10 +43,10 @@ void HLW8012Component::dump_config() { " Voltage Divider: %.1f", this->change_mode_every_, this->current_resistor_ * 1000.0f, this->voltage_divider_); LOG_UPDATE_INTERVAL(this) - LOG_SENSOR(" ", "Voltage", this->voltage_sensor_) - LOG_SENSOR(" ", "Current", this->current_sensor_) - LOG_SENSOR(" ", "Power", this->power_sensor_) - LOG_SENSOR(" ", "Energy", this->energy_sensor_) + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); + LOG_SENSOR(" ", "Power", this->power_sensor_); + LOG_SENSOR(" ", "Energy", this->energy_sensor_); } float HLW8012Component::get_setup_priority() const { return setup_priority::DATA; } void HLW8012Component::update() { diff --git a/esphome/components/ntc/ntc.cpp b/esphome/components/ntc/ntc.cpp index 333dbc5a75..b08f84029b 100644 --- a/esphome/components/ntc/ntc.cpp +++ b/esphome/components/ntc/ntc.cpp @@ -11,7 +11,7 @@ void NTC::setup() { if (this->sensor_->has_state()) this->process_(this->sensor_->state); } -void NTC::dump_config() { LOG_SENSOR("", "NTC Sensor", this) } +void NTC::dump_config() { LOG_SENSOR("", "NTC Sensor", this); } float NTC::get_setup_priority() const { return setup_priority::DATA; } void NTC::process_(float value) { if (std::isnan(value)) { diff --git a/esphome/components/pulse_width/pulse_width.cpp b/esphome/components/pulse_width/pulse_width.cpp index 8d66861049..c086ceaa23 100644 --- a/esphome/components/pulse_width/pulse_width.cpp +++ b/esphome/components/pulse_width/pulse_width.cpp @@ -17,7 +17,7 @@ void IRAM_ATTR PulseWidthSensorStore::gpio_intr(PulseWidthSensorStore *arg) { } void PulseWidthSensor::dump_config() { - LOG_SENSOR("", "Pulse Width", this) + LOG_SENSOR("", "Pulse Width", this); LOG_UPDATE_INTERVAL(this) LOG_PIN(" Pin: ", this->pin_); } diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 0a82677bc9..6df6347c18 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -6,6 +6,33 @@ namespace sensor { static const char *const TAG = "sensor"; +// Function implementation of LOG_SENSOR macro to reduce code size +void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *obj) { + if (obj == nullptr) { + return; + } + + ESP_LOGCONFIG(tag, + "%s%s '%s'\n" + "%s State Class: '%s'\n" + "%s Unit of Measurement: '%s'\n" + "%s Accuracy Decimals: %d", + prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()).c_str(), + prefix, obj->get_unit_of_measurement().c_str(), prefix, obj->get_accuracy_decimals()); + + if (!obj->get_device_class().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); + } + + if (!obj->get_icon().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + } + + if (obj->get_force_update()) { + ESP_LOGV(tag, "%s Force Update: YES", prefix); + } +} + std::string state_class_to_string(StateClass state_class) { switch (state_class) { case STATE_CLASS_MEASUREMENT: diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index c2ded0f2c3..b3206d8dab 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -12,26 +12,9 @@ namespace esphome { namespace sensor { -#define LOG_SENSOR(prefix, type, obj) \ - if ((obj) != nullptr) { \ - ESP_LOGCONFIG(TAG, \ - "%s%s '%s'\n" \ - "%s State Class: '%s'\n" \ - "%s Unit of Measurement: '%s'\n" \ - "%s Accuracy Decimals: %d", \ - prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str(), prefix, \ - state_class_to_string((obj)->get_state_class()).c_str(), prefix, \ - (obj)->get_unit_of_measurement().c_str(), prefix, (obj)->get_accuracy_decimals()); \ - if (!(obj)->get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ - } \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ - } \ - if ((obj)->get_force_update()) { \ - ESP_LOGV(TAG, "%s Force Update: YES", prefix); \ - } \ - } +void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *obj); + +#define LOG_SENSOR(prefix, type, obj) log_sensor(TAG, prefix, LOG_STR_LITERAL(type), obj) #define SUB_SENSOR(name) \ protected: \ diff --git a/esphome/components/ufire_ec/ufire_ec.cpp b/esphome/components/ufire_ec/ufire_ec.cpp index 364a133776..9e0055a2cc 100644 --- a/esphome/components/ufire_ec/ufire_ec.cpp +++ b/esphome/components/ufire_ec/ufire_ec.cpp @@ -105,9 +105,9 @@ void UFireECComponent::dump_config() { ESP_LOGCONFIG(TAG, "uFire-EC"); LOG_I2C_DEVICE(this) LOG_UPDATE_INTERVAL(this) - LOG_SENSOR(" ", "EC Sensor", this->ec_sensor_) - LOG_SENSOR(" ", "Temperature Sensor", this->temperature_sensor_) - LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_) + LOG_SENSOR(" ", "EC Sensor", this->ec_sensor_); + LOG_SENSOR(" ", "Temperature Sensor", this->temperature_sensor_); + LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_); ESP_LOGCONFIG(TAG, " Temperature Compensation: %f\n" " Temperature Coefficient: %f", diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index 503d993fb7..9e0e7e265d 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -142,9 +142,9 @@ void UFireISEComponent::dump_config() { ESP_LOGCONFIG(TAG, "uFire-ISE"); LOG_I2C_DEVICE(this) LOG_UPDATE_INTERVAL(this) - LOG_SENSOR(" ", "PH Sensor", this->ph_sensor_) - LOG_SENSOR(" ", "Temperature Sensor", this->temperature_sensor_) - LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_) + LOG_SENSOR(" ", "PH Sensor", this->ph_sensor_); + LOG_SENSOR(" ", "Temperature Sensor", this->temperature_sensor_); + LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_); } } // namespace ufire_ise From 4ccc6aee095f5f0bff362643faf03b5e8c4bf359 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:35:53 -0500 Subject: [PATCH 0643/1626] [button] Convert LOG_BUTTON macro to function to reduce flash usage (#10295) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/button/button.cpp | 13 +++++++++++++ esphome/components/button/button.h | 11 ++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/esphome/components/button/button.cpp b/esphome/components/button/button.cpp index 4c4cb7740c..63d71dcb8a 100644 --- a/esphome/components/button/button.cpp +++ b/esphome/components/button/button.cpp @@ -6,6 +6,19 @@ namespace button { static const char *const TAG = "button"; +// Function implementation of LOG_BUTTON macro to reduce code size +void log_button(const char *tag, const char *prefix, const char *type, Button *obj) { + if (obj == nullptr) { + return; + } + + ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); + + if (!obj->get_icon().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + } +} + void Button::press() { ESP_LOGD(TAG, "'%s' Pressed.", this->get_name().c_str()); this->press_action(); diff --git a/esphome/components/button/button.h b/esphome/components/button/button.h index 9488eca221..75b76f9dcf 100644 --- a/esphome/components/button/button.h +++ b/esphome/components/button/button.h @@ -7,13 +7,10 @@ namespace esphome { namespace button { -#define LOG_BUTTON(prefix, type, obj) \ - if ((obj) != nullptr) { \ - ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ - } \ - } +class Button; +void log_button(const char *tag, const char *prefix, const char *type, Button *obj); + +#define LOG_BUTTON(prefix, type, obj) log_button(TAG, prefix, LOG_STR_LITERAL(type), obj) #define SUB_BUTTON(name) \ protected: \ From e2a9b85924c3107afb09d61d33a809ae746ecc0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:36:05 -0500 Subject: [PATCH 0644/1626] [number] Convert LOG_NUMBER macro to function to reduce flash usage (#10293) --- esphome/components/number/number.cpp | 21 +++++++++++++++++++++ esphome/components/number/number.h | 17 ++++------------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index b6a845b19b..4769c1ed12 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -6,6 +6,27 @@ namespace number { static const char *const TAG = "number"; +// Function implementation of LOG_NUMBER macro to reduce code size +void log_number(const char *tag, const char *prefix, const char *type, Number *obj) { + if (obj == nullptr) { + return; + } + + ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); + + if (!obj->get_icon().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + } + + if (!obj->traits.get_unit_of_measurement().empty()) { + ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj->traits.get_unit_of_measurement().c_str()); + } + + if (!obj->traits.get_device_class().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->traits.get_device_class().c_str()); + } +} + void Number::publish_state(float state) { this->set_has_state(true); this->state = state; diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index 49bcbb857c..da91d70d53 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -9,19 +9,10 @@ namespace esphome { namespace number { -#define LOG_NUMBER(prefix, type, obj) \ - if ((obj) != nullptr) { \ - ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ - } \ - if (!(obj)->traits.get_unit_of_measurement().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Unit of Measurement: '%s'", prefix, (obj)->traits.get_unit_of_measurement().c_str()); \ - } \ - if (!(obj)->traits.get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->traits.get_device_class().c_str()); \ - } \ - } +class Number; +void log_number(const char *tag, const char *prefix, const char *type, Number *obj); + +#define LOG_NUMBER(prefix, type, obj) log_number(TAG, prefix, LOG_STR_LITERAL(type), obj) #define SUB_NUMBER(name) \ protected: \ From 634f687c3e448a6ff05eacd17897016c3e1a651c Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Wed, 20 Aug 2025 04:38:13 +0200 Subject: [PATCH 0645/1626] [light] Add support for querying effects by index (#10195) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../light/addressable_light_effect.h | 7 ++++ esphome/components/light/base_light_effects.h | 8 ++++ esphome/components/light/light_effect.cpp | 36 ++++++++++++++++++ esphome/components/light/light_effect.h | 14 +++++++ .../components/light/light_json_schema.cpp | 10 ++++- esphome/components/light/light_state.h | 38 +++++++++++++++++++ 6 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 esphome/components/light/light_effect.cpp diff --git a/esphome/components/light/addressable_light_effect.h b/esphome/components/light/addressable_light_effect.h index d622ec0375..fcf76b3cb0 100644 --- a/esphome/components/light/addressable_light_effect.h +++ b/esphome/components/light/addressable_light_effect.h @@ -44,6 +44,13 @@ class AddressableLightEffect : public LightEffect { this->apply(*this->get_addressable_(), current_color); } + /// Get effect index specifically for addressable effects. + /// Can be used by effects to modify behavior based on their position in the list. + uint32_t get_effect_index() const { return this->get_index(); } + + /// Check if this is the currently running addressable effect. + bool is_current_effect() const { return this->is_active() && this->get_addressable_()->is_effect_active(); } + protected: AddressableLight *get_addressable_() const { return (AddressableLight *) this->state_->get_output(); } }; diff --git a/esphome/components/light/base_light_effects.h b/esphome/components/light/base_light_effects.h index 9e02e889c9..ff6cd1ccfe 100644 --- a/esphome/components/light/base_light_effects.h +++ b/esphome/components/light/base_light_effects.h @@ -125,6 +125,10 @@ class LambdaLightEffect : public LightEffect { } } + /// Get the current effect index for use in lambda functions. + /// This can be useful for lambda effects that need to know their own index. + uint32_t get_current_index() const { return this->get_index(); } + protected: std::function f_; uint32_t update_interval_; @@ -143,6 +147,10 @@ class AutomationLightEffect : public LightEffect { } Trigger<> *get_trig() const { return trig_; } + /// Get the current effect index for use in automations. + /// Useful for automations that need to know which effect is running. + uint32_t get_current_index() const { return this->get_index(); } + protected: Trigger<> *trig_{new Trigger<>}; }; diff --git a/esphome/components/light/light_effect.cpp b/esphome/components/light/light_effect.cpp new file mode 100644 index 0000000000..a210b48e5b --- /dev/null +++ b/esphome/components/light/light_effect.cpp @@ -0,0 +1,36 @@ +#include "light_effect.h" +#include "light_state.h" + +namespace esphome { +namespace light { + +uint32_t LightEffect::get_index() const { + if (this->state_ == nullptr) { + return 0; + } + return this->get_index_in_parent_(); +} + +bool LightEffect::is_active() const { + if (this->state_ == nullptr) { + return false; + } + return this->get_index() != 0 && this->state_->get_current_effect_index() == this->get_index(); +} + +uint32_t LightEffect::get_index_in_parent_() const { + if (this->state_ == nullptr) { + return 0; + } + + const auto &effects = this->state_->get_effects(); + for (size_t i = 0; i < effects.size(); i++) { + if (effects[i] == this) { + return i + 1; // Effects are 1-indexed in the API + } + } + return 0; // Not found +} + +} // namespace light +} // namespace esphome diff --git a/esphome/components/light/light_effect.h b/esphome/components/light/light_effect.h index 8da51fe8b3..dbaf1faf24 100644 --- a/esphome/components/light/light_effect.h +++ b/esphome/components/light/light_effect.h @@ -34,9 +34,23 @@ class LightEffect { this->init(); } + /// Get the index of this effect in the parent light's effect list. + /// Returns 0 if not found or not initialized. + uint32_t get_index() const; + + /// Check if this effect is currently active. + bool is_active() const; + + /// Get a reference to the parent light state. + /// Returns nullptr if not initialized. + LightState *get_light_state() const { return this->state_; } + protected: LightState *state_{nullptr}; std::string name_; + + /// Internal method to find this effect's index in the parent light's effect list. + uint32_t get_index_in_parent_() const; }; } // namespace light diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 896b821705..010e130612 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -36,8 +36,11 @@ static constexpr const char *get_color_mode_json_str(ColorMode mode) { void LightJSONSchema::dump_json(LightState &state, JsonObject root) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - if (state.supports_effects()) + if (state.supports_effects()) { root["effect"] = state.get_effect_name(); + root["effect_index"] = state.get_current_effect_index(); + root["effect_count"] = state.get_effect_count(); + } auto values = state.remote_values; auto traits = state.get_output()->get_traits(); @@ -160,6 +163,11 @@ void LightJSONSchema::parse_json(LightState &state, LightCall &call, JsonObject const char *effect = root["effect"]; call.set_effect(effect); } + + if (root["effect_index"].is()) { + uint32_t effect_index = root["effect_index"]; + call.set_effect(effect_index); + } } } // namespace light diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 94b81dee61..48323dd3c3 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -163,6 +163,44 @@ class LightState : public EntityBase, public Component { /// Add effects for this light state. void add_effects(const std::vector &effects); + /// Get the total number of effects available for this light. + size_t get_effect_count() const { return this->effects_.size(); } + + /// Get the currently active effect index (0 = no effect, 1+ = effect index). + uint32_t get_current_effect_index() const { return this->active_effect_index_; } + + /// Get effect index by name. Returns 0 if effect not found. + uint32_t get_effect_index(const std::string &effect_name) const { + if (strcasecmp(effect_name.c_str(), "none") == 0) { + return 0; + } + for (size_t i = 0; i < this->effects_.size(); i++) { + if (strcasecmp(effect_name.c_str(), this->effects_[i]->get_name().c_str()) == 0) { + return i + 1; // Effects are 1-indexed in active_effect_index_ + } + } + return 0; // Effect not found + } + + /// Get effect by index. Returns nullptr if index is invalid. + LightEffect *get_effect_by_index(uint32_t index) const { + if (index == 0 || index > this->effects_.size()) { + return nullptr; + } + return this->effects_[index - 1]; // Effects are 1-indexed in active_effect_index_ + } + + /// Get effect name by index. Returns "None" for index 0, empty string for invalid index. + std::string get_effect_name_by_index(uint32_t index) const { + if (index == 0) { + return "None"; + } + if (index > this->effects_.size()) { + return ""; // Invalid index + } + return this->effects_[index - 1]->get_name(); + } + /// The result of all the current_values_as_* methods have gamma correction applied. void current_values_as_binary(bool *binary); From 6819bbd8f8bf90e307914fef6124b9f18814f645 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:38:32 -0500 Subject: [PATCH 0646/1626] [esp32_ble_client] Add log helper functions to reduce flash usage by 120 bytes (#10243) --- .../esp32_ble_client/ble_client_base.cpp | 42 ++++++++++--------- .../esp32_ble_client/ble_client_base.h | 4 ++ 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 2d84436d84..e23be2e0c1 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -159,8 +159,7 @@ void BLEClientBase::disconnect() { return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { - ESP_LOGW(TAG, "[%d] [%s] Disconnecting before connected, disconnect scheduled.", this->connection_index_, - this->address_str_.c_str()); + this->log_warning_("Disconnect before connected, disconnect scheduled."); this->want_disconnect_ = true; return; } @@ -172,13 +171,11 @@ void BLEClientBase::unconditional_disconnect() { ESP_LOGI(TAG, "[%d] [%s] Disconnecting (conn_id: %d).", this->connection_index_, this->address_str_.c_str(), this->conn_id_); if (this->state_ == espbt::ClientState::DISCONNECTING) { - ESP_LOGE(TAG, "[%d] [%s] Tried to disconnect while already disconnecting.", this->connection_index_, - this->address_str_.c_str()); + this->log_error_("Already disconnecting"); return; } if (this->conn_id_ == UNSET_CONN_ID) { - ESP_LOGE(TAG, "[%d] [%s] No connection ID set, cannot disconnect.", this->connection_index_, - this->address_str_.c_str()); + this->log_error_("conn id unset, cannot disconnect"); return; } auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_); @@ -234,6 +231,18 @@ void BLEClientBase::log_connection_params_(const char *param_type) { ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); } +void BLEClientBase::log_error_(const char *message) { + ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); +} + +void BLEClientBase::log_error_(const char *message, int code) { + ESP_LOGE(TAG, "[%d] [%s] %s=%d", this->connection_index_, this->address_str_.c_str(), message, code); +} + +void BLEClientBase::log_warning_(const char *message) { + ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); +} + void BLEClientBase::restore_medium_conn_params_() { // Restore to medium connection parameters after initial connection phase // This balances performance with bandwidth usage for normal operation @@ -264,8 +273,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->app_id); this->gattc_if_ = esp_gattc_if; } else { - ESP_LOGE(TAG, "[%d] [%s] gattc app registration failed id=%d code=%d", this->connection_index_, - this->address_str_.c_str(), param->reg.app_id, param->reg.status); + this->log_error_("gattc app registration failed status", param->reg.status); this->status_ = param->reg.status; this->mark_failed(); } @@ -281,8 +289,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // This should not happen but lets log it in case it does // because it means we have a bad assumption about how the // ESP BT stack works. - ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while in %s state, status=%d", this->connection_index_, - this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status); + this->log_error_("ESP_GATTC_OPEN_EVT wrong state status", param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); @@ -307,7 +314,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->state_ = espbt::ClientState::ESTABLISHED; break; } - ESP_LOGD(TAG, "[%d] [%s] Searching for services", this->connection_index_, this->address_str_.c_str()); + this->log_event_("Searching for services"); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); break; } @@ -332,8 +339,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // Check if we were disconnected while waiting for service discovery if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state_ == espbt::ClientState::CONNECTED) { - ESP_LOGW(TAG, "[%d] [%s] Disconnected by remote during service discovery", this->connection_index_, - this->address_str_.c_str()); + this->log_warning_("Remote closed during discovery"); } else { ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, this->address_str_.c_str(), param->disconnect.reason); @@ -506,16 +512,14 @@ void BLEClientBase::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_ return; esp_bd_addr_t bd_addr; memcpy(bd_addr, param->ble_security.auth_cmpl.bd_addr, sizeof(esp_bd_addr_t)); - ESP_LOGI(TAG, "[%d] [%s] auth complete. remote BD_ADDR: %s", this->connection_index_, this->address_str_.c_str(), + ESP_LOGI(TAG, "[%d] [%s] auth complete addr: %s", this->connection_index_, this->address_str_.c_str(), format_hex(bd_addr, 6).c_str()); if (!param->ble_security.auth_cmpl.success) { - ESP_LOGE(TAG, "[%d] [%s] auth fail reason = 0x%x", this->connection_index_, this->address_str_.c_str(), - param->ble_security.auth_cmpl.fail_reason); + this->log_error_("auth fail reason", param->ble_security.auth_cmpl.fail_reason); } else { this->paired_ = true; - ESP_LOGD(TAG, "[%d] [%s] auth success. address type = %d auth mode = %d", this->connection_index_, - this->address_str_.c_str(), param->ble_security.auth_cmpl.addr_type, - param->ble_security.auth_cmpl.auth_mode); + ESP_LOGD(TAG, "[%d] [%s] auth success type = %d mode = %d", this->connection_index_, this->address_str_.c_str(), + param->ble_security.auth_cmpl.addr_type, param->ble_security.auth_cmpl.auth_mode); } break; diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 2d00688dbd..1850b2c5b3 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -137,6 +137,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); + // Compact error logging helpers to reduce flash usage + void log_error_(const char *message); + void log_error_(const char *message, int code); + void log_warning_(const char *message); }; } // namespace esphome::esp32_ble_client From 5a6db28f1deb694e230013260768a9044de9f456 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:39:29 +1200 Subject: [PATCH 0647/1626] [CI] Base ``too-big`` label on new additions only (#10307) --- .github/workflows/auto-label-pr.yml | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 63f059eb6d..748235df30 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -105,7 +105,9 @@ jobs: // Calculate data from PR files const changedFiles = prFiles.map(file => file.filename); - const totalChanges = prFiles.reduce((sum, file) => sum + (file.additions || 0) + (file.deletions || 0), 0); + const totalAdditions = prFiles.reduce((sum, file) => sum + (file.additions || 0), 0); + const totalDeletions = prFiles.reduce((sum, file) => sum + (file.deletions || 0), 0); + const totalChanges = totalAdditions + totalDeletions; console.log('Current labels:', currentLabels.join(', ')); console.log('Changed files:', changedFiles.length); @@ -231,16 +233,21 @@ jobs: // Strategy: PR size detection async function detectPRSize() { const labels = new Set(); - const testChanges = prFiles - .filter(file => file.filename.startsWith('tests/')) - .reduce((sum, file) => sum + (file.additions || 0) + (file.deletions || 0), 0); - - const nonTestChanges = totalChanges - testChanges; if (totalChanges <= SMALL_PR_THRESHOLD) { labels.add('small-pr'); + return labels; } + const testAdditions = prFiles + .filter(file => file.filename.startsWith('tests/')) + .reduce((sum, file) => sum + (file.additions || 0), 0); + const testDeletions = prFiles + .filter(file => file.filename.startsWith('tests/')) + .reduce((sum, file) => sum + (file.deletions || 0), 0); + + const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); + // Don't add too-big if mega-pr label is already present if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) { labels.add('too-big'); @@ -412,10 +419,13 @@ jobs: // Too big message if (finalLabels.includes('too-big')) { - const testChanges = prFiles + const testAdditions = prFiles .filter(file => file.filename.startsWith('tests/')) - .reduce((sum, file) => sum + (file.additions || 0) + (file.deletions || 0), 0); - const nonTestChanges = totalChanges - testChanges; + .reduce((sum, file) => sum + (file.additions || 0), 0); + const testDeletions = prFiles + .filter(file => file.filename.startsWith('tests/')) + .reduce((sum, file) => sum + (file.deletions || 0), 0); + const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); const tooManyLabels = finalLabels.length > MAX_LABELS; const tooManyChanges = nonTestChanges > TOO_BIG_THRESHOLD; From 0089619518a9aa7cd132cd30a0bafbb071e15a22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:41:34 -0500 Subject: [PATCH 0648/1626] [web_server] Reduce flash usage by consolidating defer calls in switch and lock handlers (#10297) --- esphome/components/web_server/web_server.cpp | 70 ++++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 399b8785ae..290992b096 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -507,14 +507,37 @@ void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlM auto detail = get_request_detail(request); std::string data = this->switch_json(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); - } else if (match.method_equals("toggle")) { - this->defer([obj]() { obj->toggle(); }); - request->send(200); + return; + } + + // Handle action methods with single defer and response + enum SwitchAction { NONE, TOGGLE, TURN_ON, TURN_OFF }; + SwitchAction action = NONE; + + if (match.method_equals("toggle")) { + action = TOGGLE; } else if (match.method_equals("turn_on")) { - this->defer([obj]() { obj->turn_on(); }); - request->send(200); + action = TURN_ON; } else if (match.method_equals("turn_off")) { - this->defer([obj]() { obj->turn_off(); }); + action = TURN_OFF; + } + + if (action != NONE) { + this->defer([obj, action]() { + switch (action) { + case TOGGLE: + obj->toggle(); + break; + case TURN_ON: + obj->turn_on(); + break; + case TURN_OFF: + obj->turn_off(); + break; + default: + break; + } + }); request->send(200); } else { request->send(404); @@ -1332,14 +1355,37 @@ void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMat auto detail = get_request_detail(request); std::string data = this->lock_json(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); - } else if (match.method_equals("lock")) { - this->defer([obj]() { obj->lock(); }); - request->send(200); + return; + } + + // Handle action methods with single defer and response + enum LockAction { NONE, LOCK, UNLOCK, OPEN }; + LockAction action = NONE; + + if (match.method_equals("lock")) { + action = LOCK; } else if (match.method_equals("unlock")) { - this->defer([obj]() { obj->unlock(); }); - request->send(200); + action = UNLOCK; } else if (match.method_equals("open")) { - this->defer([obj]() { obj->open(); }); + action = OPEN; + } + + if (action != NONE) { + this->defer([obj, action]() { + switch (action) { + case LOCK: + obj->lock(); + break; + case UNLOCK: + obj->unlock(); + break; + case OPEN: + obj->open(); + break; + default: + break; + } + }); request->send(200); } else { request->send(404); From fc1b49e87d2066c0f3d08e58c7c8f29b321dcdfa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:42:33 +1200 Subject: [PATCH 0649/1626] Bump ruamel-yaml from 0.18.14 to 0.18.15 (#10310) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0675115c02..2665211381 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ esphome-dashboard==20250814.0 aioesphomeapi==39.0.0 zeroconf==0.147.0 puremagic==1.30 -ruamel.yaml==0.18.14 # dashboard_import +ruamel.yaml==0.18.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==10.4.0 cairosvg==2.8.2 From 56b6dd31f13fccf83f7bd04f819257019ffdc66b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:45:13 -0500 Subject: [PATCH 0650/1626] [core] Eliminate heap allocation in teardown_components by using StaticVector (#10256) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/application.cpp | 79 +++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index d2d47fe171..dc745a2a46 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -256,30 +256,79 @@ void Application::run_powerdown_hooks() { void Application::teardown_components(uint32_t timeout_ms) { uint32_t start_time = millis(); - // Copy all components in reverse order using reverse iterators + // Use a StaticVector instead of std::vector to avoid heap allocation + // since we know the actual size at compile time + StaticVector pending_components; + + // Copy all components in reverse order // Reverse order matches the behavior of run_safe_shutdown_hooks() above and ensures // components are torn down in the opposite order of their setup_priority (which is // used to sort components during Application::setup()) - std::vector pending_components(this->components_.rbegin(), this->components_.rend()); + size_t num_components = this->components_.size(); + for (size_t i = 0; i < num_components; ++i) { + pending_components[i] = this->components_[num_components - 1 - i]; + } uint32_t now = start_time; - while (!pending_components.empty() && (now - start_time) < timeout_ms) { + size_t pending_count = num_components; + + // Teardown Algorithm + // ================== + // We iterate through pending components, calling teardown() on each. + // Components that return false (need more time) are copied forward + // in the array. Components that return true (finished) are skipped. + // + // The compaction happens in-place during iteration: + // - still_pending tracks the write position (where to put next pending component) + // - i tracks the read position (which component we're testing) + // - When teardown() returns false, we copy component[i] to component[still_pending] + // - When teardown() returns true, we just skip it (don't increment still_pending) + // + // Example with 4 components where B can teardown immediately: + // + // Start: + // pending_components: [A, B, C, D] + // pending_count: 4 ^----------^ + // + // Iteration 1: + // i=0: A needs more time → keep at pos 0 (no copy needed) + // i=1: B finished → skip + // i=2: C needs more time → copy to pos 1 + // i=3: D needs more time → copy to pos 2 + // + // After iteration 1: + // pending_components: [A, C, D | D] + // pending_count: 3 ^--------^ + // + // Iteration 2: + // i=0: A finished → skip + // i=1: C needs more time → copy to pos 0 + // i=2: D finished → skip + // + // After iteration 2: + // pending_components: [C | C, D, D] (positions 1-3 have old values) + // pending_count: 1 ^--^ + + while (pending_count > 0 && (now - start_time) < timeout_ms) { // Feed watchdog during teardown to prevent triggering this->feed_wdt(now); - // Use iterator to safely erase elements - for (auto it = pending_components.begin(); it != pending_components.end();) { - if ((*it)->teardown()) { - // Component finished teardown, erase it - it = pending_components.erase(it); - } else { - // Component still needs time - ++it; + // Process components and compact the array, keeping only those still pending + size_t still_pending = 0; + for (size_t i = 0; i < pending_count; ++i) { + if (!pending_components[i]->teardown()) { + // Component still needs time, copy it forward + if (still_pending != i) { + pending_components[still_pending] = pending_components[i]; + } + ++still_pending; } + // Component finished teardown, skip it (don't increment still_pending) } + pending_count = still_pending; // Give some time for I/O operations if components are still pending - if (!pending_components.empty()) { + if (pending_count > 0) { this->yield_with_select_(1); } @@ -287,11 +336,11 @@ void Application::teardown_components(uint32_t timeout_ms) { now = millis(); } - if (!pending_components.empty()) { + if (pending_count > 0) { // Note: At this point, connections are either disconnected or in a bad state, // so this warning will only appear via serial rather than being transmitted to clients - for (auto *component : pending_components) { - ESP_LOGW(TAG, "%s did not complete teardown within %" PRIu32 " ms", component->get_component_source(), + for (size_t i = 0; i < pending_count; ++i) { + ESP_LOGW(TAG, "%s did not complete teardown within %" PRIu32 " ms", pending_components[i]->get_component_source(), timeout_ms); } } From 2cbf4f30f97e676613da0c1cba0a9f9236268ade Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:48:04 -0500 Subject: [PATCH 0651/1626] [libretiny] Optimize preferences is_changed() by replacing temporary vector with unique_ptr (#10272) --- esphome/components/libretiny/preferences.cpp | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index ce4ed915c0..fc535c99b4 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -5,7 +5,7 @@ #include "esphome/core/preferences.h" #include #include -#include +#include #include namespace esphome { @@ -139,21 +139,29 @@ class LibreTinyPreferences : public ESPPreferences { } bool is_changed(const fdb_kvdb_t db, const NVSData &to_save) { - NVSData stored_data{}; struct fdb_kv kv; fdb_kv_t kvp = fdb_kv_get_obj(db, to_save.key.c_str(), &kv); if (kvp == nullptr) { ESP_LOGV(TAG, "fdb_kv_get_obj('%s'): nullptr - the key might not be set yet", to_save.key.c_str()); return true; } - stored_data.data.resize(kv.value_len); - fdb_blob_make(&blob, stored_data.data.data(), kv.value_len); + + // Check size first - if different, data has changed + if (kv.value_len != to_save.data.size()) { + return true; + } + + // Allocate buffer on heap to avoid stack allocation for large data + auto stored_data = std::make_unique(kv.value_len); + fdb_blob_make(&blob, stored_data.get(), kv.value_len); size_t actual_len = fdb_kv_get_blob(db, to_save.key.c_str(), &blob); if (actual_len != kv.value_len) { ESP_LOGV(TAG, "fdb_kv_get_blob('%s') len mismatch: %u != %u", to_save.key.c_str(), actual_len, kv.value_len); return true; } - return to_save.data != stored_data.data; + + // Compare the actual data + return memcmp(to_save.data.data(), stored_data.get(), kv.value_len) != 0; } bool reset() override { From 3ff5b4773b7783065e5bcb64059f47700d56f55f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:48:40 -0500 Subject: [PATCH 0652/1626] [bluetooth_proxy] Mark BluetoothConnection and BluetoothProxy as final for compiler optimizations (#10280) --- esphome/components/bluetooth_proxy/bluetooth_connection.h | 2 +- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index a975d25d91..e5d5ff2dd6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -8,7 +8,7 @@ namespace esphome::bluetooth_proxy { class BluetoothProxy; -class BluetoothConnection : public esp32_ble_client::BLEClientBase { +class BluetoothConnection final : public esp32_ble_client::BLEClientBase { public: void dump_config() override; void loop() override; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index bc8d3ed762..c81c8c9532 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -50,7 +50,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; -class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, public Component { friend class BluetoothConnection; // Allow connection to update connections_free_response_ public: BluetoothProxy(); From c5b794e41c18d1b548a26e6185387af2e4eb9e6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:53:28 -0500 Subject: [PATCH 0653/1626] merge --- esphome/core/application.cpp | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 4107287224..dc745a2a46 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -272,28 +272,6 @@ void Application::teardown_components(uint32_t timeout_ms) { uint32_t now = start_time; size_t pending_count = num_components; -<<<<<<< HEAD - // Compaction algorithm for teardown - // ================================== - // We repeatedly call teardown() on each component until it returns true. - // Components that are done are removed using array compaction: - // - // Initial state (all components pending): - // pending_components: [A, B, C, D, E, F] - // pending_count: 6 ^ - // - // After first iteration (B and D finish teardown): - // pending_components: [A, C, E, F | B, D] (B, D are still in memory but ignored) - // pending_count: 4 ^ - // - // After second iteration (A finishes): - // pending_components: [C, E, F | A, B, D] - // pending_count: 3 ^ - // - // The algorithm compacts remaining components to the front of the array, - // tracking only the count of pending components. This avoids expensive - // erase operations while maintaining O(n) complexity per iteration. -======= // Teardown Algorithm // ================== // We iterate through pending components, calling teardown() on each. @@ -330,7 +308,6 @@ void Application::teardown_components(uint32_t timeout_ms) { // After iteration 2: // pending_components: [C | C, D, D] (positions 1-3 have old values) // pending_count: 1 ^--^ ->>>>>>> upstream/dev while (pending_count > 0 && (now - start_time) < timeout_ms) { // Feed watchdog during teardown to prevent triggering @@ -340,11 +317,7 @@ void Application::teardown_components(uint32_t timeout_ms) { size_t still_pending = 0; for (size_t i = 0; i < pending_count; ++i) { if (!pending_components[i]->teardown()) { -<<<<<<< HEAD - // Component still needs time, keep it in the list -======= // Component still needs time, copy it forward ->>>>>>> upstream/dev if (still_pending != i) { pending_components[still_pending] = pending_components[i]; } From 86f306ba9e32e020b5ce77f5d1a74f4bf7060daa Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 20 Aug 2025 22:02:14 +1200 Subject: [PATCH 0654/1626] [CI] Also require tests for ``new-features`` (#10311) --- .github/workflows/auto-label-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 748235df30..c42b5330d2 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -382,7 +382,7 @@ jobs: const labels = new Set(); // Check for missing tests - if ((allLabels.has('new-component') || allLabels.has('new-platform')) && !allLabels.has('has-tests')) { + if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) { labels.add('needs-tests'); } From d8c85bfc447b7f69aad3d491b97aa9075d4dc526 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 06:44:25 -0500 Subject: [PATCH 0655/1626] [bluetooth_proxy] Remove unused ClientState::SEARCHING state --- .../bluetooth_proxy/bluetooth_proxy.cpp | 16 +++++++++------- .../esp32_ble_client/ble_client_base.cpp | 5 ++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 2 -- .../esp32_ble_tracker/esp32_ble_tracker.h | 9 +-------- 4 files changed, 12 insertions(+), 20 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 723466a5ff..80b7fbe960 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -183,6 +183,12 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_device_connection(msg.address, false); return; } + if (!msg.has_address_type) { + ESP_LOGE(TAG, "[%d] [%s] Missing address type in connect request", connection->get_connection_index(), + connection->address_str().c_str()); + this->send_device_connection(msg.address, false); + return; + } if (connection->state() == espbt::ClientState::CONNECTED || connection->state() == espbt::ClientState::ESTABLISHED) { this->log_connection_request_ignored_(connection, connection->state()); @@ -209,13 +215,9 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); } - if (msg.has_address_type) { - uint64_to_bd_addr(msg.address, connection->remote_bda_); - connection->set_remote_addr_type(static_cast(msg.address_type)); - connection->set_state(espbt::ClientState::DISCOVERED); - } else { - connection->set_state(espbt::ClientState::SEARCHING); - } + uint64_to_bd_addr(msg.address, connection->remote_bda_); + connection->set_remote_addr_type(static_cast(msg.address_type)); + connection->set_state(espbt::ClientState::DISCOVERED); this->send_connections_free(); break; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e23be2e0c1..30e1866f9f 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -92,7 +92,7 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { return false; if (this->address_ == 0 || device.address_uint64() != this->address_) return false; - if (this->state_ != espbt::ClientState::IDLE && this->state_ != espbt::ClientState::SEARCHING) + if (this->state_ != espbt::ClientState::IDLE) return false; this->log_event_("Found device"); @@ -190,8 +190,7 @@ void BLEClientBase::unconditional_disconnect() { this->log_gattc_warning_("esp_ble_gattc_close", err); } - if (this->state_ == espbt::ClientState::SEARCHING || this->state_ == espbt::ClientState::READY_TO_CONNECT || - this->state_ == espbt::ClientState::DISCOVERED) { + if (this->state_ == espbt::ClientState::READY_TO_CONNECT || this->state_ == espbt::ClientState::DISCOVERED) { this->set_address(0); this->set_state(espbt::ClientState::IDLE); } else { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 0455d136df..b385ef0097 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -49,8 +49,6 @@ const char *client_state_to_string(ClientState state) { return "DISCONNECTING"; case ClientState::IDLE: return "IDLE"; - case ClientState::SEARCHING: - return "SEARCHING"; case ClientState::DISCOVERED: return "DISCOVERED"; case ClientState::READY_TO_CONNECT: diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 3022eb25d2..c369608d18 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -141,12 +141,10 @@ class ESPBTDeviceListener { struct ClientStateCounts { uint8_t connecting = 0; uint8_t discovered = 0; - uint8_t searching = 0; uint8_t disconnecting = 0; bool operator==(const ClientStateCounts &other) const { - return connecting == other.connecting && discovered == other.discovered && searching == other.searching && - disconnecting == other.disconnecting; + return connecting == other.connecting && discovered == other.discovered && disconnecting == other.disconnecting; } bool operator!=(const ClientStateCounts &other) const { return !(*this == other); } @@ -159,8 +157,6 @@ enum class ClientState : uint8_t { DISCONNECTING, // Connection is idle, no device detected. IDLE, - // Searching for device. - SEARCHING, // Device advertisement found. DISCOVERED, // Device is discovered and the scanner is stopped @@ -321,9 +317,6 @@ class ESP32BLETracker : public Component, case ClientState::DISCOVERED: counts.discovered++; break; - case ClientState::SEARCHING: - counts.searching++; - break; case ClientState::CONNECTING: case ClientState::READY_TO_CONNECT: counts.connecting++; From d45944a9e26ab1aa1340e3bb011aca4e3e56412a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 20 Aug 2025 23:47:20 +1200 Subject: [PATCH 0656/1626] [api] Add ``USE_API_HOMEASSISTANT_SERVICES`` if using ``tag_scanned`` action (#10316) --- esphome/components/api/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index ba5f994e9a..2672ea1edb 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -321,6 +321,7 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value( HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA, ) async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args): + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) cg.add(var.set_service("esphome.tag_scanned")) From fbc9b751c5d9c6fafc63260bf03aae7c84a0c83b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 06:58:20 -0500 Subject: [PATCH 0657/1626] preen --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index b385ef0097..e71f79b4fe 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -134,9 +134,8 @@ void ESP32BLETracker::loop() { ClientStateCounts counts = this->count_client_states_(); if (counts != this->client_state_counts_) { this->client_state_counts_ = counts; - ESP_LOGD(TAG, "connecting: %d, discovered: %d, searching: %d, disconnecting: %d", - this->client_state_counts_.connecting, this->client_state_counts_.discovered, - this->client_state_counts_.searching, this->client_state_counts_.disconnecting); + ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d", this->client_state_counts_.connecting, + this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); } if (this->scanner_state_ == ScannerState::FAILED || From 963b0333baab87d926fcded9a1d7fa09e6e8c24f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 06:58:27 -0500 Subject: [PATCH 0658/1626] preen --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index e71f79b4fe..b0057e93a3 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -155,7 +155,7 @@ void ESP32BLETracker::loop() { https://github.com/espressif/esp-idf/issues/6688 */ - bool promote_to_connecting = counts.discovered && !counts.searching && !counts.connecting; + bool promote_to_connecting = counts.discovered && !counts.connecting; if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && !promote_to_connecting) { From df0ed5766735d7a23987fd9306b5c6f7d6ce34b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 06:58:43 -0500 Subject: [PATCH 0659/1626] preen --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index b0057e93a3..d7df6bd51c 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -637,9 +637,8 @@ void ESP32BLETracker::dump_config() { this->scan_duration_, this->scan_interval_ * 0.625f, this->scan_window_ * 0.625f, this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); ESP_LOGCONFIG(TAG, " Scanner State: %s", this->scanner_state_to_string_(this->scanner_state_)); - ESP_LOGCONFIG(TAG, " Connecting: %d, discovered: %d, searching: %d, disconnecting: %d", - this->client_state_counts_.connecting, this->client_state_counts_.discovered, - this->client_state_counts_.searching, this->client_state_counts_.disconnecting); + ESP_LOGCONFIG(TAG, " Connecting: %d, discovered: %d, disconnecting: %d", this->client_state_counts_.connecting, + this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); if (this->scan_start_fail_count_) { ESP_LOGCONFIG(TAG, " Scan Start Fail Count: %d", this->scan_start_fail_count_); } From 9f4e31b07cc5ff87beb8a5ba207d31406b2610d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 07:01:13 -0500 Subject: [PATCH 0660/1626] preen --- .../components/esp32_ble_tracker/esp32_ble_tracker.cpp | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index d7df6bd51c..62fcd51ccf 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -155,10 +155,8 @@ void ESP32BLETracker::loop() { https://github.com/espressif/esp-idf/issues/6688 */ - bool promote_to_connecting = counts.discovered && !counts.connecting; - if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && - !promote_to_connecting) { + if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && !counts.discovered) { #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE this->update_coex_preference_(false); #endif @@ -167,12 +165,11 @@ void ESP32BLETracker::loop() { } } // If there is a discovered client and no connecting - // clients and no clients using the scanner to search for - // devices, then promote the discovered client to ready to connect. + // clients, then promote the discovered client to ready to connect. // We check both RUNNING and IDLE states because: // - RUNNING: gap_scan_event_handler initiates stop_scan_() but promotion can happen immediately // - IDLE: Scanner has already stopped (naturally or by gap_scan_event_handler) - if (promote_to_connecting && + if (counts.discovered && !counts.connecting && (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::IDLE)) { this->try_promote_discovered_clients_(); } From d03eec5a583753e218ddcc5caf1ce4f7bbfd3715 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 07:44:35 -0500 Subject: [PATCH 0661/1626] [esp32_ble_tracker] Remove duplicate client promotion logic --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 32 ++----------------- 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 0455d136df..00bd1fe34c 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -307,14 +307,7 @@ void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { // Process the scan result immediately - bool found_discovered_client = this->process_scan_result_(scan_result); - - // If we found a discovered client that needs promotion, stop scanning - // This replaces the promote_to_connecting logic from loop() - if (found_discovered_client && this->scanner_state_ == ScannerState::RUNNING) { - ESP_LOGD(TAG, "Found discovered client, stopping scan for connection"); - this->stop_scan_(); - } + this->process_scan_result_(scan_result); } else if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { // Scan finished on its own if (this->scanner_state_ != ScannerState::RUNNING) { @@ -720,20 +713,9 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); } -bool ESP32BLETracker::has_connecting_clients_() const { - for (auto *client : this->clients_) { - auto state = client->state(); - if (state == ClientState::CONNECTING || state == ClientState::READY_TO_CONNECT) { - return true; - } - } - return false; -} #endif // USE_ESP32_BLE_DEVICE -bool ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { - bool found_discovered_client = false; - +void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { // Process raw advertisements if (this->raw_advertisements_) { for (auto *listener : this->listeners_) { @@ -759,14 +741,6 @@ bool ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { for (auto *client : this->clients_) { if (client->parse_device(device)) { found = true; - // Check if this client is discovered and needs promotion - if (client->state() == ClientState::DISCOVERED) { - // Only check for connecting clients if we found a discovered client - // This matches the original logic: !connecting && client->state() == DISCOVERED - if (!this->has_connecting_clients_()) { - found_discovered_client = true; - } - } } } @@ -775,8 +749,6 @@ bool ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { } #endif // USE_ESP32_BLE_DEVICE } - - return found_discovered_client; } void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { From d5557663066aa8c7cbb7753b34a0d7d9a7cb6a96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 08:06:27 -0500 Subject: [PATCH 0662/1626] fix --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 3022eb25d2..7266242284 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -293,7 +293,7 @@ class ESP32BLETracker : public Component, void cleanup_scan_state_(bool is_stop_complete); /// Process a single scan result immediately /// Returns true if a discovered client needs promotion to READY_TO_CONNECT - bool process_scan_result_(const BLEScanResult &scan_result); + void process_scan_result_(const BLEScanResult &scan_result); #ifdef USE_ESP32_BLE_DEVICE /// Check if any clients are in connecting or ready to connect state bool has_connecting_clients_() const; From 4d4ab5b804c936a7a9dfa521429f7a989f6ca705 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 08:07:16 -0500 Subject: [PATCH 0663/1626] preen --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 5 ----- 1 file changed, 5 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 7266242284..763fa9f1c6 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -292,12 +292,7 @@ class ESP32BLETracker : public Component, /// Common cleanup logic when transitioning scanner to IDLE state void cleanup_scan_state_(bool is_stop_complete); /// Process a single scan result immediately - /// Returns true if a discovered client needs promotion to READY_TO_CONNECT void process_scan_result_(const BLEScanResult &scan_result); -#ifdef USE_ESP32_BLE_DEVICE - /// Check if any clients are in connecting or ready to connect state - bool has_connecting_clients_() const; -#endif /// Handle scanner failure states void handle_scanner_failure_(); /// Try to promote discovered clients to ready to connect From 35411d199f79ca92fce2dc18ab91f050d088ba8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 09:10:20 -0500 Subject: [PATCH 0664/1626] [homeassistant] Add compilation test for homeassistant.tag_scanned action (#10319) --- .../homeassistant/test-tag-scanned.esp32-idf.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/components/homeassistant/test-tag-scanned.esp32-idf.yaml diff --git a/tests/components/homeassistant/test-tag-scanned.esp32-idf.yaml b/tests/components/homeassistant/test-tag-scanned.esp32-idf.yaml new file mode 100644 index 0000000000..ef148174d7 --- /dev/null +++ b/tests/components/homeassistant/test-tag-scanned.esp32-idf.yaml @@ -0,0 +1,14 @@ +wifi: + ssid: MySSID + password: password1 + +api: + +esphome: + on_boot: + then: + - homeassistant.tag_scanned: 'test_tag_123' + - homeassistant.tag_scanned: + tag: 'another_tag' + - homeassistant.tag_scanned: + tag: !lambda 'return "dynamic_tag";' From 72c58ae36d85490256bb1bce15b47ccefa6693a6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 21 Aug 2025 02:13:50 +1200 Subject: [PATCH 0665/1626] [core] Add idf-tidy env for esp32-c6 (#10270) --- .clang-tidy.hash | 2 +- platformio.ini | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 30cf982649..a6de2366bc 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -6af8b429b94191fe8e239fcb3b73f7982d0266cb5b05ffbc81edaeac1bc8c273 +0440e35cf89a49e8a35fd3690ed453a72b7b6f61b9d346ced6140e1c0d39dff6 diff --git a/platformio.ini b/platformio.ini index d9f2f879ec..47fc5205bc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -357,6 +357,19 @@ build_flags = ${common:esp32-idf.build_flags} ${flags:runtime.build_flags} -DUSE_ESP32_VARIANT_ESP32C6 +build_unflags = + ${common.build_unflags} + +[env:esp32c6-idf-tidy] +extends = common:esp32-idf +board = esp32-c6-devkitc-1 +board_build.esp-idf.sdkconfig_path = .temp/sdkconfig-esp32c6-idf-tidy +build_flags = + ${common:esp32-idf.build_flags} + ${flags:clangtidy.build_flags} + -DUSE_ESP32_VARIANT_ESP32C6 +build_unflags = + ${common.build_unflags} ;;;;;;;; ESP32-S2 ;;;;;;;; From 998a9264a19bdf75e8f233d95c3e5c270dc85427 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 10:05:46 -0500 Subject: [PATCH 0666/1626] fix race --- .../display/pvvx_display.cpp | 49 +++++++++++++++++-- .../pvvx_mithermometer/display/pvvx_display.h | 2 + 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 4b6c11b332..5ec7296ffd 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -46,10 +46,35 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t } this->connection_established_ = true; this->char_handle_ = chr->handle; -#ifdef USE_TIME - this->sync_time_(); -#endif - this->display(); + + // Check if already paired - if not, writes will be deferred to next update cycle + if (this->parent_->is_paired()) { + ESP_LOGD(TAG, "[%s] Device is paired, writing immediately.", this->parent_->address_str().c_str()); + this->sync_time_and_display_(); + } else { + ESP_LOGD(TAG, "[%s] Device not paired yet, deferring writes until authentication completes.", + this->parent_->address_str().c_str()); + } + break; + } + default: + break; + } +} + +void PVVXDisplay::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + if (!this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr)) + return; + + if (param->ble_security.auth_cmpl.success) { + ESP_LOGD(TAG, "[%s] Authentication successful, performing writes.", this->parent_->address_str().c_str()); + // Now that pairing is complete, perform the pending writes + this->sync_time_and_display_(); + } else { + ESP_LOGW(TAG, "[%s] Authentication failed.", this->parent_->address_str().c_str()); + } break; } default: @@ -81,6 +106,11 @@ void PVVXDisplay::display() { this->parent_->address_str().c_str()); return; } + // Check if authentication is required and not complete + if (!this->parent_->is_paired()) { + ESP_LOGD(TAG, "[%s] Waiting for pairing to complete before writing.", this->parent_->address_str().c_str()); + return; + } ESP_LOGD(TAG, "[%s] Send to display: bignum %d, smallnum: %d, cfg: 0x%02x, validity period: %u.", this->parent_->address_str().c_str(), this->bignum_, this->smallnum_, this->cfg_, this->validity_period_); uint8_t blk[8] = {}; @@ -109,6 +139,10 @@ void PVVXDisplay::send_to_setup_char_(uint8_t *blk, size_t size) { ESP_LOGW(TAG, "[%s] Not connected to BLE client.", this->parent_->address_str().c_str()); return; } + if (!this->parent_->is_paired()) { + ESP_LOGW(TAG, "[%s] Cannot write - authentication not complete.", this->parent_->address_str().c_str()); + return; + } auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, size, blk, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); @@ -127,6 +161,13 @@ void PVVXDisplay::delayed_disconnect_() { this->set_timeout("disconnect", this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); }); } +void PVVXDisplay::sync_time_and_display_() { +#ifdef USE_TIME + this->sync_time_(); +#endif + this->display(); +} + #ifdef USE_TIME void PVVXDisplay::sync_time_() { if (this->time_ == nullptr) diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index 9739362024..c7fc523420 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -43,6 +43,7 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; /// Set validity period of the display information in seconds (1..65535) void set_validity_period(uint16_t validity_period) { this->validity_period_ = validity_period; } @@ -112,6 +113,7 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { void setcfgbit_(uint8_t bit, bool value); void send_to_setup_char_(uint8_t *blk, size_t size); void delayed_disconnect_(); + void sync_time_and_display_(); #ifdef USE_TIME void sync_time_(); time::RealTimeClock *time_{nullptr}; From c88f2eb4d1cfd89bad84ea48ecd818d2a027fc7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 11:38:07 -0500 Subject: [PATCH 0667/1626] reduce --- .../display/pvvx_display.cpp | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 5ec7296ffd..b6916ad68f 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -47,14 +47,11 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t this->connection_established_ = true; this->char_handle_ = chr->handle; - // Check if already paired - if not, writes will be deferred to next update cycle - if (this->parent_->is_paired()) { - ESP_LOGD(TAG, "[%s] Device is paired, writing immediately.", this->parent_->address_str().c_str()); - this->sync_time_and_display_(); - } else { - ESP_LOGD(TAG, "[%s] Device not paired yet, deferring writes until authentication completes.", - this->parent_->address_str().c_str()); - } + // Attempt to write immediately + // For devices without security, this will work + // For devices with security that are already paired, this will work + // For devices that need pairing, the write will be retried after auth completes + this->sync_time_and_display_(); break; } default: @@ -106,11 +103,6 @@ void PVVXDisplay::display() { this->parent_->address_str().c_str()); return; } - // Check if authentication is required and not complete - if (!this->parent_->is_paired()) { - ESP_LOGD(TAG, "[%s] Waiting for pairing to complete before writing.", this->parent_->address_str().c_str()); - return; - } ESP_LOGD(TAG, "[%s] Send to display: bignum %d, smallnum: %d, cfg: 0x%02x, validity period: %u.", this->parent_->address_str().c_str(), this->bignum_, this->smallnum_, this->cfg_, this->validity_period_); uint8_t blk[8] = {}; @@ -139,10 +131,6 @@ void PVVXDisplay::send_to_setup_char_(uint8_t *blk, size_t size) { ESP_LOGW(TAG, "[%s] Not connected to BLE client.", this->parent_->address_str().c_str()); return; } - if (!this->parent_->is_paired()) { - ESP_LOGW(TAG, "[%s] Cannot write - authentication not complete.", this->parent_->address_str().c_str()); - return; - } auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, size, blk, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); From ea5da950c01a47d973fb10d7e4d7abb97365d136 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 12:09:26 -0500 Subject: [PATCH 0668/1626] [core] Improve error reporting for entity name conflicts with non-ASCII characters --- esphome/core/entity_helpers.py | 10 ++++++++ tests/unit_tests/core/test_entity_helpers.py | 25 ++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 1ccc3e2683..c0759e3bb6 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -236,10 +236,20 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" + # Show both original names and their ASCII-only versions if they differ + sanitized_msg = "" + if entity_name != name_key or existing_name != name_key: + sanitized_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + f"\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') to distinguish them" + ) + raise cv.Invalid( f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " f"{conflict_msg}. " f"Each entity on a device must have a unique name within its platform." + f"{sanitized_msg}" ) # Store metadata about this entity diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index db99243a1a..005728dca8 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -705,3 +705,28 @@ def test_empty_or_null_device_id_on_entity() -> None: config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: None} validated2 = validator(config2) assert validated2 == config2 + + +def test_entity_duplicate_validator_non_ascii_names() -> None: + """Test that non-ASCII names show helpful error messages.""" + # Create validator for binary_sensor platform + validator = entity_duplicate_validator("binary_sensor") + + # First Russian sensor should pass + config1 = {CONF_NAME: "Датчик открытия основного крана"} + validated1 = validator(config1) + assert validated1 == config1 + + # Second Russian sensor with different text but same ASCII conversion should fail + config2 = {CONF_NAME: "Датчик закрытия основного крана"} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", + re.DOTALL, + ), + ): + validator(config2) From d182ce8bf6f4fdd1c92fbc3fbb662b1ded563765 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 12:12:39 -0500 Subject: [PATCH 0669/1626] preen --- esphome/core/entity_helpers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index c0759e3bb6..447c894495 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -242,13 +242,14 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy sanitized_msg = ( f"\n Original names: '{entity_name}' and '{existing_name}'" f"\n Both convert to ASCII ID: '{name_key}'" - f"\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') to distinguish them" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" ) raise cv.Invalid( f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " f"{conflict_msg}. " - f"Each entity on a device must have a unique name within its platform." + "Each entity on a device must have a unique name within its platform." f"{sanitized_msg}" ) From 86c3812174ea7907e10d1e84486401d0ef02bdef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 12:15:54 -0500 Subject: [PATCH 0670/1626] preen --- esphome/core/entity_helpers.py | 4 ++-- tests/unit_tests/core/test_entity_helpers.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 447c894495..e1b2a8264b 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -238,12 +238,12 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # Show both original names and their ASCII-only versions if they differ sanitized_msg = "" - if entity_name != name_key or existing_name != name_key: + if entity_name != existing_name: sanitized_msg = ( f"\n Original names: '{entity_name}' and '{existing_name}'" f"\n Both convert to ASCII ID: '{name_key}'" "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" - "\n to distinguish them" + "\n to distinguish them" ) raise cv.Invalid( diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 005728dca8..9ba5367413 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -730,3 +730,23 @@ def test_entity_duplicate_validator_non_ascii_names() -> None: ), ): validator(config2) + + +def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: + """Test that identical names don't show the enhanced message.""" + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # First entity should pass + config1 = {CONF_NAME: "Temperature"} + validated1 = validator(config1) + assert validated1 == config1 + + # Second entity with exact same name should fail without enhanced message + config2 = {CONF_NAME: "Temperature"} + with pytest.raises( + Invalid, + match=r"Duplicate sensor entity with name 'Temperature' found.*" + r"Each entity on a device must have a unique name within its platform\.$", + ): + validator(config2) From 757ad2ff9636b946e341b2b5a96b9df1485d10cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 13:02:14 -0500 Subject: [PATCH 0671/1626] [core] Fix preference storage to account for device_id --- esphome/components/climate/climate.cpp | 2 +- esphome/components/cover/cover.cpp | 2 +- .../components/duty_time/duty_time_sensor.cpp | 2 +- esphome/components/fan/fan.cpp | 3 +- esphome/components/haier/haier_base.cpp | 2 +- esphome/components/haier/hon_climate.cpp | 2 +- .../integration/integration_sensor.cpp | 2 +- esphome/components/ld2450/ld2450.cpp | 2 +- esphome/components/light/light_state.cpp | 4 +- esphome/components/lvgl/number/lvgl_number.h | 2 +- esphome/components/lvgl/select/lvgl_select.h | 2 +- esphome/components/number/automation.cpp | 2 +- .../components/opentherm/number/number.cpp | 2 +- .../rotary_encoder/rotary_encoder.cpp | 2 +- esphome/components/sensor/automation.h | 2 +- .../media_player/speaker_media_player.cpp | 2 +- esphome/components/sprinkler/sprinkler.cpp | 2 +- esphome/components/switch/switch.cpp | 2 +- .../template_alarm_control_panel.cpp | 2 +- .../template/datetime/template_date.cpp | 2 +- .../template/datetime/template_datetime.cpp | 4 +- .../template/datetime/template_time.cpp | 2 +- .../template/number/template_number.cpp | 2 +- .../template/select/template_select.cpp | 2 +- .../template/text/template_text.cpp | 2 +- .../total_daily_energy/total_daily_energy.cpp | 2 +- .../components/tuya/number/tuya_number.cpp | 2 +- esphome/components/valve/valve.cpp | 2 +- esphome/core/entity_base.h | 11 + .../fixtures/multi_device_preferences.yaml | 190 ++++++++++++++++++ .../test_multi_device_preferences.py | 154 ++++++++++++++ 31 files changed, 386 insertions(+), 30 deletions(-) create mode 100644 tests/integration/fixtures/multi_device_preferences.yaml create mode 100644 tests/integration/test_multi_device_preferences.py diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index edebc0de69..be56310b35 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -327,7 +327,7 @@ void Climate::add_on_control_callback(std::function &&callb static const uint32_t RESTORE_STATE_VERSION = 0x848EA6ADUL; optional Climate::restore_state_() { - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash() ^ + this->rtc_ = global_preferences->make_preference(this->get_preference_hash() ^ RESTORE_STATE_VERSION); ClimateDeviceRestoreState recovered{}; if (!this->rtc_.load(&recovered)) diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index 68dfab111b..700bceec01 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -194,7 +194,7 @@ void Cover::publish_state(bool save) { } } optional Cover::restore_state_() { - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); CoverRestoreState recovered{}; if (!this->rtc_.load(&recovered)) return {}; diff --git a/esphome/components/duty_time/duty_time_sensor.cpp b/esphome/components/duty_time/duty_time_sensor.cpp index c7319f7c33..f77f1fcf53 100644 --- a/esphome/components/duty_time/duty_time_sensor.cpp +++ b/esphome/components/duty_time/duty_time_sensor.cpp @@ -41,7 +41,7 @@ void DutyTimeSensor::setup() { uint32_t seconds = 0; if (this->restore_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); this->pref_.load(&seconds); } diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 82fc5319e0..26065ed644 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -148,7 +148,8 @@ void Fan::publish_state() { constexpr uint32_t RESTORE_STATE_VERSION = 0x71700ABA; optional Fan::restore_state_() { FanRestoreState recovered{}; - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash() ^ RESTORE_STATE_VERSION); + this->rtc_ = + global_preferences->make_preference(this->get_preference_hash() ^ RESTORE_STATE_VERSION); bool restored = this->rtc_.load(&recovered); switch (this->restore_mode_) { diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 4f933b08e3..55a2454fca 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -351,7 +351,7 @@ ClimateTraits HaierClimateBase::traits() { return traits_; } void HaierClimateBase::initialization() { constexpr uint32_t restore_settings_version = 0xA77D21EF; this->base_rtc_ = - global_preferences->make_preference(this->get_object_id_hash() ^ restore_settings_version); + global_preferences->make_preference(this->get_preference_hash() ^ restore_settings_version); HaierBaseSettings recovered; if (!this->base_rtc_.load(&recovered)) { recovered = {false, true}; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index fd2d6a5800..9614bb1e47 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -516,7 +516,7 @@ void HonClimate::initialization() { HaierClimateBase::initialization(); constexpr uint32_t restore_settings_version = 0x57EB59DDUL; this->hon_rtc_ = - global_preferences->make_preference(this->get_object_id_hash() ^ restore_settings_version); + global_preferences->make_preference(this->get_preference_hash() ^ restore_settings_version); HonSettings recovered; if (this->hon_rtc_.load(&recovered)) { this->settings_ = recovered; diff --git a/esphome/components/integration/integration_sensor.cpp b/esphome/components/integration/integration_sensor.cpp index c09778e79e..80c718dc8d 100644 --- a/esphome/components/integration/integration_sensor.cpp +++ b/esphome/components/integration/integration_sensor.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "integration"; void IntegrationSensor::setup() { if (this->restore_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); float preference_value = 0; this->pref_.load(&preference_value); this->result_ = preference_value; diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index b123d541d9..f30752e5a2 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -184,7 +184,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui void LD2450Component::setup() { #ifdef USE_NUMBER if (this->presence_timeout_number_ != nullptr) { - this->pref_ = global_preferences->make_preference(this->presence_timeout_number_->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->presence_timeout_number_->get_preference_hash()); this->set_presence_timeout(); } #endif diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9e42b2f1e2..f18d5ba1de 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -41,7 +41,7 @@ void LightState::setup() { case LIGHT_RESTORE_DEFAULT_ON: case LIGHT_RESTORE_INVERTED_DEFAULT_OFF: case LIGHT_RESTORE_INVERTED_DEFAULT_ON: - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); // Attempt to load from preferences, else fall back to default values if (!this->rtc_.load(&recovered)) { recovered.state = (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON || @@ -54,7 +54,7 @@ void LightState::setup() { break; case LIGHT_RESTORE_AND_OFF: case LIGHT_RESTORE_AND_ON: - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); this->rtc_.load(&recovered); recovered.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON); break; diff --git a/esphome/components/lvgl/number/lvgl_number.h b/esphome/components/lvgl/number/lvgl_number.h index 277494673b..7bc44c9e20 100644 --- a/esphome/components/lvgl/number/lvgl_number.h +++ b/esphome/components/lvgl/number/lvgl_number.h @@ -21,7 +21,7 @@ class LVGLNumber : public number::Number, public Component { void setup() override { float value = this->value_lambda_(); if (this->restore_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (this->pref_.load(&value)) { this->control_lambda_(value); } diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index 5b43209a5f..a0e60295a6 100644 --- a/esphome/components/lvgl/select/lvgl_select.h +++ b/esphome/components/lvgl/select/lvgl_select.h @@ -20,7 +20,7 @@ class LVGLSelect : public select::Select, public Component { this->set_options_(); if (this->restore_) { size_t index; - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (this->pref_.load(&index)) this->widget_->set_selected_index(index, LV_ANIM_OFF); } diff --git a/esphome/components/number/automation.cpp b/esphome/components/number/automation.cpp index cadc6f54f6..bfc59d0465 100644 --- a/esphome/components/number/automation.cpp +++ b/esphome/components/number/automation.cpp @@ -15,7 +15,7 @@ void ValueRangeTrigger::setup() { float local_min = this->min_.value(0.0); float local_max = this->max_.value(0.0); convert hash = {.from = (local_max - local_min)}; - uint32_t myhash = hash.to ^ this->parent_->get_object_id_hash(); + uint32_t myhash = hash.to ^ this->parent_->get_preference_hash(); this->rtc_ = global_preferences->make_preference(myhash); bool initial_state; if (this->rtc_.load(&initial_state)) { diff --git a/esphome/components/opentherm/number/number.cpp b/esphome/components/opentherm/number/number.cpp index 90ab5d6490..fc0fb91a14 100644 --- a/esphome/components/opentherm/number/number.cpp +++ b/esphome/components/opentherm/number/number.cpp @@ -17,7 +17,7 @@ void OpenthermNumber::setup() { if (!this->restore_value_) { value = this->initial_value_; } else { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->pref_.load(&value)) { if (!std::isnan(this->initial_value_)) { value = this->initial_value_; diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 20ea8d0293..26e20664f2 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -132,7 +132,7 @@ void RotaryEncoderSensor::setup() { int32_t initial_value = 0; switch (this->restore_mode_) { case ROTARY_ENCODER_RESTORE_DEFAULT_ZERO: - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->rtc_.load(&initial_value)) { initial_value = 0; } diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 8cd0adbeb2..4f34c35023 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -40,7 +40,7 @@ class ValueRangeTrigger : public Trigger, public Component { template void set_max(V max) { this->max_ = max; } void setup() override { - this->rtc_ = global_preferences->make_preference(this->parent_->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->parent_->get_preference_hash()); bool initial_state; if (this->rtc_.load(&initial_state)) { this->previous_in_range_ = initial_state; diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 2c30f17c78..b45a78010a 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -55,7 +55,7 @@ void SpeakerMediaPlayer::setup() { this->media_control_command_queue_ = xQueueCreate(MEDIA_CONTROLS_QUEUE_LENGTH, sizeof(MediaCallCommand)); - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); VolumeRestoreState volume_restore_state; if (this->pref_.load(&volume_restore_state)) { diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index e191498857..7676e17468 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -81,7 +81,7 @@ void SprinklerControllerNumber::setup() { if (!this->restore_value_) { value = this->initial_value_; } else { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->pref_.load(&value)) { if (!std::isnan(this->initial_value_)) { value = this->initial_value_; diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 13c12c1213..49acd274b2 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -32,7 +32,7 @@ optional Switch::get_initial_state() { if (!(restore_mode & RESTORE_MODE_PERSISTENT_MASK)) return {}; - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); bool initial_state; if (!this->rtc_.load(&initial_state)) return {}; diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index 11a148830d..eac0629480 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -86,7 +86,7 @@ void TemplateAlarmControlPanel::setup() { break; case ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED: { uint8_t value; - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (this->pref_.load(&value)) { this->current_state_ = static_cast(value); } else { diff --git a/esphome/components/template/datetime/template_date.cpp b/esphome/components/template/datetime/template_date.cpp index 01e15e532e..2fa8016802 100644 --- a/esphome/components/template/datetime/template_date.cpp +++ b/esphome/components/template/datetime/template_date.cpp @@ -20,7 +20,7 @@ void TemplateDate::setup() { } else { datetime::DateEntityRestoreState temp; this->pref_ = - global_preferences->make_preference(194434030U ^ this->get_object_id_hash()); + global_preferences->make_preference(194434030U ^ this->get_preference_hash()); if (this->pref_.load(&temp)) { temp.apply(this); return; diff --git a/esphome/components/template/datetime/template_datetime.cpp b/esphome/components/template/datetime/template_datetime.cpp index 3ab74e197f..a4a4e47d65 100644 --- a/esphome/components/template/datetime/template_datetime.cpp +++ b/esphome/components/template/datetime/template_datetime.cpp @@ -19,8 +19,8 @@ void TemplateDateTime::setup() { state = this->initial_value_; } else { datetime::DateTimeEntityRestoreState temp; - this->pref_ = global_preferences->make_preference(194434090U ^ - this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference( + 194434090U ^ this->get_preference_hash()); if (this->pref_.load(&temp)) { temp.apply(this); return; diff --git a/esphome/components/template/datetime/template_time.cpp b/esphome/components/template/datetime/template_time.cpp index 0e4d734d16..349700f187 100644 --- a/esphome/components/template/datetime/template_time.cpp +++ b/esphome/components/template/datetime/template_time.cpp @@ -20,7 +20,7 @@ void TemplateTime::setup() { } else { datetime::TimeEntityRestoreState temp; this->pref_ = - global_preferences->make_preference(194434060U ^ this->get_object_id_hash()); + global_preferences->make_preference(194434060U ^ this->get_preference_hash()); if (this->pref_.load(&temp)) { temp.apply(this); return; diff --git a/esphome/components/template/number/template_number.cpp b/esphome/components/template/number/template_number.cpp index aaf5b27a71..187f426273 100644 --- a/esphome/components/template/number/template_number.cpp +++ b/esphome/components/template/number/template_number.cpp @@ -14,7 +14,7 @@ void TemplateNumber::setup() { if (!this->restore_value_) { value = this->initial_value_; } else { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->pref_.load(&value)) { if (!std::isnan(this->initial_value_)) { value = this->initial_value_; diff --git a/esphome/components/template/select/template_select.cpp b/esphome/components/template/select/template_select.cpp index 6ec29c8ef0..95b0ee0d2b 100644 --- a/esphome/components/template/select/template_select.cpp +++ b/esphome/components/template/select/template_select.cpp @@ -16,7 +16,7 @@ void TemplateSelect::setup() { ESP_LOGD(TAG, "State from initial: %s", value.c_str()); } else { size_t index; - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->pref_.load(&index)) { value = this->initial_option_; ESP_LOGD(TAG, "State from initial (could not load stored index): %s", value.c_str()); diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index f5df7287c5..d8e840ba7e 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -15,7 +15,7 @@ void TemplateText::setup() { if (!this->pref_) { ESP_LOGD(TAG, "State from initial: %s", value.c_str()); } else { - uint32_t key = this->get_object_id_hash(); + uint32_t key = this->get_preference_hash(); key += this->traits.get_min_length() << 2; key += this->traits.get_max_length() << 4; key += fnv1_hash(this->traits.get_pattern()) << 6; diff --git a/esphome/components/total_daily_energy/total_daily_energy.cpp b/esphome/components/total_daily_energy/total_daily_energy.cpp index 7c316c495d..818696f99b 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.cpp +++ b/esphome/components/total_daily_energy/total_daily_energy.cpp @@ -10,7 +10,7 @@ void TotalDailyEnergy::setup() { float initial_value = 0; if (this->restore_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); this->pref_.load(&initial_value); } this->publish_state_and_save(initial_value); diff --git a/esphome/components/tuya/number/tuya_number.cpp b/esphome/components/tuya/number/tuya_number.cpp index 68a7f8f2a7..44b22167de 100644 --- a/esphome/components/tuya/number/tuya_number.cpp +++ b/esphome/components/tuya/number/tuya_number.cpp @@ -8,7 +8,7 @@ static const char *const TAG = "tuya.number"; void TuyaNumber::setup() { if (this->restore_value_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); } this->parent_->register_listener(this->number_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index d1ec17945a..0ee710fc02 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -155,7 +155,7 @@ void Valve::publish_state(bool save) { } } optional Valve::restore_state_() { - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); ValveRestoreState recovered{}; if (!this->rtc_.load(&recovered)) return {}; diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 68163ce8c3..91137b4853 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -85,6 +85,17 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } + // Get a unique hash for preferences that includes device_id + uint32_t get_preference_hash() { +#ifdef USE_DEVICES + // Combine object_id_hash with device_id to ensure uniqueness across devices + return this->get_object_id_hash() ^ this->get_device_id(); +#else + // Without devices, just use object_id_hash as before + return this->get_object_id_hash(); +#endif + } + protected: friend class api::APIConnection; diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml new file mode 100644 index 0000000000..4835a057cc --- /dev/null +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -0,0 +1,190 @@ +esphome: + name: multi-device-preferences-test + # Define multiple devices for testing preference storage + devices: + - id: device_a + name: Device A + - id: device_b + name: Device B + +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +# Test entities with restore modes to verify preference storage + +# Switches with same name on different devices - test restore mode +switch: + - platform: template + name: Light + id: light_device_a + device_id: device_a + restore_mode: RESTORE_DEFAULT_OFF + turn_on_action: + - lambda: |- + ESP_LOGI("test", "Device A Light turned ON"); + turn_off_action: + - lambda: |- + ESP_LOGI("test", "Device A Light turned OFF"); + + - platform: template + name: Light + id: light_device_b + device_id: device_b + restore_mode: RESTORE_DEFAULT_ON # Different default to test uniqueness + turn_on_action: + - lambda: |- + ESP_LOGI("test", "Device B Light turned ON"); + turn_off_action: + - lambda: |- + ESP_LOGI("test", "Device B Light turned OFF"); + + - platform: template + name: Light + id: light_main + restore_mode: RESTORE_DEFAULT_OFF + turn_on_action: + - lambda: |- + ESP_LOGI("test", "Main Light turned ON"); + turn_off_action: + - lambda: |- + ESP_LOGI("test", "Main Light turned OFF"); + +# Numbers with restore to test preference storage +number: + - platform: template + name: Setpoint + id: setpoint_device_a + device_id: device_a + min_value: 10.0 + max_value: 30.0 + step: 0.5 + restore_value: true + initial_value: 20.0 + set_action: + - lambda: |- + ESP_LOGI("test", "Device A Setpoint set to %.1f", x); + id(setpoint_device_a).state = x; + + - platform: template + name: Setpoint + id: setpoint_device_b + device_id: device_b + min_value: 10.0 + max_value: 30.0 + step: 0.5 + restore_value: true + initial_value: 25.0 # Different initial to test uniqueness + set_action: + - lambda: |- + ESP_LOGI("test", "Device B Setpoint set to %.1f", x); + id(setpoint_device_b).state = x; + + - platform: template + name: Setpoint + id: setpoint_main + min_value: 10.0 + max_value: 30.0 + step: 0.5 + restore_value: true + initial_value: 22.0 + set_action: + - lambda: |- + ESP_LOGI("test", "Main Setpoint set to %.1f", x); + id(setpoint_main).state = x; + +# Selects with restore to test preference storage +select: + - platform: template + name: Mode + id: mode_device_a + device_id: device_a + options: + - "Auto" + - "Manual" + - "Off" + restore_value: true + initial_option: "Auto" + set_action: + - lambda: |- + ESP_LOGI("test", "Device A Mode set to %s", x.c_str()); + id(mode_device_a).state = x; + + - platform: template + name: Mode + id: mode_device_b + device_id: device_b + options: + - "Auto" + - "Manual" + - "Off" + restore_value: true + initial_option: "Manual" # Different initial to test uniqueness + set_action: + - lambda: |- + ESP_LOGI("test", "Device B Mode set to %s", x.c_str()); + id(mode_device_b).state = x; + + - platform: template + name: Mode + id: mode_main + options: + - "Auto" + - "Manual" + - "Off" + restore_value: true + initial_option: "Off" + set_action: + - lambda: |- + ESP_LOGI("test", "Main Mode set to %s", x.c_str()); + id(mode_main).state = x; + +# Test sensors for reading entity states +sensor: + - platform: template + name: Switch State A + id: switch_state_a + device_id: device_a + lambda: |- + return id(light_device_a).state ? 1.0 : 0.0; + update_interval: 0.5s + + - platform: template + name: Switch State B + id: switch_state_b + device_id: device_b + lambda: |- + return id(light_device_b).state ? 1.0 : 0.0; + update_interval: 0.5s + + - platform: template + name: Switch State Main + id: switch_state_main + lambda: |- + return id(light_main).state ? 1.0 : 0.0; + update_interval: 0.5s + +# Button to trigger preference save/restore test +button: + - platform: template + name: Test Preferences + on_press: + - lambda: |- + ESP_LOGI("test", "Testing preference storage uniqueness:"); + ESP_LOGI("test", "Device A Light state: %s", id(light_device_a).state ? "ON" : "OFF"); + ESP_LOGI("test", "Device B Light state: %s", id(light_device_b).state ? "ON" : "OFF"); + ESP_LOGI("test", "Main Light state: %s", id(light_main).state ? "ON" : "OFF"); + ESP_LOGI("test", "Device A Setpoint: %.1f", id(setpoint_device_a).state); + ESP_LOGI("test", "Device B Setpoint: %.1f", id(setpoint_device_b).state); + ESP_LOGI("test", "Main Setpoint: %.1f", id(setpoint_main).state); + ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).state.c_str()); + ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).state.c_str()); + ESP_LOGI("test", "Main Mode: %s", id(mode_main).state.c_str()); + // Log preference hashes for entities that actually store preferences + ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); diff --git a/tests/integration/test_multi_device_preferences.py b/tests/integration/test_multi_device_preferences.py new file mode 100644 index 0000000000..b9d2202f9d --- /dev/null +++ b/tests/integration/test_multi_device_preferences.py @@ -0,0 +1,154 @@ +"""Test multi-device preference storage functionality.""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import ButtonInfo, NumberInfo, SelectInfo, SensorInfo, SwitchInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_multi_device_preferences( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that entities with same names on different devices have unique preference storage.""" + loop = asyncio.get_running_loop() + log_lines: list[str] = [] + preferences_logged = loop.create_future() + + # Patterns to match preference hash logs + switch_hash_pattern_device = re.compile(r"Device ([AB]) Switch Pref Hash: (\d+)") + switch_hash_pattern_main = re.compile(r"Main Switch Pref Hash: (\d+)") + number_hash_pattern_device = re.compile(r"Device ([AB]) Number Pref Hash: (\d+)") + number_hash_pattern_main = re.compile(r"Main Number Pref Hash: (\d+)") + switch_hashes: dict[str, int] = {} + number_hashes: dict[str, int] = {} + + def check_output(line: str) -> None: + """Check log output for preference hash information.""" + log_lines.append(line) + + # Look for device switch preference hash logs + match = switch_hash_pattern_device.search(line) + if match: + device = match.group(1) + hash_value = int(match.group(2)) + switch_hashes[device] = hash_value + + # Look for main switch preference hash + match = switch_hash_pattern_main.search(line) + if match: + hash_value = int(match.group(1)) + switch_hashes["Main"] = hash_value + + # Look for device number preference hash logs + match = number_hash_pattern_device.search(line) + if match: + device = match.group(1) + hash_value = int(match.group(2)) + number_hashes[device] = hash_value + + # Look for main number preference hash + match = number_hash_pattern_main.search(line) + if match: + hash_value = int(match.group(1)) + number_hashes["Main"] = hash_value + + # If we have all hashes, complete the future + if ( + len(switch_hashes) == 3 + and len(number_hashes) == 3 + and not preferences_logged.done() + ): + preferences_logged.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get entity list + entities, _ = await client.list_entities_services() + + # Verify we have the expected entities with duplicate names on different devices + + # Check switches (3 with name "Light") + switches = [ + e for e in entities if isinstance(e, SwitchInfo) and e.name == "Light" + ] + assert len(switches) == 3, f"Expected 3 'Light' switches, got {len(switches)}" + + # Check numbers (3 with name "Setpoint") + numbers = [ + e for e in entities if isinstance(e, NumberInfo) and e.name == "Setpoint" + ] + assert len(numbers) == 3, f"Expected 3 'Setpoint' numbers, got {len(numbers)}" + + # Check selects (3 with name "Mode") + selects = [ + e for e in entities if isinstance(e, SelectInfo) and e.name == "Mode" + ] + assert len(selects) == 3, f"Expected 3 'Mode' selects, got {len(selects)}" + + # Check sensors for switch state monitoring + state_sensors = [ + e + for e in entities + if isinstance(e, SensorInfo) and "Switch State" in e.name + ] + assert len(state_sensors) == 3, ( + f"Expected 3 'Switch State' sensors, got {len(state_sensors)}" + ) + + # Find the test button entity to trigger preference logging + buttons = [e for e in entities if isinstance(e, ButtonInfo)] + test_button = next((b for b in buttons if b.name == "Test Preferences"), None) + assert test_button is not None, "Test Preferences button not found" + + # Press the button to trigger logging + client.button_command(test_button.key) + + # Wait for preference hashes to be logged + try: + await asyncio.wait_for(preferences_logged, timeout=5.0) + except TimeoutError: + pytest.fail("Preference hashes not logged within timeout") + + # Verify all switch preference hashes are unique + assert len(switch_hashes) == 3, ( + f"Expected 3 devices with switches, got {switch_hashes}" + ) + switch_hash_values = list(switch_hashes.values()) + assert len(switch_hash_values) == len(set(switch_hash_values)), ( + f"Switch preference hashes are not unique: {switch_hashes}" + ) + + # Verify all number preference hashes are unique + assert len(number_hashes) == 3, ( + f"Expected 3 devices with numbers, got {number_hashes}" + ) + number_hash_values = list(number_hashes.values()) + assert len(number_hash_values) == len(set(number_hash_values)), ( + f"Number preference hashes are not unique: {number_hashes}" + ) + + # Verify Device A and Device B have different hashes (they have device_id set) + assert switch_hashes["A"] != switch_hashes["B"], ( + f"Device A and B switches should have different hashes: A={switch_hashes['A']}, B={switch_hashes['B']}" + ) + assert number_hashes["A"] != number_hashes["B"], ( + f"Device A and B numbers should have different hashes: A={number_hashes['A']}, B={number_hashes['B']}" + ) + + # Verify Main device hash is different from both A and B + assert switch_hashes["Main"] != switch_hashes["A"], ( + f"Main and Device A switches should have different hashes: Main={switch_hashes['Main']}, A={switch_hashes['A']}" + ) + assert switch_hashes["Main"] != switch_hashes["B"], ( + f"Main and Device B switches should have different hashes: Main={switch_hashes['Main']}, B={switch_hashes['B']}" + ) From 2c44198cb5042bbf53ad05965b33bcb5d12be5c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 13:06:18 -0500 Subject: [PATCH 0672/1626] preen --- .../fixtures/multi_device_preferences.yaml | 27 +------------------ .../test_multi_device_preferences.py | 12 +-------- 2 files changed, 2 insertions(+), 37 deletions(-) diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 4835a057cc..634d7157b2 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -140,32 +140,7 @@ select: ESP_LOGI("test", "Main Mode set to %s", x.c_str()); id(mode_main).state = x; -# Test sensors for reading entity states -sensor: - - platform: template - name: Switch State A - id: switch_state_a - device_id: device_a - lambda: |- - return id(light_device_a).state ? 1.0 : 0.0; - update_interval: 0.5s - - - platform: template - name: Switch State B - id: switch_state_b - device_id: device_b - lambda: |- - return id(light_device_b).state ? 1.0 : 0.0; - update_interval: 0.5s - - - platform: template - name: Switch State Main - id: switch_state_main - lambda: |- - return id(light_main).state ? 1.0 : 0.0; - update_interval: 0.5s - -# Button to trigger preference save/restore test +# Button to trigger preference logging test button: - platform: template name: Test Preferences diff --git a/tests/integration/test_multi_device_preferences.py b/tests/integration/test_multi_device_preferences.py index b9d2202f9d..625f83f16e 100644 --- a/tests/integration/test_multi_device_preferences.py +++ b/tests/integration/test_multi_device_preferences.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio import re -from aioesphomeapi import ButtonInfo, NumberInfo, SelectInfo, SensorInfo, SwitchInfo +from aioesphomeapi import ButtonInfo, NumberInfo, SelectInfo, SwitchInfo import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -95,16 +95,6 @@ async def test_multi_device_preferences( ] assert len(selects) == 3, f"Expected 3 'Mode' selects, got {len(selects)}" - # Check sensors for switch state monitoring - state_sensors = [ - e - for e in entities - if isinstance(e, SensorInfo) and "Switch State" in e.name - ] - assert len(state_sensors) == 3, ( - f"Expected 3 'Switch State' sensors, got {len(state_sensors)}" - ) - # Find the test button entity to trigger preference logging buttons = [e for e in entities if isinstance(e, ButtonInfo)] test_button = next((b for b in buttons if b.name == "Test Preferences"), None) From 6c01e7196cb42524d2ac87ef5608e31ae7f30977 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 13:09:15 -0500 Subject: [PATCH 0673/1626] preen --- esphome/core/entity_base.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 91137b4853..e17a649051 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -89,6 +89,8 @@ class EntityBase { uint32_t get_preference_hash() { #ifdef USE_DEVICES // Combine object_id_hash with device_id to ensure uniqueness across devices + // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash + // This ensures backward compatibility for existing single-device configurations return this->get_object_id_hash() ^ this->get_device_id(); #else // Without devices, just use object_id_hash as before From 977ff9b48195e260c64e8cbdb9631458f3b29d42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 14:53:42 -0500 Subject: [PATCH 0674/1626] [esp32_ble_client] Fix race condition causing "ESP_GATTC_OPEN_EVT in IDLE state" error spam --- .../components/esp32_ble_client/ble_client_base.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e23be2e0c1..509feb5aa8 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -285,11 +285,22 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->log_gattc_event_("OPEN"); // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; + + // ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an + // error, if the error occurred at the BTA/GATT layer. This can result in the event + // arriving after we've already transitioned to IDLE state. + if (this->state_ == espbt::ClientState::IDLE) { + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_, + this->address_str_.c_str(), param->open.status); + break; + } + if (this->state_ != espbt::ClientState::CONNECTING) { // This should not happen but lets log it in case it does // because it means we have a bad assumption about how the // ESP BT stack works. - this->log_error_("ESP_GATTC_OPEN_EVT wrong state status", param->open.status); + ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, + this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); From 33eddb60352f63e6069f7908c0c57dc0bf1e9796 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:46:04 -0500 Subject: [PATCH 0675/1626] Bump codecov/codecov-action from 5.4.3 to 5.5.0 (#10336) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e50f2aef40..8f429f7b40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5.4.3 + uses: codecov/codecov-action@v5.5.0 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From 8ee46435a33051f803652dfa0731c36dac68b2cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 20:04:13 -0500 Subject: [PATCH 0676/1626] cleanup --- esphome/components/esp32_ble_client/ble_client_base.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 509feb5aa8..301cf25e2f 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -159,7 +159,8 @@ void BLEClientBase::disconnect() { return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { - this->log_warning_("Disconnect before connected, disconnect scheduled."); + ESP_LOGD(TAG, "[%d] [%s] Disconnect before connected, disconnect scheduled", this->connection_index_, + this->address_str_.c_str()); this->want_disconnect_ = true; return; } From a6850786e252548fe17f819314a5cb12aa419f32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 20:23:37 -0500 Subject: [PATCH 0677/1626] [esp32_ble_client] Add missing ESP_GATTC_UNREG_FOR_NOTIFY_EVT logging --- esphome/components/esp32_ble_client/ble_client_base.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e23be2e0c1..38909a3609 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -484,6 +484,13 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ break; } + case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { + if (this->conn_id_ != param->unreg_for_notify.conn_id) + return false; + this->log_gattc_event_("UNREG_FOR_NOTIFY"); + break; + } + default: // ideally would check all other events for matching conn_id ESP_LOGD(TAG, "[%d] [%s] Event %d", this->connection_index_, this->address_str_.c_str(), event); From 4dd01ea9acc1d4c312bad48d06874b8bc4880eb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Aug 2025 20:25:50 -0500 Subject: [PATCH 0678/1626] [esp32_ble_client] Add missing ESP_GATTC_UNREG_FOR_NOTIFY_EVT logging --- esphome/components/esp32_ble_client/ble_client_base.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 38909a3609..dd87d0765d 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -485,8 +485,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { - if (this->conn_id_ != param->unreg_for_notify.conn_id) - return false; this->log_gattc_event_("UNREG_FOR_NOTIFY"); break; } From 3ca00152847d37e205ac2464eded443fd3d87b31 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:48:48 +1200 Subject: [PATCH 0679/1626] [opentherm] Rename c++ files for predictable doxygen generation (#10314) --- esphome/components/opentherm/hub.h | 12 ++++++------ .../number/{number.cpp => opentherm_number.cpp} | 2 +- .../number/{number.h => opentherm_number.h} | 0 .../output/{output.cpp => opentherm_output.cpp} | 2 +- .../output/{output.h => opentherm_output.h} | 0 .../switch/{switch.cpp => opentherm_switch.cpp} | 2 +- .../switch/{switch.h => opentherm_switch.h} | 0 7 files changed, 9 insertions(+), 9 deletions(-) rename esphome/components/opentherm/number/{number.cpp => opentherm_number.cpp} (97%) rename esphome/components/opentherm/number/{number.h => opentherm_number.h} (100%) rename esphome/components/opentherm/output/{output.cpp => opentherm_output.cpp} (95%) rename esphome/components/opentherm/output/{output.h => opentherm_output.h} (100%) rename esphome/components/opentherm/switch/{switch.cpp => opentherm_switch.cpp} (96%) rename esphome/components/opentherm/switch/{switch.h => opentherm_switch.h} (100%) diff --git a/esphome/components/opentherm/hub.h b/esphome/components/opentherm/hub.h index 80fd268820..ee0cfd104d 100644 --- a/esphome/components/opentherm/hub.h +++ b/esphome/components/opentherm/hub.h @@ -1,10 +1,10 @@ #pragma once +#include +#include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" -#include "esphome/core/component.h" #include "esphome/core/log.h" -#include #include "opentherm.h" @@ -17,21 +17,21 @@ #endif #ifdef OPENTHERM_USE_SWITCH -#include "esphome/components/opentherm/switch/switch.h" +#include "esphome/components/opentherm/switch/opentherm_switch.h" #endif #ifdef OPENTHERM_USE_OUTPUT -#include "esphome/components/opentherm/output/output.h" +#include "esphome/components/opentherm/output/opentherm_output.h" #endif #ifdef OPENTHERM_USE_NUMBER -#include "esphome/components/opentherm/number/number.h" +#include "esphome/components/opentherm/number/opentherm_number.h" #endif +#include #include #include #include -#include #include "opentherm_macros.h" diff --git a/esphome/components/opentherm/number/number.cpp b/esphome/components/opentherm/number/opentherm_number.cpp similarity index 97% rename from esphome/components/opentherm/number/number.cpp rename to esphome/components/opentherm/number/opentherm_number.cpp index 90ab5d6490..c5d094aa36 100644 --- a/esphome/components/opentherm/number/number.cpp +++ b/esphome/components/opentherm/number/opentherm_number.cpp @@ -1,4 +1,4 @@ -#include "number.h" +#include "opentherm_number.h" namespace esphome { namespace opentherm { diff --git a/esphome/components/opentherm/number/number.h b/esphome/components/opentherm/number/opentherm_number.h similarity index 100% rename from esphome/components/opentherm/number/number.h rename to esphome/components/opentherm/number/opentherm_number.h diff --git a/esphome/components/opentherm/output/output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp similarity index 95% rename from esphome/components/opentherm/output/output.cpp rename to esphome/components/opentherm/output/opentherm_output.cpp index 486aa0d4e7..ff82ddd72c 100644 --- a/esphome/components/opentherm/output/output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -1,5 +1,5 @@ #include "esphome/core/helpers.h" // for clamp() and lerp() -#include "output.h" +#include "opentherm_output.h" namespace esphome { namespace opentherm { diff --git a/esphome/components/opentherm/output/output.h b/esphome/components/opentherm/output/opentherm_output.h similarity index 100% rename from esphome/components/opentherm/output/output.h rename to esphome/components/opentherm/output/opentherm_output.h diff --git a/esphome/components/opentherm/switch/switch.cpp b/esphome/components/opentherm/switch/opentherm_switch.cpp similarity index 96% rename from esphome/components/opentherm/switch/switch.cpp rename to esphome/components/opentherm/switch/opentherm_switch.cpp index 228d9ac8f3..5c5d62e68e 100644 --- a/esphome/components/opentherm/switch/switch.cpp +++ b/esphome/components/opentherm/switch/opentherm_switch.cpp @@ -1,4 +1,4 @@ -#include "switch.h" +#include "opentherm_switch.h" namespace esphome { namespace opentherm { diff --git a/esphome/components/opentherm/switch/switch.h b/esphome/components/opentherm/switch/opentherm_switch.h similarity index 100% rename from esphome/components/opentherm/switch/switch.h rename to esphome/components/opentherm/switch/opentherm_switch.h From 94accd5abe8d70713f3b4db7809e4b47824e7ce4 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:49:26 +1200 Subject: [PATCH 0680/1626] [ld2420] Rename c++ files for predictable doxygen generation (#10315) --- .../text_sensor/{text_sensor.cpp => ld2420_text_sensor.cpp} | 2 +- .../ld2420/text_sensor/{text_sensor.h => ld2420_text_sensor.h} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename esphome/components/ld2420/text_sensor/{text_sensor.cpp => ld2420_text_sensor.cpp} (91%) rename esphome/components/ld2420/text_sensor/{text_sensor.h => ld2420_text_sensor.h} (100%) diff --git a/esphome/components/ld2420/text_sensor/text_sensor.cpp b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp similarity index 91% rename from esphome/components/ld2420/text_sensor/text_sensor.cpp rename to esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp index 73af3b3660..f647a36936 100644 --- a/esphome/components/ld2420/text_sensor/text_sensor.cpp +++ b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp @@ -1,4 +1,4 @@ -#include "text_sensor.h" +#include "ld2420_text_sensor.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/ld2420/text_sensor/text_sensor.h b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h similarity index 100% rename from esphome/components/ld2420/text_sensor/text_sensor.h rename to esphome/components/ld2420/text_sensor/ld2420_text_sensor.h From 2f101c0a202bb1f58ee4caac8e33021a0ac8e415 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Aug 2025 08:11:17 -0500 Subject: [PATCH 0681/1626] [esp32_ble_client] Adjust connection parameters to improve device compatibility --- .../esp32_ble_client/ble_client_base.cpp | 69 ++++++++----------- .../esp32_ble_client/ble_client_base.h | 3 +- 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e23be2e0c1..040e3db613 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -111,36 +111,8 @@ void BLEClientBase::connect() { this->remote_addr_type_); this->paired_ = false; - // Set preferred connection parameters before connecting - // Use FAST for all V3 connections (better latency and reliability) - // Use MEDIUM for V1/legacy connections (balanced performance) - uint16_t min_interval, max_interval, timeout; - const char *param_type; - - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - min_interval = FAST_MIN_CONN_INTERVAL; - max_interval = FAST_MAX_CONN_INTERVAL; - timeout = FAST_CONN_TIMEOUT; - param_type = "fast"; - } else { - min_interval = MEDIUM_MIN_CONN_INTERVAL; - max_interval = MEDIUM_MAX_CONN_INTERVAL; - timeout = MEDIUM_CONN_TIMEOUT; - param_type = "medium"; - } - - auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, - 0, // latency: 0 - timeout); - if (param_ret != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, - this->address_str_.c_str(), param_ret); - } else { - this->log_connection_params_(param_type); - } - - // Now open the connection + // Open the connection without setting connection parameters + // Parameters will be set after connection is established if needed auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); if (ret) { this->log_gattc_warning_("esp_ble_gattc_open", ret); @@ -243,8 +215,21 @@ void BLEClientBase::log_warning_(const char *message) { ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); } -void BLEClientBase::restore_medium_conn_params_() { - // Restore to medium connection parameters after initial connection phase +void BLEClientBase::set_fast_conn_params_() { + // Switch to fast connection parameters for service discovery + // This improves discovery speed for devices with short timeouts + esp_ble_conn_update_params_t conn_params = {{0}}; + memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + conn_params.min_int = FAST_MIN_CONN_INTERVAL; + conn_params.max_int = FAST_MAX_CONN_INTERVAL; + conn_params.latency = 0; + conn_params.timeout = FAST_CONN_TIMEOUT; + this->log_connection_params_("fast"); + esp_ble_gap_update_conn_params(&conn_params); +} + +void BLEClientBase::set_medium_conn_params_() { + // Set medium connection parameters for balanced performance // This balances performance with bandwidth usage for normal operation esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); @@ -308,12 +293,19 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->set_state(espbt::ClientState::CONNECTED); ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - // Restore to medium connection parameters for cached connections too - this->restore_medium_conn_params_(); + // Cached connections use medium connection parameters + this->set_medium_conn_params_(); // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; } + // For V3_WITHOUT_CACHE, switch to fast params for service discovery + // Service discovery period is critical - we typically have only 10s to complete + // discovery before the device disconnects us. Fast connection parameters are + // essential to finish service resolution in time and avoid retry loops. + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + this->set_fast_conn_params_(); + } this->log_event_("Searching for services"); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); break; @@ -395,12 +387,11 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->conn_id_ != param->search_cmpl.conn_id) return false; this->log_gattc_event_("SEARCH_CMPL"); - // For V3 connections, restore to medium connection parameters after service discovery + // For V3_WITHOUT_CACHE, switch back to medium connection parameters after service discovery // This balances performance with bandwidth usage after the critical discovery phase - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - this->restore_medium_conn_params_(); - } else { + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + this->set_medium_conn_params_(); + } else if (this->connection_type_ != espbt::ConnectionType::V3_WITH_CACHE) { #ifdef USE_ESP32_BLE_DEVICE for (auto &svc : this->services_) { ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 1850b2c5b3..2bfa0f6759 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -133,7 +133,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_event_(const char *name); void log_gattc_event_(const char *name); - void restore_medium_conn_params_(); + void set_fast_conn_params_(); + void set_medium_conn_params_(); void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); From 1ba37ca7c97d36d6cd7b3a2eaf71525ba7c4a5f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Aug 2025 08:25:13 -0500 Subject: [PATCH 0682/1626] preen --- esphome/components/esp32_ble_client/ble_client_base.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 040e3db613..03340a2018 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -303,7 +303,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // Service discovery period is critical - we typically have only 10s to complete // discovery before the device disconnects us. Fast connection parameters are // essential to finish service resolution in time and avoid retry loops. - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + else if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { this->set_fast_conn_params_(); } this->log_event_("Searching for services"); From f12bcc621c5fdd5726965b7f104a780376e8b90e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Aug 2025 09:00:01 -0500 Subject: [PATCH 0683/1626] dry, review --- .../esp32_ble_client/ble_client_base.cpp | 33 ++++++++++--------- .../esp32_ble_client/ble_client_base.h | 2 ++ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 03340a2018..07bd3f8a64 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -215,30 +215,31 @@ void BLEClientBase::log_warning_(const char *message) { ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); } +void BLEClientBase::set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type) { + esp_ble_conn_update_params_t conn_params = {{0}}; + memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + conn_params.min_int = min_interval; + conn_params.max_int = max_interval; + conn_params.latency = latency; + conn_params.timeout = timeout; + this->log_connection_params_(param_type); + esp_err_t err = esp_ble_gap_update_conn_params(&conn_params); + if (err != ESP_OK) { + this->log_gattc_warning_("esp_ble_gap_update_conn_params", err); + } +} + void BLEClientBase::set_fast_conn_params_() { // Switch to fast connection parameters for service discovery // This improves discovery speed for devices with short timeouts - esp_ble_conn_update_params_t conn_params = {{0}}; - memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); - conn_params.min_int = FAST_MIN_CONN_INTERVAL; - conn_params.max_int = FAST_MAX_CONN_INTERVAL; - conn_params.latency = 0; - conn_params.timeout = FAST_CONN_TIMEOUT; - this->log_connection_params_("fast"); - esp_ble_gap_update_conn_params(&conn_params); + this->set_conn_params_(FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT, "fast"); } void BLEClientBase::set_medium_conn_params_() { // Set medium connection parameters for balanced performance // This balances performance with bandwidth usage for normal operation - esp_ble_conn_update_params_t conn_params = {{0}}; - memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); - conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; - conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; - conn_params.latency = 0; - conn_params.timeout = MEDIUM_CONN_TIMEOUT; - this->log_connection_params_("medium"); - esp_ble_gap_update_conn_params(&conn_params); + this->set_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); } bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 2bfa0f6759..d6eea0ff93 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -133,6 +133,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_event_(const char *name); void log_gattc_event_(const char *name); + void set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); void set_fast_conn_params_(); void set_medium_conn_params_(); void log_gattc_warning_(const char *operation, esp_gatt_status_t status); From 83fe4b4ff334d04a9f0cc70e296e5b375cea352f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:36:06 -0500 Subject: [PATCH 0684/1626] Bump ruff from 0.12.9 to 0.12.10 (#10362) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5540733131..8b76523b2e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.9 + rev: v0.12.10 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index f0a16fd7f3..f55618c0f8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.8 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.12.9 # also change in .pre-commit-config.yaml when updating +ruff==0.12.10 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From 9d16eeeb776deb0d9a14977fb9ada07d52cd9355 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Aug 2025 20:27:04 -0500 Subject: [PATCH 0685/1626] tweak --- .../esp32_ble_client/ble_client_base.cpp | 39 ++++++++++++++++--- .../esp32_ble_client/ble_client_base.h | 3 ++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 07bd3f8a64..d9d3737791 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -12,6 +12,12 @@ namespace esphome::esp32_ble_client { static const char *const TAG = "esp32_ble_client"; +// Default connection parameters matching ESP-IDF's BTM_BLE_CONN_INT_*_DEF +// These are conservative values that work well with most devices +static const uint16_t DEFAULT_MIN_CONN_INTERVAL = 0x0A; // 10 * 1.25ms = 12.5ms +static const uint16_t DEFAULT_MAX_CONN_INTERVAL = 0x0C; // 12 * 1.25ms = 15ms +static const uint16_t DEFAULT_CONN_TIMEOUT = 600; // 600 * 10ms = 6s + // Intermediate connection parameters for standard operation // ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies, // causing disconnections. These medium parameters balance responsiveness with bandwidth usage. @@ -111,8 +117,12 @@ void BLEClientBase::connect() { this->remote_addr_type_); this->paired_ = false; - // Open the connection without setting connection parameters - // Parameters will be set after connection is established if needed + // Set default connection parameters before connecting + // This ensures we use conservative parameters that work well with weak signal devices + // rather than potentially aggressive parameters from a previous connection + this->set_default_conn_params_(); + + // Open the connection with default parameters auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); if (ret) { this->log_gattc_warning_("esp_ble_gattc_open", ret); @@ -215,8 +225,8 @@ void BLEClientBase::log_warning_(const char *message) { ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); } -void BLEClientBase::set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, - const char *param_type) { +void BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout, const char *param_type) { esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; @@ -230,16 +240,33 @@ void BLEClientBase::set_conn_params_(uint16_t min_interval, uint16_t max_interva } } +void BLEClientBase::set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type) { + // Set preferred connection parameters before connecting + // These will be used when establishing the connection + this->log_connection_params_(param_type); + esp_err_t err = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, latency, timeout); + if (err != ESP_OK) { + this->log_gattc_warning_("esp_ble_gap_set_prefer_conn_params", err); + } +} + void BLEClientBase::set_fast_conn_params_() { // Switch to fast connection parameters for service discovery // This improves discovery speed for devices with short timeouts - this->set_conn_params_(FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT, "fast"); + this->update_conn_params_(FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT, "fast"); } void BLEClientBase::set_medium_conn_params_() { // Set medium connection parameters for balanced performance // This balances performance with bandwidth usage for normal operation - this->set_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); +} + +void BLEClientBase::set_default_conn_params_() { + // Set default connection parameters before connecting + // These conservative values work well with most devices including weak signal ones + this->set_conn_params_(DEFAULT_MIN_CONN_INTERVAL, DEFAULT_MAX_CONN_INTERVAL, 0, DEFAULT_CONN_TIMEOUT, "default"); } bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index d6eea0ff93..4fd1b28f19 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -133,10 +133,13 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_event_(const char *name); void log_gattc_event_(const char *name); + void update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); void set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type); void set_fast_conn_params_(); void set_medium_conn_params_(); + void set_default_conn_params_(); void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); From 65eb57ca1b2139dcc8b22bf9b35eb9782a1bc0cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Aug 2025 21:44:59 -0500 Subject: [PATCH 0686/1626] tweak --- esphome/components/esp32_ble/__init__.py | 7 ++ .../esp32_ble_client/ble_client_base.cpp | 72 +++++++------------ .../esp32_ble_client/ble_client_base.h | 4 +- 3 files changed, 34 insertions(+), 49 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 2edd69c6c0..a7fb8c9312 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -4,6 +4,7 @@ import re from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant +from esphome.components.esp32.const import VARIANT_ESP32C3, VARIANT_ESP32S3 import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ESPHOME, CONF_ID, CONF_NAME from esphome.core import CORE, TimePeriod @@ -259,6 +260,12 @@ async def to_code(config): if CORE.using_esp_idf: add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + # Enable BLE 5.0 features on ESP32-S3/C3 + # Note: Despite ESP-IDF docs stating BLE 4.2 and 5.0 can't be used simultaneously, + # both were already enabled by default and this configuration works in practice. + # We're making it explicit here for clarity and to ensure both APIs are available. + if get_esp32_variant() in (VARIANT_ESP32S3, VARIANT_ESP32C3): + add_idf_sdkconfig_option("CONFIG_BT_BLE_50_FEATURES_SUPPORTED", True) # Register the core BLE loggers that are always needed register_bt_logger(BTLoggers.GAP, BTLoggers.BTM, BTLoggers.HCI) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index d9d3737791..b9b7c75e0a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -7,17 +7,12 @@ #include #include +#include namespace esphome::esp32_ble_client { static const char *const TAG = "esp32_ble_client"; -// Default connection parameters matching ESP-IDF's BTM_BLE_CONN_INT_*_DEF -// These are conservative values that work well with most devices -static const uint16_t DEFAULT_MIN_CONN_INTERVAL = 0x0A; // 10 * 1.25ms = 12.5ms -static const uint16_t DEFAULT_MAX_CONN_INTERVAL = 0x0C; // 12 * 1.25ms = 15ms -static const uint16_t DEFAULT_CONN_TIMEOUT = 600; // 600 * 10ms = 6s - // Intermediate connection parameters for standard operation // ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies, // causing disconnections. These medium parameters balance responsiveness with bandwidth usage. @@ -117,19 +112,19 @@ void BLEClientBase::connect() { this->remote_addr_type_); this->paired_ = false; - // Set default connection parameters before connecting - // This ensures we use conservative parameters that work well with weak signal devices - // rather than potentially aggressive parameters from a previous connection - this->set_default_conn_params_(); - - // Open the connection with default parameters - auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); - if (ret) { - this->log_gattc_warning_("esp_ble_gattc_open", ret); - this->set_state(espbt::ClientState::IDLE); - } else { - this->set_state(espbt::ClientState::CONNECTING); + // Determine connection parameters based on connection type + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + // V3 without cache needs fast params for service discovery + this->set_conn_params_(FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT, "fast"); + } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { + // V3 with cache can use medium params + this->set_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); } + // For V1/Legacy, don't set params - use ESP-IDF defaults + + // Open the connection + auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); + this->handle_connection_result_(ret); } esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); } @@ -213,6 +208,15 @@ void BLEClientBase::log_connection_params_(const char *param_type) { ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); } +void BLEClientBase::handle_connection_result_(esp_err_t ret) { + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_open", ret); + this->set_state(espbt::ClientState::IDLE); + } else { + this->set_state(espbt::ClientState::CONNECTING); + } +} + void BLEClientBase::log_error_(const char *message) { ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); } @@ -251,24 +255,6 @@ void BLEClientBase::set_conn_params_(uint16_t min_interval, uint16_t max_interva } } -void BLEClientBase::set_fast_conn_params_() { - // Switch to fast connection parameters for service discovery - // This improves discovery speed for devices with short timeouts - this->update_conn_params_(FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT, "fast"); -} - -void BLEClientBase::set_medium_conn_params_() { - // Set medium connection parameters for balanced performance - // This balances performance with bandwidth usage for normal operation - this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); -} - -void BLEClientBase::set_default_conn_params_() { - // Set default connection parameters before connecting - // These conservative values work well with most devices including weak signal ones - this->set_conn_params_(DEFAULT_MIN_CONN_INTERVAL, DEFAULT_MAX_CONN_INTERVAL, 0, DEFAULT_CONN_TIMEOUT, "default"); -} - bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, esp_ble_gattc_cb_param_t *param) { if (event == ESP_GATTC_REG_EVT && this->app_id != param->reg.app_id) @@ -321,19 +307,13 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->set_state(espbt::ClientState::CONNECTED); ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - // Cached connections use medium connection parameters - this->set_medium_conn_params_(); + // Cached connections already connected with medium parameters, no update needed // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; } - // For V3_WITHOUT_CACHE, switch to fast params for service discovery - // Service discovery period is critical - we typically have only 10s to complete - // discovery before the device disconnects us. Fast connection parameters are - // essential to finish service resolution in time and avoid retry loops. - else if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { - this->set_fast_conn_params_(); - } + // For V3_WITHOUT_CACHE, we already set fast params before connecting + // No need to update them again here this->log_event_("Searching for services"); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); break; @@ -418,7 +398,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // For V3_WITHOUT_CACHE, switch back to medium connection parameters after service discovery // This balances performance with bandwidth usage after the critical discovery phase if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { - this->set_medium_conn_params_(); + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); } else if (this->connection_type_ != espbt::ConnectionType::V3_WITH_CACHE) { #ifdef USE_ESP32_BLE_DEVICE for (auto &svc : this->services_) { diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 4fd1b28f19..acfad9e9b0 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -137,12 +137,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { const char *param_type); void set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type); - void set_fast_conn_params_(); - void set_medium_conn_params_(); - void set_default_conn_params_(); void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); + void handle_connection_result_(esp_err_t ret); // Compact error logging helpers to reduce flash usage void log_error_(const char *message); void log_error_(const char *message, int code); From 7d7dbefb60ed88a346fea06500a9776187d91275 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Aug 2025 21:46:36 -0500 Subject: [PATCH 0687/1626] tweak --- esphome/components/esp32_ble/__init__.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index a7fb8c9312..2edd69c6c0 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -4,7 +4,6 @@ import re from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant -from esphome.components.esp32.const import VARIANT_ESP32C3, VARIANT_ESP32S3 import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ESPHOME, CONF_ID, CONF_NAME from esphome.core import CORE, TimePeriod @@ -260,12 +259,6 @@ async def to_code(config): if CORE.using_esp_idf: add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) - # Enable BLE 5.0 features on ESP32-S3/C3 - # Note: Despite ESP-IDF docs stating BLE 4.2 and 5.0 can't be used simultaneously, - # both were already enabled by default and this configuration works in practice. - # We're making it explicit here for clarity and to ensure both APIs are available. - if get_esp32_variant() in (VARIANT_ESP32S3, VARIANT_ESP32C3): - add_idf_sdkconfig_option("CONFIG_BT_BLE_50_FEATURES_SUPPORTED", True) # Register the core BLE loggers that are always needed register_bt_logger(BTLoggers.GAP, BTLoggers.BTM, BTLoggers.HCI) From a38b994f2bd7b4b92a98356cf0b1765a851eaa3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 Aug 2025 08:45:35 -0500 Subject: [PATCH 0688/1626] [esp32_ble] Increase GATT connection retry count to use full timeout window --- esphome/components/esp32_ble/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 2edd69c6c0..bc6d8e74df 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -280,6 +280,14 @@ async def to_code(config): add_idf_sdkconfig_option( "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds ) + else: + # Default to 20 seconds if not specified + add_idf_sdkconfig_option("CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", 20) + + # Increase GATT client connection retry count for problematic devices + # Default in ESP-IDF is 3, we increase to 10 for better reliability with + # low-power/timing-sensitive devices + add_idf_sdkconfig_option("CONFIG_BT_GATTC_CONNECT_RETRY_COUNT", 10) # Set the maximum number of notification registrations # This controls how many BLE characteristics can have notifications enabled From 29b25194babcd04dd49f75d90c095f82abeeafc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 Aug 2025 08:48:27 -0500 Subject: [PATCH 0689/1626] [esp32_ble] Increase GATT connection retry count to use full timeout window --- esphome/components/esp32_ble/__init__.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index bc6d8e74df..cc06058b65 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -280,14 +280,10 @@ async def to_code(config): add_idf_sdkconfig_option( "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds ) - else: - # Default to 20 seconds if not specified - add_idf_sdkconfig_option("CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", 20) - - # Increase GATT client connection retry count for problematic devices - # Default in ESP-IDF is 3, we increase to 10 for better reliability with - # low-power/timing-sensitive devices - add_idf_sdkconfig_option("CONFIG_BT_GATTC_CONNECT_RETRY_COUNT", 10) + # Increase GATT client connection retry count for problematic devices + # Default in ESP-IDF is 3, we increase to 10 for better reliability with + # low-power/timing-sensitive devices + add_idf_sdkconfig_option("CONFIG_BT_GATTC_CONNECT_RETRY_COUNT", 10) # Set the maximum number of notification registrations # This controls how many BLE characteristics can have notifications enabled From 5cd9a86dcba809120cb3ccd62e6ea2af088c55ad Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 23 Aug 2025 08:20:16 +0200 Subject: [PATCH 0690/1626] [nrf52] update toolchain to v0.17.4, support mac (#10391) --- .clang-tidy.hash | 2 +- esphome/components/nrf52/__init__.py | 2 +- platformio.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index a6de2366bc..07d9b3931e 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -0440e35cf89a49e8a35fd3690ed453a72b7b6f61b9d346ced6140e1c0d39dff6 +8158e5b8ed99b8c2d1d8953db0561393e07cc2555e2230ba685b591db9af156e diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 908a855f70..e0ccd60c1a 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -120,7 +120,7 @@ async def to_code(config: ConfigType) -> None: "platform_packages", [ "platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-4.zip", - "platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.16.1-1.zip", + "platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.17.4-0.zip", ], ) diff --git a/platformio.ini b/platformio.ini index 47fc5205bc..7b9cbbc9b7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -222,7 +222,7 @@ platform = https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tag framework = zephyr platform_packages = platformio/framework-zephyr @ https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-4.zip - platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.16.1-1.zip + platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.17.4-0.zip build_flags = ${common.build_flags} -DUSE_ZEPHYR From 17f787fc36a04617f033efaddd704c7c79cb4e14 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 23 Aug 2025 14:17:42 +0200 Subject: [PATCH 0691/1626] [nrf52] fix build in dashboard (#10323) --- .clang-tidy.hash | 2 +- esphome/components/nrf52/__init__.py | 2 +- platformio.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 07d9b3931e..f61b79de4d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -8158e5b8ed99b8c2d1d8953db0561393e07cc2555e2230ba685b591db9af156e +4368db58e8f884aff245996b1e8b644cc0796c0bb2fa706d5740d40b823d3ac9 diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index e0ccd60c1a..e75bf8192c 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -119,7 +119,7 @@ async def to_code(config: ConfigType) -> None: cg.add_platformio_option( "platform_packages", [ - "platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-4.zip", + "platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-7.zip", "platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.17.4-0.zip", ], ) diff --git a/platformio.ini b/platformio.ini index 7b9cbbc9b7..d97607fac5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -221,7 +221,7 @@ extends = common platform = https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tags/v10.3.0-1.zip framework = zephyr platform_packages = - platformio/framework-zephyr @ https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-4.zip + platformio/framework-zephyr @ https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-7.zip platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.17.4-0.zip build_flags = ${common.build_flags} From f4deb0f70bf6a54f956e6637b7ce889d5556d0bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Aug 2025 14:41:45 -0500 Subject: [PATCH 0692/1626] [esp32_ble_tracker] Fix on_scan_end trigger compilation without USE_ESP32_BLE_DEVICE --- esphome/components/esp32_ble_tracker/automation.h | 5 ++++- .../esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index c0e6eee138..784f2eaaa2 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -80,14 +80,17 @@ class BLEManufacturerDataAdvertiseTrigger : public Trigger, ESPBTUUID uuid_; }; +#endif // USE_ESP32_BLE_DEVICE + class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { public: explicit BLEEndOfScanTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } +#ifdef USE_ESP32_BLE_DEVICE bool parse_device(const ESPBTDevice &device) override { return false; } +#endif void on_scan_end() override { this->trigger(); } }; -#endif // USE_ESP32_BLE_DEVICE template class ESP32BLEStartScanAction : public Action { public: diff --git a/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml b/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml new file mode 100644 index 0000000000..4e9849a540 --- /dev/null +++ b/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml @@ -0,0 +1,3 @@ +esp32_ble_tracker: + on_scan_end: + - logger.log: "Scan ended!" From bef783451b790954d9813fc238d619fd5d2b2876 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Aug 2025 19:03:18 -0500 Subject: [PATCH 0693/1626] [esphome] Fix OTA watchdog resets by validating all magic bytes before blocking --- .../components/esphome/ota/ota_esphome.cpp | 77 ++++++++++--------- esphome/components/esphome/ota/ota_esphome.h | 8 +- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 5217e9c61f..fc10e5366e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -100,8 +100,8 @@ void ESPHomeOTAComponent::handle_handshake_() { /// Handle the initial OTA handshake. /// /// This method is non-blocking and will return immediately if no data is available. - /// It waits for the first magic byte (0x6C) before proceeding to handle_data_(). - /// A 10-second timeout is enforced from initial connection. + /// It reads all 5 magic bytes (0x6C, 0x26, 0xF7, 0x5C, 0x45) non-blocking + /// before proceeding to handle_data_(). A 10-second timeout is enforced from initial connection. if (this->client_ == nullptr) { // We already checked server_->ready() in loop(), so we can accept directly @@ -126,6 +126,7 @@ void ESPHomeOTAComponent::handle_handshake_() { } this->log_start_("handshake"); this->client_connect_time_ = App.get_loop_component_start_time(); + this->magic_buf_pos_ = 0; // Reset magic buffer position } // Check for handshake timeout @@ -136,34 +137,47 @@ void ESPHomeOTAComponent::handle_handshake_() { return; } - // Try to read first byte of magic bytes - uint8_t first_byte; - ssize_t read = this->client_->read(&first_byte, 1); + // Try to read remaining magic bytes + if (this->magic_buf_pos_ < 5) { + // Read as many bytes as available + uint8_t bytes_to_read = 5 - this->magic_buf_pos_; + ssize_t read = this->client_->read(this->magic_buf_ + this->magic_buf_pos_, bytes_to_read); - if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { - return; // No data yet, try again next loop - } - - if (read <= 0) { - // Error or connection closed - if (read == -1) { - this->log_socket_error_("reading first byte"); - } else { - ESP_LOGW(TAG, "Remote closed during handshake"); + if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + return; // No data yet, try again next loop } - this->cleanup_connection_(); - return; + + if (read <= 0) { + // Error or connection closed + if (read == -1) { + this->log_socket_error_("reading magic bytes"); + } else { + ESP_LOGW(TAG, "Remote closed during handshake"); + } + this->cleanup_connection_(); + return; + } + + this->magic_buf_pos_ += read; } - // Got first byte, check if it's the magic byte - if (first_byte != 0x6C) { - ESP_LOGW(TAG, "Invalid initial byte: 0x%02X", first_byte); - this->cleanup_connection_(); - return; - } + // Check if we have all 5 magic bytes + if (this->magic_buf_pos_ == 5) { + // Validate magic bytes + static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; + if (memcmp(this->magic_buf_, MAGIC_BYTES, 5) != 0) { + ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->magic_buf_[0], + this->magic_buf_[1], this->magic_buf_[2], this->magic_buf_[3], this->magic_buf_[4]); + // Send error response (non-blocking, best effort) + uint8_t error = static_cast(ota::OTA_RESPONSE_ERROR_MAGIC); + this->client_->write(&error, 1); + this->cleanup_connection_(); + return; + } - // First byte is valid, continue with data handling - this->handle_data_(); + // All 5 magic bytes are valid, continue with data handling + this->handle_data_(); + } } void ESPHomeOTAComponent::handle_data_() { @@ -186,18 +200,6 @@ void ESPHomeOTAComponent::handle_data_() { size_t size_acknowledged = 0; #endif - // Read remaining 4 bytes of magic (we already read the first byte 0x6C in handle_handshake_) - if (!this->readall_(buf, 4)) { - this->log_read_error_("magic bytes"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - // Check remaining magic bytes: 0x26, 0xF7, 0x5C, 0x45 - if (buf[0] != 0x26 || buf[1] != 0xF7 || buf[2] != 0x5C || buf[3] != 0x45) { - ESP_LOGW(TAG, "Magic bytes mismatch! 0x6C-0x%02X-0x%02X-0x%02X-0x%02X", buf[0], buf[1], buf[2], buf[3]); - error_code = ota::OTA_RESPONSE_ERROR_MAGIC; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - // Send OK and version - 2 bytes buf[0] = ota::OTA_RESPONSE_OK; buf[1] = USE_OTA_VERSION; @@ -487,6 +489,7 @@ void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; this->client_connect_time_ = 0; + this->magic_buf_pos_ = 0; } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 8397b86528..c1919c71e9 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -41,11 +41,13 @@ class ESPHomeOTAComponent : public ota::OTAComponent { std::string password_; #endif // USE_OTA_PASSWORD - uint16_t port_; - uint32_t client_connect_time_{0}; - std::unique_ptr server_; std::unique_ptr client_; + + uint32_t client_connect_time_{0}; + uint16_t port_; + uint8_t magic_buf_[5]; + uint8_t magic_buf_pos_{0}; }; } // namespace esphome From c096c6934d6e610c9192eaa09a23b01f41bbaba3 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Sun, 24 Aug 2025 10:56:06 +0200 Subject: [PATCH 0694/1626] fix temperature config validation regex (#9575) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/config_validation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index f811fbf7c2..866ed4f8aa 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1112,8 +1112,8 @@ voltage = float_with_unit("voltage", "(v|V|volt|Volts)?") distance = float_with_unit("distance", "(m)") framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)") angle = float_with_unit("angle", "(°|deg)", optional_unit=True) -_temperature_c = float_with_unit("temperature", "(°C|° C|°|C)?") -_temperature_k = float_with_unit("temperature", "(° K|° K|K)?") +_temperature_c = float_with_unit("temperature", "(°C|° C|C|°)?") +_temperature_k = float_with_unit("temperature", "(°K|° K|K)?") _temperature_f = float_with_unit("temperature", "(°F|° F|F)?") decibel = float_with_unit("decibel", "(dB|dBm|db|dbm)", optional_unit=True) pressure = float_with_unit("pressure", "(bar|Bar)", optional_unit=True) From 12ba4b142e8dd2950e9406de2f8d378631772688 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Sun, 24 Aug 2025 11:03:14 +0200 Subject: [PATCH 0695/1626] Update Python to 3.11 in AI instructions (#10407) --- .ai/instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ai/instructions.md b/.ai/instructions.md index 6c002f9617..4d6cd71f85 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -9,7 +9,7 @@ This document provides essential context for AI models interacting with this pro ## 2. Core Technologies & Stack -* **Languages:** Python (>=3.10), C++ (gnu++20) +* **Languages:** Python (>=3.11), C++ (gnu++20) * **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF. * **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative. * **Configuration:** YAML. From be9c20c35758c64eae2289a8e8be4d04f5472212 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 05:52:52 +1000 Subject: [PATCH 0696/1626] [mipi_spi] Add model (#10392) --- esphome/components/mipi_spi/models/jc.py | 229 +++++++++++++++++++++++ 1 file changed, 229 insertions(+) diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index f1f046a427..5dbf049ded 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -255,4 +255,233 @@ DriverChip( ), ) +DriverChip( + "JC3636W518V2", + height=360, + width=360, + offset_height=1, + draw_rounding=1, + cs_pin=10, + reset_pin=47, + invert_colors=True, + color_order=MODE_RGB, + bus_mode=TYPE_QUAD, + data_rate="40MHz", + initsequence=( + (0xF0, 0x28), + (0xF2, 0x28), + (0x73, 0xF0), + (0x7C, 0xD1), + (0x83, 0xE0), + (0x84, 0x61), + (0xF2, 0x82), + (0xF0, 0x00), + (0xF0, 0x01), + (0xF1, 0x01), + (0xB0, 0x56), + (0xB1, 0x4D), + (0xB2, 0x24), + (0xB4, 0x87), + (0xB5, 0x44), + (0xB6, 0x8B), + (0xB7, 0x40), + (0xB8, 0x86), + (0xBA, 0x00), + (0xBB, 0x08), + (0xBC, 0x08), + (0xBD, 0x00), + (0xC0, 0x80), + (0xC1, 0x10), + (0xC2, 0x37), + (0xC3, 0x80), + (0xC4, 0x10), + (0xC5, 0x37), + (0xC6, 0xA9), + (0xC7, 0x41), + (0xC8, 0x01), + (0xC9, 0xA9), + (0xCA, 0x41), + (0xCB, 0x01), + (0xD0, 0x91), + (0xD1, 0x68), + (0xD2, 0x68), + (0xF5, 0x00, 0xA5), + (0xDD, 0x4F), + (0xDE, 0x4F), + (0xF1, 0x10), + (0xF0, 0x00), + (0xF0, 0x02), + ( + 0xE0, + 0xF0, + 0x0A, + 0x10, + 0x09, + 0x09, + 0x36, + 0x35, + 0x33, + 0x4A, + 0x29, + 0x15, + 0x15, + 0x2E, + 0x34, + ), + ( + 0xE1, + 0xF0, + 0x0A, + 0x0F, + 0x08, + 0x08, + 0x05, + 0x34, + 0x33, + 0x4A, + 0x39, + 0x15, + 0x15, + 0x2D, + 0x33, + ), + (0xF0, 0x10), + (0xF3, 0x10), + (0xE0, 0x07), + (0xE1, 0x00), + (0xE2, 0x00), + (0xE3, 0x00), + (0xE4, 0xE0), + (0xE5, 0x06), + (0xE6, 0x21), + (0xE7, 0x01), + (0xE8, 0x05), + (0xE9, 0x02), + (0xEA, 0xDA), + (0xEB, 0x00), + (0xEC, 0x00), + (0xED, 0x0F), + (0xEE, 0x00), + (0xEF, 0x00), + (0xF8, 0x00), + (0xF9, 0x00), + (0xFA, 0x00), + (0xFB, 0x00), + (0xFC, 0x00), + (0xFD, 0x00), + (0xFE, 0x00), + (0xFF, 0x00), + (0x60, 0x40), + (0x61, 0x04), + (0x62, 0x00), + (0x63, 0x42), + (0x64, 0xD9), + (0x65, 0x00), + (0x66, 0x00), + (0x67, 0x00), + (0x68, 0x00), + (0x69, 0x00), + (0x6A, 0x00), + (0x6B, 0x00), + (0x70, 0x40), + (0x71, 0x03), + (0x72, 0x00), + (0x73, 0x42), + (0x74, 0xD8), + (0x75, 0x00), + (0x76, 0x00), + (0x77, 0x00), + (0x78, 0x00), + (0x79, 0x00), + (0x7A, 0x00), + (0x7B, 0x00), + (0x80, 0x48), + (0x81, 0x00), + (0x82, 0x06), + (0x83, 0x02), + (0x84, 0xD6), + (0x85, 0x04), + (0x86, 0x00), + (0x87, 0x00), + (0x88, 0x48), + (0x89, 0x00), + (0x8A, 0x08), + (0x8B, 0x02), + (0x8C, 0xD8), + (0x8D, 0x04), + (0x8E, 0x00), + (0x8F, 0x00), + (0x90, 0x48), + (0x91, 0x00), + (0x92, 0x0A), + (0x93, 0x02), + (0x94, 0xDA), + (0x95, 0x04), + (0x96, 0x00), + (0x97, 0x00), + (0x98, 0x48), + (0x99, 0x00), + (0x9A, 0x0C), + (0x9B, 0x02), + (0x9C, 0xDC), + (0x9D, 0x04), + (0x9E, 0x00), + (0x9F, 0x00), + (0xA0, 0x48), + (0xA1, 0x00), + (0xA2, 0x05), + (0xA3, 0x02), + (0xA4, 0xD5), + (0xA5, 0x04), + (0xA6, 0x00), + (0xA7, 0x00), + (0xA8, 0x48), + (0xA9, 0x00), + (0xAA, 0x07), + (0xAB, 0x02), + (0xAC, 0xD7), + (0xAD, 0x04), + (0xAE, 0x00), + (0xAF, 0x00), + (0xB0, 0x48), + (0xB1, 0x00), + (0xB2, 0x09), + (0xB3, 0x02), + (0xB4, 0xD9), + (0xB5, 0x04), + (0xB6, 0x00), + (0xB7, 0x00), + (0xB8, 0x48), + (0xB9, 0x00), + (0xBA, 0x0B), + (0xBB, 0x02), + (0xBC, 0xDB), + (0xBD, 0x04), + (0xBE, 0x00), + (0xBF, 0x00), + (0xC0, 0x10), + (0xC1, 0x47), + (0xC2, 0x56), + (0xC3, 0x65), + (0xC4, 0x74), + (0xC5, 0x88), + (0xC6, 0x99), + (0xC7, 0x01), + (0xC8, 0xBB), + (0xC9, 0xAA), + (0xD0, 0x10), + (0xD1, 0x47), + (0xD2, 0x56), + (0xD3, 0x65), + (0xD4, 0x74), + (0xD5, 0x88), + (0xD6, 0x99), + (0xD7, 0x01), + (0xD8, 0xBB), + (0xD9, 0xAA), + (0xF3, 0x01), + (0xF0, 0x00), + ), +) + models = {} From 9737b355791baf7348ae5eb5eaee85a88901fca3 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 05:55:44 +1000 Subject: [PATCH 0697/1626] [http_request] Fix for host after ArduinoJson library bump (#10348) --- esphome/components/http_request/http_request_host.cpp | 7 +++++-- esphome/components/http_request/http_request_host.h | 6 +----- esphome/components/http_request/httplib.h | 10 +++++++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 192032c1ac..0b4c998a40 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -1,7 +1,10 @@ -#include "http_request_host.h" - #ifdef USE_HOST +#define USE_HTTP_REQUEST_HOST_H +#define CPPHTTPLIB_NO_EXCEPTIONS +#include "httplib.h" +#include "http_request_host.h" + #include #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 49fd3b43fe..bbeed87f70 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -1,11 +1,7 @@ #pragma once -#include "http_request.h" - #ifdef USE_HOST - -#define CPPHTTPLIB_NO_EXCEPTIONS -#include "httplib.h" +#include "http_request.h" namespace esphome { namespace http_request { diff --git a/esphome/components/http_request/httplib.h b/esphome/components/http_request/httplib.h index a2f4436ec7..8b08699702 100644 --- a/esphome/components/http_request/httplib.h +++ b/esphome/components/http_request/httplib.h @@ -3,12 +3,10 @@ /** * NOTE: This is a copy of httplib.h from https://github.com/yhirose/cpp-httplib * - * It has been modified only to add ifdefs for USE_HOST. While it contains many functions unused in ESPHome, + * It has been modified to add ifdefs for USE_HOST. While it contains many functions unused in ESPHome, * it was considered preferable to use it with as few changes as possible, to facilitate future updates. */ -#include "esphome/core/defines.h" - // // httplib.h // @@ -17,6 +15,11 @@ // #ifdef USE_HOST +// Prevent this code being included in main.cpp +#ifdef USE_HTTP_REQUEST_HOST_H + +#include "esphome/core/defines.h" + #ifndef CPPHTTPLIB_HTTPLIB_H #define CPPHTTPLIB_HTTPLIB_H @@ -9687,5 +9690,6 @@ inline SSL_CTX *Client::ssl_context() const { #endif #endif // CPPHTTPLIB_HTTPLIB_H +#endif // USE_HTTP_REQUEST_HOST_H #endif From ca19959d7c991eb1ab6357c1578effe12e4a29b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:11:54 +0200 Subject: [PATCH 0698/1626] [core] Improve error reporting for entity name conflicts with non-ASCII characters (#10329) --- esphome/core/entity_helpers.py | 13 +++++- tests/unit_tests/core/test_entity_helpers.py | 45 ++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 1ccc3e2683..e1b2a8264b 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -236,10 +236,21 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" + # Show both original names and their ASCII-only versions if they differ + sanitized_msg = "" + if entity_name != existing_name: + sanitized_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" + ) + raise cv.Invalid( f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " f"{conflict_msg}. " - f"Each entity on a device must have a unique name within its platform." + "Each entity on a device must have a unique name within its platform." + f"{sanitized_msg}" ) # Store metadata about this entity diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index db99243a1a..9ba5367413 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -705,3 +705,48 @@ def test_empty_or_null_device_id_on_entity() -> None: config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: None} validated2 = validator(config2) assert validated2 == config2 + + +def test_entity_duplicate_validator_non_ascii_names() -> None: + """Test that non-ASCII names show helpful error messages.""" + # Create validator for binary_sensor platform + validator = entity_duplicate_validator("binary_sensor") + + # First Russian sensor should pass + config1 = {CONF_NAME: "Датчик открытия основного крана"} + validated1 = validator(config1) + assert validated1 == config1 + + # Second Russian sensor with different text but same ASCII conversion should fail + config2 = {CONF_NAME: "Датчик закрытия основного крана"} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", + re.DOTALL, + ), + ): + validator(config2) + + +def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: + """Test that identical names don't show the enhanced message.""" + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # First entity should pass + config1 = {CONF_NAME: "Temperature"} + validated1 = validator(config1) + assert validated1 == config1 + + # Second entity with exact same name should fail without enhanced message + config2 = {CONF_NAME: "Temperature"} + with pytest.raises( + Invalid, + match=r"Duplicate sensor entity with name 'Temperature' found.*" + r"Each entity on a device must have a unique name within its platform\.$", + ): + validator(config2) From 88303f39fa8174b17c9854ef5b8adafbb1ccb0a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:16:12 +0200 Subject: [PATCH 0699/1626] [pvvx_mithermometer] Fix race condition with BLE authentication (#10327) --- .../display/pvvx_display.cpp | 37 +++++++++++++++++-- .../pvvx_mithermometer/display/pvvx_display.h | 2 + 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 4b6c11b332..b6916ad68f 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -46,10 +46,32 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t } this->connection_established_ = true; this->char_handle_ = chr->handle; -#ifdef USE_TIME - this->sync_time_(); -#endif - this->display(); + + // Attempt to write immediately + // For devices without security, this will work + // For devices with security that are already paired, this will work + // For devices that need pairing, the write will be retried after auth completes + this->sync_time_and_display_(); + break; + } + default: + break; + } +} + +void PVVXDisplay::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + if (!this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr)) + return; + + if (param->ble_security.auth_cmpl.success) { + ESP_LOGD(TAG, "[%s] Authentication successful, performing writes.", this->parent_->address_str().c_str()); + // Now that pairing is complete, perform the pending writes + this->sync_time_and_display_(); + } else { + ESP_LOGW(TAG, "[%s] Authentication failed.", this->parent_->address_str().c_str()); + } break; } default: @@ -127,6 +149,13 @@ void PVVXDisplay::delayed_disconnect_() { this->set_timeout("disconnect", this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); }); } +void PVVXDisplay::sync_time_and_display_() { +#ifdef USE_TIME + this->sync_time_(); +#endif + this->display(); +} + #ifdef USE_TIME void PVVXDisplay::sync_time_() { if (this->time_ == nullptr) diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index 9739362024..c7fc523420 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -43,6 +43,7 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; /// Set validity period of the display information in seconds (1..65535) void set_validity_period(uint16_t validity_period) { this->validity_period_ = validity_period; } @@ -112,6 +113,7 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { void setcfgbit_(uint8_t bit, bool value); void send_to_setup_char_(uint8_t *blk, size_t size); void delayed_disconnect_(); + void sync_time_and_display_(); #ifdef USE_TIME void sync_time_(); time::RealTimeClock *time_{nullptr}; From acfce581fa714eda03d4742927fc6d7d3c59275c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:17:26 +0200 Subject: [PATCH 0700/1626] [esp32_ble_client] Optimize BLE connection parameters for different connection types (#10356) --- .../esp32_ble_client/ble_client_base.cpp | 99 +++++++++---------- .../esp32_ble_client/ble_client_base.h | 6 +- 2 files changed, 54 insertions(+), 51 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e23be2e0c1..b9b7c75e0a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace esphome::esp32_ble_client { @@ -111,43 +112,19 @@ void BLEClientBase::connect() { this->remote_addr_type_); this->paired_ = false; - // Set preferred connection parameters before connecting - // Use FAST for all V3 connections (better latency and reliability) - // Use MEDIUM for V1/legacy connections (balanced performance) - uint16_t min_interval, max_interval, timeout; - const char *param_type; - - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - min_interval = FAST_MIN_CONN_INTERVAL; - max_interval = FAST_MAX_CONN_INTERVAL; - timeout = FAST_CONN_TIMEOUT; - param_type = "fast"; - } else { - min_interval = MEDIUM_MIN_CONN_INTERVAL; - max_interval = MEDIUM_MAX_CONN_INTERVAL; - timeout = MEDIUM_CONN_TIMEOUT; - param_type = "medium"; + // Determine connection parameters based on connection type + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + // V3 without cache needs fast params for service discovery + this->set_conn_params_(FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT, "fast"); + } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { + // V3 with cache can use medium params + this->set_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); } + // For V1/Legacy, don't set params - use ESP-IDF defaults - auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, - 0, // latency: 0 - timeout); - if (param_ret != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, - this->address_str_.c_str(), param_ret); - } else { - this->log_connection_params_(param_type); - } - - // Now open the connection + // Open the connection auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); - if (ret) { - this->log_gattc_warning_("esp_ble_gattc_open", ret); - this->set_state(espbt::ClientState::IDLE); - } else { - this->set_state(espbt::ClientState::CONNECTING); - } + this->handle_connection_result_(ret); } esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); } @@ -231,6 +208,15 @@ void BLEClientBase::log_connection_params_(const char *param_type) { ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); } +void BLEClientBase::handle_connection_result_(esp_err_t ret) { + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_open", ret); + this->set_state(espbt::ClientState::IDLE); + } else { + this->set_state(espbt::ClientState::CONNECTING); + } +} + void BLEClientBase::log_error_(const char *message) { ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); } @@ -243,17 +229,30 @@ void BLEClientBase::log_warning_(const char *message) { ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); } -void BLEClientBase::restore_medium_conn_params_() { - // Restore to medium connection parameters after initial connection phase - // This balances performance with bandwidth usage for normal operation +void BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout, const char *param_type) { esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); - conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; - conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; - conn_params.latency = 0; - conn_params.timeout = MEDIUM_CONN_TIMEOUT; - this->log_connection_params_("medium"); - esp_ble_gap_update_conn_params(&conn_params); + conn_params.min_int = min_interval; + conn_params.max_int = max_interval; + conn_params.latency = latency; + conn_params.timeout = timeout; + this->log_connection_params_(param_type); + esp_err_t err = esp_ble_gap_update_conn_params(&conn_params); + if (err != ESP_OK) { + this->log_gattc_warning_("esp_ble_gap_update_conn_params", err); + } +} + +void BLEClientBase::set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type) { + // Set preferred connection parameters before connecting + // These will be used when establishing the connection + this->log_connection_params_(param_type); + esp_err_t err = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, latency, timeout); + if (err != ESP_OK) { + this->log_gattc_warning_("esp_ble_gap_set_prefer_conn_params", err); + } } bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, @@ -308,12 +307,13 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->set_state(espbt::ClientState::CONNECTED); ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - // Restore to medium connection parameters for cached connections too - this->restore_medium_conn_params_(); + // Cached connections already connected with medium parameters, no update needed // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; } + // For V3_WITHOUT_CACHE, we already set fast params before connecting + // No need to update them again here this->log_event_("Searching for services"); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); break; @@ -395,12 +395,11 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->conn_id_ != param->search_cmpl.conn_id) return false; this->log_gattc_event_("SEARCH_CMPL"); - // For V3 connections, restore to medium connection parameters after service discovery + // For V3_WITHOUT_CACHE, switch back to medium connection parameters after service discovery // This balances performance with bandwidth usage after the critical discovery phase - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - this->restore_medium_conn_params_(); - } else { + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); + } else if (this->connection_type_ != espbt::ConnectionType::V3_WITH_CACHE) { #ifdef USE_ESP32_BLE_DEVICE for (auto &svc : this->services_) { ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 1850b2c5b3..acfad9e9b0 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -133,10 +133,14 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_event_(const char *name); void log_gattc_event_(const char *name); - void restore_medium_conn_params_(); + void update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); + void set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); + void handle_connection_result_(esp_err_t ret); // Compact error logging helpers to reduce flash usage void log_error_(const char *message); void log_error_(const char *message, int code); From 4396bc0d1acbba2e6414c6f4ae16febe8b471307 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:49:37 +0200 Subject: [PATCH 0701/1626] [esp32_ble] Increase GATT connection retry count to use full timeout window (#10376) --- esphome/components/esp32_ble/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 2edd69c6c0..cc06058b65 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -280,6 +280,10 @@ async def to_code(config): add_idf_sdkconfig_option( "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds ) + # Increase GATT client connection retry count for problematic devices + # Default in ESP-IDF is 3, we increase to 10 for better reliability with + # low-power/timing-sensitive devices + add_idf_sdkconfig_option("CONFIG_BT_GATTC_CONNECT_RETRY_COUNT", 10) # Set the maximum number of notification registrations # This controls how many BLE characteristics can have notifications enabled From 61a50238880937c0f7b13e18a6f06ad5aebcb38a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:49:52 +0200 Subject: [PATCH 0702/1626] [script] Fix parallel mode scripts with delays cancelling each other (#10324) --- esphome/core/base_automation.h | 13 +++- esphome/core/scheduler.cpp | 19 +++-- esphome/core/scheduler.h | 9 ++- .../fixtures/parallel_script_delays.yaml | 45 ++++++++++++ tests/integration/test_automations.py | 70 +++++++++++++++++++ 5 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 tests/integration/fixtures/parallel_script_delays.yaml diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 740e10700b..ba942e5e43 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -5,6 +5,8 @@ #include "esphome/core/hal.h" #include "esphome/core/defines.h" #include "esphome/core/preferences.h" +#include "esphome/core/scheduler.h" +#include "esphome/core/application.h" #include @@ -158,7 +160,16 @@ template class DelayAction : public Action, public Compon void play_complex(Ts... x) override { auto f = std::bind(&DelayAction::play_next_, this, x...); this->num_running_++; - this->set_timeout("delay", this->delay_.value(x...), f); + + // If num_running_ > 1, we have multiple instances running in parallel + // In single/restart/queued modes, only one instance runs at a time + // Parallel mode uses skip_cancel=true to allow multiple delays to coexist + // WARNING: This can accumulate delays if scripts are triggered faster than they complete! + // Users should set max_runs on parallel scripts to limit concurrent executions. + // Issue #10264: This is a workaround for parallel script delays interfering with each other. + App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, + /* is_static_string= */ true, "delay", this->delay_.value(x...), std::move(f), + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } float get_setup_priority() const override { return setup_priority::HARDWARE; } diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index c3ade260ac..a907b89b02 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -65,14 +65,17 @@ static void validate_static_string(const char *name) { // Common implementation for both timeout and interval void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, - const void *name_ptr, uint32_t delay, std::function func, bool is_retry) { + const void *name_ptr, uint32_t delay, std::function func, bool is_retry, + bool skip_cancel) { // Get the name as const char* const char *name_cstr = this->get_name_cstr_(is_static_string, name_ptr); if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if name is not empty - LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_cstr, type); + if (!skip_cancel) { + LockGuard guard{this->lock_}; + this->cancel_item_locked_(component, name_cstr, type); + } return; } @@ -97,7 +100,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type if (delay == 0 && type == SchedulerItem::TIMEOUT) { // Put in defer queue for guaranteed FIFO execution LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_cstr, type); + if (!skip_cancel) { + this->cancel_item_locked_(component, name_cstr, type); + } this->defer_queue_.push_back(std::move(item)); return; } @@ -150,9 +155,11 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } - // If name is provided, do atomic cancel-and-add + // If name is provided, do atomic cancel-and-add (unless skip_cancel is true) // Cancel existing items - this->cancel_item_locked_(component, name_cstr, type); + if (!skip_cancel) { + this->cancel_item_locked_(component, name_cstr, type); + } // Add new item directly to to_add_ // since we have the lock held this->to_add_.push_back(std::move(item)); diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index c73bd55d5d..f469a60d5c 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -21,8 +21,13 @@ struct RetryArgs; void retry_handler(const std::shared_ptr &args); class Scheduler { - // Allow retry_handler to access protected members + // Allow retry_handler to access protected members for internal retry mechanism friend void ::esphome::retry_handler(const std::shared_ptr &args); + // Allow DelayAction to call set_timer_common_ with skip_cancel=true for parallel script delays. + // This is needed to fix issue #10264 where parallel scripts with delays interfere with each other. + // We use friend instead of a public API because skip_cancel is dangerous - it can cause delays + // to accumulate and overload the scheduler if misused. + template friend class DelayAction; public: // Public API - accepts std::string for backward compatibility @@ -184,7 +189,7 @@ class Scheduler { // Common implementation for both timeout and interval void set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, const void *name_ptr, - uint32_t delay, std::function func, bool is_retry = false); + uint32_t delay, std::function func, bool is_retry = false, bool skip_cancel = false); // Common implementation for retry void set_retry_common_(Component *component, bool is_static_string, const void *name_ptr, uint32_t initial_wait_time, diff --git a/tests/integration/fixtures/parallel_script_delays.yaml b/tests/integration/fixtures/parallel_script_delays.yaml new file mode 100644 index 0000000000..6887045913 --- /dev/null +++ b/tests/integration/fixtures/parallel_script_delays.yaml @@ -0,0 +1,45 @@ +esphome: + name: test-parallel-delays + +host: + +logger: + level: DEBUG + +api: + actions: + - action: test_parallel_delays + then: + # Start three parallel script instances with small delays between starts + - globals.set: + id: instance_counter + value: '1' + - script.execute: parallel_delay_script + - delay: 10ms + - globals.set: + id: instance_counter + value: '2' + - script.execute: parallel_delay_script + - delay: 10ms + - globals.set: + id: instance_counter + value: '3' + - script.execute: parallel_delay_script + +globals: + - id: instance_counter + type: int + initial_value: '0' + +script: + - id: parallel_delay_script + mode: parallel + then: + - lambda: !lambda |- + int instance = id(instance_counter); + ESP_LOGI("TEST", "Parallel script instance %d started", instance); + - delay: 1s + - lambda: !lambda |- + static int completed_counter = 0; + completed_counter++; + ESP_LOGI("TEST", "Parallel script instance %d completed after delay", completed_counter); diff --git a/tests/integration/test_automations.py b/tests/integration/test_automations.py index bd2082e86b..83268c1eea 100644 --- a/tests/integration/test_automations.py +++ b/tests/integration/test_automations.py @@ -89,3 +89,73 @@ async def test_delay_action_cancellation( assert 0.4 < time_from_second_start < 0.6, ( f"Delay completed {time_from_second_start:.3f}s after second start, expected ~0.5s" ) + + +@pytest.mark.asyncio +async def test_parallel_script_delays( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that parallel scripts with delays don't interfere with each other.""" + loop = asyncio.get_running_loop() + + # Track script executions + script_starts: list[float] = [] + script_ends: list[float] = [] + + # Patterns to match + start_pattern = re.compile(r"Parallel script instance \d+ started") + end_pattern = re.compile(r"Parallel script instance \d+ completed after delay") + + # Future to track when all scripts have completed + all_scripts_completed = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for parallel script messages.""" + current_time = loop.time() + + if start_pattern.search(line): + script_starts.append(current_time) + + if end_pattern.search(line): + script_ends.append(current_time) + # Check if we have all 3 completions + if len(script_ends) == 3 and not all_scripts_completed.done(): + all_scripts_completed.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get services + entities, services = await client.list_entities_services() + + # Find our test service + test_service = next( + (s for s in services if s.name == "test_parallel_delays"), None + ) + assert test_service is not None, "test_parallel_delays service not found" + + # Execute the test - this will start 3 parallel scripts with 1 second delays + client.execute_service(test_service, {}) + + # Wait for all scripts to complete (should take ~1 second, not 3) + await asyncio.wait_for(all_scripts_completed, timeout=2.0) + + # Verify we had 3 starts and 3 ends + assert len(script_starts) == 3, ( + f"Expected 3 script starts, got {len(script_starts)}" + ) + assert len(script_ends) == 3, f"Expected 3 script ends, got {len(script_ends)}" + + # Verify they ran in parallel - all should complete within ~1.5 seconds + first_start = min(script_starts) + last_end = max(script_ends) + total_time = last_end - first_start + + # If running in parallel, total time should be close to 1 second + # If they were interfering (running sequentially), it would take 3+ seconds + assert total_time < 1.5, ( + f"Parallel scripts took {total_time:.2f}s total, should be ~1s if running in parallel" + ) From b41a61c76e9fec271f8211be5c5cfdd3276728c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:51:23 +0200 Subject: [PATCH 0703/1626] [deep_sleep] Fix ESP32-C6 compilation error with gpio_deep_sleep_hold_en() (#10345) --- esphome/components/deep_sleep/deep_sleep_esp32.cpp | 9 +++++++++ tests/components/deep_sleep/test.esp32-c6-idf.yaml | 5 +++++ tests/components/deep_sleep/test.esp32-s2-idf.yaml | 5 +++++ tests/components/deep_sleep/test.esp32-s3-idf.yaml | 5 +++++ 4 files changed, 24 insertions(+) create mode 100644 tests/components/deep_sleep/test.esp32-c6-idf.yaml create mode 100644 tests/components/deep_sleep/test.esp32-s2-idf.yaml create mode 100644 tests/components/deep_sleep/test.esp32-s3-idf.yaml diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index c5c1fe0835..e9d0a4981f 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -1,4 +1,5 @@ #ifdef USE_ESP32 +#include "soc/soc_caps.h" #include "driver/gpio.h" #include "deep_sleep_component.h" #include "esphome/core/log.h" @@ -83,7 +84,11 @@ void DeepSleepComponent::deep_sleep_() { } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_hold_en(gpio_pin); +#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP + // Some ESP32 variants support holding a single GPIO during deep sleep without this function + // For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep gpio_deep_sleep_hold_en(); +#endif bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; @@ -120,7 +125,11 @@ void DeepSleepComponent::deep_sleep_() { } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_hold_en(gpio_pin); +#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP + // Some ESP32 variants support holding a single GPIO during deep sleep without this function + // For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep gpio_deep_sleep_hold_en(); +#endif bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; diff --git a/tests/components/deep_sleep/test.esp32-c6-idf.yaml b/tests/components/deep_sleep/test.esp32-c6-idf.yaml new file mode 100644 index 0000000000..10c17af0f5 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c6-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32.yaml diff --git a/tests/components/deep_sleep/test.esp32-s2-idf.yaml b/tests/components/deep_sleep/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..10c17af0f5 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-s2-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32.yaml diff --git a/tests/components/deep_sleep/test.esp32-s3-idf.yaml b/tests/components/deep_sleep/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..10c17af0f5 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32.yaml From 8fe582309e18b019136c1f5d2347935d2fe53de9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:51:54 +0200 Subject: [PATCH 0704/1626] [esp32_ble_client] Reduce log level for harmless BLE timeout race conditions (#10339) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../esp32_ble_client/ble_client_base.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index b9b7c75e0a..351658279f 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -136,7 +136,8 @@ void BLEClientBase::disconnect() { return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { - this->log_warning_("Disconnect before connected, disconnect scheduled."); + ESP_LOGD(TAG, "[%d] [%s] Disconnect before connected, disconnect scheduled", this->connection_index_, + this->address_str_.c_str()); this->want_disconnect_ = true; return; } @@ -284,11 +285,22 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->log_gattc_event_("OPEN"); // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; + + // ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an + // error, if the error occurred at the BTA/GATT layer. This can result in the event + // arriving after we've already transitioned to IDLE state. + if (this->state_ == espbt::ClientState::IDLE) { + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_, + this->address_str_.c_str(), param->open.status); + break; + } + if (this->state_ != espbt::ClientState::CONNECTING) { // This should not happen but lets log it in case it does // because it means we have a bad assumption about how the // ESP BT stack works. - this->log_error_("ESP_GATTC_OPEN_EVT wrong state status", param->open.status); + ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, + this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); From a58c3950bc7f055eb91f61655087fc6eb5f19ad0 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 06:52:37 +1000 Subject: [PATCH 0705/1626] [lvgl] Fix meter rotation (#10342) --- esphome/components/lvgl/widgets/canvas.py | 10 +++++----- esphome/components/lvgl/widgets/meter.py | 9 +++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 4fd81b6e4a..217e8935f1 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -24,7 +24,7 @@ from ..defines import ( literal, ) from ..lv_validation import ( - lv_angle, + lv_angle_degrees, lv_bool, lv_color, lv_image, @@ -395,15 +395,15 @@ ARC_PROPS = { DRAW_OPA_SCHEMA.extend( { cv.Required(CONF_RADIUS): pixels, - cv.Required(CONF_START_ANGLE): lv_angle, - cv.Required(CONF_END_ANGLE): lv_angle, + cv.Required(CONF_START_ANGLE): lv_angle_degrees, + cv.Required(CONF_END_ANGLE): lv_angle_degrees, } ).extend({cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()}), ) async def canvas_draw_arc(config, action_id, template_arg, args): radius = await size.process(config[CONF_RADIUS]) - start_angle = await lv_angle.process(config[CONF_START_ANGLE]) - end_angle = await lv_angle.process(config[CONF_END_ANGLE]) + start_angle = await lv_angle_degrees.process(config[CONF_START_ANGLE]) + end_angle = await lv_angle_degrees.process(config[CONF_END_ANGLE]) async def do_draw_arc(w: Widget, x, y, dsc_addr): lv.canvas_draw_arc(w.obj, x, y, radius, start_angle, end_angle, dsc_addr) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index acec986f99..aefda0e71a 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_VALUE, CONF_WIDTH, ) -from esphome.cpp_generator import IntLiteral from ..automation import action_to_code from ..defines import ( @@ -32,7 +31,7 @@ from ..helpers import add_lv_use, lvgl_components_required from ..lv_validation import ( get_end_value, get_start_value, - lv_angle, + lv_angle_degrees, lv_bool, lv_color, lv_float, @@ -163,7 +162,7 @@ SCALE_SCHEMA = cv.Schema( cv.Optional(CONF_RANGE_FROM, default=0.0): cv.float_, cv.Optional(CONF_RANGE_TO, default=100.0): cv.float_, cv.Optional(CONF_ANGLE_RANGE, default=270): cv.int_range(0, 360), - cv.Optional(CONF_ROTATION): lv_angle, + cv.Optional(CONF_ROTATION): lv_angle_degrees, cv.Optional(CONF_INDICATORS): cv.ensure_list(INDICATOR_SCHEMA), } ) @@ -188,9 +187,7 @@ class MeterType(WidgetType): for scale_conf in config.get(CONF_SCALES, ()): rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2 if CONF_ROTATION in scale_conf: - rotation = await lv_angle.process(scale_conf[CONF_ROTATION]) - if isinstance(rotation, IntLiteral): - rotation = int(str(rotation)) // 10 + rotation = await lv_angle_degrees.process(scale_conf[CONF_ROTATION]) with LocalVariable( "meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var) ) as meter_var: From 07bca6103f24bf0e9b3715db21638f345bced996 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:57:09 +0200 Subject: [PATCH 0706/1626] [esp32_ble_tracker] Fix on_scan_end trigger compilation without USE_ESP32_BLE_DEVICE (#10399) --- esphome/components/esp32_ble_tracker/automation.h | 5 ++++- .../esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index c0e6eee138..784f2eaaa2 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -80,14 +80,17 @@ class BLEManufacturerDataAdvertiseTrigger : public Trigger, ESPBTUUID uuid_; }; +#endif // USE_ESP32_BLE_DEVICE + class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { public: explicit BLEEndOfScanTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } +#ifdef USE_ESP32_BLE_DEVICE bool parse_device(const ESPBTDevice &device) override { return false; } +#endif void on_scan_end() override { this->trigger(); } }; -#endif // USE_ESP32_BLE_DEVICE template class ESP32BLEStartScanAction : public Action { public: diff --git a/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml b/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml new file mode 100644 index 0000000000..4e9849a540 --- /dev/null +++ b/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml @@ -0,0 +1,3 @@ +esp32_ble_tracker: + on_scan_end: + - logger.log: "Scan ended!" From 9f0257528794ac60d3d9f3eb353f83b69845e30f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:58:46 +0200 Subject: [PATCH 0707/1626] [test] Add integration test for light effect memory corruption fix (#10417) --- tests/integration/fixtures/light_calls.yaml | 32 ++++++++++++++- tests/integration/test_light_calls.py | 45 +++++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/tests/integration/fixtures/light_calls.yaml b/tests/integration/fixtures/light_calls.yaml index d692a11765..2b7650526f 100644 --- a/tests/integration/fixtures/light_calls.yaml +++ b/tests/integration/fixtures/light_calls.yaml @@ -56,10 +56,29 @@ light: warm_white_color_temperature: 2000 K constant_brightness: true effects: + # Use default parameters: - random: - name: "Random Effect" + # Customize parameters - use longer names to potentially trigger buffer issues + - random: + name: "My Very Slow Random Effect With Long Name" + transition_length: 30ms + update_interval: 30ms + - random: + name: "My Fast Random Effect That Changes Quickly" + transition_length: 4ms + update_interval: 5ms + - random: + name: "Random Effect With Medium Length Name Here" transition_length: 100ms update_interval: 200ms + - random: + name: "Another Random Effect With Different Parameters" + transition_length: 2ms + update_interval: 3ms + - random: + name: "Yet Another Random Effect To Test Memory" + transition_length: 15ms + update_interval: 20ms - strobe: name: "Strobe Effect" - pulse: @@ -73,6 +92,17 @@ light: red: test_red green: test_green blue: test_blue + effects: + # Same random effects to test for cross-contamination + - random: + - random: + name: "RGB Slow Random" + transition_length: 20ms + update_interval: 25ms + - random: + name: "RGB Fast Random" + transition_length: 2ms + update_interval: 3ms - platform: binary name: "Test Binary Light" diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index 1a0a9e553f..af90ddbe86 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -108,14 +108,51 @@ async def test_light_calls( # Wait for flash to end state = await wait_for_state_change(rgbcw_light.key) - # Test 13: effect only + # Test 13: effect only - test all random effects # First ensure light is on client.light_command(key=rgbcw_light.key, state=True) state = await wait_for_state_change(rgbcw_light.key) - # Now set effect - client.light_command(key=rgbcw_light.key, effect="Random Effect") + + # Test 13a: Default random effect (no name, gets default name "Random") + client.light_command(key=rgbcw_light.key, effect="Random") state = await wait_for_state_change(rgbcw_light.key) - assert state.effect == "Random Effect" + assert state.effect == "Random" + + # Test 13b: Slow random effect with long name + client.light_command( + key=rgbcw_light.key, effect="My Very Slow Random Effect With Long Name" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "My Very Slow Random Effect With Long Name" + + # Test 13c: Fast random effect with long name + client.light_command( + key=rgbcw_light.key, effect="My Fast Random Effect That Changes Quickly" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "My Fast Random Effect That Changes Quickly" + + # Test 13d: Random effect with medium length name + client.light_command( + key=rgbcw_light.key, effect="Random Effect With Medium Length Name Here" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "Random Effect With Medium Length Name Here" + + # Test 13e: Another random effect + client.light_command( + key=rgbcw_light.key, + effect="Another Random Effect With Different Parameters", + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "Another Random Effect With Different Parameters" + + # Test 13f: Yet another random effect + client.light_command( + key=rgbcw_light.key, effect="Yet Another Random Effect To Test Memory" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "Yet Another Random Effect To Test Memory" # Test 14: stop effect client.light_command(key=rgbcw_light.key, effect="None") From 456c31262d558c74100ca2481c70bb7f2e64b220 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 07:04:32 +1000 Subject: [PATCH 0708/1626] [web_server] Use oi.esphome.io for css and js assets (#10296) --- esphome/components/web_server/__init__.py | 4 ++-- esphome/components/web_server/web_server.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 695757e137..be193bbab8 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -52,9 +52,9 @@ def default_url(config: ConfigType) -> ConfigType: config = config.copy() if config[CONF_VERSION] == 1: if CONF_CSS_URL not in config: - config[CONF_CSS_URL] = "https://esphome.io/_static/webserver-v1.min.css" + config[CONF_CSS_URL] = "https://oi.esphome.io/v1/webserver-v1.min.css" if CONF_JS_URL not in config: - config[CONF_JS_URL] = "https://esphome.io/_static/webserver-v1.min.js" + config[CONF_JS_URL] = "https://oi.esphome.io/v1/webserver-v1.min.js" if config[CONF_VERSION] == 2: if CONF_CSS_URL not in config: config[CONF_CSS_URL] = "" diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 6bece732fc..e42c35b32d 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -173,14 +173,14 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { #if USE_WEBSERVER_VERSION == 1 /** Set the URL to the CSS that's sent to each client. Defaults to - * https://esphome.io/_static/webserver-v1.min.css + * https://oi.esphome.io/v1/webserver-v1.min.css * * @param css_url The url to the web server stylesheet. */ void set_css_url(const char *css_url); /** Set the URL to the script that's embedded in the index page. Defaults to - * https://esphome.io/_static/webserver-v1.min.js + * https://oi.esphome.io/v1/webserver-v1.min.js * * @param js_url The url to the web server script. */ From ecfeb8e4d320f1f63e222caa36e593dcfe5b4c0e Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Mon, 25 Aug 2025 01:51:28 +0200 Subject: [PATCH 0709/1626] improve AI instructions (#10416) --- .ai/instructions.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.ai/instructions.md b/.ai/instructions.md index 4d6cd71f85..1cd77b9136 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -38,7 +38,7 @@ This document provides essential context for AI models interacting with this pro 5. **Dashboard** (`esphome/dashboard/`): A web-based interface for device configuration, management, and OTA updates. * **Platform Support:** - 1. **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (S2, S3, C3, etc.) and both IDF and Arduino frameworks. + 1. **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (Original, C2, C3, C5, C6, H2, P4, S2, S3) with ESP-IDF framework. Arduino framework supports only a subset of the variants (Original, C3, S2, S3). 2. **ESP8266** (`components/esp8266/`): Espressif ESP8266. Arduino framework only, with memory constraints. 3. **RP2040** (`components/rp2040/`): Raspberry Pi Pico/RP2040. Arduino framework with PIO (Programmable I/O) support. 4. **LibreTiny** (`components/libretiny/`): Realtek and Beken chips. Supports multiple chip families and auto-generated components. @@ -60,7 +60,7 @@ This document provides essential context for AI models interacting with this pro ├── __init__.py # Component configuration schema and code generation ├── [component].h # C++ header file (if needed) ├── [component].cpp # C++ implementation (if needed) - └── [platform]/ # Platform-specific implementations + └── [platform]/ # Platform-specific implementations ├── __init__.py # Platform-specific configuration ├── [platform].h # Platform C++ header └── [platform].cpp # Platform C++ implementation @@ -150,7 +150,8 @@ This document provides essential context for AI models interacting with this pro * **Configuration Validation:** * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`. * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`. - * **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `cv.only_with_arduino`. + * **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`. + * **Framework-Specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`, `cv.only_with_esp_idf`. * **Schema Extensions:** ```python CONFIG_SCHEMA = cv.Schema({ ... }) From 6004367ee29942a74164db5f1d7487d5d6198271 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Aug 2025 02:07:04 +0200 Subject: [PATCH 0710/1626] [esp32_ble_client] Add missing ESP_GATTC_UNREG_FOR_NOTIFY_EVT logging (#10347) --- esphome/components/esp32_ble_client/ble_client_base.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 351658279f..4805855960 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -495,6 +495,11 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ break; } + case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { + this->log_gattc_event_("UNREG_FOR_NOTIFY"); + break; + } + default: // ideally would check all other events for matching conn_id ESP_LOGD(TAG, "[%d] [%s] Event %d", this->connection_index_, this->address_str_.c_str(), event); From d2752b38c963f42336e0b48e104c4613e890fc99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Aug 2025 02:22:16 +0200 Subject: [PATCH 0711/1626] [core] Fix preference storage to account for device_id (#10333) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/climate/climate.cpp | 2 +- esphome/components/cover/cover.cpp | 2 +- .../components/duty_time/duty_time_sensor.cpp | 2 +- esphome/components/fan/fan.cpp | 3 +- esphome/components/haier/haier_base.cpp | 2 +- esphome/components/haier/hon_climate.cpp | 2 +- .../integration/integration_sensor.cpp | 2 +- esphome/components/ld2450/ld2450.cpp | 2 +- esphome/components/light/light_state.cpp | 4 +- esphome/components/lvgl/number/lvgl_number.h | 2 +- esphome/components/lvgl/select/lvgl_select.h | 2 +- esphome/components/number/automation.cpp | 2 +- .../opentherm/number/opentherm_number.cpp | 2 +- .../rotary_encoder/rotary_encoder.cpp | 2 +- esphome/components/sensor/automation.h | 2 +- .../media_player/speaker_media_player.cpp | 2 +- esphome/components/sprinkler/sprinkler.cpp | 2 +- esphome/components/switch/switch.cpp | 2 +- .../template_alarm_control_panel.cpp | 2 +- .../template/datetime/template_date.cpp | 2 +- .../template/datetime/template_datetime.cpp | 4 +- .../template/datetime/template_time.cpp | 2 +- .../template/number/template_number.cpp | 2 +- .../template/select/template_select.cpp | 2 +- .../template/text/template_text.cpp | 2 +- .../total_daily_energy/total_daily_energy.cpp | 2 +- .../components/tuya/number/tuya_number.cpp | 2 +- esphome/components/valve/valve.cpp | 2 +- esphome/core/entity_base.h | 29 +++ .../fixtures/multi_device_preferences.yaml | 165 ++++++++++++++++++ .../test_multi_device_preferences.py | 144 +++++++++++++++ 31 files changed, 369 insertions(+), 30 deletions(-) create mode 100644 tests/integration/fixtures/multi_device_preferences.yaml create mode 100644 tests/integration/test_multi_device_preferences.py diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index edebc0de69..be56310b35 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -327,7 +327,7 @@ void Climate::add_on_control_callback(std::function &&callb static const uint32_t RESTORE_STATE_VERSION = 0x848EA6ADUL; optional Climate::restore_state_() { - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash() ^ + this->rtc_ = global_preferences->make_preference(this->get_preference_hash() ^ RESTORE_STATE_VERSION); ClimateDeviceRestoreState recovered{}; if (!this->rtc_.load(&recovered)) diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index 68dfab111b..700bceec01 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -194,7 +194,7 @@ void Cover::publish_state(bool save) { } } optional Cover::restore_state_() { - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); CoverRestoreState recovered{}; if (!this->rtc_.load(&recovered)) return {}; diff --git a/esphome/components/duty_time/duty_time_sensor.cpp b/esphome/components/duty_time/duty_time_sensor.cpp index c7319f7c33..f77f1fcf53 100644 --- a/esphome/components/duty_time/duty_time_sensor.cpp +++ b/esphome/components/duty_time/duty_time_sensor.cpp @@ -41,7 +41,7 @@ void DutyTimeSensor::setup() { uint32_t seconds = 0; if (this->restore_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); this->pref_.load(&seconds); } diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 82fc5319e0..26065ed644 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -148,7 +148,8 @@ void Fan::publish_state() { constexpr uint32_t RESTORE_STATE_VERSION = 0x71700ABA; optional Fan::restore_state_() { FanRestoreState recovered{}; - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash() ^ RESTORE_STATE_VERSION); + this->rtc_ = + global_preferences->make_preference(this->get_preference_hash() ^ RESTORE_STATE_VERSION); bool restored = this->rtc_.load(&recovered); switch (this->restore_mode_) { diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 4f933b08e3..55a2454fca 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -351,7 +351,7 @@ ClimateTraits HaierClimateBase::traits() { return traits_; } void HaierClimateBase::initialization() { constexpr uint32_t restore_settings_version = 0xA77D21EF; this->base_rtc_ = - global_preferences->make_preference(this->get_object_id_hash() ^ restore_settings_version); + global_preferences->make_preference(this->get_preference_hash() ^ restore_settings_version); HaierBaseSettings recovered; if (!this->base_rtc_.load(&recovered)) { recovered = {false, true}; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index fd2d6a5800..9614bb1e47 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -516,7 +516,7 @@ void HonClimate::initialization() { HaierClimateBase::initialization(); constexpr uint32_t restore_settings_version = 0x57EB59DDUL; this->hon_rtc_ = - global_preferences->make_preference(this->get_object_id_hash() ^ restore_settings_version); + global_preferences->make_preference(this->get_preference_hash() ^ restore_settings_version); HonSettings recovered; if (this->hon_rtc_.load(&recovered)) { this->settings_ = recovered; diff --git a/esphome/components/integration/integration_sensor.cpp b/esphome/components/integration/integration_sensor.cpp index c09778e79e..80c718dc8d 100644 --- a/esphome/components/integration/integration_sensor.cpp +++ b/esphome/components/integration/integration_sensor.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "integration"; void IntegrationSensor::setup() { if (this->restore_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); float preference_value = 0; this->pref_.load(&preference_value); this->result_ = preference_value; diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index b123d541d9..f30752e5a2 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -184,7 +184,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui void LD2450Component::setup() { #ifdef USE_NUMBER if (this->presence_timeout_number_ != nullptr) { - this->pref_ = global_preferences->make_preference(this->presence_timeout_number_->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->presence_timeout_number_->get_preference_hash()); this->set_presence_timeout(); } #endif diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9e42b2f1e2..f18d5ba1de 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -41,7 +41,7 @@ void LightState::setup() { case LIGHT_RESTORE_DEFAULT_ON: case LIGHT_RESTORE_INVERTED_DEFAULT_OFF: case LIGHT_RESTORE_INVERTED_DEFAULT_ON: - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); // Attempt to load from preferences, else fall back to default values if (!this->rtc_.load(&recovered)) { recovered.state = (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON || @@ -54,7 +54,7 @@ void LightState::setup() { break; case LIGHT_RESTORE_AND_OFF: case LIGHT_RESTORE_AND_ON: - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); this->rtc_.load(&recovered); recovered.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON); break; diff --git a/esphome/components/lvgl/number/lvgl_number.h b/esphome/components/lvgl/number/lvgl_number.h index 277494673b..7bc44c9e20 100644 --- a/esphome/components/lvgl/number/lvgl_number.h +++ b/esphome/components/lvgl/number/lvgl_number.h @@ -21,7 +21,7 @@ class LVGLNumber : public number::Number, public Component { void setup() override { float value = this->value_lambda_(); if (this->restore_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (this->pref_.load(&value)) { this->control_lambda_(value); } diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index 5b43209a5f..a0e60295a6 100644 --- a/esphome/components/lvgl/select/lvgl_select.h +++ b/esphome/components/lvgl/select/lvgl_select.h @@ -20,7 +20,7 @@ class LVGLSelect : public select::Select, public Component { this->set_options_(); if (this->restore_) { size_t index; - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (this->pref_.load(&index)) this->widget_->set_selected_index(index, LV_ANIM_OFF); } diff --git a/esphome/components/number/automation.cpp b/esphome/components/number/automation.cpp index cadc6f54f6..bfc59d0465 100644 --- a/esphome/components/number/automation.cpp +++ b/esphome/components/number/automation.cpp @@ -15,7 +15,7 @@ void ValueRangeTrigger::setup() { float local_min = this->min_.value(0.0); float local_max = this->max_.value(0.0); convert hash = {.from = (local_max - local_min)}; - uint32_t myhash = hash.to ^ this->parent_->get_object_id_hash(); + uint32_t myhash = hash.to ^ this->parent_->get_preference_hash(); this->rtc_ = global_preferences->make_preference(myhash); bool initial_state; if (this->rtc_.load(&initial_state)) { diff --git a/esphome/components/opentherm/number/opentherm_number.cpp b/esphome/components/opentherm/number/opentherm_number.cpp index c5d094aa36..f0c69144c8 100644 --- a/esphome/components/opentherm/number/opentherm_number.cpp +++ b/esphome/components/opentherm/number/opentherm_number.cpp @@ -17,7 +17,7 @@ void OpenthermNumber::setup() { if (!this->restore_value_) { value = this->initial_value_; } else { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->pref_.load(&value)) { if (!std::isnan(this->initial_value_)) { value = this->initial_value_; diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 20ea8d0293..26e20664f2 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -132,7 +132,7 @@ void RotaryEncoderSensor::setup() { int32_t initial_value = 0; switch (this->restore_mode_) { case ROTARY_ENCODER_RESTORE_DEFAULT_ZERO: - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->rtc_.load(&initial_value)) { initial_value = 0; } diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 8cd0adbeb2..4f34c35023 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -40,7 +40,7 @@ class ValueRangeTrigger : public Trigger, public Component { template void set_max(V max) { this->max_ = max; } void setup() override { - this->rtc_ = global_preferences->make_preference(this->parent_->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->parent_->get_preference_hash()); bool initial_state; if (this->rtc_.load(&initial_state)) { this->previous_in_range_ = initial_state; diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 2c30f17c78..b45a78010a 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -55,7 +55,7 @@ void SpeakerMediaPlayer::setup() { this->media_control_command_queue_ = xQueueCreate(MEDIA_CONTROLS_QUEUE_LENGTH, sizeof(MediaCallCommand)); - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); VolumeRestoreState volume_restore_state; if (this->pref_.load(&volume_restore_state)) { diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index e191498857..7676e17468 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -81,7 +81,7 @@ void SprinklerControllerNumber::setup() { if (!this->restore_value_) { value = this->initial_value_; } else { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->pref_.load(&value)) { if (!std::isnan(this->initial_value_)) { value = this->initial_value_; diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 13c12c1213..49acd274b2 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -32,7 +32,7 @@ optional Switch::get_initial_state() { if (!(restore_mode & RESTORE_MODE_PERSISTENT_MASK)) return {}; - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); bool initial_state; if (!this->rtc_.load(&initial_state)) return {}; diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index 11a148830d..eac0629480 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -86,7 +86,7 @@ void TemplateAlarmControlPanel::setup() { break; case ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED: { uint8_t value; - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (this->pref_.load(&value)) { this->current_state_ = static_cast(value); } else { diff --git a/esphome/components/template/datetime/template_date.cpp b/esphome/components/template/datetime/template_date.cpp index 01e15e532e..2fa8016802 100644 --- a/esphome/components/template/datetime/template_date.cpp +++ b/esphome/components/template/datetime/template_date.cpp @@ -20,7 +20,7 @@ void TemplateDate::setup() { } else { datetime::DateEntityRestoreState temp; this->pref_ = - global_preferences->make_preference(194434030U ^ this->get_object_id_hash()); + global_preferences->make_preference(194434030U ^ this->get_preference_hash()); if (this->pref_.load(&temp)) { temp.apply(this); return; diff --git a/esphome/components/template/datetime/template_datetime.cpp b/esphome/components/template/datetime/template_datetime.cpp index 3ab74e197f..a4a4e47d65 100644 --- a/esphome/components/template/datetime/template_datetime.cpp +++ b/esphome/components/template/datetime/template_datetime.cpp @@ -19,8 +19,8 @@ void TemplateDateTime::setup() { state = this->initial_value_; } else { datetime::DateTimeEntityRestoreState temp; - this->pref_ = global_preferences->make_preference(194434090U ^ - this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference( + 194434090U ^ this->get_preference_hash()); if (this->pref_.load(&temp)) { temp.apply(this); return; diff --git a/esphome/components/template/datetime/template_time.cpp b/esphome/components/template/datetime/template_time.cpp index 0e4d734d16..349700f187 100644 --- a/esphome/components/template/datetime/template_time.cpp +++ b/esphome/components/template/datetime/template_time.cpp @@ -20,7 +20,7 @@ void TemplateTime::setup() { } else { datetime::TimeEntityRestoreState temp; this->pref_ = - global_preferences->make_preference(194434060U ^ this->get_object_id_hash()); + global_preferences->make_preference(194434060U ^ this->get_preference_hash()); if (this->pref_.load(&temp)) { temp.apply(this); return; diff --git a/esphome/components/template/number/template_number.cpp b/esphome/components/template/number/template_number.cpp index aaf5b27a71..187f426273 100644 --- a/esphome/components/template/number/template_number.cpp +++ b/esphome/components/template/number/template_number.cpp @@ -14,7 +14,7 @@ void TemplateNumber::setup() { if (!this->restore_value_) { value = this->initial_value_; } else { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->pref_.load(&value)) { if (!std::isnan(this->initial_value_)) { value = this->initial_value_; diff --git a/esphome/components/template/select/template_select.cpp b/esphome/components/template/select/template_select.cpp index 6ec29c8ef0..95b0ee0d2b 100644 --- a/esphome/components/template/select/template_select.cpp +++ b/esphome/components/template/select/template_select.cpp @@ -16,7 +16,7 @@ void TemplateSelect::setup() { ESP_LOGD(TAG, "State from initial: %s", value.c_str()); } else { size_t index; - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->pref_.load(&index)) { value = this->initial_option_; ESP_LOGD(TAG, "State from initial (could not load stored index): %s", value.c_str()); diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index f5df7287c5..d8e840ba7e 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -15,7 +15,7 @@ void TemplateText::setup() { if (!this->pref_) { ESP_LOGD(TAG, "State from initial: %s", value.c_str()); } else { - uint32_t key = this->get_object_id_hash(); + uint32_t key = this->get_preference_hash(); key += this->traits.get_min_length() << 2; key += this->traits.get_max_length() << 4; key += fnv1_hash(this->traits.get_pattern()) << 6; diff --git a/esphome/components/total_daily_energy/total_daily_energy.cpp b/esphome/components/total_daily_energy/total_daily_energy.cpp index 7c316c495d..818696f99b 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.cpp +++ b/esphome/components/total_daily_energy/total_daily_energy.cpp @@ -10,7 +10,7 @@ void TotalDailyEnergy::setup() { float initial_value = 0; if (this->restore_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); this->pref_.load(&initial_value); } this->publish_state_and_save(initial_value); diff --git a/esphome/components/tuya/number/tuya_number.cpp b/esphome/components/tuya/number/tuya_number.cpp index 68a7f8f2a7..44b22167de 100644 --- a/esphome/components/tuya/number/tuya_number.cpp +++ b/esphome/components/tuya/number/tuya_number.cpp @@ -8,7 +8,7 @@ static const char *const TAG = "tuya.number"; void TuyaNumber::setup() { if (this->restore_value_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); } this->parent_->register_listener(this->number_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index d1ec17945a..0ee710fc02 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -155,7 +155,7 @@ void Valve::publish_state(bool save) { } } optional Valve::restore_state_() { - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); ValveRestoreState recovered{}; if (!this->rtc_.load(&recovered)) return {}; diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 68163ce8c3..8a65a9627a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -85,6 +85,35 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } + /** + * @brief Get a unique hash for storing preferences/settings for this entity. + * + * This method returns a hash that uniquely identifies the entity for the purpose of + * storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(), + * this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness + * across multiple devices that may have entities with the same object_id. + * + * Use this method when storing or retrieving preferences/settings that should be unique + * per device-entity pair. Use get_object_id_hash() when you need a hash that identifies + * the entity regardless of the device it belongs to. + * + * For backward compatibility, if device_id is 0 (the main device), the hash is unchanged + * from previous versions, so existing single-device configurations will continue to work. + * + * @return uint32_t The unique hash for preferences, including device_id if available. + */ + uint32_t get_preference_hash() { +#ifdef USE_DEVICES + // Combine object_id_hash with device_id to ensure uniqueness across devices + // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash + // This ensures backward compatibility for existing single-device configurations + return this->get_object_id_hash() ^ this->get_device_id(); +#else + // Without devices, just use object_id_hash as before + return this->get_object_id_hash(); +#endif + } + protected: friend class api::APIConnection; diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml new file mode 100644 index 0000000000..634d7157b2 --- /dev/null +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -0,0 +1,165 @@ +esphome: + name: multi-device-preferences-test + # Define multiple devices for testing preference storage + devices: + - id: device_a + name: Device A + - id: device_b + name: Device B + +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +# Test entities with restore modes to verify preference storage + +# Switches with same name on different devices - test restore mode +switch: + - platform: template + name: Light + id: light_device_a + device_id: device_a + restore_mode: RESTORE_DEFAULT_OFF + turn_on_action: + - lambda: |- + ESP_LOGI("test", "Device A Light turned ON"); + turn_off_action: + - lambda: |- + ESP_LOGI("test", "Device A Light turned OFF"); + + - platform: template + name: Light + id: light_device_b + device_id: device_b + restore_mode: RESTORE_DEFAULT_ON # Different default to test uniqueness + turn_on_action: + - lambda: |- + ESP_LOGI("test", "Device B Light turned ON"); + turn_off_action: + - lambda: |- + ESP_LOGI("test", "Device B Light turned OFF"); + + - platform: template + name: Light + id: light_main + restore_mode: RESTORE_DEFAULT_OFF + turn_on_action: + - lambda: |- + ESP_LOGI("test", "Main Light turned ON"); + turn_off_action: + - lambda: |- + ESP_LOGI("test", "Main Light turned OFF"); + +# Numbers with restore to test preference storage +number: + - platform: template + name: Setpoint + id: setpoint_device_a + device_id: device_a + min_value: 10.0 + max_value: 30.0 + step: 0.5 + restore_value: true + initial_value: 20.0 + set_action: + - lambda: |- + ESP_LOGI("test", "Device A Setpoint set to %.1f", x); + id(setpoint_device_a).state = x; + + - platform: template + name: Setpoint + id: setpoint_device_b + device_id: device_b + min_value: 10.0 + max_value: 30.0 + step: 0.5 + restore_value: true + initial_value: 25.0 # Different initial to test uniqueness + set_action: + - lambda: |- + ESP_LOGI("test", "Device B Setpoint set to %.1f", x); + id(setpoint_device_b).state = x; + + - platform: template + name: Setpoint + id: setpoint_main + min_value: 10.0 + max_value: 30.0 + step: 0.5 + restore_value: true + initial_value: 22.0 + set_action: + - lambda: |- + ESP_LOGI("test", "Main Setpoint set to %.1f", x); + id(setpoint_main).state = x; + +# Selects with restore to test preference storage +select: + - platform: template + name: Mode + id: mode_device_a + device_id: device_a + options: + - "Auto" + - "Manual" + - "Off" + restore_value: true + initial_option: "Auto" + set_action: + - lambda: |- + ESP_LOGI("test", "Device A Mode set to %s", x.c_str()); + id(mode_device_a).state = x; + + - platform: template + name: Mode + id: mode_device_b + device_id: device_b + options: + - "Auto" + - "Manual" + - "Off" + restore_value: true + initial_option: "Manual" # Different initial to test uniqueness + set_action: + - lambda: |- + ESP_LOGI("test", "Device B Mode set to %s", x.c_str()); + id(mode_device_b).state = x; + + - platform: template + name: Mode + id: mode_main + options: + - "Auto" + - "Manual" + - "Off" + restore_value: true + initial_option: "Off" + set_action: + - lambda: |- + ESP_LOGI("test", "Main Mode set to %s", x.c_str()); + id(mode_main).state = x; + +# Button to trigger preference logging test +button: + - platform: template + name: Test Preferences + on_press: + - lambda: |- + ESP_LOGI("test", "Testing preference storage uniqueness:"); + ESP_LOGI("test", "Device A Light state: %s", id(light_device_a).state ? "ON" : "OFF"); + ESP_LOGI("test", "Device B Light state: %s", id(light_device_b).state ? "ON" : "OFF"); + ESP_LOGI("test", "Main Light state: %s", id(light_main).state ? "ON" : "OFF"); + ESP_LOGI("test", "Device A Setpoint: %.1f", id(setpoint_device_a).state); + ESP_LOGI("test", "Device B Setpoint: %.1f", id(setpoint_device_b).state); + ESP_LOGI("test", "Main Setpoint: %.1f", id(setpoint_main).state); + ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).state.c_str()); + ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).state.c_str()); + ESP_LOGI("test", "Main Mode: %s", id(mode_main).state.c_str()); + // Log preference hashes for entities that actually store preferences + ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); diff --git a/tests/integration/test_multi_device_preferences.py b/tests/integration/test_multi_device_preferences.py new file mode 100644 index 0000000000..625f83f16e --- /dev/null +++ b/tests/integration/test_multi_device_preferences.py @@ -0,0 +1,144 @@ +"""Test multi-device preference storage functionality.""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import ButtonInfo, NumberInfo, SelectInfo, SwitchInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_multi_device_preferences( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that entities with same names on different devices have unique preference storage.""" + loop = asyncio.get_running_loop() + log_lines: list[str] = [] + preferences_logged = loop.create_future() + + # Patterns to match preference hash logs + switch_hash_pattern_device = re.compile(r"Device ([AB]) Switch Pref Hash: (\d+)") + switch_hash_pattern_main = re.compile(r"Main Switch Pref Hash: (\d+)") + number_hash_pattern_device = re.compile(r"Device ([AB]) Number Pref Hash: (\d+)") + number_hash_pattern_main = re.compile(r"Main Number Pref Hash: (\d+)") + switch_hashes: dict[str, int] = {} + number_hashes: dict[str, int] = {} + + def check_output(line: str) -> None: + """Check log output for preference hash information.""" + log_lines.append(line) + + # Look for device switch preference hash logs + match = switch_hash_pattern_device.search(line) + if match: + device = match.group(1) + hash_value = int(match.group(2)) + switch_hashes[device] = hash_value + + # Look for main switch preference hash + match = switch_hash_pattern_main.search(line) + if match: + hash_value = int(match.group(1)) + switch_hashes["Main"] = hash_value + + # Look for device number preference hash logs + match = number_hash_pattern_device.search(line) + if match: + device = match.group(1) + hash_value = int(match.group(2)) + number_hashes[device] = hash_value + + # Look for main number preference hash + match = number_hash_pattern_main.search(line) + if match: + hash_value = int(match.group(1)) + number_hashes["Main"] = hash_value + + # If we have all hashes, complete the future + if ( + len(switch_hashes) == 3 + and len(number_hashes) == 3 + and not preferences_logged.done() + ): + preferences_logged.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get entity list + entities, _ = await client.list_entities_services() + + # Verify we have the expected entities with duplicate names on different devices + + # Check switches (3 with name "Light") + switches = [ + e for e in entities if isinstance(e, SwitchInfo) and e.name == "Light" + ] + assert len(switches) == 3, f"Expected 3 'Light' switches, got {len(switches)}" + + # Check numbers (3 with name "Setpoint") + numbers = [ + e for e in entities if isinstance(e, NumberInfo) and e.name == "Setpoint" + ] + assert len(numbers) == 3, f"Expected 3 'Setpoint' numbers, got {len(numbers)}" + + # Check selects (3 with name "Mode") + selects = [ + e for e in entities if isinstance(e, SelectInfo) and e.name == "Mode" + ] + assert len(selects) == 3, f"Expected 3 'Mode' selects, got {len(selects)}" + + # Find the test button entity to trigger preference logging + buttons = [e for e in entities if isinstance(e, ButtonInfo)] + test_button = next((b for b in buttons if b.name == "Test Preferences"), None) + assert test_button is not None, "Test Preferences button not found" + + # Press the button to trigger logging + client.button_command(test_button.key) + + # Wait for preference hashes to be logged + try: + await asyncio.wait_for(preferences_logged, timeout=5.0) + except TimeoutError: + pytest.fail("Preference hashes not logged within timeout") + + # Verify all switch preference hashes are unique + assert len(switch_hashes) == 3, ( + f"Expected 3 devices with switches, got {switch_hashes}" + ) + switch_hash_values = list(switch_hashes.values()) + assert len(switch_hash_values) == len(set(switch_hash_values)), ( + f"Switch preference hashes are not unique: {switch_hashes}" + ) + + # Verify all number preference hashes are unique + assert len(number_hashes) == 3, ( + f"Expected 3 devices with numbers, got {number_hashes}" + ) + number_hash_values = list(number_hashes.values()) + assert len(number_hash_values) == len(set(number_hash_values)), ( + f"Number preference hashes are not unique: {number_hashes}" + ) + + # Verify Device A and Device B have different hashes (they have device_id set) + assert switch_hashes["A"] != switch_hashes["B"], ( + f"Device A and B switches should have different hashes: A={switch_hashes['A']}, B={switch_hashes['B']}" + ) + assert number_hashes["A"] != number_hashes["B"], ( + f"Device A and B numbers should have different hashes: A={number_hashes['A']}, B={number_hashes['B']}" + ) + + # Verify Main device hash is different from both A and B + assert switch_hashes["Main"] != switch_hashes["A"], ( + f"Main and Device A switches should have different hashes: Main={switch_hashes['Main']}, A={switch_hashes['A']}" + ) + assert switch_hashes["Main"] != switch_hashes["B"], ( + f"Main and Device B switches should have different hashes: Main={switch_hashes['Main']}, B={switch_hashes['B']}" + ) From 6da8ec8d555972ec6a3829dccfad96adaea75f32 Mon Sep 17 00:00:00 2001 From: Jonathan Rascher Date: Sun, 24 Aug 2025 22:40:19 -0500 Subject: [PATCH 0712/1626] Merge commit from fork Ensures auth check doesn't pass erroneously when the client-supplied digest is shorter than the correct digest, but happens to match a prefix of the correct value (e.g., same username + certain substrings of the password). --- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 3226444b1b..55b07c0f5e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -253,7 +253,7 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw esp_crypto_base64_encode(reinterpret_cast(digest.get()), n, &out, reinterpret_cast(user_info.c_str()), user_info.size()); - return strncmp(digest.get(), auth_str + auth_prefix_len, auth.value().size() - auth_prefix_len) == 0; + return strcmp(digest.get(), auth_str + auth_prefix_len) == 0; } void AsyncWebServerRequest::requestAuthentication(const char *realm) const { From da16887915e9e44c5e534ee03ad99e3efed3749e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:26:53 -0500 Subject: [PATCH 0713/1626] [api] Add zero-copy StringRef methods for compilation_time and effect_name (#10257) --- esphome/components/api/api_connection.cpp | 8 ++------ esphome/components/light/light_state.cpp | 14 ++++++++++++-- esphome/components/light/light_state.h | 3 +++ esphome/core/application.h | 3 +++ 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cdeabb5cac..a5bde1d2ec 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -455,9 +455,7 @@ uint16_t APIConnection::try_send_light_state(EntityBase *entity, APIConnection * resp.cold_white = values.get_cold_white(); resp.warm_white = values.get_warm_white(); if (light->supports_effects()) { - // get_effect_name() returns temporary std::string - must store it - std::string effect_name = light->get_effect_name(); - resp.set_effect(StringRef(effect_name)); + resp.set_effect(light->get_effect_name_ref()); } return fill_and_encode_entity_state(light, resp, LightStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -1415,9 +1413,7 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); resp.set_esphome_version(ESPHOME_VERSION_REF); - // get_compilation_time() returns temporary std::string - must store it - std::string compilation_time = App.get_compilation_time(); - resp.set_compilation_time(StringRef(compilation_time)); + resp.set_compilation_time(App.get_compilation_time_ref()); // Compile-time StringRef constants for manufacturers #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 5b57707d6b..9e42b2f1e2 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -140,12 +140,22 @@ float LightState::get_setup_priority() const { return setup_priority::HARDWARE - void LightState::publish_state() { this->remote_values_callback_.call(); } LightOutput *LightState::get_output() const { return this->output_; } + +static constexpr const char *EFFECT_NONE = "None"; +static constexpr auto EFFECT_NONE_REF = StringRef::from_lit("None"); + std::string LightState::get_effect_name() { if (this->active_effect_index_ > 0) { return this->effects_[this->active_effect_index_ - 1]->get_name(); - } else { - return "None"; } + return EFFECT_NONE; +} + +StringRef LightState::get_effect_name_ref() { + if (this->active_effect_index_ > 0) { + return StringRef(this->effects_[this->active_effect_index_ - 1]->get_name()); + } + return EFFECT_NONE_REF; } void LightState::add_new_remote_values_callback(std::function &&send_callback) { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 72cb99223e..94b81dee61 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -4,6 +4,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/optional.h" #include "esphome/core/preferences.h" +#include "esphome/core/string_ref.h" #include "light_call.h" #include "light_color_values.h" #include "light_effect.h" @@ -116,6 +117,8 @@ class LightState : public EntityBase, public Component { /// Return the name of the current effect, or if no effect is active "None". std::string get_effect_name(); + /// Return the name of the current effect as StringRef (for API usage) + StringRef get_effect_name_ref(); /** * This lets front-end components subscribe to light change events. This callback is called once diff --git a/esphome/core/application.h b/esphome/core/application.h index 4120afff53..9cb2a4c638 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -10,6 +10,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include "esphome/core/scheduler.h" +#include "esphome/core/string_ref.h" #ifdef USE_DEVICES #include "esphome/core/device.h" @@ -248,6 +249,8 @@ class Application { bool is_name_add_mac_suffix_enabled() const { return this->name_add_mac_suffix_; } std::string get_compilation_time() const { return this->compilation_time_; } + /// Get the compilation time as StringRef (for API usage) + StringRef get_compilation_time_ref() const { return StringRef(this->compilation_time_); } /// Get the cached time in milliseconds from when the current component started its loop execution inline uint32_t IRAM_ATTR HOT get_loop_component_start_time() const { return this->loop_component_start_time_; } From d054709c2df078efaef84dbdb235cbd0ab51aae6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:38:32 -0500 Subject: [PATCH 0714/1626] [esp32_ble_client] Add log helper functions to reduce flash usage by 120 bytes (#10243) --- .../esp32_ble_client/ble_client_base.cpp | 42 ++++++++++--------- .../esp32_ble_client/ble_client_base.h | 4 ++ 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 2d84436d84..e23be2e0c1 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -159,8 +159,7 @@ void BLEClientBase::disconnect() { return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { - ESP_LOGW(TAG, "[%d] [%s] Disconnecting before connected, disconnect scheduled.", this->connection_index_, - this->address_str_.c_str()); + this->log_warning_("Disconnect before connected, disconnect scheduled."); this->want_disconnect_ = true; return; } @@ -172,13 +171,11 @@ void BLEClientBase::unconditional_disconnect() { ESP_LOGI(TAG, "[%d] [%s] Disconnecting (conn_id: %d).", this->connection_index_, this->address_str_.c_str(), this->conn_id_); if (this->state_ == espbt::ClientState::DISCONNECTING) { - ESP_LOGE(TAG, "[%d] [%s] Tried to disconnect while already disconnecting.", this->connection_index_, - this->address_str_.c_str()); + this->log_error_("Already disconnecting"); return; } if (this->conn_id_ == UNSET_CONN_ID) { - ESP_LOGE(TAG, "[%d] [%s] No connection ID set, cannot disconnect.", this->connection_index_, - this->address_str_.c_str()); + this->log_error_("conn id unset, cannot disconnect"); return; } auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_); @@ -234,6 +231,18 @@ void BLEClientBase::log_connection_params_(const char *param_type) { ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); } +void BLEClientBase::log_error_(const char *message) { + ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); +} + +void BLEClientBase::log_error_(const char *message, int code) { + ESP_LOGE(TAG, "[%d] [%s] %s=%d", this->connection_index_, this->address_str_.c_str(), message, code); +} + +void BLEClientBase::log_warning_(const char *message) { + ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); +} + void BLEClientBase::restore_medium_conn_params_() { // Restore to medium connection parameters after initial connection phase // This balances performance with bandwidth usage for normal operation @@ -264,8 +273,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->app_id); this->gattc_if_ = esp_gattc_if; } else { - ESP_LOGE(TAG, "[%d] [%s] gattc app registration failed id=%d code=%d", this->connection_index_, - this->address_str_.c_str(), param->reg.app_id, param->reg.status); + this->log_error_("gattc app registration failed status", param->reg.status); this->status_ = param->reg.status; this->mark_failed(); } @@ -281,8 +289,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // This should not happen but lets log it in case it does // because it means we have a bad assumption about how the // ESP BT stack works. - ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while in %s state, status=%d", this->connection_index_, - this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status); + this->log_error_("ESP_GATTC_OPEN_EVT wrong state status", param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); @@ -307,7 +314,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->state_ = espbt::ClientState::ESTABLISHED; break; } - ESP_LOGD(TAG, "[%d] [%s] Searching for services", this->connection_index_, this->address_str_.c_str()); + this->log_event_("Searching for services"); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); break; } @@ -332,8 +339,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // Check if we were disconnected while waiting for service discovery if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state_ == espbt::ClientState::CONNECTED) { - ESP_LOGW(TAG, "[%d] [%s] Disconnected by remote during service discovery", this->connection_index_, - this->address_str_.c_str()); + this->log_warning_("Remote closed during discovery"); } else { ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, this->address_str_.c_str(), param->disconnect.reason); @@ -506,16 +512,14 @@ void BLEClientBase::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_ return; esp_bd_addr_t bd_addr; memcpy(bd_addr, param->ble_security.auth_cmpl.bd_addr, sizeof(esp_bd_addr_t)); - ESP_LOGI(TAG, "[%d] [%s] auth complete. remote BD_ADDR: %s", this->connection_index_, this->address_str_.c_str(), + ESP_LOGI(TAG, "[%d] [%s] auth complete addr: %s", this->connection_index_, this->address_str_.c_str(), format_hex(bd_addr, 6).c_str()); if (!param->ble_security.auth_cmpl.success) { - ESP_LOGE(TAG, "[%d] [%s] auth fail reason = 0x%x", this->connection_index_, this->address_str_.c_str(), - param->ble_security.auth_cmpl.fail_reason); + this->log_error_("auth fail reason", param->ble_security.auth_cmpl.fail_reason); } else { this->paired_ = true; - ESP_LOGD(TAG, "[%d] [%s] auth success. address type = %d auth mode = %d", this->connection_index_, - this->address_str_.c_str(), param->ble_security.auth_cmpl.addr_type, - param->ble_security.auth_cmpl.auth_mode); + ESP_LOGD(TAG, "[%d] [%s] auth success type = %d mode = %d", this->connection_index_, this->address_str_.c_str(), + param->ble_security.auth_cmpl.addr_type, param->ble_security.auth_cmpl.auth_mode); } break; diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 2d00688dbd..1850b2c5b3 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -137,6 +137,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); + // Compact error logging helpers to reduce flash usage + void log_error_(const char *message); + void log_error_(const char *message, int code); + void log_warning_(const char *message); }; } // namespace esphome::esp32_ble_client From 0de725942817e6a9fd6a31d199f449fee6d83a0c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 20 Aug 2025 23:47:20 +1200 Subject: [PATCH 0715/1626] [api] Add ``USE_API_HOMEASSISTANT_SERVICES`` if using ``tag_scanned`` action (#10316) --- esphome/components/api/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index ba5f994e9a..2672ea1edb 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -321,6 +321,7 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value( HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA, ) async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args): + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) cg.add(var.set_service("esphome.tag_scanned")) From 50408d9abb3a59109e045e33692ba8dc6dd9fb0c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 05:55:44 +1000 Subject: [PATCH 0716/1626] [http_request] Fix for host after ArduinoJson library bump (#10348) --- esphome/components/http_request/http_request_host.cpp | 7 +++++-- esphome/components/http_request/http_request_host.h | 6 +----- esphome/components/http_request/httplib.h | 10 +++++++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 192032c1ac..0b4c998a40 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -1,7 +1,10 @@ -#include "http_request_host.h" - #ifdef USE_HOST +#define USE_HTTP_REQUEST_HOST_H +#define CPPHTTPLIB_NO_EXCEPTIONS +#include "httplib.h" +#include "http_request_host.h" + #include #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 49fd3b43fe..bbeed87f70 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -1,11 +1,7 @@ #pragma once -#include "http_request.h" - #ifdef USE_HOST - -#define CPPHTTPLIB_NO_EXCEPTIONS -#include "httplib.h" +#include "http_request.h" namespace esphome { namespace http_request { diff --git a/esphome/components/http_request/httplib.h b/esphome/components/http_request/httplib.h index a2f4436ec7..8b08699702 100644 --- a/esphome/components/http_request/httplib.h +++ b/esphome/components/http_request/httplib.h @@ -3,12 +3,10 @@ /** * NOTE: This is a copy of httplib.h from https://github.com/yhirose/cpp-httplib * - * It has been modified only to add ifdefs for USE_HOST. While it contains many functions unused in ESPHome, + * It has been modified to add ifdefs for USE_HOST. While it contains many functions unused in ESPHome, * it was considered preferable to use it with as few changes as possible, to facilitate future updates. */ -#include "esphome/core/defines.h" - // // httplib.h // @@ -17,6 +15,11 @@ // #ifdef USE_HOST +// Prevent this code being included in main.cpp +#ifdef USE_HTTP_REQUEST_HOST_H + +#include "esphome/core/defines.h" + #ifndef CPPHTTPLIB_HTTPLIB_H #define CPPHTTPLIB_HTTPLIB_H @@ -9687,5 +9690,6 @@ inline SSL_CTX *Client::ssl_context() const { #endif #endif // CPPHTTPLIB_HTTPLIB_H +#endif // USE_HTTP_REQUEST_HOST_H #endif From 03836ee2d238403f23fae53249c18ac4a651ef65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:11:54 +0200 Subject: [PATCH 0717/1626] [core] Improve error reporting for entity name conflicts with non-ASCII characters (#10329) --- esphome/core/entity_helpers.py | 13 +++++- tests/unit_tests/core/test_entity_helpers.py | 45 ++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 1ccc3e2683..e1b2a8264b 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -236,10 +236,21 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" + # Show both original names and their ASCII-only versions if they differ + sanitized_msg = "" + if entity_name != existing_name: + sanitized_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" + ) + raise cv.Invalid( f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " f"{conflict_msg}. " - f"Each entity on a device must have a unique name within its platform." + "Each entity on a device must have a unique name within its platform." + f"{sanitized_msg}" ) # Store metadata about this entity diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index db99243a1a..9ba5367413 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -705,3 +705,48 @@ def test_empty_or_null_device_id_on_entity() -> None: config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: None} validated2 = validator(config2) assert validated2 == config2 + + +def test_entity_duplicate_validator_non_ascii_names() -> None: + """Test that non-ASCII names show helpful error messages.""" + # Create validator for binary_sensor platform + validator = entity_duplicate_validator("binary_sensor") + + # First Russian sensor should pass + config1 = {CONF_NAME: "Датчик открытия основного крана"} + validated1 = validator(config1) + assert validated1 == config1 + + # Second Russian sensor with different text but same ASCII conversion should fail + config2 = {CONF_NAME: "Датчик закрытия основного крана"} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", + re.DOTALL, + ), + ): + validator(config2) + + +def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: + """Test that identical names don't show the enhanced message.""" + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # First entity should pass + config1 = {CONF_NAME: "Temperature"} + validated1 = validator(config1) + assert validated1 == config1 + + # Second entity with exact same name should fail without enhanced message + config2 = {CONF_NAME: "Temperature"} + with pytest.raises( + Invalid, + match=r"Duplicate sensor entity with name 'Temperature' found.*" + r"Each entity on a device must have a unique name within its platform\.$", + ): + validator(config2) From 07715dd50f65eaa292d5da18df4c717cd1087794 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:16:12 +0200 Subject: [PATCH 0718/1626] [pvvx_mithermometer] Fix race condition with BLE authentication (#10327) --- .../display/pvvx_display.cpp | 37 +++++++++++++++++-- .../pvvx_mithermometer/display/pvvx_display.h | 2 + 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 4b6c11b332..b6916ad68f 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -46,10 +46,32 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t } this->connection_established_ = true; this->char_handle_ = chr->handle; -#ifdef USE_TIME - this->sync_time_(); -#endif - this->display(); + + // Attempt to write immediately + // For devices without security, this will work + // For devices with security that are already paired, this will work + // For devices that need pairing, the write will be retried after auth completes + this->sync_time_and_display_(); + break; + } + default: + break; + } +} + +void PVVXDisplay::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + if (!this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr)) + return; + + if (param->ble_security.auth_cmpl.success) { + ESP_LOGD(TAG, "[%s] Authentication successful, performing writes.", this->parent_->address_str().c_str()); + // Now that pairing is complete, perform the pending writes + this->sync_time_and_display_(); + } else { + ESP_LOGW(TAG, "[%s] Authentication failed.", this->parent_->address_str().c_str()); + } break; } default: @@ -127,6 +149,13 @@ void PVVXDisplay::delayed_disconnect_() { this->set_timeout("disconnect", this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); }); } +void PVVXDisplay::sync_time_and_display_() { +#ifdef USE_TIME + this->sync_time_(); +#endif + this->display(); +} + #ifdef USE_TIME void PVVXDisplay::sync_time_() { if (this->time_ == nullptr) diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index 9739362024..c7fc523420 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -43,6 +43,7 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; /// Set validity period of the display information in seconds (1..65535) void set_validity_period(uint16_t validity_period) { this->validity_period_ = validity_period; } @@ -112,6 +113,7 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { void setcfgbit_(uint8_t bit, bool value); void send_to_setup_char_(uint8_t *blk, size_t size); void delayed_disconnect_(); + void sync_time_and_display_(); #ifdef USE_TIME void sync_time_(); time::RealTimeClock *time_{nullptr}; From 959ffde60e4c0596dcf1fa2d99c99ec5ae4f90c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:17:26 +0200 Subject: [PATCH 0719/1626] [esp32_ble_client] Optimize BLE connection parameters for different connection types (#10356) --- .../esp32_ble_client/ble_client_base.cpp | 99 +++++++++---------- .../esp32_ble_client/ble_client_base.h | 6 +- 2 files changed, 54 insertions(+), 51 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e23be2e0c1..b9b7c75e0a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace esphome::esp32_ble_client { @@ -111,43 +112,19 @@ void BLEClientBase::connect() { this->remote_addr_type_); this->paired_ = false; - // Set preferred connection parameters before connecting - // Use FAST for all V3 connections (better latency and reliability) - // Use MEDIUM for V1/legacy connections (balanced performance) - uint16_t min_interval, max_interval, timeout; - const char *param_type; - - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - min_interval = FAST_MIN_CONN_INTERVAL; - max_interval = FAST_MAX_CONN_INTERVAL; - timeout = FAST_CONN_TIMEOUT; - param_type = "fast"; - } else { - min_interval = MEDIUM_MIN_CONN_INTERVAL; - max_interval = MEDIUM_MAX_CONN_INTERVAL; - timeout = MEDIUM_CONN_TIMEOUT; - param_type = "medium"; + // Determine connection parameters based on connection type + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + // V3 without cache needs fast params for service discovery + this->set_conn_params_(FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT, "fast"); + } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { + // V3 with cache can use medium params + this->set_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); } + // For V1/Legacy, don't set params - use ESP-IDF defaults - auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, - 0, // latency: 0 - timeout); - if (param_ret != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, - this->address_str_.c_str(), param_ret); - } else { - this->log_connection_params_(param_type); - } - - // Now open the connection + // Open the connection auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); - if (ret) { - this->log_gattc_warning_("esp_ble_gattc_open", ret); - this->set_state(espbt::ClientState::IDLE); - } else { - this->set_state(espbt::ClientState::CONNECTING); - } + this->handle_connection_result_(ret); } esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); } @@ -231,6 +208,15 @@ void BLEClientBase::log_connection_params_(const char *param_type) { ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); } +void BLEClientBase::handle_connection_result_(esp_err_t ret) { + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_open", ret); + this->set_state(espbt::ClientState::IDLE); + } else { + this->set_state(espbt::ClientState::CONNECTING); + } +} + void BLEClientBase::log_error_(const char *message) { ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); } @@ -243,17 +229,30 @@ void BLEClientBase::log_warning_(const char *message) { ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); } -void BLEClientBase::restore_medium_conn_params_() { - // Restore to medium connection parameters after initial connection phase - // This balances performance with bandwidth usage for normal operation +void BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout, const char *param_type) { esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); - conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; - conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; - conn_params.latency = 0; - conn_params.timeout = MEDIUM_CONN_TIMEOUT; - this->log_connection_params_("medium"); - esp_ble_gap_update_conn_params(&conn_params); + conn_params.min_int = min_interval; + conn_params.max_int = max_interval; + conn_params.latency = latency; + conn_params.timeout = timeout; + this->log_connection_params_(param_type); + esp_err_t err = esp_ble_gap_update_conn_params(&conn_params); + if (err != ESP_OK) { + this->log_gattc_warning_("esp_ble_gap_update_conn_params", err); + } +} + +void BLEClientBase::set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type) { + // Set preferred connection parameters before connecting + // These will be used when establishing the connection + this->log_connection_params_(param_type); + esp_err_t err = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, latency, timeout); + if (err != ESP_OK) { + this->log_gattc_warning_("esp_ble_gap_set_prefer_conn_params", err); + } } bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, @@ -308,12 +307,13 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->set_state(espbt::ClientState::CONNECTED); ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - // Restore to medium connection parameters for cached connections too - this->restore_medium_conn_params_(); + // Cached connections already connected with medium parameters, no update needed // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; } + // For V3_WITHOUT_CACHE, we already set fast params before connecting + // No need to update them again here this->log_event_("Searching for services"); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); break; @@ -395,12 +395,11 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->conn_id_ != param->search_cmpl.conn_id) return false; this->log_gattc_event_("SEARCH_CMPL"); - // For V3 connections, restore to medium connection parameters after service discovery + // For V3_WITHOUT_CACHE, switch back to medium connection parameters after service discovery // This balances performance with bandwidth usage after the critical discovery phase - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - this->restore_medium_conn_params_(); - } else { + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); + } else if (this->connection_type_ != espbt::ConnectionType::V3_WITH_CACHE) { #ifdef USE_ESP32_BLE_DEVICE for (auto &svc : this->services_) { ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 1850b2c5b3..acfad9e9b0 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -133,10 +133,14 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_event_(const char *name); void log_gattc_event_(const char *name); - void restore_medium_conn_params_(); + void update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); + void set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); + void handle_connection_result_(esp_err_t ret); // Compact error logging helpers to reduce flash usage void log_error_(const char *message); void log_error_(const char *message, int code); From fcc3c8e1b6a21669a34e525e469766e080520898 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:49:37 +0200 Subject: [PATCH 0720/1626] [esp32_ble] Increase GATT connection retry count to use full timeout window (#10376) --- esphome/components/esp32_ble/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 2edd69c6c0..cc06058b65 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -280,6 +280,10 @@ async def to_code(config): add_idf_sdkconfig_option( "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds ) + # Increase GATT client connection retry count for problematic devices + # Default in ESP-IDF is 3, we increase to 10 for better reliability with + # low-power/timing-sensitive devices + add_idf_sdkconfig_option("CONFIG_BT_GATTC_CONNECT_RETRY_COUNT", 10) # Set the maximum number of notification registrations # This controls how many BLE characteristics can have notifications enabled From d560831d796239f6356815cbecbf6f7ccd3a9c67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:49:52 +0200 Subject: [PATCH 0721/1626] [script] Fix parallel mode scripts with delays cancelling each other (#10324) --- esphome/core/base_automation.h | 13 +++- esphome/core/scheduler.cpp | 19 +++-- esphome/core/scheduler.h | 9 ++- .../fixtures/parallel_script_delays.yaml | 45 ++++++++++++ tests/integration/test_automations.py | 70 +++++++++++++++++++ 5 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 tests/integration/fixtures/parallel_script_delays.yaml diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 740e10700b..ba942e5e43 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -5,6 +5,8 @@ #include "esphome/core/hal.h" #include "esphome/core/defines.h" #include "esphome/core/preferences.h" +#include "esphome/core/scheduler.h" +#include "esphome/core/application.h" #include @@ -158,7 +160,16 @@ template class DelayAction : public Action, public Compon void play_complex(Ts... x) override { auto f = std::bind(&DelayAction::play_next_, this, x...); this->num_running_++; - this->set_timeout("delay", this->delay_.value(x...), f); + + // If num_running_ > 1, we have multiple instances running in parallel + // In single/restart/queued modes, only one instance runs at a time + // Parallel mode uses skip_cancel=true to allow multiple delays to coexist + // WARNING: This can accumulate delays if scripts are triggered faster than they complete! + // Users should set max_runs on parallel scripts to limit concurrent executions. + // Issue #10264: This is a workaround for parallel script delays interfering with each other. + App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, + /* is_static_string= */ true, "delay", this->delay_.value(x...), std::move(f), + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } float get_setup_priority() const override { return setup_priority::HARDWARE; } diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index c3ade260ac..a907b89b02 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -65,14 +65,17 @@ static void validate_static_string(const char *name) { // Common implementation for both timeout and interval void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, - const void *name_ptr, uint32_t delay, std::function func, bool is_retry) { + const void *name_ptr, uint32_t delay, std::function func, bool is_retry, + bool skip_cancel) { // Get the name as const char* const char *name_cstr = this->get_name_cstr_(is_static_string, name_ptr); if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if name is not empty - LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_cstr, type); + if (!skip_cancel) { + LockGuard guard{this->lock_}; + this->cancel_item_locked_(component, name_cstr, type); + } return; } @@ -97,7 +100,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type if (delay == 0 && type == SchedulerItem::TIMEOUT) { // Put in defer queue for guaranteed FIFO execution LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_cstr, type); + if (!skip_cancel) { + this->cancel_item_locked_(component, name_cstr, type); + } this->defer_queue_.push_back(std::move(item)); return; } @@ -150,9 +155,11 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } - // If name is provided, do atomic cancel-and-add + // If name is provided, do atomic cancel-and-add (unless skip_cancel is true) // Cancel existing items - this->cancel_item_locked_(component, name_cstr, type); + if (!skip_cancel) { + this->cancel_item_locked_(component, name_cstr, type); + } // Add new item directly to to_add_ // since we have the lock held this->to_add_.push_back(std::move(item)); diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index c73bd55d5d..f469a60d5c 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -21,8 +21,13 @@ struct RetryArgs; void retry_handler(const std::shared_ptr &args); class Scheduler { - // Allow retry_handler to access protected members + // Allow retry_handler to access protected members for internal retry mechanism friend void ::esphome::retry_handler(const std::shared_ptr &args); + // Allow DelayAction to call set_timer_common_ with skip_cancel=true for parallel script delays. + // This is needed to fix issue #10264 where parallel scripts with delays interfere with each other. + // We use friend instead of a public API because skip_cancel is dangerous - it can cause delays + // to accumulate and overload the scheduler if misused. + template friend class DelayAction; public: // Public API - accepts std::string for backward compatibility @@ -184,7 +189,7 @@ class Scheduler { // Common implementation for both timeout and interval void set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, const void *name_ptr, - uint32_t delay, std::function func, bool is_retry = false); + uint32_t delay, std::function func, bool is_retry = false, bool skip_cancel = false); // Common implementation for retry void set_retry_common_(Component *component, bool is_static_string, const void *name_ptr, uint32_t initial_wait_time, diff --git a/tests/integration/fixtures/parallel_script_delays.yaml b/tests/integration/fixtures/parallel_script_delays.yaml new file mode 100644 index 0000000000..6887045913 --- /dev/null +++ b/tests/integration/fixtures/parallel_script_delays.yaml @@ -0,0 +1,45 @@ +esphome: + name: test-parallel-delays + +host: + +logger: + level: DEBUG + +api: + actions: + - action: test_parallel_delays + then: + # Start three parallel script instances with small delays between starts + - globals.set: + id: instance_counter + value: '1' + - script.execute: parallel_delay_script + - delay: 10ms + - globals.set: + id: instance_counter + value: '2' + - script.execute: parallel_delay_script + - delay: 10ms + - globals.set: + id: instance_counter + value: '3' + - script.execute: parallel_delay_script + +globals: + - id: instance_counter + type: int + initial_value: '0' + +script: + - id: parallel_delay_script + mode: parallel + then: + - lambda: !lambda |- + int instance = id(instance_counter); + ESP_LOGI("TEST", "Parallel script instance %d started", instance); + - delay: 1s + - lambda: !lambda |- + static int completed_counter = 0; + completed_counter++; + ESP_LOGI("TEST", "Parallel script instance %d completed after delay", completed_counter); diff --git a/tests/integration/test_automations.py b/tests/integration/test_automations.py index bd2082e86b..83268c1eea 100644 --- a/tests/integration/test_automations.py +++ b/tests/integration/test_automations.py @@ -89,3 +89,73 @@ async def test_delay_action_cancellation( assert 0.4 < time_from_second_start < 0.6, ( f"Delay completed {time_from_second_start:.3f}s after second start, expected ~0.5s" ) + + +@pytest.mark.asyncio +async def test_parallel_script_delays( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that parallel scripts with delays don't interfere with each other.""" + loop = asyncio.get_running_loop() + + # Track script executions + script_starts: list[float] = [] + script_ends: list[float] = [] + + # Patterns to match + start_pattern = re.compile(r"Parallel script instance \d+ started") + end_pattern = re.compile(r"Parallel script instance \d+ completed after delay") + + # Future to track when all scripts have completed + all_scripts_completed = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for parallel script messages.""" + current_time = loop.time() + + if start_pattern.search(line): + script_starts.append(current_time) + + if end_pattern.search(line): + script_ends.append(current_time) + # Check if we have all 3 completions + if len(script_ends) == 3 and not all_scripts_completed.done(): + all_scripts_completed.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get services + entities, services = await client.list_entities_services() + + # Find our test service + test_service = next( + (s for s in services if s.name == "test_parallel_delays"), None + ) + assert test_service is not None, "test_parallel_delays service not found" + + # Execute the test - this will start 3 parallel scripts with 1 second delays + client.execute_service(test_service, {}) + + # Wait for all scripts to complete (should take ~1 second, not 3) + await asyncio.wait_for(all_scripts_completed, timeout=2.0) + + # Verify we had 3 starts and 3 ends + assert len(script_starts) == 3, ( + f"Expected 3 script starts, got {len(script_starts)}" + ) + assert len(script_ends) == 3, f"Expected 3 script ends, got {len(script_ends)}" + + # Verify they ran in parallel - all should complete within ~1.5 seconds + first_start = min(script_starts) + last_end = max(script_ends) + total_time = last_end - first_start + + # If running in parallel, total time should be close to 1 second + # If they were interfering (running sequentially), it would take 3+ seconds + assert total_time < 1.5, ( + f"Parallel scripts took {total_time:.2f}s total, should be ~1s if running in parallel" + ) From 684384892a779bc47b600200f1d9826806cf67e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:51:23 +0200 Subject: [PATCH 0722/1626] [deep_sleep] Fix ESP32-C6 compilation error with gpio_deep_sleep_hold_en() (#10345) --- esphome/components/deep_sleep/deep_sleep_esp32.cpp | 9 +++++++++ tests/components/deep_sleep/test.esp32-c6-idf.yaml | 5 +++++ tests/components/deep_sleep/test.esp32-s2-idf.yaml | 5 +++++ tests/components/deep_sleep/test.esp32-s3-idf.yaml | 5 +++++ 4 files changed, 24 insertions(+) create mode 100644 tests/components/deep_sleep/test.esp32-c6-idf.yaml create mode 100644 tests/components/deep_sleep/test.esp32-s2-idf.yaml create mode 100644 tests/components/deep_sleep/test.esp32-s3-idf.yaml diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index c5c1fe0835..e9d0a4981f 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -1,4 +1,5 @@ #ifdef USE_ESP32 +#include "soc/soc_caps.h" #include "driver/gpio.h" #include "deep_sleep_component.h" #include "esphome/core/log.h" @@ -83,7 +84,11 @@ void DeepSleepComponent::deep_sleep_() { } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_hold_en(gpio_pin); +#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP + // Some ESP32 variants support holding a single GPIO during deep sleep without this function + // For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep gpio_deep_sleep_hold_en(); +#endif bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; @@ -120,7 +125,11 @@ void DeepSleepComponent::deep_sleep_() { } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_hold_en(gpio_pin); +#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP + // Some ESP32 variants support holding a single GPIO during deep sleep without this function + // For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep gpio_deep_sleep_hold_en(); +#endif bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; diff --git a/tests/components/deep_sleep/test.esp32-c6-idf.yaml b/tests/components/deep_sleep/test.esp32-c6-idf.yaml new file mode 100644 index 0000000000..10c17af0f5 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c6-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32.yaml diff --git a/tests/components/deep_sleep/test.esp32-s2-idf.yaml b/tests/components/deep_sleep/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..10c17af0f5 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-s2-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32.yaml diff --git a/tests/components/deep_sleep/test.esp32-s3-idf.yaml b/tests/components/deep_sleep/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..10c17af0f5 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32.yaml From 8517c2e90371eff9748c406a1c8ed855259f9a29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:51:54 +0200 Subject: [PATCH 0723/1626] [esp32_ble_client] Reduce log level for harmless BLE timeout race conditions (#10339) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../esp32_ble_client/ble_client_base.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index b9b7c75e0a..351658279f 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -136,7 +136,8 @@ void BLEClientBase::disconnect() { return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { - this->log_warning_("Disconnect before connected, disconnect scheduled."); + ESP_LOGD(TAG, "[%d] [%s] Disconnect before connected, disconnect scheduled", this->connection_index_, + this->address_str_.c_str()); this->want_disconnect_ = true; return; } @@ -284,11 +285,22 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->log_gattc_event_("OPEN"); // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; + + // ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an + // error, if the error occurred at the BTA/GATT layer. This can result in the event + // arriving after we've already transitioned to IDLE state. + if (this->state_ == espbt::ClientState::IDLE) { + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_, + this->address_str_.c_str(), param->open.status); + break; + } + if (this->state_ != espbt::ClientState::CONNECTING) { // This should not happen but lets log it in case it does // because it means we have a bad assumption about how the // ESP BT stack works. - this->log_error_("ESP_GATTC_OPEN_EVT wrong state status", param->open.status); + ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, + this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); From d9dcfe66ec67ff78332c030cd25feace3b7fb275 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 06:52:37 +1000 Subject: [PATCH 0724/1626] [lvgl] Fix meter rotation (#10342) --- esphome/components/lvgl/widgets/canvas.py | 10 +++++----- esphome/components/lvgl/widgets/meter.py | 9 +++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 4fd81b6e4a..217e8935f1 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -24,7 +24,7 @@ from ..defines import ( literal, ) from ..lv_validation import ( - lv_angle, + lv_angle_degrees, lv_bool, lv_color, lv_image, @@ -395,15 +395,15 @@ ARC_PROPS = { DRAW_OPA_SCHEMA.extend( { cv.Required(CONF_RADIUS): pixels, - cv.Required(CONF_START_ANGLE): lv_angle, - cv.Required(CONF_END_ANGLE): lv_angle, + cv.Required(CONF_START_ANGLE): lv_angle_degrees, + cv.Required(CONF_END_ANGLE): lv_angle_degrees, } ).extend({cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()}), ) async def canvas_draw_arc(config, action_id, template_arg, args): radius = await size.process(config[CONF_RADIUS]) - start_angle = await lv_angle.process(config[CONF_START_ANGLE]) - end_angle = await lv_angle.process(config[CONF_END_ANGLE]) + start_angle = await lv_angle_degrees.process(config[CONF_START_ANGLE]) + end_angle = await lv_angle_degrees.process(config[CONF_END_ANGLE]) async def do_draw_arc(w: Widget, x, y, dsc_addr): lv.canvas_draw_arc(w.obj, x, y, radius, start_angle, end_angle, dsc_addr) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index acec986f99..aefda0e71a 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_VALUE, CONF_WIDTH, ) -from esphome.cpp_generator import IntLiteral from ..automation import action_to_code from ..defines import ( @@ -32,7 +31,7 @@ from ..helpers import add_lv_use, lvgl_components_required from ..lv_validation import ( get_end_value, get_start_value, - lv_angle, + lv_angle_degrees, lv_bool, lv_color, lv_float, @@ -163,7 +162,7 @@ SCALE_SCHEMA = cv.Schema( cv.Optional(CONF_RANGE_FROM, default=0.0): cv.float_, cv.Optional(CONF_RANGE_TO, default=100.0): cv.float_, cv.Optional(CONF_ANGLE_RANGE, default=270): cv.int_range(0, 360), - cv.Optional(CONF_ROTATION): lv_angle, + cv.Optional(CONF_ROTATION): lv_angle_degrees, cv.Optional(CONF_INDICATORS): cv.ensure_list(INDICATOR_SCHEMA), } ) @@ -188,9 +187,7 @@ class MeterType(WidgetType): for scale_conf in config.get(CONF_SCALES, ()): rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2 if CONF_ROTATION in scale_conf: - rotation = await lv_angle.process(scale_conf[CONF_ROTATION]) - if isinstance(rotation, IntLiteral): - rotation = int(str(rotation)) // 10 + rotation = await lv_angle_degrees.process(scale_conf[CONF_ROTATION]) with LocalVariable( "meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var) ) as meter_var: From c542db8bfe894c12aeaafba3fc09598e4e1efc74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:57:09 +0200 Subject: [PATCH 0725/1626] [esp32_ble_tracker] Fix on_scan_end trigger compilation without USE_ESP32_BLE_DEVICE (#10399) --- esphome/components/esp32_ble_tracker/automation.h | 5 ++++- .../esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index c0e6eee138..784f2eaaa2 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -80,14 +80,17 @@ class BLEManufacturerDataAdvertiseTrigger : public Trigger, ESPBTUUID uuid_; }; +#endif // USE_ESP32_BLE_DEVICE + class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { public: explicit BLEEndOfScanTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } +#ifdef USE_ESP32_BLE_DEVICE bool parse_device(const ESPBTDevice &device) override { return false; } +#endif void on_scan_end() override { this->trigger(); } }; -#endif // USE_ESP32_BLE_DEVICE template class ESP32BLEStartScanAction : public Action { public: diff --git a/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml b/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml new file mode 100644 index 0000000000..4e9849a540 --- /dev/null +++ b/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml @@ -0,0 +1,3 @@ +esp32_ble_tracker: + on_scan_end: + - logger.log: "Scan ended!" From aebd21958acd7e5a9dd24d75117974c809da1864 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:58:46 +0200 Subject: [PATCH 0726/1626] [test] Add integration test for light effect memory corruption fix (#10417) --- tests/integration/fixtures/light_calls.yaml | 32 ++++++++++++++- tests/integration/test_light_calls.py | 45 +++++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/tests/integration/fixtures/light_calls.yaml b/tests/integration/fixtures/light_calls.yaml index d692a11765..2b7650526f 100644 --- a/tests/integration/fixtures/light_calls.yaml +++ b/tests/integration/fixtures/light_calls.yaml @@ -56,10 +56,29 @@ light: warm_white_color_temperature: 2000 K constant_brightness: true effects: + # Use default parameters: - random: - name: "Random Effect" + # Customize parameters - use longer names to potentially trigger buffer issues + - random: + name: "My Very Slow Random Effect With Long Name" + transition_length: 30ms + update_interval: 30ms + - random: + name: "My Fast Random Effect That Changes Quickly" + transition_length: 4ms + update_interval: 5ms + - random: + name: "Random Effect With Medium Length Name Here" transition_length: 100ms update_interval: 200ms + - random: + name: "Another Random Effect With Different Parameters" + transition_length: 2ms + update_interval: 3ms + - random: + name: "Yet Another Random Effect To Test Memory" + transition_length: 15ms + update_interval: 20ms - strobe: name: "Strobe Effect" - pulse: @@ -73,6 +92,17 @@ light: red: test_red green: test_green blue: test_blue + effects: + # Same random effects to test for cross-contamination + - random: + - random: + name: "RGB Slow Random" + transition_length: 20ms + update_interval: 25ms + - random: + name: "RGB Fast Random" + transition_length: 2ms + update_interval: 3ms - platform: binary name: "Test Binary Light" diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index 1a0a9e553f..af90ddbe86 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -108,14 +108,51 @@ async def test_light_calls( # Wait for flash to end state = await wait_for_state_change(rgbcw_light.key) - # Test 13: effect only + # Test 13: effect only - test all random effects # First ensure light is on client.light_command(key=rgbcw_light.key, state=True) state = await wait_for_state_change(rgbcw_light.key) - # Now set effect - client.light_command(key=rgbcw_light.key, effect="Random Effect") + + # Test 13a: Default random effect (no name, gets default name "Random") + client.light_command(key=rgbcw_light.key, effect="Random") state = await wait_for_state_change(rgbcw_light.key) - assert state.effect == "Random Effect" + assert state.effect == "Random" + + # Test 13b: Slow random effect with long name + client.light_command( + key=rgbcw_light.key, effect="My Very Slow Random Effect With Long Name" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "My Very Slow Random Effect With Long Name" + + # Test 13c: Fast random effect with long name + client.light_command( + key=rgbcw_light.key, effect="My Fast Random Effect That Changes Quickly" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "My Fast Random Effect That Changes Quickly" + + # Test 13d: Random effect with medium length name + client.light_command( + key=rgbcw_light.key, effect="Random Effect With Medium Length Name Here" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "Random Effect With Medium Length Name Here" + + # Test 13e: Another random effect + client.light_command( + key=rgbcw_light.key, + effect="Another Random Effect With Different Parameters", + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "Another Random Effect With Different Parameters" + + # Test 13f: Yet another random effect + client.light_command( + key=rgbcw_light.key, effect="Yet Another Random Effect To Test Memory" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "Yet Another Random Effect To Test Memory" # Test 14: stop effect client.light_command(key=rgbcw_light.key, effect="None") From 7a459c8c2006ed7662c7eea2c32b668a7fa6aa35 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 07:04:32 +1000 Subject: [PATCH 0727/1626] [web_server] Use oi.esphome.io for css and js assets (#10296) --- esphome/components/web_server/__init__.py | 4 ++-- esphome/components/web_server/web_server.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 695757e137..be193bbab8 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -52,9 +52,9 @@ def default_url(config: ConfigType) -> ConfigType: config = config.copy() if config[CONF_VERSION] == 1: if CONF_CSS_URL not in config: - config[CONF_CSS_URL] = "https://esphome.io/_static/webserver-v1.min.css" + config[CONF_CSS_URL] = "https://oi.esphome.io/v1/webserver-v1.min.css" if CONF_JS_URL not in config: - config[CONF_JS_URL] = "https://esphome.io/_static/webserver-v1.min.js" + config[CONF_JS_URL] = "https://oi.esphome.io/v1/webserver-v1.min.js" if config[CONF_VERSION] == 2: if CONF_CSS_URL not in config: config[CONF_CSS_URL] = "" diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 6bece732fc..e42c35b32d 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -173,14 +173,14 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { #if USE_WEBSERVER_VERSION == 1 /** Set the URL to the CSS that's sent to each client. Defaults to - * https://esphome.io/_static/webserver-v1.min.css + * https://oi.esphome.io/v1/webserver-v1.min.css * * @param css_url The url to the web server stylesheet. */ void set_css_url(const char *css_url); /** Set the URL to the script that's embedded in the index page. Defaults to - * https://esphome.io/_static/webserver-v1.min.js + * https://oi.esphome.io/v1/webserver-v1.min.js * * @param js_url The url to the web server script. */ From d071a074ef6d45adc4e6f995e65cdce08ceb4d79 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:59:35 +1200 Subject: [PATCH 0728/1626] Bump version to 2025.8.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 5ed1a4afc0..24dcc2e859 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.8.0 +PROJECT_NUMBER = 2025.8.1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 69da0b9af0..555434e4b9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.8.0" +__version__ = "2025.8.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 2aceb56606ec8afec5f49c92e140c8050a6ccbe5 Mon Sep 17 00:00:00 2001 From: Jonathan Rascher Date: Sun, 24 Aug 2025 22:40:19 -0500 Subject: [PATCH 0729/1626] Merge commit from fork Ensures auth check doesn't pass erroneously when the client-supplied digest is shorter than the correct digest, but happens to match a prefix of the correct value (e.g., same username + certain substrings of the password). --- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 3226444b1b..55b07c0f5e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -253,7 +253,7 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw esp_crypto_base64_encode(reinterpret_cast(digest.get()), n, &out, reinterpret_cast(user_info.c_str()), user_info.size()); - return strncmp(digest.get(), auth_str + auth_prefix_len, auth.value().size() - auth_prefix_len) == 0; + return strcmp(digest.get(), auth_str + auth_prefix_len) == 0; } void AsyncWebServerRequest::requestAuthentication(const char *realm) const { From 5e508f7461349948c63e4fa85c845f3086156b95 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 26 Aug 2025 07:46:54 +1200 Subject: [PATCH 0730/1626] [core] Dont copy platform source files if there are no entities of that type (#10436) --- esphome/config.py | 5 ++++- esphome/writer.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index 90325cbf6e..ebf1989693 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +from collections import defaultdict from contextlib import contextmanager import contextvars import functools @@ -37,11 +38,13 @@ from esphome.yaml_util import ESPForceValue, ESPHomeDataBase, is_secret _LOGGER = logging.getLogger(__name__) -def iter_components(config): +def iter_components(config: ConfigType, platform_counts: defaultdict[str, int]): for domain, conf in config.items(): component = get_component(domain) yield domain, component if component.is_platform_component: + if not platform_counts[domain]: + continue for p_config in conf: p_name = f"{domain}.{p_config[CONF_PLATFORM]}" platform = get_platform(domain, p_config[CONF_PLATFORM]) diff --git a/esphome/writer.py b/esphome/writer.py index 4b25a25f7e..45aa996295 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -181,7 +181,7 @@ the custom_components folder or the external_components feature. def copy_src_tree(): source_files: list[loader.FileResource] = [] - for _, component in iter_components(CORE.config): + for _, component in iter_components(CORE.config, CORE.platform_counts): source_files += component.resources source_files_map = { Path(x.package.replace(".", "/") + "/" + x.resource): x for x in source_files From c01a26607ee469853fdf1af90be7d5616c5665be Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Mon, 25 Aug 2025 23:45:03 +0200 Subject: [PATCH 0731/1626] improve const imports of `esphome.const` (#10438) --- esphome/components/esp32_ble/__init__.py | 9 +++++++-- esphome/components/sdm_meter/sensor.py | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index cc06058b65..d2eaa3ce6f 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -5,9 +5,14 @@ from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant import esphome.config_validation as cv -from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ESPHOME, CONF_ID, CONF_NAME +from esphome.const import ( + CONF_ENABLE_ON_BOOT, + CONF_ESPHOME, + CONF_ID, + CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, +) from esphome.core import CORE, TimePeriod -from esphome.core.config import CONF_NAME_ADD_MAC_SUFFIX import esphome.final_validate as fv DEPENDENCIES = ["esp32"] diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 24ae32c7bc..affbc0409e 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -1,6 +1,5 @@ import esphome.codegen as cg from esphome.components import modbus, sensor -from esphome.components.atm90e32.sensor import CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE_POWER, @@ -12,7 +11,10 @@ from esphome.const import ( CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, + CONF_PHASE_A, CONF_PHASE_ANGLE, + CONF_PHASE_B, + CONF_PHASE_C, CONF_POWER_FACTOR, CONF_REACTIVE_POWER, CONF_TOTAL_POWER, From 9007621fd70621b37df418203b76afdf4eee70c4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 26 Aug 2025 09:15:44 +1000 Subject: [PATCH 0732/1626] Revert "[core] Dont copy platform source files if there are no entities of that type" (#10441) --- esphome/config.py | 5 +---- esphome/writer.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index ebf1989693..90325cbf6e 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1,7 +1,6 @@ from __future__ import annotations import abc -from collections import defaultdict from contextlib import contextmanager import contextvars import functools @@ -38,13 +37,11 @@ from esphome.yaml_util import ESPForceValue, ESPHomeDataBase, is_secret _LOGGER = logging.getLogger(__name__) -def iter_components(config: ConfigType, platform_counts: defaultdict[str, int]): +def iter_components(config): for domain, conf in config.items(): component = get_component(domain) yield domain, component if component.is_platform_component: - if not platform_counts[domain]: - continue for p_config in conf: p_name = f"{domain}.{p_config[CONF_PLATFORM]}" platform = get_platform(domain, p_config[CONF_PLATFORM]) diff --git a/esphome/writer.py b/esphome/writer.py index 45aa996295..4b25a25f7e 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -181,7 +181,7 @@ the custom_components folder or the external_components feature. def copy_src_tree(): source_files: list[loader.FileResource] = [] - for _, component in iter_components(CORE.config, CORE.platform_counts): + for _, component in iter_components(CORE.config): source_files += component.resources source_files_map = { Path(x.package.replace(".", "/") + "/" + x.resource): x for x in source_files From 6e681a5f3e835065385d03c5da03f56497057985 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Aug 2025 17:20:55 -0500 Subject: [PATCH 0733/1626] [wifi] Fix reconnection failures after adapter restart by not clearing netif pointers --- esphome/components/wifi/wifi_component_esp32_arduino.cpp | 6 ------ esphome/components/wifi/wifi_component_esp_idf.cpp | 6 ------ 2 files changed, 12 deletions(-) diff --git a/esphome/components/wifi/wifi_component_esp32_arduino.cpp b/esphome/components/wifi/wifi_component_esp32_arduino.cpp index 67b1f565ff..89298e07c7 100644 --- a/esphome/components/wifi/wifi_component_esp32_arduino.cpp +++ b/esphome/components/wifi/wifi_component_esp32_arduino.cpp @@ -547,8 +547,6 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); - // Clear the STA interface handle to prevent use-after-free - s_sta_netif = nullptr; break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { @@ -638,10 +636,6 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_AP_STOP: { ESP_LOGV(TAG, "AP stop"); -#ifdef USE_WIFI_AP - // Clear the AP interface handle to prevent use-after-free - s_ap_netif = nullptr; -#endif break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 94f1f5125f..d465b346b3 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -697,8 +697,6 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) { ESP_LOGV(TAG, "STA stop"); s_sta_started = false; - // Clear the STA interface handle to prevent use-after-free - s_sta_netif = nullptr; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) { const auto &it = data->data.sta_authmode_change; @@ -797,10 +795,6 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); s_ap_started = false; -#ifdef USE_WIFI_AP - // Clear the AP interface handle to prevent use-after-free - s_ap_netif = nullptr; -#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_PROBEREQRECVED) { const auto &it = data->data.ap_probe_req_rx; From ed7054cdb723d260eb40caab5d28827693b99e7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Aug 2025 18:20:38 -0500 Subject: [PATCH 0734/1626] Fix AttributeError when uploading OTA to offline OpenThread devices --- esphome/__main__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 8e8fc7d5d9..aab3035a5e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -132,14 +132,17 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": - if (show_ota and "ota" in CORE.config) or ( - show_api and "api" in CORE.config + if CORE.address and ( + (show_ota and "ota" in CORE.config) + or (show_api and "api" in CORE.config) ): resolved.append(CORE.address) elif show_mqtt and has_mqtt_logging(): resolved.append("MQTT") else: resolved.append(device) + if not resolved: + _LOGGER.error("All specified devices: %s could not be resolved.", defaults) return resolved # No devices specified, show interactive chooser From 9e712e412732aa056625a9fca55ebe9d148252ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Aug 2025 06:49:47 +0200 Subject: [PATCH 0735/1626] [wifi] Fix reconnection failures after adapter restart by not clearing netif pointers (#10458) --- esphome/components/wifi/wifi_component_esp32_arduino.cpp | 6 ------ esphome/components/wifi/wifi_component_esp_idf.cpp | 6 ------ 2 files changed, 12 deletions(-) diff --git a/esphome/components/wifi/wifi_component_esp32_arduino.cpp b/esphome/components/wifi/wifi_component_esp32_arduino.cpp index 67b1f565ff..89298e07c7 100644 --- a/esphome/components/wifi/wifi_component_esp32_arduino.cpp +++ b/esphome/components/wifi/wifi_component_esp32_arduino.cpp @@ -547,8 +547,6 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); - // Clear the STA interface handle to prevent use-after-free - s_sta_netif = nullptr; break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { @@ -638,10 +636,6 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_AP_STOP: { ESP_LOGV(TAG, "AP stop"); -#ifdef USE_WIFI_AP - // Clear the AP interface handle to prevent use-after-free - s_ap_netif = nullptr; -#endif break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 94f1f5125f..d465b346b3 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -697,8 +697,6 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) { ESP_LOGV(TAG, "STA stop"); s_sta_started = false; - // Clear the STA interface handle to prevent use-after-free - s_sta_netif = nullptr; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) { const auto &it = data->data.sta_authmode_change; @@ -797,10 +795,6 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); s_ap_started = false; -#ifdef USE_WIFI_AP - // Clear the AP interface handle to prevent use-after-free - s_ap_netif = nullptr; -#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_PROBEREQRECVED) { const auto &it = data->data.ap_probe_req_rx; From 65d63de9b608c7c911371db880b1ede3f2e9cdfb Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 27 Aug 2025 19:30:01 +1000 Subject: [PATCH 0736/1626] [mipi_spi] Fix dimensions (#10443) --- esphome/components/mipi/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index f610f160b0..570a021cff 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -309,8 +309,12 @@ class DriverChip: CONF_NATIVE_HEIGHT, height + offset_height * 2 ) offset_height = native_height - height - offset_height - # Swap default dimensions if swap_xy is set - if transform[CONF_SWAP_XY] is True: + # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer + rotated = not requires_buffer(config) and config.get(CONF_ROTATION, 0) in ( + 90, + 270, + ) + if transform[CONF_SWAP_XY] is True or rotated: width, height = height, width offset_height, offset_width = offset_width, offset_height return width, height, offset_width, offset_height From c171d13c8cc56fddc694ca1c84a71dde98cd2b35 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 27 Aug 2025 19:30:33 +1000 Subject: [PATCH 0737/1626] [i2c] Perform register reads as single transactions (#10389) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../touchscreen/axs15231_touchscreen.cpp | 2 +- esphome/components/bmi160/bmi160.cpp | 2 +- .../components/bmp280_base/bmp280_base.cpp | 25 +- esphome/components/bmp280_base/bmp280_base.h | 10 +- esphome/components/bmp280_i2c/bmp280_i2c.cpp | 13 - esphome/components/bmp280_i2c/bmp280_i2c.h | 10 +- esphome/components/bmp280_spi/bmp280_spi.cpp | 8 +- esphome/components/bmp280_spi/bmp280_spi.h | 8 +- esphome/components/ch422g/ch422g.cpp | 4 +- esphome/components/ee895/ee895.cpp | 2 +- esphome/components/hte501/hte501.cpp | 5 +- esphome/components/i2c/__init__.py | 27 +- esphome/components/i2c/i2c.cpp | 64 ++-- esphome/components/i2c/i2c.h | 89 +++-- esphome/components/i2c/i2c_bus.h | 112 +++--- esphome/components/i2c/i2c_bus_arduino.cpp | 93 ++--- esphome/components/i2c/i2c_bus_arduino.h | 4 +- esphome/components/i2c/i2c_bus_esp_idf.cpp | 357 +++--------------- esphome/components/i2c/i2c_bus_esp_idf.h | 46 +-- esphome/components/iaqcore/iaqcore.cpp | 2 +- esphome/components/ina2xx_i2c/ina2xx_i2c.cpp | 2 +- esphome/components/kmeteriso/kmeteriso.cpp | 4 +- esphome/components/lc709203f/lc709203f.cpp | 4 +- esphome/components/mcp4461/mcp4461.cpp | 4 +- esphome/components/mlx90614/mlx90614.cpp | 6 +- esphome/components/mpl3115a2/mpl3115a2.cpp | 12 +- esphome/components/npi19/npi19.cpp | 2 +- esphome/components/opt3001/opt3001.cpp | 2 +- esphome/components/pca6416a/pca6416a.cpp | 6 +- esphome/components/pca9554/pca9554.cpp | 4 +- esphome/components/st7567_i2c/st7567_i2c.cpp | 3 +- esphome/components/tca9548a/tca9548a.cpp | 14 +- esphome/components/tca9548a/tca9548a.h | 4 +- esphome/components/tee501/tee501.cpp | 4 +- .../components/tlc59208f/tlc59208f_output.cpp | 3 +- esphome/components/veml3235/veml3235.cpp | 6 +- esphome/components/veml7700/veml7700.cpp | 8 +- esphome/components/veml7700/veml7700.h | 1 - 38 files changed, 329 insertions(+), 643 deletions(-) diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp index 4adf0bbbe0..6304516164 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp @@ -41,7 +41,7 @@ void AXS15231Touchscreen::update_touches() { i2c::ErrorCode err; uint8_t data[8]{}; - err = this->write(AXS_READ_TOUCHPAD, sizeof(AXS_READ_TOUCHPAD), false); + err = this->write(AXS_READ_TOUCHPAD, sizeof(AXS_READ_TOUCHPAD)); ERROR_CHECK(err); err = this->read(data, sizeof(data)); ERROR_CHECK(err); diff --git a/esphome/components/bmi160/bmi160.cpp b/esphome/components/bmi160/bmi160.cpp index b041c7c2dc..4fcc3edb82 100644 --- a/esphome/components/bmi160/bmi160.cpp +++ b/esphome/components/bmi160/bmi160.cpp @@ -203,7 +203,7 @@ void BMI160Component::dump_config() { i2c::ErrorCode BMI160Component::read_le_int16_(uint8_t reg, int16_t *value, uint8_t len) { uint8_t raw_data[len * 2]; // read using read_register because we have little-endian data, and read_bytes_16 will swap it - i2c::ErrorCode err = this->read_register(reg, raw_data, len * 2, true); + i2c::ErrorCode err = this->read_register(reg, raw_data, len * 2); if (err != i2c::ERROR_OK) { return err; } diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index e3cc2d9a57..39654f5875 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -63,12 +63,12 @@ void BMP280Component::setup() { // Read the chip id twice, to work around a bug where the first read is 0. // https://community.st.com/t5/stm32-mcus-products/issue-with-reading-bmp280-chip-id-using-spi/td-p/691855 - if (!this->read_byte(0xD0, &chip_id)) { + if (!this->bmp_read_byte(0xD0, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(ESP_LOG_MSG_COMM_FAIL); return; } - if (!this->read_byte(0xD0, &chip_id)) { + if (!this->bmp_read_byte(0xD0, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(ESP_LOG_MSG_COMM_FAIL); return; @@ -80,7 +80,7 @@ void BMP280Component::setup() { } // Send a soft reset. - if (!this->write_byte(BMP280_REGISTER_RESET, BMP280_SOFT_RESET)) { + if (!this->bmp_write_byte(BMP280_REGISTER_RESET, BMP280_SOFT_RESET)) { this->mark_failed("Reset failed"); return; } @@ -89,7 +89,7 @@ void BMP280Component::setup() { uint8_t retry = 5; do { delay(2); - if (!this->read_byte(BMP280_REGISTER_STATUS, &status)) { + if (!this->bmp_read_byte(BMP280_REGISTER_STATUS, &status)) { this->mark_failed("Error reading status register"); return; } @@ -115,14 +115,14 @@ void BMP280Component::setup() { this->calibration_.p9 = this->read_s16_le_(0x9E); uint8_t config_register = 0; - if (!this->read_byte(BMP280_REGISTER_CONFIG, &config_register)) { + if (!this->bmp_read_byte(BMP280_REGISTER_CONFIG, &config_register)) { this->mark_failed("Read config"); return; } config_register &= ~0b11111100; config_register |= 0b000 << 5; // 0.5 ms standby time config_register |= (this->iir_filter_ & 0b111) << 2; - if (!this->write_byte(BMP280_REGISTER_CONFIG, config_register)) { + if (!this->bmp_write_byte(BMP280_REGISTER_CONFIG, config_register)) { this->mark_failed("Write config"); return; } @@ -159,7 +159,7 @@ void BMP280Component::update() { meas_value |= (this->temperature_oversampling_ & 0b111) << 5; meas_value |= (this->pressure_oversampling_ & 0b111) << 2; meas_value |= 0b01; // Forced mode - if (!this->write_byte(BMP280_REGISTER_CONTROL, meas_value)) { + if (!this->bmp_write_byte(BMP280_REGISTER_CONTROL, meas_value)) { this->status_set_warning(); return; } @@ -188,9 +188,10 @@ void BMP280Component::update() { } float BMP280Component::read_temperature_(int32_t *t_fine) { - uint8_t data[3]; - if (!this->read_bytes(BMP280_REGISTER_TEMPDATA, data, 3)) + uint8_t data[3]{}; + if (!this->bmp_read_bytes(BMP280_REGISTER_TEMPDATA, data, 3)) return NAN; + ESP_LOGV(TAG, "Read temperature data, raw: %02X %02X %02X", data[0], data[1], data[2]); int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); adc >>= 4; if (adc == 0x80000) { @@ -212,7 +213,7 @@ float BMP280Component::read_temperature_(int32_t *t_fine) { float BMP280Component::read_pressure_(int32_t t_fine) { uint8_t data[3]; - if (!this->read_bytes(BMP280_REGISTER_PRESSUREDATA, data, 3)) + if (!this->bmp_read_bytes(BMP280_REGISTER_PRESSUREDATA, data, 3)) return NAN; int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); adc >>= 4; @@ -258,12 +259,12 @@ void BMP280Component::set_pressure_oversampling(BMP280Oversampling pressure_over void BMP280Component::set_iir_filter(BMP280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } uint8_t BMP280Component::read_u8_(uint8_t a_register) { uint8_t data = 0; - this->read_byte(a_register, &data); + this->bmp_read_byte(a_register, &data); return data; } uint16_t BMP280Component::read_u16_le_(uint8_t a_register) { uint16_t data = 0; - this->read_byte_16(a_register, &data); + this->bmp_read_byte_16(a_register, &data); return (data >> 8) | (data << 8); } int16_t BMP280Component::read_s16_le_(uint8_t a_register) { return this->read_u16_le_(a_register); } diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 4b22e98f13..a47a794e96 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -67,12 +67,12 @@ class BMP280Component : public PollingComponent { float get_setup_priority() const override; void update() override; - virtual bool read_byte(uint8_t a_register, uint8_t *data) = 0; - virtual bool write_byte(uint8_t a_register, uint8_t data) = 0; - virtual bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; - virtual bool read_byte_16(uint8_t a_register, uint16_t *data) = 0; - protected: + virtual bool bmp_read_byte(uint8_t a_register, uint8_t *data) = 0; + virtual bool bmp_write_byte(uint8_t a_register, uint8_t data) = 0; + virtual bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; + virtual bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) = 0; + /// Read the temperature value and store the calculated ambient temperature in t_fine. float read_temperature_(int32_t *t_fine); /// Read the pressure value in hPa using the provided t_fine value. diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.cpp b/esphome/components/bmp280_i2c/bmp280_i2c.cpp index 04b8bd8b10..75d899008d 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.cpp +++ b/esphome/components/bmp280_i2c/bmp280_i2c.cpp @@ -5,19 +5,6 @@ namespace esphome { namespace bmp280_i2c { -bool BMP280I2CComponent::read_byte(uint8_t a_register, uint8_t *data) { - return I2CDevice::read_byte(a_register, data); -}; -bool BMP280I2CComponent::write_byte(uint8_t a_register, uint8_t data) { - return I2CDevice::write_byte(a_register, data); -}; -bool BMP280I2CComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t len) { - return I2CDevice::read_bytes(a_register, data, len); -}; -bool BMP280I2CComponent::read_byte_16(uint8_t a_register, uint16_t *data) { - return I2CDevice::read_byte_16(a_register, data); -}; - void BMP280I2CComponent::dump_config() { LOG_I2C_DEVICE(this); BMP280Component::dump_config(); diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index 66d78d788b..0ac956202b 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -11,10 +11,12 @@ static const char *const TAG = "bmp280_i2c.sensor"; /// This class implements support for the BMP280 Temperature+Pressure i2c sensor. class BMP280I2CComponent : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { public: - bool read_byte(uint8_t a_register, uint8_t *data) override; - bool write_byte(uint8_t a_register, uint8_t data) override; - bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; - bool read_byte_16(uint8_t a_register, uint16_t *data) override; + bool bmp_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } + bool bmp_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } + bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override { + return read_bytes(a_register, data, len); + } + bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) override { return read_byte_16(a_register, data); } void dump_config() override; }; diff --git a/esphome/components/bmp280_spi/bmp280_spi.cpp b/esphome/components/bmp280_spi/bmp280_spi.cpp index a35e829432..88983e77c3 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.cpp +++ b/esphome/components/bmp280_spi/bmp280_spi.cpp @@ -28,7 +28,7 @@ void BMP280SPIComponent::setup() { // 0x77 is transferred, for read access, the byte 0xF7 is transferred. // https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bmp280-ds001.pdf -bool BMP280SPIComponent::read_byte(uint8_t a_register, uint8_t *data) { +bool BMP280SPIComponent::bmp_read_byte(uint8_t a_register, uint8_t *data) { this->enable(); this->transfer_byte(set_bit(a_register, 7)); *data = this->transfer_byte(0); @@ -36,7 +36,7 @@ bool BMP280SPIComponent::read_byte(uint8_t a_register, uint8_t *data) { return true; } -bool BMP280SPIComponent::write_byte(uint8_t a_register, uint8_t data) { +bool BMP280SPIComponent::bmp_write_byte(uint8_t a_register, uint8_t data) { this->enable(); this->transfer_byte(clear_bit(a_register, 7)); this->transfer_byte(data); @@ -44,7 +44,7 @@ bool BMP280SPIComponent::write_byte(uint8_t a_register, uint8_t data) { return true; } -bool BMP280SPIComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t len) { +bool BMP280SPIComponent::bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) { this->enable(); this->transfer_byte(set_bit(a_register, 7)); this->read_array(data, len); @@ -52,7 +52,7 @@ bool BMP280SPIComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t le return true; } -bool BMP280SPIComponent::read_byte_16(uint8_t a_register, uint16_t *data) { +bool BMP280SPIComponent::bmp_read_byte_16(uint8_t a_register, uint16_t *data) { this->enable(); this->transfer_byte(set_bit(a_register, 7)); ((uint8_t *) data)[1] = this->transfer_byte(0); diff --git a/esphome/components/bmp280_spi/bmp280_spi.h b/esphome/components/bmp280_spi/bmp280_spi.h index dd226502f6..1bb7678e55 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.h +++ b/esphome/components/bmp280_spi/bmp280_spi.h @@ -10,10 +10,10 @@ class BMP280SPIComponent : public esphome::bmp280_base::BMP280Component, public spi::SPIDevice { void setup() override; - bool read_byte(uint8_t a_register, uint8_t *data) override; - bool write_byte(uint8_t a_register, uint8_t data) override; - bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; - bool read_byte_16(uint8_t a_register, uint16_t *data) override; + bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; + bool bmp_write_byte(uint8_t a_register, uint8_t data) override; + bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; + bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) override; }; } // namespace bmp280_spi diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index 6f652cb0c6..9a4e342525 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -91,7 +91,7 @@ bool CH422GComponent::read_inputs_() { // Write a register. Can't use the standard write_byte() method because there is no single pre-configured i2c address. bool CH422GComponent::write_reg_(uint8_t reg, uint8_t value) { - auto err = this->bus_->write(reg, &value, 1); + auto err = this->bus_->write_readv(reg, &value, 1, nullptr, 0); if (err != i2c::ERROR_OK) { this->status_set_warning(str_sprintf("write failed for register 0x%X, error %d", reg, err).c_str()); return false; @@ -102,7 +102,7 @@ bool CH422GComponent::write_reg_(uint8_t reg, uint8_t value) { uint8_t CH422GComponent::read_reg_(uint8_t reg) { uint8_t value; - auto err = this->bus_->read(reg, &value, 1); + auto err = this->bus_->write_readv(reg, nullptr, 0, &value, 1); if (err != i2c::ERROR_OK) { this->status_set_warning(str_sprintf("read failed for register 0x%X, error %d", reg, err).c_str()); return 0; diff --git a/esphome/components/ee895/ee895.cpp b/esphome/components/ee895/ee895.cpp index 3a8a9b3725..c6eaf4e728 100644 --- a/esphome/components/ee895/ee895.cpp +++ b/esphome/components/ee895/ee895.cpp @@ -83,7 +83,7 @@ void EE895Component::write_command_(uint16_t addr, uint16_t reg_cnt) { crc16 = calc_crc16_(address, 6); address[5] = crc16 & 0xFF; address[6] = (crc16 >> 8) & 0xFF; - this->write(address, 7, true); + this->write(address, 7); } float EE895Component::read_float_() { diff --git a/esphome/components/hte501/hte501.cpp b/esphome/components/hte501/hte501.cpp index fa81640f50..b7d3be63fe 100644 --- a/esphome/components/hte501/hte501.cpp +++ b/esphome/components/hte501/hte501.cpp @@ -9,9 +9,8 @@ static const char *const TAG = "hte501"; void HTE501Component::setup() { uint8_t address[] = {0x70, 0x29}; - this->write(address, 2, false); uint8_t identification[9]; - this->read(identification, 9); + this->write_read(address, sizeof address, identification, sizeof identification); if (identification[8] != crc8(identification, 8, 0xFF, 0x31, true)) { this->error_code_ = CRC_CHECK_FAILED; this->mark_failed(); @@ -42,7 +41,7 @@ void HTE501Component::dump_config() { float HTE501Component::get_setup_priority() const { return setup_priority::DATA; } void HTE501Component::update() { uint8_t address_1[] = {0x2C, 0x1B}; - this->write(address_1, 2, true); + this->write(address_1, 2); this->set_timeout(50, [this]() { uint8_t i2c_response[6]; this->read(i2c_response, 6); diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 4172b23845..35b9fab9e4 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -2,7 +2,6 @@ import logging from esphome import pins import esphome.codegen as cg -from esphome.components import esp32 from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -14,8 +13,6 @@ from esphome.const import ( CONF_SCL, CONF_SDA, CONF_TIMEOUT, - KEY_CORE, - KEY_FRAMEWORK_VERSION, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, @@ -48,28 +45,8 @@ def _bus_declare_type(value): def validate_config(config): - if ( - config[CONF_SCAN] - and CORE.is_esp32 - and CORE.using_esp_idf - and esp32.get_esp32_variant() - in [ - esp32.const.VARIANT_ESP32C5, - esp32.const.VARIANT_ESP32C6, - esp32.const.VARIANT_ESP32P4, - ] - ): - version: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - if version.major == 5 and ( - (version.minor == 3 and version.patch <= 3) - or (version.minor == 4 and version.patch <= 1) - ): - LOGGER.warning( - "There is a bug in esp-idf version %s that breaks I2C scan, I2C scan " - "has been disabled, see https://github.com/esphome/issues/issues/7128", - str(version), - ) - config[CONF_SCAN] = False + if CORE.using_esp_idf: + return cv.require_framework_version(esp_idf=cv.Version(5, 4, 2))(config) return config diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 2b2190d28b..e66ab8ba73 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -1,4 +1,6 @@ #include "i2c.h" + +#include "esphome/core/defines.h" #include "esphome/core/log.h" #include @@ -7,38 +9,48 @@ namespace i2c { static const char *const TAG = "i2c"; -ErrorCode I2CDevice::read_register(uint8_t a_register, uint8_t *data, size_t len, bool stop) { - ErrorCode err = this->write(&a_register, 1, stop); - if (err != ERROR_OK) - return err; - return bus_->read(address_, data, len); +void I2CBus::i2c_scan_() { + // suppress logs from the IDF I2C library during the scan +#if defined(USE_ESP32) && defined(USE_LOGGER) + auto previous = esp_log_level_get("*"); + esp_log_level_set("*", ESP_LOG_NONE); +#endif + + for (uint8_t address = 8; address != 120; address++) { + auto err = write_readv(address, nullptr, 0, nullptr, 0); + if (err == ERROR_OK) { + scan_results_.emplace_back(address, true); + } else if (err == ERROR_UNKNOWN) { + scan_results_.emplace_back(address, false); + } + } +#if defined(USE_ESP32) && defined(USE_LOGGER) + esp_log_level_set("*", previous); +#endif } -ErrorCode I2CDevice::read_register16(uint16_t a_register, uint8_t *data, size_t len, bool stop) { +ErrorCode I2CDevice::read_register(uint8_t a_register, uint8_t *data, size_t len) { + return bus_->write_readv(this->address_, &a_register, 1, data, len); +} + +ErrorCode I2CDevice::read_register16(uint16_t a_register, uint8_t *data, size_t len) { a_register = convert_big_endian(a_register); - ErrorCode const err = this->write(reinterpret_cast(&a_register), 2, stop); - if (err != ERROR_OK) - return err; - return bus_->read(address_, data, len); + return bus_->write_readv(this->address_, reinterpret_cast(&a_register), 2, data, len); } -ErrorCode I2CDevice::write_register(uint8_t a_register, const uint8_t *data, size_t len, bool stop) { - WriteBuffer buffers[2]; - buffers[0].data = &a_register; - buffers[0].len = 1; - buffers[1].data = data; - buffers[1].len = len; - return bus_->writev(address_, buffers, 2, stop); +ErrorCode I2CDevice::write_register(uint8_t a_register, const uint8_t *data, size_t len) const { + std::vector v{}; + v.push_back(a_register); + v.insert(v.end(), data, data + len); + return bus_->write_readv(this->address_, v.data(), v.size(), nullptr, 0); } -ErrorCode I2CDevice::write_register16(uint16_t a_register, const uint8_t *data, size_t len, bool stop) { - a_register = convert_big_endian(a_register); - WriteBuffer buffers[2]; - buffers[0].data = reinterpret_cast(&a_register); - buffers[0].len = 2; - buffers[1].data = data; - buffers[1].len = len; - return bus_->writev(address_, buffers, 2, stop); +ErrorCode I2CDevice::write_register16(uint16_t a_register, const uint8_t *data, size_t len) const { + std::vector v(len + 2); + v.push_back(a_register >> 8); + v.push_back(a_register); + v.insert(v.end(), data, data + len); + return bus_->write_readv(this->address_, v.data(), v.size(), nullptr, 0); } bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { @@ -49,7 +61,7 @@ bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { return true; } -bool I2CDevice::write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) { +bool I2CDevice::write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) const { // we have to copy in order to be able to change byte order std::unique_ptr temp{new uint16_t[len]}; for (size_t i = 0; i < len; i++) diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 15f786245b..48a6e751cf 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -1,10 +1,10 @@ #pragma once -#include "i2c_bus.h" -#include "esphome/core/helpers.h" -#include "esphome/core/optional.h" #include #include +#include "esphome/core/helpers.h" +#include "esphome/core/optional.h" +#include "i2c_bus.h" namespace esphome { namespace i2c { @@ -161,51 +161,53 @@ class I2CDevice { /// @param data pointer to an array to store the bytes /// @param len length of the buffer = number of bytes to read /// @return an i2c::ErrorCode - ErrorCode read(uint8_t *data, size_t len) { return bus_->read(address_, data, len); } + ErrorCode read(uint8_t *data, size_t len) const { return bus_->write_readv(this->address_, nullptr, 0, data, len); } /// @brief reads an array of bytes from a specific register in the I²C device /// @param a_register an 8 bits internal address of the I²C register to read from /// @param data pointer to an array to store the bytes /// @param len length of the buffer = number of bytes to read - /// @param stop (true/false): True will send a stop message, releasing the bus after - /// transmission. False will send a restart, keeping the connection active. /// @return an i2c::ErrorCode - ErrorCode read_register(uint8_t a_register, uint8_t *data, size_t len, bool stop = true); + ErrorCode read_register(uint8_t a_register, uint8_t *data, size_t len); /// @brief reads an array of bytes from a specific register in the I²C device /// @param a_register the 16 bits internal address of the I²C register to read from /// @param data pointer to an array of bytes to store the information /// @param len length of the buffer = number of bytes to read - /// @param stop (true/false): True will send a stop message, releasing the bus after - /// transmission. False will send a restart, keeping the connection active. /// @return an i2c::ErrorCode - ErrorCode read_register16(uint16_t a_register, uint8_t *data, size_t len, bool stop = true); + ErrorCode read_register16(uint16_t a_register, uint8_t *data, size_t len); /// @brief writes an array of bytes to a device using an I2CBus /// @param data pointer to an array that contains the bytes to send /// @param len length of the buffer = number of bytes to write - /// @param stop (true/false): True will send a stop message, releasing the bus after - /// transmission. False will send a restart, keeping the connection active. /// @return an i2c::ErrorCode - ErrorCode write(const uint8_t *data, size_t len, bool stop = true) { return bus_->write(address_, data, len, stop); } + ErrorCode write(const uint8_t *data, size_t len) const { + return bus_->write_readv(this->address_, data, len, nullptr, 0); + } + + /// @brief writes an array of bytes to a device, then reads an array, as a single transaction + /// @param write_data pointer to an array that contains the bytes to send + /// @param write_len length of the buffer = number of bytes to write + /// @param read_data pointer to an array to store the bytes read + /// @param read_len length of the buffer = number of bytes to read + /// @return an i2c::ErrorCode + ErrorCode write_read(const uint8_t *write_data, size_t write_len, uint8_t *read_data, size_t read_len) const { + return bus_->write_readv(this->address_, write_data, write_len, read_data, read_len); + } /// @brief writes an array of bytes to a specific register in the I²C device /// @param a_register the internal address of the register to read from /// @param data pointer to an array to store the bytes /// @param len length of the buffer = number of bytes to read - /// @param stop (true/false): True will send a stop message, releasing the bus after - /// transmission. False will send a restart, keeping the connection active. /// @return an i2c::ErrorCode - ErrorCode write_register(uint8_t a_register, const uint8_t *data, size_t len, bool stop = true); + ErrorCode write_register(uint8_t a_register, const uint8_t *data, size_t len) const; /// @brief write an array of bytes to a specific register in the I²C device /// @param a_register the 16 bits internal address of the register to read from /// @param data pointer to an array to store the bytes /// @param len length of the buffer = number of bytes to read - /// @param stop (true/false): True will send a stop message, releasing the bus after - /// transmission. False will send a restart, keeping the connection active. /// @return an i2c::ErrorCode - ErrorCode write_register16(uint16_t a_register, const uint8_t *data, size_t len, bool stop = true); + ErrorCode write_register16(uint16_t a_register, const uint8_t *data, size_t len) const; /// /// Compat APIs @@ -217,7 +219,7 @@ class I2CDevice { return read_register(a_register, data, len) == ERROR_OK; } - bool read_bytes_raw(uint8_t *data, uint8_t len) { return read(data, len) == ERROR_OK; } + bool read_bytes_raw(uint8_t *data, uint8_t len) const { return read(data, len) == ERROR_OK; } template optional> read_bytes(uint8_t a_register) { std::array res; @@ -236,9 +238,7 @@ class I2CDevice { bool read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len); - bool read_byte(uint8_t a_register, uint8_t *data, bool stop = true) { - return read_register(a_register, data, 1, stop) == ERROR_OK; - } + bool read_byte(uint8_t a_register, uint8_t *data) { return read_register(a_register, data, 1) == ERROR_OK; } optional read_byte(uint8_t a_register) { uint8_t data; @@ -249,11 +249,11 @@ class I2CDevice { bool read_byte_16(uint8_t a_register, uint16_t *data) { return read_bytes_16(a_register, data, 1); } - bool write_bytes(uint8_t a_register, const uint8_t *data, uint8_t len, bool stop = true) { - return write_register(a_register, data, len, stop) == ERROR_OK; + bool write_bytes(uint8_t a_register, const uint8_t *data, uint8_t len) const { + return write_register(a_register, data, len) == ERROR_OK; } - bool write_bytes(uint8_t a_register, const std::vector &data) { + bool write_bytes(uint8_t a_register, const std::vector &data) const { return write_bytes(a_register, data.data(), data.size()); } @@ -261,13 +261,42 @@ class I2CDevice { return write_bytes(a_register, data.data(), data.size()); } - bool write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len); + bool write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) const; - bool write_byte(uint8_t a_register, uint8_t data, bool stop = true) { - return write_bytes(a_register, &data, 1, stop); + bool write_byte(uint8_t a_register, uint8_t data) const { return write_bytes(a_register, &data, 1); } + + bool write_byte_16(uint8_t a_register, uint16_t data) const { return write_bytes_16(a_register, &data, 1); } + + // Deprecated functions + + ESPDEPRECATED("The stop argument is no longer used. This will be removed from ESPHome 2026.3.0", "2025.9.0") + ErrorCode read_register(uint8_t a_register, uint8_t *data, size_t len, bool stop) { + return this->read_register(a_register, data, len); } - bool write_byte_16(uint8_t a_register, uint16_t data) { return write_bytes_16(a_register, &data, 1); } + ESPDEPRECATED("The stop argument is no longer used. This will be removed from ESPHome 2026.3.0", "2025.9.0") + ErrorCode read_register16(uint16_t a_register, uint8_t *data, size_t len, bool stop) { + return this->read_register16(a_register, data, len); + } + + ESPDEPRECATED("The stop argument is no longer used; use write_read() for consecutive write and read. This will be " + "removed from ESPHome 2026.3.0", + "2025.9.0") + ErrorCode write(const uint8_t *data, size_t len, bool stop) const { return this->write(data, len); } + + ESPDEPRECATED("The stop argument is no longer used; use write_read() for consecutive write and read. This will be " + "removed from ESPHome 2026.3.0", + "2025.9.0") + ErrorCode write_register(uint8_t a_register, const uint8_t *data, size_t len, bool stop) const { + return this->write_register(a_register, data, len); + } + + ESPDEPRECATED("The stop argument is no longer used; use write_read() for consecutive write and read. This will be " + "removed from ESPHome 2026.3.0", + "2025.9.0") + ErrorCode write_register16(uint16_t a_register, const uint8_t *data, size_t len, bool stop) const { + return this->write_register16(a_register, data, len); + } protected: uint8_t address_{0x00}; ///< store the address of the device on the bus diff --git a/esphome/components/i2c/i2c_bus.h b/esphome/components/i2c/i2c_bus.h index da94aa940d..df4df628e8 100644 --- a/esphome/components/i2c/i2c_bus.h +++ b/esphome/components/i2c/i2c_bus.h @@ -1,9 +1,12 @@ #pragma once #include #include +#include #include #include +#include "esphome/core/helpers.h" + namespace esphome { namespace i2c { @@ -39,71 +42,66 @@ struct WriteBuffer { /// note https://www.nxp.com/docs/en/application-note/AN10216.pdf class I2CBus { public: - /// @brief Creates a ReadBuffer and calls the virtual readv() method to read bytes into this buffer - /// @param address address of the I²C component on the i2c bus - /// @param buffer pointer to an array of bytes that will be used to store the data received - /// @param len length of the buffer = number of bytes to read - /// @return an i2c::ErrorCode - virtual ErrorCode read(uint8_t address, uint8_t *buffer, size_t len) { - ReadBuffer buf; - buf.data = buffer; - buf.len = len; - return readv(address, &buf, 1); - } + virtual ~I2CBus() = default; - /// @brief This virtual method reads bytes from an I2CBus into an array of ReadBuffer. - /// @param address address of the I²C component on the i2c bus - /// @param buffers pointer to an array of ReadBuffer - /// @param count number of ReadBuffer to read - /// @return an i2c::ErrorCode - /// @details This is a pure virtual method that must be implemented in a subclass. - virtual ErrorCode readv(uint8_t address, ReadBuffer *buffers, size_t count) = 0; - - virtual ErrorCode write(uint8_t address, const uint8_t *buffer, size_t len) { - return write(address, buffer, len, true); - } - - /// @brief Creates a WriteBuffer and calls the writev() method to send the bytes from this buffer - /// @param address address of the I²C component on the i2c bus - /// @param buffer pointer to an array of bytes that contains the data to be sent - /// @param len length of the buffer = number of bytes to write - /// @param stop true or false: True will send a stop message, releasing the bus after - /// transmission. False will send a restart, keeping the connection active. - /// @return an i2c::ErrorCode - virtual ErrorCode write(uint8_t address, const uint8_t *buffer, size_t len, bool stop) { - WriteBuffer buf; - buf.data = buffer; - buf.len = len; - return writev(address, &buf, 1, stop); - } - - virtual ErrorCode writev(uint8_t address, WriteBuffer *buffers, size_t cnt) { - return writev(address, buffers, cnt, true); - } - - /// @brief This virtual method writes bytes to an I2CBus from an array of WriteBuffer. - /// @param address address of the I²C component on the i2c bus - /// @param buffers pointer to an array of WriteBuffer - /// @param count number of WriteBuffer to write - /// @param stop true or false: True will send a stop message, releasing the bus after + /// @brief This virtual method writes bytes to an I2CBus from an array, + /// then reads bytes into an array of ReadBuffer. + /// @param address address of the I²C device on the i2c bus + /// @param write_buffer pointer to data + /// @param write_count number of bytes to write + /// @param read_buffer pointer to an array to receive data + /// @param read_count number of bytes to read /// transmission. False will send a restart, keeping the connection active. /// @return an i2c::ErrorCode /// @details This is a pure virtual method that must be implemented in the subclass. - virtual ErrorCode writev(uint8_t address, WriteBuffer *buffers, size_t count, bool stop) = 0; + virtual ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) = 0; + + // Legacy functions for compatibility + + ErrorCode read(uint8_t address, uint8_t *buffer, size_t len) { + return this->write_readv(address, nullptr, 0, buffer, len); + } + + ErrorCode write(uint8_t address, const uint8_t *buffer, size_t len, bool stop = true) { + return this->write_readv(address, buffer, len, nullptr, 0); + } + + ESPDEPRECATED("This method is deprecated and will be removed in ESPHome 2026.3.0. Use write_readv() instead.", + "2025.9.0") + ErrorCode readv(uint8_t address, ReadBuffer *read_buffers, size_t count) { + size_t total_len = 0; + for (size_t i = 0; i != count; i++) { + total_len += read_buffers[i].len; + } + std::vector buffer(total_len); + auto err = this->write_readv(address, nullptr, 0, buffer.data(), total_len); + if (err != ERROR_OK) + return err; + size_t pos = 0; + for (size_t i = 0; i != count; i++) { + if (read_buffers[i].len != 0) { + std::memcpy(read_buffers[i].data, buffer.data() + pos, read_buffers[i].len); + pos += read_buffers[i].len; + } + } + return ERROR_OK; + } + + ESPDEPRECATED("This method is deprecated and will be removed in ESPHome 2026.3.0. Use write_readv() instead.", + "2025.9.0") + ErrorCode writev(uint8_t address, const WriteBuffer *write_buffers, size_t count, bool stop = true) { + std::vector buffer{}; + for (size_t i = 0; i != count; i++) { + buffer.insert(buffer.end(), write_buffers[i].data, write_buffers[i].data + write_buffers[i].len); + } + return this->write_readv(address, buffer.data(), buffer.size(), nullptr, 0); + } protected: /// @brief Scans the I2C bus for devices. Devices presence is kept in an array of std::pair /// that contains the address and the corresponding bool presence flag. - virtual void i2c_scan() { - for (uint8_t address = 8; address < 120; address++) { - auto err = writev(address, nullptr, 0); - if (err == ERROR_OK) { - scan_results_.emplace_back(address, true); - } else if (err == ERROR_UNKNOWN) { - scan_results_.emplace_back(address, false); - } - } - } + void i2c_scan_(); std::vector> scan_results_; ///< array containing scan results bool scan_{false}; ///< Should we scan ? Can be set in the yaml }; diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 24385745eb..221423418b 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -41,7 +41,7 @@ void ArduinoI2CBus::setup() { this->initialized_ = true; if (this->scan_) { ESP_LOGV(TAG, "Scanning bus for active devices"); - this->i2c_scan(); + this->i2c_scan_(); } } @@ -111,88 +111,37 @@ void ArduinoI2CBus::dump_config() { } } -ErrorCode ArduinoI2CBus::readv(uint8_t address, ReadBuffer *buffers, size_t cnt) { +ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count) { #if defined(USE_ESP8266) this->set_pins_and_clock_(); // reconfigure Wire global state in case there are multiple instances #endif - - // logging is only enabled with vv level, if warnings are shown the caller - // should log them if (!initialized_) { - ESP_LOGVV(TAG, "i2c bus not initialized!"); - return ERROR_NOT_INITIALIZED; - } - size_t to_request = 0; - for (size_t i = 0; i < cnt; i++) - to_request += buffers[i].len; - size_t ret = wire_->requestFrom(address, to_request, true); - if (ret != to_request) { - ESP_LOGVV(TAG, "RX %u from %02X failed with error %u", to_request, address, ret); - return ERROR_TIMEOUT; - } - - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - for (size_t j = 0; j < buf.len; j++) - buf.data[j] = wire_->read(); - } - -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - char debug_buf[4]; - std::string debug_hex; - - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - for (size_t j = 0; j < buf.len; j++) { - snprintf(debug_buf, sizeof(debug_buf), "%02X", buf.data[j]); - debug_hex += debug_buf; - } - } - ESP_LOGVV(TAG, "0x%02X RX %s", address, debug_hex.c_str()); -#endif - - return ERROR_OK; -} -ErrorCode ArduinoI2CBus::writev(uint8_t address, WriteBuffer *buffers, size_t cnt, bool stop) { -#if defined(USE_ESP8266) - this->set_pins_and_clock_(); // reconfigure Wire global state in case there are multiple instances -#endif - - // logging is only enabled with vv level, if warnings are shown the caller - // should log them - if (!initialized_) { - ESP_LOGVV(TAG, "i2c bus not initialized!"); + ESP_LOGD(TAG, "i2c bus not initialized!"); return ERROR_NOT_INITIALIZED; } -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - char debug_buf[4]; - std::string debug_hex; + ESP_LOGV(TAG, "0x%02X TX %s", address, format_hex_pretty(write_buffer, write_count).c_str()); - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - for (size_t j = 0; j < buf.len; j++) { - snprintf(debug_buf, sizeof(debug_buf), "%02X", buf.data[j]); - debug_hex += debug_buf; - } - } - ESP_LOGVV(TAG, "0x%02X TX %s", address, debug_hex.c_str()); -#endif - - wire_->beginTransmission(address); - size_t written = 0; - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - if (buf.len == 0) - continue; - size_t ret = wire_->write(buf.data, buf.len); - written += ret; - if (ret != buf.len) { - ESP_LOGVV(TAG, "TX failed at %u", written); + uint8_t status = 0; + if (write_count != 0 || read_count == 0) { + wire_->beginTransmission(address); + size_t ret = wire_->write(write_buffer, write_count); + if (ret != write_count) { + ESP_LOGV(TAG, "TX failed"); return ERROR_UNKNOWN; } + status = wire_->endTransmission(read_count == 0); + } + if (status == 0 && read_count != 0) { + size_t ret2 = wire_->requestFrom(address, read_count, true); + if (ret2 != read_count) { + ESP_LOGVV(TAG, "RX %u from %02X failed with error %u", read_count, address, ret2); + return ERROR_TIMEOUT; + } + for (size_t j = 0; j != read_count; j++) + read_buffer[j] = wire_->read(); } - uint8_t status = wire_->endTransmission(stop); switch (status) { case 0: return ERROR_OK; diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index 7e6616cbce..b441828353 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -19,8 +19,8 @@ class ArduinoI2CBus : public InternalI2CBus, public Component { public: void setup() override; void dump_config() override; - ErrorCode readv(uint8_t address, ReadBuffer *buffers, size_t cnt) override; - ErrorCode writev(uint8_t address, WriteBuffer *buffers, size_t cnt, bool stop) override; + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override; float get_setup_priority() const override { return setup_priority::BUS; } void set_scan(bool scan) { scan_ = scan; } diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index cf31ba1c0d..bf50ea0586 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP_IDF #include "i2c_bus_esp_idf.h" + #include #include #include @@ -9,10 +10,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0) -#define SOC_HP_I2C_NUM SOC_I2C_NUM -#endif - namespace esphome { namespace i2c { @@ -34,7 +31,6 @@ void IDFI2CBus::setup() { this->recover_(); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) next_port = (i2c_port_t) (next_port + 1); i2c_master_bus_config_t bus_conf{}; @@ -77,56 +73,8 @@ void IDFI2CBus::setup() { if (this->scan_) { ESP_LOGV(TAG, "Scanning for devices"); - this->i2c_scan(); + this->i2c_scan_(); } -#else -#if SOC_HP_I2C_NUM > 1 - next_port = (next_port == I2C_NUM_0) ? I2C_NUM_1 : I2C_NUM_MAX; -#else - next_port = I2C_NUM_MAX; -#endif - - i2c_config_t conf{}; - memset(&conf, 0, sizeof(conf)); - conf.mode = I2C_MODE_MASTER; - conf.sda_io_num = sda_pin_; - conf.sda_pullup_en = sda_pullup_enabled_; - conf.scl_io_num = scl_pin_; - conf.scl_pullup_en = scl_pullup_enabled_; - conf.master.clk_speed = frequency_; -#ifdef USE_ESP32_VARIANT_ESP32S2 - // workaround for https://github.com/esphome/issues/issues/6718 - conf.clk_flags = I2C_SCLK_SRC_FLAG_AWARE_DFS; -#endif - esp_err_t err = i2c_param_config(port_, &conf); - if (err != ESP_OK) { - ESP_LOGW(TAG, "i2c_param_config failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - if (timeout_ > 0) { - err = i2c_set_timeout(port_, timeout_ * 80); // unit: APB 80MHz clock cycle - if (err != ESP_OK) { - ESP_LOGW(TAG, "i2c_set_timeout failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } else { - ESP_LOGV(TAG, "i2c_timeout set to %" PRIu32 " ticks (%" PRIu32 " us)", timeout_ * 80, timeout_); - } - } - err = i2c_driver_install(port_, I2C_MODE_MASTER, 0, 0, 0); - if (err != ESP_OK) { - ESP_LOGW(TAG, "i2c_driver_install failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - initialized_ = true; - if (this->scan_) { - ESP_LOGV(TAG, "Scanning bus for active devices"); - this->i2c_scan(); - } -#endif } void IDFI2CBus::dump_config() { @@ -166,267 +114,73 @@ void IDFI2CBus::dump_config() { } } -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) -void IDFI2CBus::i2c_scan() { - for (uint8_t address = 8; address < 120; address++) { - auto err = i2c_master_probe(this->bus_, address, 20); - if (err == ESP_OK) { - this->scan_results_.emplace_back(address, true); - } - } -} -#endif - -ErrorCode IDFI2CBus::readv(uint8_t address, ReadBuffer *buffers, size_t cnt) { - // logging is only enabled with vv level, if warnings are shown the caller +ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) { + // logging is only enabled with v level, if warnings are shown the caller // should log them if (!initialized_) { - ESP_LOGVV(TAG, "i2c bus not initialized!"); + ESP_LOGW(TAG, "i2c bus not initialized!"); return ERROR_NOT_INITIALIZED; } -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) - i2c_operation_job_t jobs[cnt + 4]; - uint8_t read = (address << 1) | I2C_MASTER_READ; - size_t last = 0, num = 0; - - jobs[num].command = I2C_MASTER_CMD_START; - num++; - - jobs[num].command = I2C_MASTER_CMD_WRITE; - jobs[num].write.ack_check = true; - jobs[num].write.data = &read; - jobs[num].write.total_bytes = 1; - num++; - - // find the last valid index - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - if (buf.len == 0) { - continue; + i2c_operation_job_t jobs[8]{}; + size_t num_jobs = 0; + uint8_t write_addr = (address << 1) | I2C_MASTER_WRITE; + uint8_t read_addr = (address << 1) | I2C_MASTER_READ; + ESP_LOGV(TAG, "Writing %zu bytes, reading %zu bytes", write_count, read_count); + if (read_count == 0 && write_count == 0) { + // basically just a bus probe. Send a start, address and stop + ESP_LOGV(TAG, "0x%02X BUS PROBE", address); + jobs[num_jobs++].command = I2C_MASTER_CMD_START; + jobs[num_jobs].command = I2C_MASTER_CMD_WRITE; + jobs[num_jobs].write.ack_check = true; + jobs[num_jobs].write.data = &write_addr; + jobs[num_jobs++].write.total_bytes = 1; + } else { + if (write_count != 0) { + ESP_LOGV(TAG, "0x%02X TX %s", address, format_hex_pretty(write_buffer, write_count).c_str()); + jobs[num_jobs++].command = I2C_MASTER_CMD_START; + jobs[num_jobs].command = I2C_MASTER_CMD_WRITE; + jobs[num_jobs].write.ack_check = true; + jobs[num_jobs].write.data = &write_addr; + jobs[num_jobs++].write.total_bytes = 1; + jobs[num_jobs].command = I2C_MASTER_CMD_WRITE; + jobs[num_jobs].write.ack_check = true; + jobs[num_jobs].write.data = (uint8_t *) write_buffer; + jobs[num_jobs++].write.total_bytes = write_count; } - last = i; - } - - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - if (buf.len == 0) { - continue; - } - if (i == last) { - // the last byte read before stop should always be a nack, - // split the last read if len is larger than 1 - if (buf.len > 1) { - jobs[num].command = I2C_MASTER_CMD_READ; - jobs[num].read.ack_value = I2C_ACK_VAL; - jobs[num].read.data = (uint8_t *) buf.data; - jobs[num].read.total_bytes = buf.len - 1; - num++; + if (read_count != 0) { + ESP_LOGV(TAG, "0x%02X RX bytes %zu", address, read_count); + jobs[num_jobs++].command = I2C_MASTER_CMD_START; + jobs[num_jobs].command = I2C_MASTER_CMD_WRITE; + jobs[num_jobs].write.ack_check = true; + jobs[num_jobs].write.data = &read_addr; + jobs[num_jobs++].write.total_bytes = 1; + if (read_count > 1) { + jobs[num_jobs].command = I2C_MASTER_CMD_READ; + jobs[num_jobs].read.ack_value = I2C_ACK_VAL; + jobs[num_jobs].read.data = read_buffer; + jobs[num_jobs++].read.total_bytes = read_count - 1; } - jobs[num].command = I2C_MASTER_CMD_READ; - jobs[num].read.ack_value = I2C_NACK_VAL; - jobs[num].read.data = (uint8_t *) buf.data + buf.len - 1; - jobs[num].read.total_bytes = 1; - num++; - } else { - jobs[num].command = I2C_MASTER_CMD_READ; - jobs[num].read.ack_value = I2C_ACK_VAL; - jobs[num].read.data = (uint8_t *) buf.data; - jobs[num].read.total_bytes = buf.len; - num++; + jobs[num_jobs].command = I2C_MASTER_CMD_READ; + jobs[num_jobs].read.ack_value = I2C_NACK_VAL; + jobs[num_jobs].read.data = read_buffer + read_count - 1; + jobs[num_jobs++].read.total_bytes = 1; } } - - jobs[num].command = I2C_MASTER_CMD_STOP; - num++; - - esp_err_t err = i2c_master_execute_defined_operations(this->dev_, jobs, num, 20); + jobs[num_jobs++].command = I2C_MASTER_CMD_STOP; + ESP_LOGV(TAG, "Sending %zu jobs", num_jobs); + esp_err_t err = i2c_master_execute_defined_operations(this->dev_, jobs, num_jobs, 20); if (err == ESP_ERR_INVALID_STATE) { - ESP_LOGVV(TAG, "RX from %02X failed: not acked", address); + ESP_LOGV(TAG, "TX to %02X failed: not acked", address); return ERROR_NOT_ACKNOWLEDGED; } else if (err == ESP_ERR_TIMEOUT) { - ESP_LOGVV(TAG, "RX from %02X failed: timeout", address); + ESP_LOGV(TAG, "TX to %02X failed: timeout", address); return ERROR_TIMEOUT; } else if (err != ESP_OK) { - ESP_LOGVV(TAG, "RX from %02X failed: %s", address, esp_err_to_name(err)); + ESP_LOGV(TAG, "TX to %02X failed: %s", address, esp_err_to_name(err)); return ERROR_UNKNOWN; } -#else - i2c_cmd_handle_t cmd = i2c_cmd_link_create(); - esp_err_t err = i2c_master_start(cmd); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "RX from %02X master start failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - err = i2c_master_write_byte(cmd, (address << 1) | I2C_MASTER_READ, true); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "RX from %02X address write failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - if (buf.len == 0) - continue; - err = i2c_master_read(cmd, buf.data, buf.len, i == cnt - 1 ? I2C_MASTER_LAST_NACK : I2C_MASTER_ACK); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "RX from %02X data read failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - } - err = i2c_master_stop(cmd); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "RX from %02X stop failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - err = i2c_master_cmd_begin(port_, cmd, 20 / portTICK_PERIOD_MS); - // i2c_master_cmd_begin() will block for a whole second if no ack: - // https://github.com/espressif/esp-idf/issues/4999 - i2c_cmd_link_delete(cmd); - if (err == ESP_FAIL) { - // transfer not acked - ESP_LOGVV(TAG, "RX from %02X failed: not acked", address); - return ERROR_NOT_ACKNOWLEDGED; - } else if (err == ESP_ERR_TIMEOUT) { - ESP_LOGVV(TAG, "RX from %02X failed: timeout", address); - return ERROR_TIMEOUT; - } else if (err != ESP_OK) { - ESP_LOGVV(TAG, "RX from %02X failed: %s", address, esp_err_to_name(err)); - return ERROR_UNKNOWN; - } -#endif - -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - char debug_buf[4]; - std::string debug_hex; - - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - for (size_t j = 0; j < buf.len; j++) { - snprintf(debug_buf, sizeof(debug_buf), "%02X", buf.data[j]); - debug_hex += debug_buf; - } - } - ESP_LOGVV(TAG, "0x%02X RX %s", address, debug_hex.c_str()); -#endif - - return ERROR_OK; -} - -ErrorCode IDFI2CBus::writev(uint8_t address, WriteBuffer *buffers, size_t cnt, bool stop) { - // logging is only enabled with vv level, if warnings are shown the caller - // should log them - if (!initialized_) { - ESP_LOGVV(TAG, "i2c bus not initialized!"); - return ERROR_NOT_INITIALIZED; - } - -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - char debug_buf[4]; - std::string debug_hex; - - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - for (size_t j = 0; j < buf.len; j++) { - snprintf(debug_buf, sizeof(debug_buf), "%02X", buf.data[j]); - debug_hex += debug_buf; - } - } - ESP_LOGVV(TAG, "0x%02X TX %s", address, debug_hex.c_str()); -#endif - -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) - i2c_operation_job_t jobs[cnt + 3]; - uint8_t write = (address << 1) | I2C_MASTER_WRITE; - size_t num = 0; - - jobs[num].command = I2C_MASTER_CMD_START; - num++; - - jobs[num].command = I2C_MASTER_CMD_WRITE; - jobs[num].write.ack_check = true; - jobs[num].write.data = &write; - jobs[num].write.total_bytes = 1; - num++; - - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - if (buf.len == 0) { - continue; - } - jobs[num].command = I2C_MASTER_CMD_WRITE; - jobs[num].write.ack_check = true; - jobs[num].write.data = (uint8_t *) buf.data; - jobs[num].write.total_bytes = buf.len; - num++; - } - - if (stop) { - jobs[num].command = I2C_MASTER_CMD_STOP; - num++; - } - - esp_err_t err = i2c_master_execute_defined_operations(this->dev_, jobs, num, 20); - if (err == ESP_ERR_INVALID_STATE) { - ESP_LOGVV(TAG, "TX to %02X failed: not acked", address); - return ERROR_NOT_ACKNOWLEDGED; - } else if (err == ESP_ERR_TIMEOUT) { - ESP_LOGVV(TAG, "TX to %02X failed: timeout", address); - return ERROR_TIMEOUT; - } else if (err != ESP_OK) { - ESP_LOGVV(TAG, "TX to %02X failed: %s", address, esp_err_to_name(err)); - return ERROR_UNKNOWN; - } -#else - i2c_cmd_handle_t cmd = i2c_cmd_link_create(); - esp_err_t err = i2c_master_start(cmd); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "TX to %02X master start failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - err = i2c_master_write_byte(cmd, (address << 1) | I2C_MASTER_WRITE, true); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "TX to %02X address write failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - if (buf.len == 0) - continue; - err = i2c_master_write(cmd, buf.data, buf.len, true); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "TX to %02X data write failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - } - if (stop) { - err = i2c_master_stop(cmd); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "TX to %02X master stop failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - } - err = i2c_master_cmd_begin(port_, cmd, 20 / portTICK_PERIOD_MS); - i2c_cmd_link_delete(cmd); - if (err == ESP_FAIL) { - // transfer not acked - ESP_LOGVV(TAG, "TX to %02X failed: not acked", address); - return ERROR_NOT_ACKNOWLEDGED; - } else if (err == ESP_ERR_TIMEOUT) { - ESP_LOGVV(TAG, "TX to %02X failed: timeout", address); - return ERROR_TIMEOUT; - } else if (err != ESP_OK) { - ESP_LOGVV(TAG, "TX to %02X failed: %s", address, esp_err_to_name(err)); - return ERROR_UNKNOWN; - } -#endif return ERROR_OK; } @@ -436,8 +190,8 @@ ErrorCode IDFI2CBus::writev(uint8_t address, WriteBuffer *buffers, size_t cnt, b void IDFI2CBus::recover_() { ESP_LOGI(TAG, "Performing bus recovery"); - const gpio_num_t scl_pin = static_cast(scl_pin_); - const gpio_num_t sda_pin = static_cast(sda_pin_); + const auto scl_pin = static_cast(scl_pin_); + const auto sda_pin = static_cast(sda_pin_); // For the upcoming operations, target for a 60kHz toggle frequency. // 1000kHz is the maximum frequency for I2C running in standard-mode, @@ -545,5 +299,4 @@ void IDFI2CBus::recover_() { } // namespace i2c } // namespace esphome - #endif // USE_ESP_IDF diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index 4e8f86fd0c..f565be4535 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -2,14 +2,9 @@ #ifdef USE_ESP_IDF -#include "esp_idf_version.h" #include "esphome/core/component.h" #include "i2c_bus.h" -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) #include -#else -#include -#endif namespace esphome { namespace i2c { @@ -24,36 +19,33 @@ class IDFI2CBus : public InternalI2CBus, public Component { public: void setup() override; void dump_config() override; - ErrorCode readv(uint8_t address, ReadBuffer *buffers, size_t cnt) override; - ErrorCode writev(uint8_t address, WriteBuffer *buffers, size_t cnt, bool stop) override; + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override; float get_setup_priority() const override { return setup_priority::BUS; } - void set_scan(bool scan) { scan_ = scan; } - void set_sda_pin(uint8_t sda_pin) { sda_pin_ = sda_pin; } - void set_sda_pullup_enabled(bool sda_pullup_enabled) { sda_pullup_enabled_ = sda_pullup_enabled; } - void set_scl_pin(uint8_t scl_pin) { scl_pin_ = scl_pin; } - void set_scl_pullup_enabled(bool scl_pullup_enabled) { scl_pullup_enabled_ = scl_pullup_enabled; } - void set_frequency(uint32_t frequency) { frequency_ = frequency; } - void set_timeout(uint32_t timeout) { timeout_ = timeout; } + void set_scan(bool scan) { this->scan_ = scan; } + void set_sda_pin(uint8_t sda_pin) { this->sda_pin_ = sda_pin; } + void set_sda_pullup_enabled(bool sda_pullup_enabled) { this->sda_pullup_enabled_ = sda_pullup_enabled; } + void set_scl_pin(uint8_t scl_pin) { this->scl_pin_ = scl_pin; } + void set_scl_pullup_enabled(bool scl_pullup_enabled) { this->scl_pullup_enabled_ = scl_pullup_enabled; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } + void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } - int get_port() const override { return static_cast(this->port_); } + int get_port() const override { return this->port_; } private: void recover_(); - RecoveryCode recovery_result_; + RecoveryCode recovery_result_{}; protected: -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) - i2c_master_dev_handle_t dev_; - i2c_master_bus_handle_t bus_; - void i2c_scan() override; -#endif - i2c_port_t port_; - uint8_t sda_pin_; - bool sda_pullup_enabled_; - uint8_t scl_pin_; - bool scl_pullup_enabled_; - uint32_t frequency_; + i2c_master_dev_handle_t dev_{}; + i2c_master_bus_handle_t bus_{}; + i2c_port_t port_{}; + uint8_t sda_pin_{}; + bool sda_pullup_enabled_{}; + uint8_t scl_pin_{}; + bool scl_pullup_enabled_{}; + uint32_t frequency_{}; uint32_t timeout_ = 0; bool initialized_ = false; }; diff --git a/esphome/components/iaqcore/iaqcore.cpp b/esphome/components/iaqcore/iaqcore.cpp index 2a84eabf75..274f9086b6 100644 --- a/esphome/components/iaqcore/iaqcore.cpp +++ b/esphome/components/iaqcore/iaqcore.cpp @@ -35,7 +35,7 @@ void IAQCore::setup() { void IAQCore::update() { uint8_t buffer[sizeof(SensorData)]; - if (this->read_register(0xB5, buffer, sizeof(buffer), false) != i2c::ERROR_OK) { + if (this->read_register(0xB5, buffer, sizeof(buffer)) != i2c::ERROR_OK) { ESP_LOGD(TAG, "Read failed"); this->status_set_warning(); this->publish_nans_(); diff --git a/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp b/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp index d28525635d..a363a9c12f 100644 --- a/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp +++ b/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp @@ -21,7 +21,7 @@ void INA2XXI2C::dump_config() { } bool INA2XXI2C::read_ina_register(uint8_t reg, uint8_t *data, size_t len) { - auto ret = this->read_register(reg, data, len, false); + auto ret = this->read_register(reg, data, len); if (ret != i2c::ERROR_OK) { ESP_LOGE(TAG, "read_ina_register_ failed. Reg=0x%02X Err=%d", reg, ret); } diff --git a/esphome/components/kmeteriso/kmeteriso.cpp b/esphome/components/kmeteriso/kmeteriso.cpp index 3aedac3f5f..d20e07460b 100644 --- a/esphome/components/kmeteriso/kmeteriso.cpp +++ b/esphome/components/kmeteriso/kmeteriso.cpp @@ -22,7 +22,7 @@ void KMeterISOComponent::setup() { this->reset_to_construction_state(); } - auto err = this->bus_->writev(this->address_, nullptr, 0); + auto err = this->bus_->write_readv(this->address_, nullptr, 0, nullptr, 0); if (err == esphome::i2c::ERROR_OK) { ESP_LOGCONFIG(TAG, "Could write to the address %d.", this->address_); } else { @@ -33,7 +33,7 @@ void KMeterISOComponent::setup() { } uint8_t read_buf[4] = {1}; - if (!this->read_bytes(KMETER_ERROR_STATUS_REG, read_buf, 1)) { + if (!this->read_register(KMETER_ERROR_STATUS_REG, read_buf, 1)) { ESP_LOGCONFIG(TAG, "Could not read from the device."); this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(); diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index f711cb4f0e..7e6ac878f8 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -185,7 +185,7 @@ uint8_t Lc709203f::get_register_(uint8_t register_to_read, uint16_t *register_va // function will send a stop between the read and the write portion of the I2C // transaction. This is bad in this case and will result in reading nothing but 0xFFFF // from the registers. - return_code = this->read_register(register_to_read, &read_buffer[3], 3, false); + return_code = this->read_register(register_to_read, &read_buffer[3], 3); if (return_code != i2c::NO_ERROR) { // Error on the i2c bus this->status_set_warning( @@ -226,7 +226,7 @@ uint8_t Lc709203f::set_register_(uint8_t register_to_set, uint16_t value_to_set) for (uint8_t i = 0; i <= LC709203F_I2C_RETRY_COUNT; i++) { // Note: we don't write the first byte of the write buffer to the device. // This is done automatically by the write() function. - return_code = this->write(&write_buffer[1], 4, true); + return_code = this->write(&write_buffer[1], 4); if (return_code == i2c::NO_ERROR) { return return_code; } else { diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 6634c5057e..55ce9b7899 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -328,7 +328,7 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Increasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::INCREMENT); - auto err = this->write(&this->address_, reg, sizeof(reg)); + auto err = this->write(&this->address_, reg); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); @@ -359,7 +359,7 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Decreasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::DECREMENT); - auto err = this->write(&this->address_, reg, sizeof(reg)); + auto err = this->write(&this->address_, reg); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index 24024df090..8e53b9e3c3 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -75,18 +75,18 @@ float MLX90614Component::get_setup_priority() const { return setup_priority::DAT void MLX90614Component::update() { uint8_t emissivity[3]; - if (this->read_register(MLX90614_EMISSIVITY, emissivity, 3, false) != i2c::ERROR_OK) { + if (this->read_register(MLX90614_EMISSIVITY, emissivity, 3) != i2c::ERROR_OK) { this->status_set_warning(); return; } uint8_t raw_object[3]; - if (this->read_register(MLX90614_TEMPERATURE_OBJECT_1, raw_object, 3, false) != i2c::ERROR_OK) { + if (this->read_register(MLX90614_TEMPERATURE_OBJECT_1, raw_object, 3) != i2c::ERROR_OK) { this->status_set_warning(); return; } uint8_t raw_ambient[3]; - if (this->read_register(MLX90614_TEMPERATURE_AMBIENT, raw_ambient, 3, false) != i2c::ERROR_OK) { + if (this->read_register(MLX90614_TEMPERATURE_AMBIENT, raw_ambient, 3) != i2c::ERROR_OK) { this->status_set_warning(); return; } diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index 9e8467a29b..a689149c89 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "mpl3115a2"; void MPL3115A2Component::setup() { uint8_t whoami = 0xFF; - if (!this->read_byte(MPL3115A2_WHOAMI, &whoami, false)) { + if (!this->read_byte(MPL3115A2_WHOAMI, &whoami)) { this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(); return; @@ -54,24 +54,24 @@ void MPL3115A2Component::dump_config() { void MPL3115A2Component::update() { uint8_t mode = MPL3115A2_CTRL_REG1_OS128; - this->write_byte(MPL3115A2_CTRL_REG1, mode, true); + this->write_byte(MPL3115A2_CTRL_REG1, mode); // Trigger a new reading mode |= MPL3115A2_CTRL_REG1_OST; if (this->altitude_ != nullptr) mode |= MPL3115A2_CTRL_REG1_ALT; - this->write_byte(MPL3115A2_CTRL_REG1, mode, true); + this->write_byte(MPL3115A2_CTRL_REG1, mode); // Wait until status shows reading available uint8_t status = 0; - if (!this->read_byte(MPL3115A2_REGISTER_STATUS, &status, false) || (status & MPL3115A2_REGISTER_STATUS_PDR) == 0) { + if (!this->read_byte(MPL3115A2_REGISTER_STATUS, &status) || (status & MPL3115A2_REGISTER_STATUS_PDR) == 0) { delay(10); - if (!this->read_byte(MPL3115A2_REGISTER_STATUS, &status, false) || (status & MPL3115A2_REGISTER_STATUS_PDR) == 0) { + if (!this->read_byte(MPL3115A2_REGISTER_STATUS, &status) || (status & MPL3115A2_REGISTER_STATUS_PDR) == 0) { return; } } uint8_t buffer[5] = {0, 0, 0, 0, 0}; - this->read_register(MPL3115A2_REGISTER_PRESSURE_MSB, buffer, 5, false); + this->read_register(MPL3115A2_REGISTER_PRESSURE_MSB, buffer, 5); float altitude = 0, pressure = 0; if (this->altitude_ != nullptr) { diff --git a/esphome/components/npi19/npi19.cpp b/esphome/components/npi19/npi19.cpp index e8c4e8abd5..c531d2ec8f 100644 --- a/esphome/components/npi19/npi19.cpp +++ b/esphome/components/npi19/npi19.cpp @@ -33,7 +33,7 @@ float NPI19Component::get_setup_priority() const { return setup_priority::DATA; i2c::ErrorCode NPI19Component::read_(uint16_t &raw_temperature, uint16_t &raw_pressure) { // initiate data read from device - i2c::ErrorCode w_err = write(&READ_COMMAND, sizeof(READ_COMMAND), true); + i2c::ErrorCode w_err = write(&READ_COMMAND, sizeof(READ_COMMAND)); if (w_err != i2c::ERROR_OK) { return w_err; } diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index 2d65f1090d..f5f7ab9412 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -72,7 +72,7 @@ void OPT3001Sensor::read_lx_(const std::function &f) { } this->set_timeout("read", OPT3001_CONVERSION_TIME_800, [this, f]() { - if (this->write(&OPT3001_REG_CONFIGURATION, 1, true) != i2c::ERROR_OK) { + if (this->write(&OPT3001_REG_CONFIGURATION, 1) != i2c::ERROR_OK) { ESP_LOGW(TAG, "Starting configuration register read failed"); f(NAN); return; diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index dc8662d1a2..730c494e34 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -33,7 +33,7 @@ void PCA6416AComponent::setup() { } // Test to see if the device supports pull-up resistors - if (this->read_register(PCAL6416A_PULL_EN0, &value, 1, true) == i2c::ERROR_OK) { + if (this->read_register(PCAL6416A_PULL_EN0, &value, 1) == i2c::ERROR_OK) { this->has_pullup_ = true; } @@ -105,7 +105,7 @@ bool PCA6416AComponent::read_register_(uint8_t reg, uint8_t *value) { return false; } - this->last_error_ = this->read_register(reg, value, 1, true); + this->last_error_ = this->read_register(reg, value, 1); if (this->last_error_ != i2c::ERROR_OK) { this->status_set_warning(); ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_); @@ -122,7 +122,7 @@ bool PCA6416AComponent::write_register_(uint8_t reg, uint8_t value) { return false; } - this->last_error_ = this->write_register(reg, &value, 1, true); + this->last_error_ = this->write_register(reg, &value, 1); if (this->last_error_ != i2c::ERROR_OK) { this->status_set_warning(); ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_); diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index f77d680bec..1166cc1a09 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -96,7 +96,7 @@ bool PCA9554Component::read_inputs_() { return false; } - this->last_error_ = this->read_register(INPUT_REG * this->reg_width_, inputs, this->reg_width_, true); + this->last_error_ = this->read_register(INPUT_REG * this->reg_width_, inputs, this->reg_width_); if (this->last_error_ != i2c::ERROR_OK) { this->status_set_warning(); ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_); @@ -114,7 +114,7 @@ bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) { uint8_t outputs[2]; outputs[0] = (uint8_t) value; outputs[1] = (uint8_t) (value >> 8); - this->last_error_ = this->write_register(reg * this->reg_width_, outputs, this->reg_width_, true); + this->last_error_ = this->write_register(reg * this->reg_width_, outputs, this->reg_width_); if (this->last_error_ != i2c::ERROR_OK) { this->status_set_warning(); ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_); diff --git a/esphome/components/st7567_i2c/st7567_i2c.cpp b/esphome/components/st7567_i2c/st7567_i2c.cpp index 4970367343..710e473b11 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.cpp +++ b/esphome/components/st7567_i2c/st7567_i2c.cpp @@ -51,8 +51,7 @@ void HOT I2CST7567::write_display_data() { static const size_t BLOCK_SIZE = 64; for (uint8_t x = 0; x < (uint8_t) this->get_width_internal(); x += BLOCK_SIZE) { this->write_register(esphome::st7567_base::ST7567_SET_START_LINE, &buffer_[y * this->get_width_internal() + x], - this->get_width_internal() - x > BLOCK_SIZE ? BLOCK_SIZE : this->get_width_internal() - x, - true); + this->get_width_internal() - x > BLOCK_SIZE ? BLOCK_SIZE : this->get_width_internal() - x); } } } diff --git a/esphome/components/tca9548a/tca9548a.cpp b/esphome/components/tca9548a/tca9548a.cpp index edd8af9a27..1de3c49108 100644 --- a/esphome/components/tca9548a/tca9548a.cpp +++ b/esphome/components/tca9548a/tca9548a.cpp @@ -6,23 +6,15 @@ namespace tca9548a { static const char *const TAG = "tca9548a"; -i2c::ErrorCode TCA9548AChannel::readv(uint8_t address, i2c::ReadBuffer *buffers, size_t cnt) { +i2c::ErrorCode TCA9548AChannel::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count) { auto err = this->parent_->switch_to_channel(channel_); if (err != i2c::ERROR_OK) return err; - err = this->parent_->bus_->readv(address, buffers, cnt); + err = this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); this->parent_->disable_all_channels(); return err; } -i2c::ErrorCode TCA9548AChannel::writev(uint8_t address, i2c::WriteBuffer *buffers, size_t cnt, bool stop) { - auto err = this->parent_->switch_to_channel(channel_); - if (err != i2c::ERROR_OK) - return err; - err = this->parent_->bus_->writev(address, buffers, cnt, stop); - this->parent_->disable_all_channels(); - return err; -} - void TCA9548AComponent::setup() { uint8_t status = 0; if (this->read(&status, 1) != i2c::ERROR_OK) { diff --git a/esphome/components/tca9548a/tca9548a.h b/esphome/components/tca9548a/tca9548a.h index 08f1674d11..0fb9ada99a 100644 --- a/esphome/components/tca9548a/tca9548a.h +++ b/esphome/components/tca9548a/tca9548a.h @@ -14,8 +14,8 @@ class TCA9548AChannel : public i2c::I2CBus { void set_channel(uint8_t channel) { channel_ = channel; } void set_parent(TCA9548AComponent *parent) { parent_ = parent; } - i2c::ErrorCode readv(uint8_t address, i2c::ReadBuffer *buffers, size_t cnt) override; - i2c::ErrorCode writev(uint8_t address, i2c::WriteBuffer *buffers, size_t cnt, bool stop) override; + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override; protected: uint8_t channel_; diff --git a/esphome/components/tee501/tee501.cpp b/esphome/components/tee501/tee501.cpp index dc803be775..d6513dbbe0 100644 --- a/esphome/components/tee501/tee501.cpp +++ b/esphome/components/tee501/tee501.cpp @@ -9,9 +9,9 @@ static const char *const TAG = "tee501"; void TEE501Component::setup() { uint8_t address[] = {0x70, 0x29}; - this->write(address, 2, false); uint8_t identification[9]; this->read(identification, 9); + this->write_read(address, sizeof address, identification, sizeof identification); if (identification[8] != crc8(identification, 8, 0xFF, 0x31, true)) { this->error_code_ = CRC_CHECK_FAILED; this->mark_failed(); @@ -41,7 +41,7 @@ void TEE501Component::dump_config() { float TEE501Component::get_setup_priority() const { return setup_priority::DATA; } void TEE501Component::update() { uint8_t address_1[] = {0x2C, 0x1B}; - this->write(address_1, 2, true); + this->write(address_1, 2); this->set_timeout(50, [this]() { uint8_t i2c_response[3]; this->read(i2c_response, 3); diff --git a/esphome/components/tlc59208f/tlc59208f_output.cpp b/esphome/components/tlc59208f/tlc59208f_output.cpp index a524f92f75..85311a877c 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.cpp +++ b/esphome/components/tlc59208f/tlc59208f_output.cpp @@ -74,7 +74,8 @@ void TLC59208FOutput::setup() { ESP_LOGV(TAG, " Resetting all devices on the bus"); // Reset all devices on the bus - if (this->bus_->write(TLC59208F_SWRST_ADDR >> 1, TLC59208F_SWRST_SEQ, 2) != i2c::ERROR_OK) { + if (this->bus_->write_readv(TLC59208F_SWRST_ADDR >> 1, TLC59208F_SWRST_SEQ, sizeof TLC59208F_SWRST_SEQ, nullptr, 0) != + i2c::ERROR_OK) { ESP_LOGE(TAG, "RESET failed"); this->mark_failed(); return; diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index f3016fb171..1e02e3e802 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -14,14 +14,12 @@ void VEML3235Sensor::setup() { this->mark_failed(); return; } - if ((this->write(&ID_REG, 1, false) != i2c::ERROR_OK) || !this->read_bytes_raw(device_id, 2)) { + if ((this->read_register(ID_REG, device_id, sizeof device_id) != i2c::ERROR_OK)) { ESP_LOGE(TAG, "Unable to read ID"); this->mark_failed(); - return; } else if (device_id[0] != DEVICE_ID) { ESP_LOGE(TAG, "Incorrect device ID - expected 0x%.2x, read 0x%.2x", DEVICE_ID, device_id[0]); this->mark_failed(); - return; } } @@ -49,7 +47,7 @@ float VEML3235Sensor::read_lx_() { } uint8_t als_regs[] = {0, 0}; - if ((this->write(&ALS_REG, 1, false) != i2c::ERROR_OK) || !this->read_bytes_raw(als_regs, 2)) { + if ((this->read_register(ALS_REG, als_regs, sizeof als_regs) != i2c::ERROR_OK)) { this->status_set_warning(); return NAN; } diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 2a4c246ac9..c3b601e288 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -279,20 +279,18 @@ ErrorCode VEML7700Component::reconfigure_time_and_gain_(IntegrationTime time, Ga } ErrorCode VEML7700Component::read_sensor_output_(Readings &data) { - auto als_err = - this->read_register((uint8_t) CommandRegisters::ALS, (uint8_t *) &data.als_counts, VEML_REG_SIZE, false); + auto als_err = this->read_register((uint8_t) CommandRegisters::ALS, (uint8_t *) &data.als_counts, VEML_REG_SIZE); if (als_err != i2c::ERROR_OK) { ESP_LOGW(TAG, "Error reading ALS register, err = %d", als_err); } auto white_err = - this->read_register((uint8_t) CommandRegisters::WHITE, (uint8_t *) &data.white_counts, VEML_REG_SIZE, false); + this->read_register((uint8_t) CommandRegisters::WHITE, (uint8_t *) &data.white_counts, VEML_REG_SIZE); if (white_err != i2c::ERROR_OK) { ESP_LOGW(TAG, "Error reading WHITE register, err = %d", white_err); } ConfigurationRegister conf{0}; - auto err = - this->read_register((uint8_t) CommandRegisters::ALS_CONF_0, (uint8_t *) conf.raw_bytes, VEML_REG_SIZE, false); + auto err = this->read_register((uint8_t) CommandRegisters::ALS_CONF_0, (uint8_t *) conf.raw_bytes, VEML_REG_SIZE); if (err != i2c::ERROR_OK) { ESP_LOGW(TAG, "Error reading ALS_CONF_0 register, err = %d", white_err); } diff --git a/esphome/components/veml7700/veml7700.h b/esphome/components/veml7700/veml7700.h index b0d1451cf0..4b5edf733d 100644 --- a/esphome/components/veml7700/veml7700.h +++ b/esphome/components/veml7700/veml7700.h @@ -3,7 +3,6 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -#include "esphome/core/optional.h" namespace esphome { namespace veml7700 { From e5d1c3079766bcbc14031b94ab5677a78b186258 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 28 Aug 2025 07:16:26 +1000 Subject: [PATCH 0738/1626] [wifi] Fix retry with hidden networks. (#10445) --- esphome/components/wifi/wifi_component.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 987e276e0c..d16c94fa13 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -151,6 +151,8 @@ void WiFiComponent::loop() { this->status_set_warning("waiting to reconnect"); if (millis() - this->action_started_ > 5000) { if (this->fast_connect_ || this->retry_hidden_) { + if (!this->selected_ap_.get_bssid().has_value()) + this->selected_ap_ = this->sta_[0]; this->start_connecting(this->selected_ap_, false); } else { this->start_scanning(); @@ -670,10 +672,12 @@ void WiFiComponent::check_connecting_finished() { return; } + ESP_LOGI(TAG, "Connected"); // We won't retry hidden networks unless a reconnect fails more than three times again + if (this->retry_hidden_ && !this->selected_ap_.get_hidden()) + ESP_LOGW(TAG, "Network '%s' should be marked as hidden", this->selected_ap_.get_ssid().c_str()); this->retry_hidden_ = false; - ESP_LOGI(TAG, "Connected"); this->print_connect_params_(); if (this->has_ap()) { From 3c7aba06813dd3a3ef85e177167b61f4e5a90390 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Aug 2025 23:23:43 +0200 Subject: [PATCH 0739/1626] Fix AttributeError when uploading OTA to offline OpenThread devices (#10459) --- esphome/__main__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 8e8fc7d5d9..aab3035a5e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -132,14 +132,17 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": - if (show_ota and "ota" in CORE.config) or ( - show_api and "api" in CORE.config + if CORE.address and ( + (show_ota and "ota" in CORE.config) + or (show_api and "api" in CORE.config) ): resolved.append(CORE.address) elif show_mqtt and has_mqtt_logging(): resolved.append("MQTT") else: resolved.append(device) + if not resolved: + _LOGGER.error("All specified devices: %s could not be resolved.", defaults) return resolved # No devices specified, show interactive chooser From 75595b08be2d58b3c030ec1a1b2508b48845a278 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Wed, 27 Aug 2025 21:53:57 -0400 Subject: [PATCH 0740/1626] [rtttl] Fix RTTTL for speakers (#10381) --- esphome/components/rtttl/rtttl.cpp | 63 +++++++++++++++++------------- esphome/components/rtttl/rtttl.h | 25 ++++++++++++ 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 65a3af1bbc..5aedc74489 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -138,11 +138,37 @@ void Rtttl::stop() { this->set_state_(STATE_STOPPING); } #endif + this->position_ = this->rtttl_.length(); + this->note_duration_ = 0; +} + +void Rtttl::finish_() { + ESP_LOGV(TAG, "Rtttl::finish_()"); +#ifdef USE_OUTPUT + if (this->output_ != nullptr) { + this->output_->set_level(0.0); + this->set_state_(State::STATE_STOPPED); + } +#endif +#ifdef USE_SPEAKER + if (this->speaker_ != nullptr) { + SpeakerSample sample[2]; + sample[0].left = 0; + sample[0].right = 0; + sample[1].left = 0; + sample[1].right = 0; + this->speaker_->play((uint8_t *) (&sample), 8); + this->speaker_->finish(); + this->set_state_(State::STATE_STOPPING); + } +#endif + // Ensure no more notes are played in case finish_() is called for an error. + this->position_ = this->rtttl_.length(); this->note_duration_ = 0; } void Rtttl::loop() { - if (this->note_duration_ == 0 || this->state_ == State::STATE_STOPPED) { + if (this->state_ == State::STATE_STOPPED) { this->disable_loop(); return; } @@ -152,6 +178,8 @@ void Rtttl::loop() { if (this->state_ == State::STATE_STOPPING) { if (this->speaker_->is_stopped()) { this->set_state_(State::STATE_STOPPED); + } else { + return; } } else if (this->state_ == State::STATE_INIT) { if (this->speaker_->is_stopped()) { @@ -207,7 +235,7 @@ void Rtttl::loop() { if (this->output_ != nullptr && millis() - this->last_note_ < this->note_duration_) return; #endif - if (!this->rtttl_[this->position_]) { + if (this->position_ >= this->rtttl_.length()) { this->finish_(); return; } @@ -346,31 +374,6 @@ void Rtttl::loop() { this->last_note_ = millis(); } -void Rtttl::finish_() { -#ifdef USE_OUTPUT - if (this->output_ != nullptr) { - this->output_->set_level(0.0); - this->set_state_(State::STATE_STOPPED); - } -#endif -#ifdef USE_SPEAKER - if (this->speaker_ != nullptr) { - SpeakerSample sample[2]; - sample[0].left = 0; - sample[0].right = 0; - sample[1].left = 0; - sample[1].right = 0; - this->speaker_->play((uint8_t *) (&sample), 8); - - this->speaker_->finish(); - this->set_state_(State::STATE_STOPPING); - } -#endif - this->note_duration_ = 0; - this->on_finished_playback_callback_.call(); - ESP_LOGD(TAG, "Playback finished"); -} - #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG static const LogString *state_to_string(State state) { switch (state) { @@ -397,7 +400,11 @@ void Rtttl::set_state_(State state) { LOG_STR_ARG(state_to_string(state))); // Clear loop_done when transitioning from STOPPED to any other state - if (old_state == State::STATE_STOPPED && state != State::STATE_STOPPED) { + if (state == State::STATE_STOPPED) { + this->disable_loop(); + this->on_finished_playback_callback_.call(); + ESP_LOGD(TAG, "Playback finished"); + } else if (old_state == State::STATE_STOPPED) { this->enable_loop(); } } diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 420948bfbf..d536c6c08e 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -60,35 +60,60 @@ class Rtttl : public Component { } return ret; } + /** + * @brief Finalizes the playback of the RTTTL string. + * + * This method is called internally when the end of the RTTTL string is reached + * or when a parsing error occurs. It stops the output, sets the component state, + * and triggers the on_finished_playback_callback_. + */ void finish_(); void set_state_(State state); + /// The RTTTL string to play. std::string rtttl_{""}; + /// The current position in the RTTTL string. size_t position_{0}; + /// The duration of a whole note in milliseconds. uint16_t wholenote_; + /// The default duration of a note (e.g. 4 for a quarter note). uint16_t default_duration_; + /// The default octave for a note. uint16_t default_octave_; + /// The time the last note was started. uint32_t last_note_; + /// The duration of the current note in milliseconds. uint16_t note_duration_; + /// The frequency of the current note in Hz. uint32_t output_freq_; + /// The gain of the output. float gain_{0.6f}; + /// The current state of the RTTTL player. State state_{State::STATE_STOPPED}; #ifdef USE_OUTPUT + /// The output to write the sound to. output::FloatOutput *output_; #endif #ifdef USE_SPEAKER + /// The speaker to write the sound to. speaker::Speaker *speaker_{nullptr}; + /// The sample rate of the speaker. int sample_rate_{16000}; + /// The number of samples for one full cycle of a note's waveform, in Q10 fixed-point format. int samples_per_wave_{0}; + /// The number of samples sent. int samples_sent_{0}; + /// The total number of samples to send. int samples_count_{0}; + /// The number of samples for the gap between notes. int samples_gap_{0}; #endif + /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; }; From 49300275575edb2b3ea9e0ab7be877c15551d458 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Aug 2025 13:11:58 -0500 Subject: [PATCH 0741/1626] [api] Fix string lifetime issue in fill_and_encode_entity_info for dynamic object_id --- esphome/components/api/api_connection.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 6254854238..72254d1536 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -303,11 +303,13 @@ class APIConnection final : public APIServerConnection { msg.key = entity->get_object_id_hash(); // Try to use static reference first to avoid allocation StringRef static_ref = entity->get_object_id_ref_for_api_(); + // Store dynamic string outside the if-else to maintain lifetime + std::string object_id; if (!static_ref.empty()) { msg.set_object_id(static_ref); } else { // Dynamic case - need to allocate - std::string object_id = entity->get_object_id(); + object_id = entity->get_object_id(); msg.set_object_id(StringRef(object_id)); } From a92a08c2de7736369f533679b0685e07ad59d990 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Aug 2025 13:40:36 -0500 Subject: [PATCH 0742/1626] [api] Fix string lifetime issue in fill_and_encode_entity_info for dynamic object_id (#10482) --- esphome/components/api/api_connection.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 6254854238..72254d1536 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -303,11 +303,13 @@ class APIConnection final : public APIServerConnection { msg.key = entity->get_object_id_hash(); // Try to use static reference first to avoid allocation StringRef static_ref = entity->get_object_id_ref_for_api_(); + // Store dynamic string outside the if-else to maintain lifetime + std::string object_id; if (!static_ref.empty()) { msg.set_object_id(static_ref); } else { // Dynamic case - need to allocate - std::string object_id = entity->get_object_id(); + object_id = entity->get_object_id(); msg.set_object_id(StringRef(object_id)); } From 2f2f2f7d15b78cf9ed96fc9078dc3e2c9c2fb82e Mon Sep 17 00:00:00 2001 From: DAVe3283 Date: Thu, 28 Aug 2025 16:04:19 -0600 Subject: [PATCH 0743/1626] [absolute_humidity] Fix typo (#10474) --- .../components/absolute_humidity/absolute_humidity.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/esphome/components/absolute_humidity/absolute_humidity.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp index b8717ac5f1..7ba3c5a1ab 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.cpp +++ b/esphome/components/absolute_humidity/absolute_humidity.cpp @@ -61,11 +61,10 @@ void AbsoluteHumidityComponent::loop() { ESP_LOGW(TAG, "No valid state from temperature sensor!"); } if (no_humidity) { - ESP_LOGW(TAG, "No valid state from temperature sensor!"); + ESP_LOGW(TAG, "No valid state from humidity sensor!"); } - ESP_LOGW(TAG, "Unable to calculate absolute humidity."); this->publish_state(NAN); - this->status_set_warning(); + this->status_set_warning("Unable to calculate absolute humidity."); return; } @@ -87,9 +86,8 @@ void AbsoluteHumidityComponent::loop() { es = es_wobus(temperature_c); break; default: - ESP_LOGE(TAG, "Invalid saturation vapor pressure equation selection!"); this->publish_state(NAN); - this->status_set_error(); + this->status_set_error("Invalid saturation vapor pressure equation selection!"); return; } ESP_LOGD(TAG, "Saturation vapor pressure %f kPa", es); From d4c11dac8c19e78be78efc3dd63096c07d39a9d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Aug 2025 17:12:38 -0500 Subject: [PATCH 0744/1626] [esphome] Fix OTA watchdog resets by validating all magic bytes before blocking (#10401) --- .../components/esphome/ota/ota_esphome.cpp | 77 ++++++++++--------- esphome/components/esphome/ota/ota_esphome.h | 8 +- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 5217e9c61f..fc10e5366e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -100,8 +100,8 @@ void ESPHomeOTAComponent::handle_handshake_() { /// Handle the initial OTA handshake. /// /// This method is non-blocking and will return immediately if no data is available. - /// It waits for the first magic byte (0x6C) before proceeding to handle_data_(). - /// A 10-second timeout is enforced from initial connection. + /// It reads all 5 magic bytes (0x6C, 0x26, 0xF7, 0x5C, 0x45) non-blocking + /// before proceeding to handle_data_(). A 10-second timeout is enforced from initial connection. if (this->client_ == nullptr) { // We already checked server_->ready() in loop(), so we can accept directly @@ -126,6 +126,7 @@ void ESPHomeOTAComponent::handle_handshake_() { } this->log_start_("handshake"); this->client_connect_time_ = App.get_loop_component_start_time(); + this->magic_buf_pos_ = 0; // Reset magic buffer position } // Check for handshake timeout @@ -136,34 +137,47 @@ void ESPHomeOTAComponent::handle_handshake_() { return; } - // Try to read first byte of magic bytes - uint8_t first_byte; - ssize_t read = this->client_->read(&first_byte, 1); + // Try to read remaining magic bytes + if (this->magic_buf_pos_ < 5) { + // Read as many bytes as available + uint8_t bytes_to_read = 5 - this->magic_buf_pos_; + ssize_t read = this->client_->read(this->magic_buf_ + this->magic_buf_pos_, bytes_to_read); - if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { - return; // No data yet, try again next loop - } - - if (read <= 0) { - // Error or connection closed - if (read == -1) { - this->log_socket_error_("reading first byte"); - } else { - ESP_LOGW(TAG, "Remote closed during handshake"); + if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + return; // No data yet, try again next loop } - this->cleanup_connection_(); - return; + + if (read <= 0) { + // Error or connection closed + if (read == -1) { + this->log_socket_error_("reading magic bytes"); + } else { + ESP_LOGW(TAG, "Remote closed during handshake"); + } + this->cleanup_connection_(); + return; + } + + this->magic_buf_pos_ += read; } - // Got first byte, check if it's the magic byte - if (first_byte != 0x6C) { - ESP_LOGW(TAG, "Invalid initial byte: 0x%02X", first_byte); - this->cleanup_connection_(); - return; - } + // Check if we have all 5 magic bytes + if (this->magic_buf_pos_ == 5) { + // Validate magic bytes + static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; + if (memcmp(this->magic_buf_, MAGIC_BYTES, 5) != 0) { + ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->magic_buf_[0], + this->magic_buf_[1], this->magic_buf_[2], this->magic_buf_[3], this->magic_buf_[4]); + // Send error response (non-blocking, best effort) + uint8_t error = static_cast(ota::OTA_RESPONSE_ERROR_MAGIC); + this->client_->write(&error, 1); + this->cleanup_connection_(); + return; + } - // First byte is valid, continue with data handling - this->handle_data_(); + // All 5 magic bytes are valid, continue with data handling + this->handle_data_(); + } } void ESPHomeOTAComponent::handle_data_() { @@ -186,18 +200,6 @@ void ESPHomeOTAComponent::handle_data_() { size_t size_acknowledged = 0; #endif - // Read remaining 4 bytes of magic (we already read the first byte 0x6C in handle_handshake_) - if (!this->readall_(buf, 4)) { - this->log_read_error_("magic bytes"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - // Check remaining magic bytes: 0x26, 0xF7, 0x5C, 0x45 - if (buf[0] != 0x26 || buf[1] != 0xF7 || buf[2] != 0x5C || buf[3] != 0x45) { - ESP_LOGW(TAG, "Magic bytes mismatch! 0x6C-0x%02X-0x%02X-0x%02X-0x%02X", buf[0], buf[1], buf[2], buf[3]); - error_code = ota::OTA_RESPONSE_ERROR_MAGIC; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - // Send OK and version - 2 bytes buf[0] = ota::OTA_RESPONSE_OK; buf[1] = USE_OTA_VERSION; @@ -487,6 +489,7 @@ void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; this->client_connect_time_ = 0; + this->magic_buf_pos_ = 0; } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 8397b86528..c1919c71e9 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -41,11 +41,13 @@ class ESPHomeOTAComponent : public ota::OTAComponent { std::string password_; #endif // USE_OTA_PASSWORD - uint16_t port_; - uint32_t client_connect_time_{0}; - std::unique_ptr server_; std::unique_ptr client_; + + uint32_t client_connect_time_{0}; + uint16_t port_; + uint8_t magic_buf_[5]; + uint8_t magic_buf_pos_{0}; }; } // namespace esphome From a7786b75a0670898d189b2cebd2160c69fc0fb64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Aug 2025 17:14:51 -0500 Subject: [PATCH 0745/1626] [esp32_ble_tracker] Remove duplicate client promotion logic (#10321) --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 32 ++----------------- .../esp32_ble_tracker/esp32_ble_tracker.h | 7 +--- 2 files changed, 3 insertions(+), 36 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 0455d136df..00bd1fe34c 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -307,14 +307,7 @@ void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { // Process the scan result immediately - bool found_discovered_client = this->process_scan_result_(scan_result); - - // If we found a discovered client that needs promotion, stop scanning - // This replaces the promote_to_connecting logic from loop() - if (found_discovered_client && this->scanner_state_ == ScannerState::RUNNING) { - ESP_LOGD(TAG, "Found discovered client, stopping scan for connection"); - this->stop_scan_(); - } + this->process_scan_result_(scan_result); } else if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { // Scan finished on its own if (this->scanner_state_ != ScannerState::RUNNING) { @@ -720,20 +713,9 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); } -bool ESP32BLETracker::has_connecting_clients_() const { - for (auto *client : this->clients_) { - auto state = client->state(); - if (state == ClientState::CONNECTING || state == ClientState::READY_TO_CONNECT) { - return true; - } - } - return false; -} #endif // USE_ESP32_BLE_DEVICE -bool ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { - bool found_discovered_client = false; - +void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { // Process raw advertisements if (this->raw_advertisements_) { for (auto *listener : this->listeners_) { @@ -759,14 +741,6 @@ bool ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { for (auto *client : this->clients_) { if (client->parse_device(device)) { found = true; - // Check if this client is discovered and needs promotion - if (client->state() == ClientState::DISCOVERED) { - // Only check for connecting clients if we found a discovered client - // This matches the original logic: !connecting && client->state() == DISCOVERED - if (!this->has_connecting_clients_()) { - found_discovered_client = true; - } - } } } @@ -775,8 +749,6 @@ bool ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { } #endif // USE_ESP32_BLE_DEVICE } - - return found_discovered_client; } void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 3022eb25d2..763fa9f1c6 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -292,12 +292,7 @@ class ESP32BLETracker : public Component, /// Common cleanup logic when transitioning scanner to IDLE state void cleanup_scan_state_(bool is_stop_complete); /// Process a single scan result immediately - /// Returns true if a discovered client needs promotion to READY_TO_CONNECT - bool process_scan_result_(const BLEScanResult &scan_result); -#ifdef USE_ESP32_BLE_DEVICE - /// Check if any clients are in connecting or ready to connect state - bool has_connecting_clients_() const; -#endif + void process_scan_result_(const BLEScanResult &scan_result); /// Handle scanner failure states void handle_scanner_failure_(); /// Try to promote discovered clients to ready to connect From 078eaff9a8430fe717af4bf24ad46b2566e53929 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Aug 2025 06:49:47 +0200 Subject: [PATCH 0746/1626] [wifi] Fix reconnection failures after adapter restart by not clearing netif pointers (#10458) --- esphome/components/wifi/wifi_component_esp32_arduino.cpp | 6 ------ esphome/components/wifi/wifi_component_esp_idf.cpp | 6 ------ 2 files changed, 12 deletions(-) diff --git a/esphome/components/wifi/wifi_component_esp32_arduino.cpp b/esphome/components/wifi/wifi_component_esp32_arduino.cpp index 67b1f565ff..89298e07c7 100644 --- a/esphome/components/wifi/wifi_component_esp32_arduino.cpp +++ b/esphome/components/wifi/wifi_component_esp32_arduino.cpp @@ -547,8 +547,6 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); - // Clear the STA interface handle to prevent use-after-free - s_sta_netif = nullptr; break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { @@ -638,10 +636,6 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_AP_STOP: { ESP_LOGV(TAG, "AP stop"); -#ifdef USE_WIFI_AP - // Clear the AP interface handle to prevent use-after-free - s_ap_netif = nullptr; -#endif break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 94f1f5125f..d465b346b3 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -697,8 +697,6 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) { ESP_LOGV(TAG, "STA stop"); s_sta_started = false; - // Clear the STA interface handle to prevent use-after-free - s_sta_netif = nullptr; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) { const auto &it = data->data.sta_authmode_change; @@ -797,10 +795,6 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); s_ap_started = false; -#ifdef USE_WIFI_AP - // Clear the AP interface handle to prevent use-after-free - s_ap_netif = nullptr; -#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_PROBEREQRECVED) { const auto &it = data->data.ap_probe_req_rx; From b6bb6699d1de62ce302a7b3931e52a39844b6cc7 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 27 Aug 2025 19:30:01 +1000 Subject: [PATCH 0747/1626] [mipi_spi] Fix dimensions (#10443) --- esphome/components/mipi/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index f610f160b0..570a021cff 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -309,8 +309,12 @@ class DriverChip: CONF_NATIVE_HEIGHT, height + offset_height * 2 ) offset_height = native_height - height - offset_height - # Swap default dimensions if swap_xy is set - if transform[CONF_SWAP_XY] is True: + # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer + rotated = not requires_buffer(config) and config.get(CONF_ROTATION, 0) in ( + 90, + 270, + ) + if transform[CONF_SWAP_XY] is True or rotated: width, height = height, width offset_height, offset_width = offset_width, offset_height return width, height, offset_width, offset_height From 4ab37b069babaf53d6aaf06f23db118ce533fc01 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 27 Aug 2025 19:30:33 +1000 Subject: [PATCH 0748/1626] [i2c] Perform register reads as single transactions (#10389) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../touchscreen/axs15231_touchscreen.cpp | 2 +- esphome/components/bmi160/bmi160.cpp | 2 +- .../components/bmp280_base/bmp280_base.cpp | 25 +- esphome/components/bmp280_base/bmp280_base.h | 10 +- esphome/components/bmp280_i2c/bmp280_i2c.cpp | 13 - esphome/components/bmp280_i2c/bmp280_i2c.h | 10 +- esphome/components/bmp280_spi/bmp280_spi.cpp | 8 +- esphome/components/bmp280_spi/bmp280_spi.h | 8 +- esphome/components/ch422g/ch422g.cpp | 4 +- esphome/components/ee895/ee895.cpp | 2 +- esphome/components/hte501/hte501.cpp | 5 +- esphome/components/i2c/__init__.py | 27 +- esphome/components/i2c/i2c.cpp | 64 ++-- esphome/components/i2c/i2c.h | 89 +++-- esphome/components/i2c/i2c_bus.h | 112 +++--- esphome/components/i2c/i2c_bus_arduino.cpp | 93 ++--- esphome/components/i2c/i2c_bus_arduino.h | 4 +- esphome/components/i2c/i2c_bus_esp_idf.cpp | 357 +++--------------- esphome/components/i2c/i2c_bus_esp_idf.h | 46 +-- esphome/components/iaqcore/iaqcore.cpp | 2 +- esphome/components/ina2xx_i2c/ina2xx_i2c.cpp | 2 +- esphome/components/kmeteriso/kmeteriso.cpp | 4 +- esphome/components/lc709203f/lc709203f.cpp | 4 +- esphome/components/mcp4461/mcp4461.cpp | 4 +- esphome/components/mlx90614/mlx90614.cpp | 6 +- esphome/components/mpl3115a2/mpl3115a2.cpp | 12 +- esphome/components/npi19/npi19.cpp | 2 +- esphome/components/opt3001/opt3001.cpp | 2 +- esphome/components/pca6416a/pca6416a.cpp | 6 +- esphome/components/pca9554/pca9554.cpp | 4 +- esphome/components/st7567_i2c/st7567_i2c.cpp | 3 +- esphome/components/tca9548a/tca9548a.cpp | 14 +- esphome/components/tca9548a/tca9548a.h | 4 +- esphome/components/tee501/tee501.cpp | 4 +- .../components/tlc59208f/tlc59208f_output.cpp | 3 +- esphome/components/veml3235/veml3235.cpp | 6 +- esphome/components/veml7700/veml7700.cpp | 8 +- esphome/components/veml7700/veml7700.h | 1 - 38 files changed, 329 insertions(+), 643 deletions(-) diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp index 4adf0bbbe0..6304516164 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp @@ -41,7 +41,7 @@ void AXS15231Touchscreen::update_touches() { i2c::ErrorCode err; uint8_t data[8]{}; - err = this->write(AXS_READ_TOUCHPAD, sizeof(AXS_READ_TOUCHPAD), false); + err = this->write(AXS_READ_TOUCHPAD, sizeof(AXS_READ_TOUCHPAD)); ERROR_CHECK(err); err = this->read(data, sizeof(data)); ERROR_CHECK(err); diff --git a/esphome/components/bmi160/bmi160.cpp b/esphome/components/bmi160/bmi160.cpp index b041c7c2dc..4fcc3edb82 100644 --- a/esphome/components/bmi160/bmi160.cpp +++ b/esphome/components/bmi160/bmi160.cpp @@ -203,7 +203,7 @@ void BMI160Component::dump_config() { i2c::ErrorCode BMI160Component::read_le_int16_(uint8_t reg, int16_t *value, uint8_t len) { uint8_t raw_data[len * 2]; // read using read_register because we have little-endian data, and read_bytes_16 will swap it - i2c::ErrorCode err = this->read_register(reg, raw_data, len * 2, true); + i2c::ErrorCode err = this->read_register(reg, raw_data, len * 2); if (err != i2c::ERROR_OK) { return err; } diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index e3cc2d9a57..39654f5875 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -63,12 +63,12 @@ void BMP280Component::setup() { // Read the chip id twice, to work around a bug where the first read is 0. // https://community.st.com/t5/stm32-mcus-products/issue-with-reading-bmp280-chip-id-using-spi/td-p/691855 - if (!this->read_byte(0xD0, &chip_id)) { + if (!this->bmp_read_byte(0xD0, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(ESP_LOG_MSG_COMM_FAIL); return; } - if (!this->read_byte(0xD0, &chip_id)) { + if (!this->bmp_read_byte(0xD0, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(ESP_LOG_MSG_COMM_FAIL); return; @@ -80,7 +80,7 @@ void BMP280Component::setup() { } // Send a soft reset. - if (!this->write_byte(BMP280_REGISTER_RESET, BMP280_SOFT_RESET)) { + if (!this->bmp_write_byte(BMP280_REGISTER_RESET, BMP280_SOFT_RESET)) { this->mark_failed("Reset failed"); return; } @@ -89,7 +89,7 @@ void BMP280Component::setup() { uint8_t retry = 5; do { delay(2); - if (!this->read_byte(BMP280_REGISTER_STATUS, &status)) { + if (!this->bmp_read_byte(BMP280_REGISTER_STATUS, &status)) { this->mark_failed("Error reading status register"); return; } @@ -115,14 +115,14 @@ void BMP280Component::setup() { this->calibration_.p9 = this->read_s16_le_(0x9E); uint8_t config_register = 0; - if (!this->read_byte(BMP280_REGISTER_CONFIG, &config_register)) { + if (!this->bmp_read_byte(BMP280_REGISTER_CONFIG, &config_register)) { this->mark_failed("Read config"); return; } config_register &= ~0b11111100; config_register |= 0b000 << 5; // 0.5 ms standby time config_register |= (this->iir_filter_ & 0b111) << 2; - if (!this->write_byte(BMP280_REGISTER_CONFIG, config_register)) { + if (!this->bmp_write_byte(BMP280_REGISTER_CONFIG, config_register)) { this->mark_failed("Write config"); return; } @@ -159,7 +159,7 @@ void BMP280Component::update() { meas_value |= (this->temperature_oversampling_ & 0b111) << 5; meas_value |= (this->pressure_oversampling_ & 0b111) << 2; meas_value |= 0b01; // Forced mode - if (!this->write_byte(BMP280_REGISTER_CONTROL, meas_value)) { + if (!this->bmp_write_byte(BMP280_REGISTER_CONTROL, meas_value)) { this->status_set_warning(); return; } @@ -188,9 +188,10 @@ void BMP280Component::update() { } float BMP280Component::read_temperature_(int32_t *t_fine) { - uint8_t data[3]; - if (!this->read_bytes(BMP280_REGISTER_TEMPDATA, data, 3)) + uint8_t data[3]{}; + if (!this->bmp_read_bytes(BMP280_REGISTER_TEMPDATA, data, 3)) return NAN; + ESP_LOGV(TAG, "Read temperature data, raw: %02X %02X %02X", data[0], data[1], data[2]); int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); adc >>= 4; if (adc == 0x80000) { @@ -212,7 +213,7 @@ float BMP280Component::read_temperature_(int32_t *t_fine) { float BMP280Component::read_pressure_(int32_t t_fine) { uint8_t data[3]; - if (!this->read_bytes(BMP280_REGISTER_PRESSUREDATA, data, 3)) + if (!this->bmp_read_bytes(BMP280_REGISTER_PRESSUREDATA, data, 3)) return NAN; int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); adc >>= 4; @@ -258,12 +259,12 @@ void BMP280Component::set_pressure_oversampling(BMP280Oversampling pressure_over void BMP280Component::set_iir_filter(BMP280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } uint8_t BMP280Component::read_u8_(uint8_t a_register) { uint8_t data = 0; - this->read_byte(a_register, &data); + this->bmp_read_byte(a_register, &data); return data; } uint16_t BMP280Component::read_u16_le_(uint8_t a_register) { uint16_t data = 0; - this->read_byte_16(a_register, &data); + this->bmp_read_byte_16(a_register, &data); return (data >> 8) | (data << 8); } int16_t BMP280Component::read_s16_le_(uint8_t a_register) { return this->read_u16_le_(a_register); } diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 4b22e98f13..a47a794e96 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -67,12 +67,12 @@ class BMP280Component : public PollingComponent { float get_setup_priority() const override; void update() override; - virtual bool read_byte(uint8_t a_register, uint8_t *data) = 0; - virtual bool write_byte(uint8_t a_register, uint8_t data) = 0; - virtual bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; - virtual bool read_byte_16(uint8_t a_register, uint16_t *data) = 0; - protected: + virtual bool bmp_read_byte(uint8_t a_register, uint8_t *data) = 0; + virtual bool bmp_write_byte(uint8_t a_register, uint8_t data) = 0; + virtual bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; + virtual bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) = 0; + /// Read the temperature value and store the calculated ambient temperature in t_fine. float read_temperature_(int32_t *t_fine); /// Read the pressure value in hPa using the provided t_fine value. diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.cpp b/esphome/components/bmp280_i2c/bmp280_i2c.cpp index 04b8bd8b10..75d899008d 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.cpp +++ b/esphome/components/bmp280_i2c/bmp280_i2c.cpp @@ -5,19 +5,6 @@ namespace esphome { namespace bmp280_i2c { -bool BMP280I2CComponent::read_byte(uint8_t a_register, uint8_t *data) { - return I2CDevice::read_byte(a_register, data); -}; -bool BMP280I2CComponent::write_byte(uint8_t a_register, uint8_t data) { - return I2CDevice::write_byte(a_register, data); -}; -bool BMP280I2CComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t len) { - return I2CDevice::read_bytes(a_register, data, len); -}; -bool BMP280I2CComponent::read_byte_16(uint8_t a_register, uint16_t *data) { - return I2CDevice::read_byte_16(a_register, data); -}; - void BMP280I2CComponent::dump_config() { LOG_I2C_DEVICE(this); BMP280Component::dump_config(); diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index 66d78d788b..0ac956202b 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -11,10 +11,12 @@ static const char *const TAG = "bmp280_i2c.sensor"; /// This class implements support for the BMP280 Temperature+Pressure i2c sensor. class BMP280I2CComponent : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { public: - bool read_byte(uint8_t a_register, uint8_t *data) override; - bool write_byte(uint8_t a_register, uint8_t data) override; - bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; - bool read_byte_16(uint8_t a_register, uint16_t *data) override; + bool bmp_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } + bool bmp_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } + bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override { + return read_bytes(a_register, data, len); + } + bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) override { return read_byte_16(a_register, data); } void dump_config() override; }; diff --git a/esphome/components/bmp280_spi/bmp280_spi.cpp b/esphome/components/bmp280_spi/bmp280_spi.cpp index a35e829432..88983e77c3 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.cpp +++ b/esphome/components/bmp280_spi/bmp280_spi.cpp @@ -28,7 +28,7 @@ void BMP280SPIComponent::setup() { // 0x77 is transferred, for read access, the byte 0xF7 is transferred. // https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bmp280-ds001.pdf -bool BMP280SPIComponent::read_byte(uint8_t a_register, uint8_t *data) { +bool BMP280SPIComponent::bmp_read_byte(uint8_t a_register, uint8_t *data) { this->enable(); this->transfer_byte(set_bit(a_register, 7)); *data = this->transfer_byte(0); @@ -36,7 +36,7 @@ bool BMP280SPIComponent::read_byte(uint8_t a_register, uint8_t *data) { return true; } -bool BMP280SPIComponent::write_byte(uint8_t a_register, uint8_t data) { +bool BMP280SPIComponent::bmp_write_byte(uint8_t a_register, uint8_t data) { this->enable(); this->transfer_byte(clear_bit(a_register, 7)); this->transfer_byte(data); @@ -44,7 +44,7 @@ bool BMP280SPIComponent::write_byte(uint8_t a_register, uint8_t data) { return true; } -bool BMP280SPIComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t len) { +bool BMP280SPIComponent::bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) { this->enable(); this->transfer_byte(set_bit(a_register, 7)); this->read_array(data, len); @@ -52,7 +52,7 @@ bool BMP280SPIComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t le return true; } -bool BMP280SPIComponent::read_byte_16(uint8_t a_register, uint16_t *data) { +bool BMP280SPIComponent::bmp_read_byte_16(uint8_t a_register, uint16_t *data) { this->enable(); this->transfer_byte(set_bit(a_register, 7)); ((uint8_t *) data)[1] = this->transfer_byte(0); diff --git a/esphome/components/bmp280_spi/bmp280_spi.h b/esphome/components/bmp280_spi/bmp280_spi.h index dd226502f6..1bb7678e55 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.h +++ b/esphome/components/bmp280_spi/bmp280_spi.h @@ -10,10 +10,10 @@ class BMP280SPIComponent : public esphome::bmp280_base::BMP280Component, public spi::SPIDevice { void setup() override; - bool read_byte(uint8_t a_register, uint8_t *data) override; - bool write_byte(uint8_t a_register, uint8_t data) override; - bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; - bool read_byte_16(uint8_t a_register, uint16_t *data) override; + bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; + bool bmp_write_byte(uint8_t a_register, uint8_t data) override; + bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; + bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) override; }; } // namespace bmp280_spi diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index 6f652cb0c6..9a4e342525 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -91,7 +91,7 @@ bool CH422GComponent::read_inputs_() { // Write a register. Can't use the standard write_byte() method because there is no single pre-configured i2c address. bool CH422GComponent::write_reg_(uint8_t reg, uint8_t value) { - auto err = this->bus_->write(reg, &value, 1); + auto err = this->bus_->write_readv(reg, &value, 1, nullptr, 0); if (err != i2c::ERROR_OK) { this->status_set_warning(str_sprintf("write failed for register 0x%X, error %d", reg, err).c_str()); return false; @@ -102,7 +102,7 @@ bool CH422GComponent::write_reg_(uint8_t reg, uint8_t value) { uint8_t CH422GComponent::read_reg_(uint8_t reg) { uint8_t value; - auto err = this->bus_->read(reg, &value, 1); + auto err = this->bus_->write_readv(reg, nullptr, 0, &value, 1); if (err != i2c::ERROR_OK) { this->status_set_warning(str_sprintf("read failed for register 0x%X, error %d", reg, err).c_str()); return 0; diff --git a/esphome/components/ee895/ee895.cpp b/esphome/components/ee895/ee895.cpp index 3a8a9b3725..c6eaf4e728 100644 --- a/esphome/components/ee895/ee895.cpp +++ b/esphome/components/ee895/ee895.cpp @@ -83,7 +83,7 @@ void EE895Component::write_command_(uint16_t addr, uint16_t reg_cnt) { crc16 = calc_crc16_(address, 6); address[5] = crc16 & 0xFF; address[6] = (crc16 >> 8) & 0xFF; - this->write(address, 7, true); + this->write(address, 7); } float EE895Component::read_float_() { diff --git a/esphome/components/hte501/hte501.cpp b/esphome/components/hte501/hte501.cpp index 75770ceffe..911cafe97a 100644 --- a/esphome/components/hte501/hte501.cpp +++ b/esphome/components/hte501/hte501.cpp @@ -9,9 +9,8 @@ static const char *const TAG = "hte501"; void HTE501Component::setup() { uint8_t address[] = {0x70, 0x29}; - this->write(address, 2, false); uint8_t identification[9]; - this->read(identification, 9); + this->write_read(address, sizeof address, identification, sizeof identification); if (identification[8] != calc_crc8_(identification, 0, 7)) { this->error_code_ = CRC_CHECK_FAILED; this->mark_failed(); @@ -42,7 +41,7 @@ void HTE501Component::dump_config() { float HTE501Component::get_setup_priority() const { return setup_priority::DATA; } void HTE501Component::update() { uint8_t address_1[] = {0x2C, 0x1B}; - this->write(address_1, 2, true); + this->write(address_1, 2); this->set_timeout(50, [this]() { uint8_t i2c_response[6]; this->read(i2c_response, 6); diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 4172b23845..35b9fab9e4 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -2,7 +2,6 @@ import logging from esphome import pins import esphome.codegen as cg -from esphome.components import esp32 from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -14,8 +13,6 @@ from esphome.const import ( CONF_SCL, CONF_SDA, CONF_TIMEOUT, - KEY_CORE, - KEY_FRAMEWORK_VERSION, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, @@ -48,28 +45,8 @@ def _bus_declare_type(value): def validate_config(config): - if ( - config[CONF_SCAN] - and CORE.is_esp32 - and CORE.using_esp_idf - and esp32.get_esp32_variant() - in [ - esp32.const.VARIANT_ESP32C5, - esp32.const.VARIANT_ESP32C6, - esp32.const.VARIANT_ESP32P4, - ] - ): - version: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - if version.major == 5 and ( - (version.minor == 3 and version.patch <= 3) - or (version.minor == 4 and version.patch <= 1) - ): - LOGGER.warning( - "There is a bug in esp-idf version %s that breaks I2C scan, I2C scan " - "has been disabled, see https://github.com/esphome/issues/issues/7128", - str(version), - ) - config[CONF_SCAN] = False + if CORE.using_esp_idf: + return cv.require_framework_version(esp_idf=cv.Version(5, 4, 2))(config) return config diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 2b2190d28b..e66ab8ba73 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -1,4 +1,6 @@ #include "i2c.h" + +#include "esphome/core/defines.h" #include "esphome/core/log.h" #include @@ -7,38 +9,48 @@ namespace i2c { static const char *const TAG = "i2c"; -ErrorCode I2CDevice::read_register(uint8_t a_register, uint8_t *data, size_t len, bool stop) { - ErrorCode err = this->write(&a_register, 1, stop); - if (err != ERROR_OK) - return err; - return bus_->read(address_, data, len); +void I2CBus::i2c_scan_() { + // suppress logs from the IDF I2C library during the scan +#if defined(USE_ESP32) && defined(USE_LOGGER) + auto previous = esp_log_level_get("*"); + esp_log_level_set("*", ESP_LOG_NONE); +#endif + + for (uint8_t address = 8; address != 120; address++) { + auto err = write_readv(address, nullptr, 0, nullptr, 0); + if (err == ERROR_OK) { + scan_results_.emplace_back(address, true); + } else if (err == ERROR_UNKNOWN) { + scan_results_.emplace_back(address, false); + } + } +#if defined(USE_ESP32) && defined(USE_LOGGER) + esp_log_level_set("*", previous); +#endif } -ErrorCode I2CDevice::read_register16(uint16_t a_register, uint8_t *data, size_t len, bool stop) { +ErrorCode I2CDevice::read_register(uint8_t a_register, uint8_t *data, size_t len) { + return bus_->write_readv(this->address_, &a_register, 1, data, len); +} + +ErrorCode I2CDevice::read_register16(uint16_t a_register, uint8_t *data, size_t len) { a_register = convert_big_endian(a_register); - ErrorCode const err = this->write(reinterpret_cast(&a_register), 2, stop); - if (err != ERROR_OK) - return err; - return bus_->read(address_, data, len); + return bus_->write_readv(this->address_, reinterpret_cast(&a_register), 2, data, len); } -ErrorCode I2CDevice::write_register(uint8_t a_register, const uint8_t *data, size_t len, bool stop) { - WriteBuffer buffers[2]; - buffers[0].data = &a_register; - buffers[0].len = 1; - buffers[1].data = data; - buffers[1].len = len; - return bus_->writev(address_, buffers, 2, stop); +ErrorCode I2CDevice::write_register(uint8_t a_register, const uint8_t *data, size_t len) const { + std::vector v{}; + v.push_back(a_register); + v.insert(v.end(), data, data + len); + return bus_->write_readv(this->address_, v.data(), v.size(), nullptr, 0); } -ErrorCode I2CDevice::write_register16(uint16_t a_register, const uint8_t *data, size_t len, bool stop) { - a_register = convert_big_endian(a_register); - WriteBuffer buffers[2]; - buffers[0].data = reinterpret_cast(&a_register); - buffers[0].len = 2; - buffers[1].data = data; - buffers[1].len = len; - return bus_->writev(address_, buffers, 2, stop); +ErrorCode I2CDevice::write_register16(uint16_t a_register, const uint8_t *data, size_t len) const { + std::vector v(len + 2); + v.push_back(a_register >> 8); + v.push_back(a_register); + v.insert(v.end(), data, data + len); + return bus_->write_readv(this->address_, v.data(), v.size(), nullptr, 0); } bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { @@ -49,7 +61,7 @@ bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { return true; } -bool I2CDevice::write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) { +bool I2CDevice::write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) const { // we have to copy in order to be able to change byte order std::unique_ptr temp{new uint16_t[len]}; for (size_t i = 0; i < len; i++) diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 15f786245b..48a6e751cf 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -1,10 +1,10 @@ #pragma once -#include "i2c_bus.h" -#include "esphome/core/helpers.h" -#include "esphome/core/optional.h" #include #include +#include "esphome/core/helpers.h" +#include "esphome/core/optional.h" +#include "i2c_bus.h" namespace esphome { namespace i2c { @@ -161,51 +161,53 @@ class I2CDevice { /// @param data pointer to an array to store the bytes /// @param len length of the buffer = number of bytes to read /// @return an i2c::ErrorCode - ErrorCode read(uint8_t *data, size_t len) { return bus_->read(address_, data, len); } + ErrorCode read(uint8_t *data, size_t len) const { return bus_->write_readv(this->address_, nullptr, 0, data, len); } /// @brief reads an array of bytes from a specific register in the I²C device /// @param a_register an 8 bits internal address of the I²C register to read from /// @param data pointer to an array to store the bytes /// @param len length of the buffer = number of bytes to read - /// @param stop (true/false): True will send a stop message, releasing the bus after - /// transmission. False will send a restart, keeping the connection active. /// @return an i2c::ErrorCode - ErrorCode read_register(uint8_t a_register, uint8_t *data, size_t len, bool stop = true); + ErrorCode read_register(uint8_t a_register, uint8_t *data, size_t len); /// @brief reads an array of bytes from a specific register in the I²C device /// @param a_register the 16 bits internal address of the I²C register to read from /// @param data pointer to an array of bytes to store the information /// @param len length of the buffer = number of bytes to read - /// @param stop (true/false): True will send a stop message, releasing the bus after - /// transmission. False will send a restart, keeping the connection active. /// @return an i2c::ErrorCode - ErrorCode read_register16(uint16_t a_register, uint8_t *data, size_t len, bool stop = true); + ErrorCode read_register16(uint16_t a_register, uint8_t *data, size_t len); /// @brief writes an array of bytes to a device using an I2CBus /// @param data pointer to an array that contains the bytes to send /// @param len length of the buffer = number of bytes to write - /// @param stop (true/false): True will send a stop message, releasing the bus after - /// transmission. False will send a restart, keeping the connection active. /// @return an i2c::ErrorCode - ErrorCode write(const uint8_t *data, size_t len, bool stop = true) { return bus_->write(address_, data, len, stop); } + ErrorCode write(const uint8_t *data, size_t len) const { + return bus_->write_readv(this->address_, data, len, nullptr, 0); + } + + /// @brief writes an array of bytes to a device, then reads an array, as a single transaction + /// @param write_data pointer to an array that contains the bytes to send + /// @param write_len length of the buffer = number of bytes to write + /// @param read_data pointer to an array to store the bytes read + /// @param read_len length of the buffer = number of bytes to read + /// @return an i2c::ErrorCode + ErrorCode write_read(const uint8_t *write_data, size_t write_len, uint8_t *read_data, size_t read_len) const { + return bus_->write_readv(this->address_, write_data, write_len, read_data, read_len); + } /// @brief writes an array of bytes to a specific register in the I²C device /// @param a_register the internal address of the register to read from /// @param data pointer to an array to store the bytes /// @param len length of the buffer = number of bytes to read - /// @param stop (true/false): True will send a stop message, releasing the bus after - /// transmission. False will send a restart, keeping the connection active. /// @return an i2c::ErrorCode - ErrorCode write_register(uint8_t a_register, const uint8_t *data, size_t len, bool stop = true); + ErrorCode write_register(uint8_t a_register, const uint8_t *data, size_t len) const; /// @brief write an array of bytes to a specific register in the I²C device /// @param a_register the 16 bits internal address of the register to read from /// @param data pointer to an array to store the bytes /// @param len length of the buffer = number of bytes to read - /// @param stop (true/false): True will send a stop message, releasing the bus after - /// transmission. False will send a restart, keeping the connection active. /// @return an i2c::ErrorCode - ErrorCode write_register16(uint16_t a_register, const uint8_t *data, size_t len, bool stop = true); + ErrorCode write_register16(uint16_t a_register, const uint8_t *data, size_t len) const; /// /// Compat APIs @@ -217,7 +219,7 @@ class I2CDevice { return read_register(a_register, data, len) == ERROR_OK; } - bool read_bytes_raw(uint8_t *data, uint8_t len) { return read(data, len) == ERROR_OK; } + bool read_bytes_raw(uint8_t *data, uint8_t len) const { return read(data, len) == ERROR_OK; } template optional> read_bytes(uint8_t a_register) { std::array res; @@ -236,9 +238,7 @@ class I2CDevice { bool read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len); - bool read_byte(uint8_t a_register, uint8_t *data, bool stop = true) { - return read_register(a_register, data, 1, stop) == ERROR_OK; - } + bool read_byte(uint8_t a_register, uint8_t *data) { return read_register(a_register, data, 1) == ERROR_OK; } optional read_byte(uint8_t a_register) { uint8_t data; @@ -249,11 +249,11 @@ class I2CDevice { bool read_byte_16(uint8_t a_register, uint16_t *data) { return read_bytes_16(a_register, data, 1); } - bool write_bytes(uint8_t a_register, const uint8_t *data, uint8_t len, bool stop = true) { - return write_register(a_register, data, len, stop) == ERROR_OK; + bool write_bytes(uint8_t a_register, const uint8_t *data, uint8_t len) const { + return write_register(a_register, data, len) == ERROR_OK; } - bool write_bytes(uint8_t a_register, const std::vector &data) { + bool write_bytes(uint8_t a_register, const std::vector &data) const { return write_bytes(a_register, data.data(), data.size()); } @@ -261,13 +261,42 @@ class I2CDevice { return write_bytes(a_register, data.data(), data.size()); } - bool write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len); + bool write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) const; - bool write_byte(uint8_t a_register, uint8_t data, bool stop = true) { - return write_bytes(a_register, &data, 1, stop); + bool write_byte(uint8_t a_register, uint8_t data) const { return write_bytes(a_register, &data, 1); } + + bool write_byte_16(uint8_t a_register, uint16_t data) const { return write_bytes_16(a_register, &data, 1); } + + // Deprecated functions + + ESPDEPRECATED("The stop argument is no longer used. This will be removed from ESPHome 2026.3.0", "2025.9.0") + ErrorCode read_register(uint8_t a_register, uint8_t *data, size_t len, bool stop) { + return this->read_register(a_register, data, len); } - bool write_byte_16(uint8_t a_register, uint16_t data) { return write_bytes_16(a_register, &data, 1); } + ESPDEPRECATED("The stop argument is no longer used. This will be removed from ESPHome 2026.3.0", "2025.9.0") + ErrorCode read_register16(uint16_t a_register, uint8_t *data, size_t len, bool stop) { + return this->read_register16(a_register, data, len); + } + + ESPDEPRECATED("The stop argument is no longer used; use write_read() for consecutive write and read. This will be " + "removed from ESPHome 2026.3.0", + "2025.9.0") + ErrorCode write(const uint8_t *data, size_t len, bool stop) const { return this->write(data, len); } + + ESPDEPRECATED("The stop argument is no longer used; use write_read() for consecutive write and read. This will be " + "removed from ESPHome 2026.3.0", + "2025.9.0") + ErrorCode write_register(uint8_t a_register, const uint8_t *data, size_t len, bool stop) const { + return this->write_register(a_register, data, len); + } + + ESPDEPRECATED("The stop argument is no longer used; use write_read() for consecutive write and read. This will be " + "removed from ESPHome 2026.3.0", + "2025.9.0") + ErrorCode write_register16(uint16_t a_register, const uint8_t *data, size_t len, bool stop) const { + return this->write_register16(a_register, data, len); + } protected: uint8_t address_{0x00}; ///< store the address of the device on the bus diff --git a/esphome/components/i2c/i2c_bus.h b/esphome/components/i2c/i2c_bus.h index da94aa940d..df4df628e8 100644 --- a/esphome/components/i2c/i2c_bus.h +++ b/esphome/components/i2c/i2c_bus.h @@ -1,9 +1,12 @@ #pragma once #include #include +#include #include #include +#include "esphome/core/helpers.h" + namespace esphome { namespace i2c { @@ -39,71 +42,66 @@ struct WriteBuffer { /// note https://www.nxp.com/docs/en/application-note/AN10216.pdf class I2CBus { public: - /// @brief Creates a ReadBuffer and calls the virtual readv() method to read bytes into this buffer - /// @param address address of the I²C component on the i2c bus - /// @param buffer pointer to an array of bytes that will be used to store the data received - /// @param len length of the buffer = number of bytes to read - /// @return an i2c::ErrorCode - virtual ErrorCode read(uint8_t address, uint8_t *buffer, size_t len) { - ReadBuffer buf; - buf.data = buffer; - buf.len = len; - return readv(address, &buf, 1); - } + virtual ~I2CBus() = default; - /// @brief This virtual method reads bytes from an I2CBus into an array of ReadBuffer. - /// @param address address of the I²C component on the i2c bus - /// @param buffers pointer to an array of ReadBuffer - /// @param count number of ReadBuffer to read - /// @return an i2c::ErrorCode - /// @details This is a pure virtual method that must be implemented in a subclass. - virtual ErrorCode readv(uint8_t address, ReadBuffer *buffers, size_t count) = 0; - - virtual ErrorCode write(uint8_t address, const uint8_t *buffer, size_t len) { - return write(address, buffer, len, true); - } - - /// @brief Creates a WriteBuffer and calls the writev() method to send the bytes from this buffer - /// @param address address of the I²C component on the i2c bus - /// @param buffer pointer to an array of bytes that contains the data to be sent - /// @param len length of the buffer = number of bytes to write - /// @param stop true or false: True will send a stop message, releasing the bus after - /// transmission. False will send a restart, keeping the connection active. - /// @return an i2c::ErrorCode - virtual ErrorCode write(uint8_t address, const uint8_t *buffer, size_t len, bool stop) { - WriteBuffer buf; - buf.data = buffer; - buf.len = len; - return writev(address, &buf, 1, stop); - } - - virtual ErrorCode writev(uint8_t address, WriteBuffer *buffers, size_t cnt) { - return writev(address, buffers, cnt, true); - } - - /// @brief This virtual method writes bytes to an I2CBus from an array of WriteBuffer. - /// @param address address of the I²C component on the i2c bus - /// @param buffers pointer to an array of WriteBuffer - /// @param count number of WriteBuffer to write - /// @param stop true or false: True will send a stop message, releasing the bus after + /// @brief This virtual method writes bytes to an I2CBus from an array, + /// then reads bytes into an array of ReadBuffer. + /// @param address address of the I²C device on the i2c bus + /// @param write_buffer pointer to data + /// @param write_count number of bytes to write + /// @param read_buffer pointer to an array to receive data + /// @param read_count number of bytes to read /// transmission. False will send a restart, keeping the connection active. /// @return an i2c::ErrorCode /// @details This is a pure virtual method that must be implemented in the subclass. - virtual ErrorCode writev(uint8_t address, WriteBuffer *buffers, size_t count, bool stop) = 0; + virtual ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) = 0; + + // Legacy functions for compatibility + + ErrorCode read(uint8_t address, uint8_t *buffer, size_t len) { + return this->write_readv(address, nullptr, 0, buffer, len); + } + + ErrorCode write(uint8_t address, const uint8_t *buffer, size_t len, bool stop = true) { + return this->write_readv(address, buffer, len, nullptr, 0); + } + + ESPDEPRECATED("This method is deprecated and will be removed in ESPHome 2026.3.0. Use write_readv() instead.", + "2025.9.0") + ErrorCode readv(uint8_t address, ReadBuffer *read_buffers, size_t count) { + size_t total_len = 0; + for (size_t i = 0; i != count; i++) { + total_len += read_buffers[i].len; + } + std::vector buffer(total_len); + auto err = this->write_readv(address, nullptr, 0, buffer.data(), total_len); + if (err != ERROR_OK) + return err; + size_t pos = 0; + for (size_t i = 0; i != count; i++) { + if (read_buffers[i].len != 0) { + std::memcpy(read_buffers[i].data, buffer.data() + pos, read_buffers[i].len); + pos += read_buffers[i].len; + } + } + return ERROR_OK; + } + + ESPDEPRECATED("This method is deprecated and will be removed in ESPHome 2026.3.0. Use write_readv() instead.", + "2025.9.0") + ErrorCode writev(uint8_t address, const WriteBuffer *write_buffers, size_t count, bool stop = true) { + std::vector buffer{}; + for (size_t i = 0; i != count; i++) { + buffer.insert(buffer.end(), write_buffers[i].data, write_buffers[i].data + write_buffers[i].len); + } + return this->write_readv(address, buffer.data(), buffer.size(), nullptr, 0); + } protected: /// @brief Scans the I2C bus for devices. Devices presence is kept in an array of std::pair /// that contains the address and the corresponding bool presence flag. - virtual void i2c_scan() { - for (uint8_t address = 8; address < 120; address++) { - auto err = writev(address, nullptr, 0); - if (err == ERROR_OK) { - scan_results_.emplace_back(address, true); - } else if (err == ERROR_UNKNOWN) { - scan_results_.emplace_back(address, false); - } - } - } + void i2c_scan_(); std::vector> scan_results_; ///< array containing scan results bool scan_{false}; ///< Should we scan ? Can be set in the yaml }; diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 24385745eb..221423418b 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -41,7 +41,7 @@ void ArduinoI2CBus::setup() { this->initialized_ = true; if (this->scan_) { ESP_LOGV(TAG, "Scanning bus for active devices"); - this->i2c_scan(); + this->i2c_scan_(); } } @@ -111,88 +111,37 @@ void ArduinoI2CBus::dump_config() { } } -ErrorCode ArduinoI2CBus::readv(uint8_t address, ReadBuffer *buffers, size_t cnt) { +ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count) { #if defined(USE_ESP8266) this->set_pins_and_clock_(); // reconfigure Wire global state in case there are multiple instances #endif - - // logging is only enabled with vv level, if warnings are shown the caller - // should log them if (!initialized_) { - ESP_LOGVV(TAG, "i2c bus not initialized!"); - return ERROR_NOT_INITIALIZED; - } - size_t to_request = 0; - for (size_t i = 0; i < cnt; i++) - to_request += buffers[i].len; - size_t ret = wire_->requestFrom(address, to_request, true); - if (ret != to_request) { - ESP_LOGVV(TAG, "RX %u from %02X failed with error %u", to_request, address, ret); - return ERROR_TIMEOUT; - } - - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - for (size_t j = 0; j < buf.len; j++) - buf.data[j] = wire_->read(); - } - -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - char debug_buf[4]; - std::string debug_hex; - - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - for (size_t j = 0; j < buf.len; j++) { - snprintf(debug_buf, sizeof(debug_buf), "%02X", buf.data[j]); - debug_hex += debug_buf; - } - } - ESP_LOGVV(TAG, "0x%02X RX %s", address, debug_hex.c_str()); -#endif - - return ERROR_OK; -} -ErrorCode ArduinoI2CBus::writev(uint8_t address, WriteBuffer *buffers, size_t cnt, bool stop) { -#if defined(USE_ESP8266) - this->set_pins_and_clock_(); // reconfigure Wire global state in case there are multiple instances -#endif - - // logging is only enabled with vv level, if warnings are shown the caller - // should log them - if (!initialized_) { - ESP_LOGVV(TAG, "i2c bus not initialized!"); + ESP_LOGD(TAG, "i2c bus not initialized!"); return ERROR_NOT_INITIALIZED; } -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - char debug_buf[4]; - std::string debug_hex; + ESP_LOGV(TAG, "0x%02X TX %s", address, format_hex_pretty(write_buffer, write_count).c_str()); - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - for (size_t j = 0; j < buf.len; j++) { - snprintf(debug_buf, sizeof(debug_buf), "%02X", buf.data[j]); - debug_hex += debug_buf; - } - } - ESP_LOGVV(TAG, "0x%02X TX %s", address, debug_hex.c_str()); -#endif - - wire_->beginTransmission(address); - size_t written = 0; - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - if (buf.len == 0) - continue; - size_t ret = wire_->write(buf.data, buf.len); - written += ret; - if (ret != buf.len) { - ESP_LOGVV(TAG, "TX failed at %u", written); + uint8_t status = 0; + if (write_count != 0 || read_count == 0) { + wire_->beginTransmission(address); + size_t ret = wire_->write(write_buffer, write_count); + if (ret != write_count) { + ESP_LOGV(TAG, "TX failed"); return ERROR_UNKNOWN; } + status = wire_->endTransmission(read_count == 0); + } + if (status == 0 && read_count != 0) { + size_t ret2 = wire_->requestFrom(address, read_count, true); + if (ret2 != read_count) { + ESP_LOGVV(TAG, "RX %u from %02X failed with error %u", read_count, address, ret2); + return ERROR_TIMEOUT; + } + for (size_t j = 0; j != read_count; j++) + read_buffer[j] = wire_->read(); } - uint8_t status = wire_->endTransmission(stop); switch (status) { case 0: return ERROR_OK; diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index 7e6616cbce..b441828353 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -19,8 +19,8 @@ class ArduinoI2CBus : public InternalI2CBus, public Component { public: void setup() override; void dump_config() override; - ErrorCode readv(uint8_t address, ReadBuffer *buffers, size_t cnt) override; - ErrorCode writev(uint8_t address, WriteBuffer *buffers, size_t cnt, bool stop) override; + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override; float get_setup_priority() const override { return setup_priority::BUS; } void set_scan(bool scan) { scan_ = scan; } diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index cf31ba1c0d..bf50ea0586 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP_IDF #include "i2c_bus_esp_idf.h" + #include #include #include @@ -9,10 +10,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0) -#define SOC_HP_I2C_NUM SOC_I2C_NUM -#endif - namespace esphome { namespace i2c { @@ -34,7 +31,6 @@ void IDFI2CBus::setup() { this->recover_(); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) next_port = (i2c_port_t) (next_port + 1); i2c_master_bus_config_t bus_conf{}; @@ -77,56 +73,8 @@ void IDFI2CBus::setup() { if (this->scan_) { ESP_LOGV(TAG, "Scanning for devices"); - this->i2c_scan(); + this->i2c_scan_(); } -#else -#if SOC_HP_I2C_NUM > 1 - next_port = (next_port == I2C_NUM_0) ? I2C_NUM_1 : I2C_NUM_MAX; -#else - next_port = I2C_NUM_MAX; -#endif - - i2c_config_t conf{}; - memset(&conf, 0, sizeof(conf)); - conf.mode = I2C_MODE_MASTER; - conf.sda_io_num = sda_pin_; - conf.sda_pullup_en = sda_pullup_enabled_; - conf.scl_io_num = scl_pin_; - conf.scl_pullup_en = scl_pullup_enabled_; - conf.master.clk_speed = frequency_; -#ifdef USE_ESP32_VARIANT_ESP32S2 - // workaround for https://github.com/esphome/issues/issues/6718 - conf.clk_flags = I2C_SCLK_SRC_FLAG_AWARE_DFS; -#endif - esp_err_t err = i2c_param_config(port_, &conf); - if (err != ESP_OK) { - ESP_LOGW(TAG, "i2c_param_config failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - if (timeout_ > 0) { - err = i2c_set_timeout(port_, timeout_ * 80); // unit: APB 80MHz clock cycle - if (err != ESP_OK) { - ESP_LOGW(TAG, "i2c_set_timeout failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } else { - ESP_LOGV(TAG, "i2c_timeout set to %" PRIu32 " ticks (%" PRIu32 " us)", timeout_ * 80, timeout_); - } - } - err = i2c_driver_install(port_, I2C_MODE_MASTER, 0, 0, 0); - if (err != ESP_OK) { - ESP_LOGW(TAG, "i2c_driver_install failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - initialized_ = true; - if (this->scan_) { - ESP_LOGV(TAG, "Scanning bus for active devices"); - this->i2c_scan(); - } -#endif } void IDFI2CBus::dump_config() { @@ -166,267 +114,73 @@ void IDFI2CBus::dump_config() { } } -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) -void IDFI2CBus::i2c_scan() { - for (uint8_t address = 8; address < 120; address++) { - auto err = i2c_master_probe(this->bus_, address, 20); - if (err == ESP_OK) { - this->scan_results_.emplace_back(address, true); - } - } -} -#endif - -ErrorCode IDFI2CBus::readv(uint8_t address, ReadBuffer *buffers, size_t cnt) { - // logging is only enabled with vv level, if warnings are shown the caller +ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) { + // logging is only enabled with v level, if warnings are shown the caller // should log them if (!initialized_) { - ESP_LOGVV(TAG, "i2c bus not initialized!"); + ESP_LOGW(TAG, "i2c bus not initialized!"); return ERROR_NOT_INITIALIZED; } -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) - i2c_operation_job_t jobs[cnt + 4]; - uint8_t read = (address << 1) | I2C_MASTER_READ; - size_t last = 0, num = 0; - - jobs[num].command = I2C_MASTER_CMD_START; - num++; - - jobs[num].command = I2C_MASTER_CMD_WRITE; - jobs[num].write.ack_check = true; - jobs[num].write.data = &read; - jobs[num].write.total_bytes = 1; - num++; - - // find the last valid index - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - if (buf.len == 0) { - continue; + i2c_operation_job_t jobs[8]{}; + size_t num_jobs = 0; + uint8_t write_addr = (address << 1) | I2C_MASTER_WRITE; + uint8_t read_addr = (address << 1) | I2C_MASTER_READ; + ESP_LOGV(TAG, "Writing %zu bytes, reading %zu bytes", write_count, read_count); + if (read_count == 0 && write_count == 0) { + // basically just a bus probe. Send a start, address and stop + ESP_LOGV(TAG, "0x%02X BUS PROBE", address); + jobs[num_jobs++].command = I2C_MASTER_CMD_START; + jobs[num_jobs].command = I2C_MASTER_CMD_WRITE; + jobs[num_jobs].write.ack_check = true; + jobs[num_jobs].write.data = &write_addr; + jobs[num_jobs++].write.total_bytes = 1; + } else { + if (write_count != 0) { + ESP_LOGV(TAG, "0x%02X TX %s", address, format_hex_pretty(write_buffer, write_count).c_str()); + jobs[num_jobs++].command = I2C_MASTER_CMD_START; + jobs[num_jobs].command = I2C_MASTER_CMD_WRITE; + jobs[num_jobs].write.ack_check = true; + jobs[num_jobs].write.data = &write_addr; + jobs[num_jobs++].write.total_bytes = 1; + jobs[num_jobs].command = I2C_MASTER_CMD_WRITE; + jobs[num_jobs].write.ack_check = true; + jobs[num_jobs].write.data = (uint8_t *) write_buffer; + jobs[num_jobs++].write.total_bytes = write_count; } - last = i; - } - - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - if (buf.len == 0) { - continue; - } - if (i == last) { - // the last byte read before stop should always be a nack, - // split the last read if len is larger than 1 - if (buf.len > 1) { - jobs[num].command = I2C_MASTER_CMD_READ; - jobs[num].read.ack_value = I2C_ACK_VAL; - jobs[num].read.data = (uint8_t *) buf.data; - jobs[num].read.total_bytes = buf.len - 1; - num++; + if (read_count != 0) { + ESP_LOGV(TAG, "0x%02X RX bytes %zu", address, read_count); + jobs[num_jobs++].command = I2C_MASTER_CMD_START; + jobs[num_jobs].command = I2C_MASTER_CMD_WRITE; + jobs[num_jobs].write.ack_check = true; + jobs[num_jobs].write.data = &read_addr; + jobs[num_jobs++].write.total_bytes = 1; + if (read_count > 1) { + jobs[num_jobs].command = I2C_MASTER_CMD_READ; + jobs[num_jobs].read.ack_value = I2C_ACK_VAL; + jobs[num_jobs].read.data = read_buffer; + jobs[num_jobs++].read.total_bytes = read_count - 1; } - jobs[num].command = I2C_MASTER_CMD_READ; - jobs[num].read.ack_value = I2C_NACK_VAL; - jobs[num].read.data = (uint8_t *) buf.data + buf.len - 1; - jobs[num].read.total_bytes = 1; - num++; - } else { - jobs[num].command = I2C_MASTER_CMD_READ; - jobs[num].read.ack_value = I2C_ACK_VAL; - jobs[num].read.data = (uint8_t *) buf.data; - jobs[num].read.total_bytes = buf.len; - num++; + jobs[num_jobs].command = I2C_MASTER_CMD_READ; + jobs[num_jobs].read.ack_value = I2C_NACK_VAL; + jobs[num_jobs].read.data = read_buffer + read_count - 1; + jobs[num_jobs++].read.total_bytes = 1; } } - - jobs[num].command = I2C_MASTER_CMD_STOP; - num++; - - esp_err_t err = i2c_master_execute_defined_operations(this->dev_, jobs, num, 20); + jobs[num_jobs++].command = I2C_MASTER_CMD_STOP; + ESP_LOGV(TAG, "Sending %zu jobs", num_jobs); + esp_err_t err = i2c_master_execute_defined_operations(this->dev_, jobs, num_jobs, 20); if (err == ESP_ERR_INVALID_STATE) { - ESP_LOGVV(TAG, "RX from %02X failed: not acked", address); + ESP_LOGV(TAG, "TX to %02X failed: not acked", address); return ERROR_NOT_ACKNOWLEDGED; } else if (err == ESP_ERR_TIMEOUT) { - ESP_LOGVV(TAG, "RX from %02X failed: timeout", address); + ESP_LOGV(TAG, "TX to %02X failed: timeout", address); return ERROR_TIMEOUT; } else if (err != ESP_OK) { - ESP_LOGVV(TAG, "RX from %02X failed: %s", address, esp_err_to_name(err)); + ESP_LOGV(TAG, "TX to %02X failed: %s", address, esp_err_to_name(err)); return ERROR_UNKNOWN; } -#else - i2c_cmd_handle_t cmd = i2c_cmd_link_create(); - esp_err_t err = i2c_master_start(cmd); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "RX from %02X master start failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - err = i2c_master_write_byte(cmd, (address << 1) | I2C_MASTER_READ, true); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "RX from %02X address write failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - if (buf.len == 0) - continue; - err = i2c_master_read(cmd, buf.data, buf.len, i == cnt - 1 ? I2C_MASTER_LAST_NACK : I2C_MASTER_ACK); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "RX from %02X data read failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - } - err = i2c_master_stop(cmd); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "RX from %02X stop failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - err = i2c_master_cmd_begin(port_, cmd, 20 / portTICK_PERIOD_MS); - // i2c_master_cmd_begin() will block for a whole second if no ack: - // https://github.com/espressif/esp-idf/issues/4999 - i2c_cmd_link_delete(cmd); - if (err == ESP_FAIL) { - // transfer not acked - ESP_LOGVV(TAG, "RX from %02X failed: not acked", address); - return ERROR_NOT_ACKNOWLEDGED; - } else if (err == ESP_ERR_TIMEOUT) { - ESP_LOGVV(TAG, "RX from %02X failed: timeout", address); - return ERROR_TIMEOUT; - } else if (err != ESP_OK) { - ESP_LOGVV(TAG, "RX from %02X failed: %s", address, esp_err_to_name(err)); - return ERROR_UNKNOWN; - } -#endif - -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - char debug_buf[4]; - std::string debug_hex; - - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - for (size_t j = 0; j < buf.len; j++) { - snprintf(debug_buf, sizeof(debug_buf), "%02X", buf.data[j]); - debug_hex += debug_buf; - } - } - ESP_LOGVV(TAG, "0x%02X RX %s", address, debug_hex.c_str()); -#endif - - return ERROR_OK; -} - -ErrorCode IDFI2CBus::writev(uint8_t address, WriteBuffer *buffers, size_t cnt, bool stop) { - // logging is only enabled with vv level, if warnings are shown the caller - // should log them - if (!initialized_) { - ESP_LOGVV(TAG, "i2c bus not initialized!"); - return ERROR_NOT_INITIALIZED; - } - -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - char debug_buf[4]; - std::string debug_hex; - - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - for (size_t j = 0; j < buf.len; j++) { - snprintf(debug_buf, sizeof(debug_buf), "%02X", buf.data[j]); - debug_hex += debug_buf; - } - } - ESP_LOGVV(TAG, "0x%02X TX %s", address, debug_hex.c_str()); -#endif - -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) - i2c_operation_job_t jobs[cnt + 3]; - uint8_t write = (address << 1) | I2C_MASTER_WRITE; - size_t num = 0; - - jobs[num].command = I2C_MASTER_CMD_START; - num++; - - jobs[num].command = I2C_MASTER_CMD_WRITE; - jobs[num].write.ack_check = true; - jobs[num].write.data = &write; - jobs[num].write.total_bytes = 1; - num++; - - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - if (buf.len == 0) { - continue; - } - jobs[num].command = I2C_MASTER_CMD_WRITE; - jobs[num].write.ack_check = true; - jobs[num].write.data = (uint8_t *) buf.data; - jobs[num].write.total_bytes = buf.len; - num++; - } - - if (stop) { - jobs[num].command = I2C_MASTER_CMD_STOP; - num++; - } - - esp_err_t err = i2c_master_execute_defined_operations(this->dev_, jobs, num, 20); - if (err == ESP_ERR_INVALID_STATE) { - ESP_LOGVV(TAG, "TX to %02X failed: not acked", address); - return ERROR_NOT_ACKNOWLEDGED; - } else if (err == ESP_ERR_TIMEOUT) { - ESP_LOGVV(TAG, "TX to %02X failed: timeout", address); - return ERROR_TIMEOUT; - } else if (err != ESP_OK) { - ESP_LOGVV(TAG, "TX to %02X failed: %s", address, esp_err_to_name(err)); - return ERROR_UNKNOWN; - } -#else - i2c_cmd_handle_t cmd = i2c_cmd_link_create(); - esp_err_t err = i2c_master_start(cmd); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "TX to %02X master start failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - err = i2c_master_write_byte(cmd, (address << 1) | I2C_MASTER_WRITE, true); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "TX to %02X address write failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - for (size_t i = 0; i < cnt; i++) { - const auto &buf = buffers[i]; - if (buf.len == 0) - continue; - err = i2c_master_write(cmd, buf.data, buf.len, true); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "TX to %02X data write failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - } - if (stop) { - err = i2c_master_stop(cmd); - if (err != ESP_OK) { - ESP_LOGVV(TAG, "TX to %02X master stop failed: %s", address, esp_err_to_name(err)); - i2c_cmd_link_delete(cmd); - return ERROR_UNKNOWN; - } - } - err = i2c_master_cmd_begin(port_, cmd, 20 / portTICK_PERIOD_MS); - i2c_cmd_link_delete(cmd); - if (err == ESP_FAIL) { - // transfer not acked - ESP_LOGVV(TAG, "TX to %02X failed: not acked", address); - return ERROR_NOT_ACKNOWLEDGED; - } else if (err == ESP_ERR_TIMEOUT) { - ESP_LOGVV(TAG, "TX to %02X failed: timeout", address); - return ERROR_TIMEOUT; - } else if (err != ESP_OK) { - ESP_LOGVV(TAG, "TX to %02X failed: %s", address, esp_err_to_name(err)); - return ERROR_UNKNOWN; - } -#endif return ERROR_OK; } @@ -436,8 +190,8 @@ ErrorCode IDFI2CBus::writev(uint8_t address, WriteBuffer *buffers, size_t cnt, b void IDFI2CBus::recover_() { ESP_LOGI(TAG, "Performing bus recovery"); - const gpio_num_t scl_pin = static_cast(scl_pin_); - const gpio_num_t sda_pin = static_cast(sda_pin_); + const auto scl_pin = static_cast(scl_pin_); + const auto sda_pin = static_cast(sda_pin_); // For the upcoming operations, target for a 60kHz toggle frequency. // 1000kHz is the maximum frequency for I2C running in standard-mode, @@ -545,5 +299,4 @@ void IDFI2CBus::recover_() { } // namespace i2c } // namespace esphome - #endif // USE_ESP_IDF diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index 4e8f86fd0c..f565be4535 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -2,14 +2,9 @@ #ifdef USE_ESP_IDF -#include "esp_idf_version.h" #include "esphome/core/component.h" #include "i2c_bus.h" -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) #include -#else -#include -#endif namespace esphome { namespace i2c { @@ -24,36 +19,33 @@ class IDFI2CBus : public InternalI2CBus, public Component { public: void setup() override; void dump_config() override; - ErrorCode readv(uint8_t address, ReadBuffer *buffers, size_t cnt) override; - ErrorCode writev(uint8_t address, WriteBuffer *buffers, size_t cnt, bool stop) override; + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override; float get_setup_priority() const override { return setup_priority::BUS; } - void set_scan(bool scan) { scan_ = scan; } - void set_sda_pin(uint8_t sda_pin) { sda_pin_ = sda_pin; } - void set_sda_pullup_enabled(bool sda_pullup_enabled) { sda_pullup_enabled_ = sda_pullup_enabled; } - void set_scl_pin(uint8_t scl_pin) { scl_pin_ = scl_pin; } - void set_scl_pullup_enabled(bool scl_pullup_enabled) { scl_pullup_enabled_ = scl_pullup_enabled; } - void set_frequency(uint32_t frequency) { frequency_ = frequency; } - void set_timeout(uint32_t timeout) { timeout_ = timeout; } + void set_scan(bool scan) { this->scan_ = scan; } + void set_sda_pin(uint8_t sda_pin) { this->sda_pin_ = sda_pin; } + void set_sda_pullup_enabled(bool sda_pullup_enabled) { this->sda_pullup_enabled_ = sda_pullup_enabled; } + void set_scl_pin(uint8_t scl_pin) { this->scl_pin_ = scl_pin; } + void set_scl_pullup_enabled(bool scl_pullup_enabled) { this->scl_pullup_enabled_ = scl_pullup_enabled; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } + void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } - int get_port() const override { return static_cast(this->port_); } + int get_port() const override { return this->port_; } private: void recover_(); - RecoveryCode recovery_result_; + RecoveryCode recovery_result_{}; protected: -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) - i2c_master_dev_handle_t dev_; - i2c_master_bus_handle_t bus_; - void i2c_scan() override; -#endif - i2c_port_t port_; - uint8_t sda_pin_; - bool sda_pullup_enabled_; - uint8_t scl_pin_; - bool scl_pullup_enabled_; - uint32_t frequency_; + i2c_master_dev_handle_t dev_{}; + i2c_master_bus_handle_t bus_{}; + i2c_port_t port_{}; + uint8_t sda_pin_{}; + bool sda_pullup_enabled_{}; + uint8_t scl_pin_{}; + bool scl_pullup_enabled_{}; + uint32_t frequency_{}; uint32_t timeout_ = 0; bool initialized_ = false; }; diff --git a/esphome/components/iaqcore/iaqcore.cpp b/esphome/components/iaqcore/iaqcore.cpp index 2a84eabf75..274f9086b6 100644 --- a/esphome/components/iaqcore/iaqcore.cpp +++ b/esphome/components/iaqcore/iaqcore.cpp @@ -35,7 +35,7 @@ void IAQCore::setup() { void IAQCore::update() { uint8_t buffer[sizeof(SensorData)]; - if (this->read_register(0xB5, buffer, sizeof(buffer), false) != i2c::ERROR_OK) { + if (this->read_register(0xB5, buffer, sizeof(buffer)) != i2c::ERROR_OK) { ESP_LOGD(TAG, "Read failed"); this->status_set_warning(); this->publish_nans_(); diff --git a/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp b/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp index d28525635d..a363a9c12f 100644 --- a/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp +++ b/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp @@ -21,7 +21,7 @@ void INA2XXI2C::dump_config() { } bool INA2XXI2C::read_ina_register(uint8_t reg, uint8_t *data, size_t len) { - auto ret = this->read_register(reg, data, len, false); + auto ret = this->read_register(reg, data, len); if (ret != i2c::ERROR_OK) { ESP_LOGE(TAG, "read_ina_register_ failed. Reg=0x%02X Err=%d", reg, ret); } diff --git a/esphome/components/kmeteriso/kmeteriso.cpp b/esphome/components/kmeteriso/kmeteriso.cpp index 3aedac3f5f..d20e07460b 100644 --- a/esphome/components/kmeteriso/kmeteriso.cpp +++ b/esphome/components/kmeteriso/kmeteriso.cpp @@ -22,7 +22,7 @@ void KMeterISOComponent::setup() { this->reset_to_construction_state(); } - auto err = this->bus_->writev(this->address_, nullptr, 0); + auto err = this->bus_->write_readv(this->address_, nullptr, 0, nullptr, 0); if (err == esphome::i2c::ERROR_OK) { ESP_LOGCONFIG(TAG, "Could write to the address %d.", this->address_); } else { @@ -33,7 +33,7 @@ void KMeterISOComponent::setup() { } uint8_t read_buf[4] = {1}; - if (!this->read_bytes(KMETER_ERROR_STATUS_REG, read_buf, 1)) { + if (!this->read_register(KMETER_ERROR_STATUS_REG, read_buf, 1)) { ESP_LOGCONFIG(TAG, "Could not read from the device."); this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(); diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index e5d12a75d4..d41c1f6bc7 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -184,7 +184,7 @@ uint8_t Lc709203f::get_register_(uint8_t register_to_read, uint16_t *register_va // function will send a stop between the read and the write portion of the I2C // transaction. This is bad in this case and will result in reading nothing but 0xFFFF // from the registers. - return_code = this->read_register(register_to_read, &read_buffer[3], 3, false); + return_code = this->read_register(register_to_read, &read_buffer[3], 3); if (return_code != i2c::NO_ERROR) { // Error on the i2c bus this->status_set_warning( @@ -225,7 +225,7 @@ uint8_t Lc709203f::set_register_(uint8_t register_to_set, uint16_t value_to_set) for (uint8_t i = 0; i <= LC709203F_I2C_RETRY_COUNT; i++) { // Note: we don't write the first byte of the write buffer to the device. // This is done automatically by the write() function. - return_code = this->write(&write_buffer[1], 4, true); + return_code = this->write(&write_buffer[1], 4); if (return_code == i2c::NO_ERROR) { return return_code; } else { diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 6634c5057e..55ce9b7899 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -328,7 +328,7 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Increasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::INCREMENT); - auto err = this->write(&this->address_, reg, sizeof(reg)); + auto err = this->write(&this->address_, reg); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); @@ -359,7 +359,7 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Decreasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::DECREMENT); - auto err = this->write(&this->address_, reg, sizeof(reg)); + auto err = this->write(&this->address_, reg); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index 2e711baf9a..1341e6935b 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -90,18 +90,18 @@ float MLX90614Component::get_setup_priority() const { return setup_priority::DAT void MLX90614Component::update() { uint8_t emissivity[3]; - if (this->read_register(MLX90614_EMISSIVITY, emissivity, 3, false) != i2c::ERROR_OK) { + if (this->read_register(MLX90614_EMISSIVITY, emissivity, 3) != i2c::ERROR_OK) { this->status_set_warning(); return; } uint8_t raw_object[3]; - if (this->read_register(MLX90614_TEMPERATURE_OBJECT_1, raw_object, 3, false) != i2c::ERROR_OK) { + if (this->read_register(MLX90614_TEMPERATURE_OBJECT_1, raw_object, 3) != i2c::ERROR_OK) { this->status_set_warning(); return; } uint8_t raw_ambient[3]; - if (this->read_register(MLX90614_TEMPERATURE_AMBIENT, raw_ambient, 3, false) != i2c::ERROR_OK) { + if (this->read_register(MLX90614_TEMPERATURE_AMBIENT, raw_ambient, 3) != i2c::ERROR_OK) { this->status_set_warning(); return; } diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index 9e8467a29b..a689149c89 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "mpl3115a2"; void MPL3115A2Component::setup() { uint8_t whoami = 0xFF; - if (!this->read_byte(MPL3115A2_WHOAMI, &whoami, false)) { + if (!this->read_byte(MPL3115A2_WHOAMI, &whoami)) { this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(); return; @@ -54,24 +54,24 @@ void MPL3115A2Component::dump_config() { void MPL3115A2Component::update() { uint8_t mode = MPL3115A2_CTRL_REG1_OS128; - this->write_byte(MPL3115A2_CTRL_REG1, mode, true); + this->write_byte(MPL3115A2_CTRL_REG1, mode); // Trigger a new reading mode |= MPL3115A2_CTRL_REG1_OST; if (this->altitude_ != nullptr) mode |= MPL3115A2_CTRL_REG1_ALT; - this->write_byte(MPL3115A2_CTRL_REG1, mode, true); + this->write_byte(MPL3115A2_CTRL_REG1, mode); // Wait until status shows reading available uint8_t status = 0; - if (!this->read_byte(MPL3115A2_REGISTER_STATUS, &status, false) || (status & MPL3115A2_REGISTER_STATUS_PDR) == 0) { + if (!this->read_byte(MPL3115A2_REGISTER_STATUS, &status) || (status & MPL3115A2_REGISTER_STATUS_PDR) == 0) { delay(10); - if (!this->read_byte(MPL3115A2_REGISTER_STATUS, &status, false) || (status & MPL3115A2_REGISTER_STATUS_PDR) == 0) { + if (!this->read_byte(MPL3115A2_REGISTER_STATUS, &status) || (status & MPL3115A2_REGISTER_STATUS_PDR) == 0) { return; } } uint8_t buffer[5] = {0, 0, 0, 0, 0}; - this->read_register(MPL3115A2_REGISTER_PRESSURE_MSB, buffer, 5, false); + this->read_register(MPL3115A2_REGISTER_PRESSURE_MSB, buffer, 5); float altitude = 0, pressure = 0; if (this->altitude_ != nullptr) { diff --git a/esphome/components/npi19/npi19.cpp b/esphome/components/npi19/npi19.cpp index e8c4e8abd5..c531d2ec8f 100644 --- a/esphome/components/npi19/npi19.cpp +++ b/esphome/components/npi19/npi19.cpp @@ -33,7 +33,7 @@ float NPI19Component::get_setup_priority() const { return setup_priority::DATA; i2c::ErrorCode NPI19Component::read_(uint16_t &raw_temperature, uint16_t &raw_pressure) { // initiate data read from device - i2c::ErrorCode w_err = write(&READ_COMMAND, sizeof(READ_COMMAND), true); + i2c::ErrorCode w_err = write(&READ_COMMAND, sizeof(READ_COMMAND)); if (w_err != i2c::ERROR_OK) { return w_err; } diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index 2d65f1090d..f5f7ab9412 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -72,7 +72,7 @@ void OPT3001Sensor::read_lx_(const std::function &f) { } this->set_timeout("read", OPT3001_CONVERSION_TIME_800, [this, f]() { - if (this->write(&OPT3001_REG_CONFIGURATION, 1, true) != i2c::ERROR_OK) { + if (this->write(&OPT3001_REG_CONFIGURATION, 1) != i2c::ERROR_OK) { ESP_LOGW(TAG, "Starting configuration register read failed"); f(NAN); return; diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index dc8662d1a2..730c494e34 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -33,7 +33,7 @@ void PCA6416AComponent::setup() { } // Test to see if the device supports pull-up resistors - if (this->read_register(PCAL6416A_PULL_EN0, &value, 1, true) == i2c::ERROR_OK) { + if (this->read_register(PCAL6416A_PULL_EN0, &value, 1) == i2c::ERROR_OK) { this->has_pullup_ = true; } @@ -105,7 +105,7 @@ bool PCA6416AComponent::read_register_(uint8_t reg, uint8_t *value) { return false; } - this->last_error_ = this->read_register(reg, value, 1, true); + this->last_error_ = this->read_register(reg, value, 1); if (this->last_error_ != i2c::ERROR_OK) { this->status_set_warning(); ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_); @@ -122,7 +122,7 @@ bool PCA6416AComponent::write_register_(uint8_t reg, uint8_t value) { return false; } - this->last_error_ = this->write_register(reg, &value, 1, true); + this->last_error_ = this->write_register(reg, &value, 1); if (this->last_error_ != i2c::ERROR_OK) { this->status_set_warning(); ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_); diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index f77d680bec..1166cc1a09 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -96,7 +96,7 @@ bool PCA9554Component::read_inputs_() { return false; } - this->last_error_ = this->read_register(INPUT_REG * this->reg_width_, inputs, this->reg_width_, true); + this->last_error_ = this->read_register(INPUT_REG * this->reg_width_, inputs, this->reg_width_); if (this->last_error_ != i2c::ERROR_OK) { this->status_set_warning(); ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_); @@ -114,7 +114,7 @@ bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) { uint8_t outputs[2]; outputs[0] = (uint8_t) value; outputs[1] = (uint8_t) (value >> 8); - this->last_error_ = this->write_register(reg * this->reg_width_, outputs, this->reg_width_, true); + this->last_error_ = this->write_register(reg * this->reg_width_, outputs, this->reg_width_); if (this->last_error_ != i2c::ERROR_OK) { this->status_set_warning(); ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_); diff --git a/esphome/components/st7567_i2c/st7567_i2c.cpp b/esphome/components/st7567_i2c/st7567_i2c.cpp index 4970367343..710e473b11 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.cpp +++ b/esphome/components/st7567_i2c/st7567_i2c.cpp @@ -51,8 +51,7 @@ void HOT I2CST7567::write_display_data() { static const size_t BLOCK_SIZE = 64; for (uint8_t x = 0; x < (uint8_t) this->get_width_internal(); x += BLOCK_SIZE) { this->write_register(esphome::st7567_base::ST7567_SET_START_LINE, &buffer_[y * this->get_width_internal() + x], - this->get_width_internal() - x > BLOCK_SIZE ? BLOCK_SIZE : this->get_width_internal() - x, - true); + this->get_width_internal() - x > BLOCK_SIZE ? BLOCK_SIZE : this->get_width_internal() - x); } } } diff --git a/esphome/components/tca9548a/tca9548a.cpp b/esphome/components/tca9548a/tca9548a.cpp index edd8af9a27..1de3c49108 100644 --- a/esphome/components/tca9548a/tca9548a.cpp +++ b/esphome/components/tca9548a/tca9548a.cpp @@ -6,23 +6,15 @@ namespace tca9548a { static const char *const TAG = "tca9548a"; -i2c::ErrorCode TCA9548AChannel::readv(uint8_t address, i2c::ReadBuffer *buffers, size_t cnt) { +i2c::ErrorCode TCA9548AChannel::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count) { auto err = this->parent_->switch_to_channel(channel_); if (err != i2c::ERROR_OK) return err; - err = this->parent_->bus_->readv(address, buffers, cnt); + err = this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); this->parent_->disable_all_channels(); return err; } -i2c::ErrorCode TCA9548AChannel::writev(uint8_t address, i2c::WriteBuffer *buffers, size_t cnt, bool stop) { - auto err = this->parent_->switch_to_channel(channel_); - if (err != i2c::ERROR_OK) - return err; - err = this->parent_->bus_->writev(address, buffers, cnt, stop); - this->parent_->disable_all_channels(); - return err; -} - void TCA9548AComponent::setup() { uint8_t status = 0; if (this->read(&status, 1) != i2c::ERROR_OK) { diff --git a/esphome/components/tca9548a/tca9548a.h b/esphome/components/tca9548a/tca9548a.h index 08f1674d11..0fb9ada99a 100644 --- a/esphome/components/tca9548a/tca9548a.h +++ b/esphome/components/tca9548a/tca9548a.h @@ -14,8 +14,8 @@ class TCA9548AChannel : public i2c::I2CBus { void set_channel(uint8_t channel) { channel_ = channel; } void set_parent(TCA9548AComponent *parent) { parent_ = parent; } - i2c::ErrorCode readv(uint8_t address, i2c::ReadBuffer *buffers, size_t cnt) override; - i2c::ErrorCode writev(uint8_t address, i2c::WriteBuffer *buffers, size_t cnt, bool stop) override; + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override; protected: uint8_t channel_; diff --git a/esphome/components/tee501/tee501.cpp b/esphome/components/tee501/tee501.cpp index 460f446865..4f8003eb10 100644 --- a/esphome/components/tee501/tee501.cpp +++ b/esphome/components/tee501/tee501.cpp @@ -9,9 +9,9 @@ static const char *const TAG = "tee501"; void TEE501Component::setup() { uint8_t address[] = {0x70, 0x29}; - this->write(address, 2, false); uint8_t identification[9]; this->read(identification, 9); + this->write_read(address, sizeof address, identification, sizeof identification); if (identification[8] != calc_crc8_(identification, 0, 7)) { this->error_code_ = CRC_CHECK_FAILED; this->mark_failed(); @@ -41,7 +41,7 @@ void TEE501Component::dump_config() { float TEE501Component::get_setup_priority() const { return setup_priority::DATA; } void TEE501Component::update() { uint8_t address_1[] = {0x2C, 0x1B}; - this->write(address_1, 2, true); + this->write(address_1, 2); this->set_timeout(50, [this]() { uint8_t i2c_response[3]; this->read(i2c_response, 3); diff --git a/esphome/components/tlc59208f/tlc59208f_output.cpp b/esphome/components/tlc59208f/tlc59208f_output.cpp index a524f92f75..85311a877c 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.cpp +++ b/esphome/components/tlc59208f/tlc59208f_output.cpp @@ -74,7 +74,8 @@ void TLC59208FOutput::setup() { ESP_LOGV(TAG, " Resetting all devices on the bus"); // Reset all devices on the bus - if (this->bus_->write(TLC59208F_SWRST_ADDR >> 1, TLC59208F_SWRST_SEQ, 2) != i2c::ERROR_OK) { + if (this->bus_->write_readv(TLC59208F_SWRST_ADDR >> 1, TLC59208F_SWRST_SEQ, sizeof TLC59208F_SWRST_SEQ, nullptr, 0) != + i2c::ERROR_OK) { ESP_LOGE(TAG, "RESET failed"); this->mark_failed(); return; diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index f3016fb171..1e02e3e802 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -14,14 +14,12 @@ void VEML3235Sensor::setup() { this->mark_failed(); return; } - if ((this->write(&ID_REG, 1, false) != i2c::ERROR_OK) || !this->read_bytes_raw(device_id, 2)) { + if ((this->read_register(ID_REG, device_id, sizeof device_id) != i2c::ERROR_OK)) { ESP_LOGE(TAG, "Unable to read ID"); this->mark_failed(); - return; } else if (device_id[0] != DEVICE_ID) { ESP_LOGE(TAG, "Incorrect device ID - expected 0x%.2x, read 0x%.2x", DEVICE_ID, device_id[0]); this->mark_failed(); - return; } } @@ -49,7 +47,7 @@ float VEML3235Sensor::read_lx_() { } uint8_t als_regs[] = {0, 0}; - if ((this->write(&ALS_REG, 1, false) != i2c::ERROR_OK) || !this->read_bytes_raw(als_regs, 2)) { + if ((this->read_register(ALS_REG, als_regs, sizeof als_regs) != i2c::ERROR_OK)) { this->status_set_warning(); return NAN; } diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 2a4c246ac9..c3b601e288 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -279,20 +279,18 @@ ErrorCode VEML7700Component::reconfigure_time_and_gain_(IntegrationTime time, Ga } ErrorCode VEML7700Component::read_sensor_output_(Readings &data) { - auto als_err = - this->read_register((uint8_t) CommandRegisters::ALS, (uint8_t *) &data.als_counts, VEML_REG_SIZE, false); + auto als_err = this->read_register((uint8_t) CommandRegisters::ALS, (uint8_t *) &data.als_counts, VEML_REG_SIZE); if (als_err != i2c::ERROR_OK) { ESP_LOGW(TAG, "Error reading ALS register, err = %d", als_err); } auto white_err = - this->read_register((uint8_t) CommandRegisters::WHITE, (uint8_t *) &data.white_counts, VEML_REG_SIZE, false); + this->read_register((uint8_t) CommandRegisters::WHITE, (uint8_t *) &data.white_counts, VEML_REG_SIZE); if (white_err != i2c::ERROR_OK) { ESP_LOGW(TAG, "Error reading WHITE register, err = %d", white_err); } ConfigurationRegister conf{0}; - auto err = - this->read_register((uint8_t) CommandRegisters::ALS_CONF_0, (uint8_t *) conf.raw_bytes, VEML_REG_SIZE, false); + auto err = this->read_register((uint8_t) CommandRegisters::ALS_CONF_0, (uint8_t *) conf.raw_bytes, VEML_REG_SIZE); if (err != i2c::ERROR_OK) { ESP_LOGW(TAG, "Error reading ALS_CONF_0 register, err = %d", white_err); } diff --git a/esphome/components/veml7700/veml7700.h b/esphome/components/veml7700/veml7700.h index b0d1451cf0..4b5edf733d 100644 --- a/esphome/components/veml7700/veml7700.h +++ b/esphome/components/veml7700/veml7700.h @@ -3,7 +3,6 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -#include "esphome/core/optional.h" namespace esphome { namespace veml7700 { From a11970aee0360d53f256330d4f5c19b437995546 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 28 Aug 2025 07:16:26 +1000 Subject: [PATCH 0749/1626] [wifi] Fix retry with hidden networks. (#10445) --- esphome/components/wifi/wifi_component.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 987e276e0c..d16c94fa13 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -151,6 +151,8 @@ void WiFiComponent::loop() { this->status_set_warning("waiting to reconnect"); if (millis() - this->action_started_ > 5000) { if (this->fast_connect_ || this->retry_hidden_) { + if (!this->selected_ap_.get_bssid().has_value()) + this->selected_ap_ = this->sta_[0]; this->start_connecting(this->selected_ap_, false); } else { this->start_scanning(); @@ -670,10 +672,12 @@ void WiFiComponent::check_connecting_finished() { return; } + ESP_LOGI(TAG, "Connected"); // We won't retry hidden networks unless a reconnect fails more than three times again + if (this->retry_hidden_ && !this->selected_ap_.get_hidden()) + ESP_LOGW(TAG, "Network '%s' should be marked as hidden", this->selected_ap_.get_ssid().c_str()); this->retry_hidden_ = false; - ESP_LOGI(TAG, "Connected"); this->print_connect_params_(); if (this->has_ap()) { From e513c0f0043f6814659e6c871d2b6fc4a32e2e48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Aug 2025 23:23:43 +0200 Subject: [PATCH 0750/1626] Fix AttributeError when uploading OTA to offline OpenThread devices (#10459) --- esphome/__main__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 8e8fc7d5d9..aab3035a5e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -132,14 +132,17 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": - if (show_ota and "ota" in CORE.config) or ( - show_api and "api" in CORE.config + if CORE.address and ( + (show_ota and "ota" in CORE.config) + or (show_api and "api" in CORE.config) ): resolved.append(CORE.address) elif show_mqtt and has_mqtt_logging(): resolved.append("MQTT") else: resolved.append(device) + if not resolved: + _LOGGER.error("All specified devices: %s could not be resolved.", defaults) return resolved # No devices specified, show interactive chooser From 015977cfdfcf99e781af4d31e964c67d508dbc3a Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Wed, 27 Aug 2025 21:53:57 -0400 Subject: [PATCH 0751/1626] [rtttl] Fix RTTTL for speakers (#10381) --- esphome/components/rtttl/rtttl.cpp | 63 +++++++++++++++++------------- esphome/components/rtttl/rtttl.h | 25 ++++++++++++ 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 65a3af1bbc..5aedc74489 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -138,11 +138,37 @@ void Rtttl::stop() { this->set_state_(STATE_STOPPING); } #endif + this->position_ = this->rtttl_.length(); + this->note_duration_ = 0; +} + +void Rtttl::finish_() { + ESP_LOGV(TAG, "Rtttl::finish_()"); +#ifdef USE_OUTPUT + if (this->output_ != nullptr) { + this->output_->set_level(0.0); + this->set_state_(State::STATE_STOPPED); + } +#endif +#ifdef USE_SPEAKER + if (this->speaker_ != nullptr) { + SpeakerSample sample[2]; + sample[0].left = 0; + sample[0].right = 0; + sample[1].left = 0; + sample[1].right = 0; + this->speaker_->play((uint8_t *) (&sample), 8); + this->speaker_->finish(); + this->set_state_(State::STATE_STOPPING); + } +#endif + // Ensure no more notes are played in case finish_() is called for an error. + this->position_ = this->rtttl_.length(); this->note_duration_ = 0; } void Rtttl::loop() { - if (this->note_duration_ == 0 || this->state_ == State::STATE_STOPPED) { + if (this->state_ == State::STATE_STOPPED) { this->disable_loop(); return; } @@ -152,6 +178,8 @@ void Rtttl::loop() { if (this->state_ == State::STATE_STOPPING) { if (this->speaker_->is_stopped()) { this->set_state_(State::STATE_STOPPED); + } else { + return; } } else if (this->state_ == State::STATE_INIT) { if (this->speaker_->is_stopped()) { @@ -207,7 +235,7 @@ void Rtttl::loop() { if (this->output_ != nullptr && millis() - this->last_note_ < this->note_duration_) return; #endif - if (!this->rtttl_[this->position_]) { + if (this->position_ >= this->rtttl_.length()) { this->finish_(); return; } @@ -346,31 +374,6 @@ void Rtttl::loop() { this->last_note_ = millis(); } -void Rtttl::finish_() { -#ifdef USE_OUTPUT - if (this->output_ != nullptr) { - this->output_->set_level(0.0); - this->set_state_(State::STATE_STOPPED); - } -#endif -#ifdef USE_SPEAKER - if (this->speaker_ != nullptr) { - SpeakerSample sample[2]; - sample[0].left = 0; - sample[0].right = 0; - sample[1].left = 0; - sample[1].right = 0; - this->speaker_->play((uint8_t *) (&sample), 8); - - this->speaker_->finish(); - this->set_state_(State::STATE_STOPPING); - } -#endif - this->note_duration_ = 0; - this->on_finished_playback_callback_.call(); - ESP_LOGD(TAG, "Playback finished"); -} - #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG static const LogString *state_to_string(State state) { switch (state) { @@ -397,7 +400,11 @@ void Rtttl::set_state_(State state) { LOG_STR_ARG(state_to_string(state))); // Clear loop_done when transitioning from STOPPED to any other state - if (old_state == State::STATE_STOPPED && state != State::STATE_STOPPED) { + if (state == State::STATE_STOPPED) { + this->disable_loop(); + this->on_finished_playback_callback_.call(); + ESP_LOGD(TAG, "Playback finished"); + } else if (old_state == State::STATE_STOPPED) { this->enable_loop(); } } diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 420948bfbf..d536c6c08e 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -60,35 +60,60 @@ class Rtttl : public Component { } return ret; } + /** + * @brief Finalizes the playback of the RTTTL string. + * + * This method is called internally when the end of the RTTTL string is reached + * or when a parsing error occurs. It stops the output, sets the component state, + * and triggers the on_finished_playback_callback_. + */ void finish_(); void set_state_(State state); + /// The RTTTL string to play. std::string rtttl_{""}; + /// The current position in the RTTTL string. size_t position_{0}; + /// The duration of a whole note in milliseconds. uint16_t wholenote_; + /// The default duration of a note (e.g. 4 for a quarter note). uint16_t default_duration_; + /// The default octave for a note. uint16_t default_octave_; + /// The time the last note was started. uint32_t last_note_; + /// The duration of the current note in milliseconds. uint16_t note_duration_; + /// The frequency of the current note in Hz. uint32_t output_freq_; + /// The gain of the output. float gain_{0.6f}; + /// The current state of the RTTTL player. State state_{State::STATE_STOPPED}; #ifdef USE_OUTPUT + /// The output to write the sound to. output::FloatOutput *output_; #endif #ifdef USE_SPEAKER + /// The speaker to write the sound to. speaker::Speaker *speaker_{nullptr}; + /// The sample rate of the speaker. int sample_rate_{16000}; + /// The number of samples for one full cycle of a note's waveform, in Q10 fixed-point format. int samples_per_wave_{0}; + /// The number of samples sent. int samples_sent_{0}; + /// The total number of samples to send. int samples_count_{0}; + /// The number of samples for the gap between notes. int samples_gap_{0}; #endif + /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; }; From ba4789970ce08ba779fe492ee356391ce2353b5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Aug 2025 17:12:38 -0500 Subject: [PATCH 0752/1626] [esphome] Fix OTA watchdog resets by validating all magic bytes before blocking (#10401) --- .../components/esphome/ota/ota_esphome.cpp | 77 ++++++++++--------- esphome/components/esphome/ota/ota_esphome.h | 8 +- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 5217e9c61f..fc10e5366e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -100,8 +100,8 @@ void ESPHomeOTAComponent::handle_handshake_() { /// Handle the initial OTA handshake. /// /// This method is non-blocking and will return immediately if no data is available. - /// It waits for the first magic byte (0x6C) before proceeding to handle_data_(). - /// A 10-second timeout is enforced from initial connection. + /// It reads all 5 magic bytes (0x6C, 0x26, 0xF7, 0x5C, 0x45) non-blocking + /// before proceeding to handle_data_(). A 10-second timeout is enforced from initial connection. if (this->client_ == nullptr) { // We already checked server_->ready() in loop(), so we can accept directly @@ -126,6 +126,7 @@ void ESPHomeOTAComponent::handle_handshake_() { } this->log_start_("handshake"); this->client_connect_time_ = App.get_loop_component_start_time(); + this->magic_buf_pos_ = 0; // Reset magic buffer position } // Check for handshake timeout @@ -136,34 +137,47 @@ void ESPHomeOTAComponent::handle_handshake_() { return; } - // Try to read first byte of magic bytes - uint8_t first_byte; - ssize_t read = this->client_->read(&first_byte, 1); + // Try to read remaining magic bytes + if (this->magic_buf_pos_ < 5) { + // Read as many bytes as available + uint8_t bytes_to_read = 5 - this->magic_buf_pos_; + ssize_t read = this->client_->read(this->magic_buf_ + this->magic_buf_pos_, bytes_to_read); - if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { - return; // No data yet, try again next loop - } - - if (read <= 0) { - // Error or connection closed - if (read == -1) { - this->log_socket_error_("reading first byte"); - } else { - ESP_LOGW(TAG, "Remote closed during handshake"); + if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + return; // No data yet, try again next loop } - this->cleanup_connection_(); - return; + + if (read <= 0) { + // Error or connection closed + if (read == -1) { + this->log_socket_error_("reading magic bytes"); + } else { + ESP_LOGW(TAG, "Remote closed during handshake"); + } + this->cleanup_connection_(); + return; + } + + this->magic_buf_pos_ += read; } - // Got first byte, check if it's the magic byte - if (first_byte != 0x6C) { - ESP_LOGW(TAG, "Invalid initial byte: 0x%02X", first_byte); - this->cleanup_connection_(); - return; - } + // Check if we have all 5 magic bytes + if (this->magic_buf_pos_ == 5) { + // Validate magic bytes + static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; + if (memcmp(this->magic_buf_, MAGIC_BYTES, 5) != 0) { + ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->magic_buf_[0], + this->magic_buf_[1], this->magic_buf_[2], this->magic_buf_[3], this->magic_buf_[4]); + // Send error response (non-blocking, best effort) + uint8_t error = static_cast(ota::OTA_RESPONSE_ERROR_MAGIC); + this->client_->write(&error, 1); + this->cleanup_connection_(); + return; + } - // First byte is valid, continue with data handling - this->handle_data_(); + // All 5 magic bytes are valid, continue with data handling + this->handle_data_(); + } } void ESPHomeOTAComponent::handle_data_() { @@ -186,18 +200,6 @@ void ESPHomeOTAComponent::handle_data_() { size_t size_acknowledged = 0; #endif - // Read remaining 4 bytes of magic (we already read the first byte 0x6C in handle_handshake_) - if (!this->readall_(buf, 4)) { - this->log_read_error_("magic bytes"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - // Check remaining magic bytes: 0x26, 0xF7, 0x5C, 0x45 - if (buf[0] != 0x26 || buf[1] != 0xF7 || buf[2] != 0x5C || buf[3] != 0x45) { - ESP_LOGW(TAG, "Magic bytes mismatch! 0x6C-0x%02X-0x%02X-0x%02X-0x%02X", buf[0], buf[1], buf[2], buf[3]); - error_code = ota::OTA_RESPONSE_ERROR_MAGIC; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - // Send OK and version - 2 bytes buf[0] = ota::OTA_RESPONSE_OK; buf[1] = USE_OTA_VERSION; @@ -487,6 +489,7 @@ void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; this->client_connect_time_ = 0; + this->magic_buf_pos_ = 0; } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 8397b86528..c1919c71e9 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -41,11 +41,13 @@ class ESPHomeOTAComponent : public ota::OTAComponent { std::string password_; #endif // USE_OTA_PASSWORD - uint16_t port_; - uint32_t client_connect_time_{0}; - std::unique_ptr server_; std::unique_ptr client_; + + uint32_t client_connect_time_{0}; + uint16_t port_; + uint8_t magic_buf_[5]; + uint8_t magic_buf_pos_{0}; }; } // namespace esphome From 07875a8b1e557a1fbff57f3ba9ce714a20498ab3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 29 Aug 2025 10:16:19 +1200 Subject: [PATCH 0753/1626] Bump version to 2025.8.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 24dcc2e859..6e0a1a42e8 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.8.1 +PROJECT_NUMBER = 2025.8.2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 555434e4b9..e0e72a6758 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.8.1" +__version__ = "2025.8.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From c526ab9a3f49cfc48632f50639ccb9f3c6af6aab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 22:20:23 +0000 Subject: [PATCH 0754/1626] Bump ruff from 0.12.10 to 0.12.11 (#10483) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8b76523b2e..e05733ec96 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.10 + rev: v0.12.11 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index f55618c0f8..22f58fd3d7 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.8 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.12.10 # also change in .pre-commit-config.yaml when updating +ruff==0.12.11 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From 5dc691874b2139e0b6468d4559ed80e1eba3f17e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Aug 2025 17:30:14 -0500 Subject: [PATCH 0755/1626] [bluetooth_proxy] Remove unused ClientState::SEARCHING state (#10318) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 16 +++++++------- .../esp32_ble_client/ble_client_base.cpp | 5 ++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 21 +++++++------------ .../esp32_ble_tracker/esp32_ble_tracker.h | 9 +------- 4 files changed, 19 insertions(+), 32 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 723466a5ff..80b7fbe960 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -183,6 +183,12 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_device_connection(msg.address, false); return; } + if (!msg.has_address_type) { + ESP_LOGE(TAG, "[%d] [%s] Missing address type in connect request", connection->get_connection_index(), + connection->address_str().c_str()); + this->send_device_connection(msg.address, false); + return; + } if (connection->state() == espbt::ClientState::CONNECTED || connection->state() == espbt::ClientState::ESTABLISHED) { this->log_connection_request_ignored_(connection, connection->state()); @@ -209,13 +215,9 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); } - if (msg.has_address_type) { - uint64_to_bd_addr(msg.address, connection->remote_bda_); - connection->set_remote_addr_type(static_cast(msg.address_type)); - connection->set_state(espbt::ClientState::DISCOVERED); - } else { - connection->set_state(espbt::ClientState::SEARCHING); - } + uint64_to_bd_addr(msg.address, connection->remote_bda_); + connection->set_remote_addr_type(static_cast(msg.address_type)); + connection->set_state(espbt::ClientState::DISCOVERED); this->send_connections_free(); break; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 4805855960..af5162afb0 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -93,7 +93,7 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { return false; if (this->address_ == 0 || device.address_uint64() != this->address_) return false; - if (this->state_ != espbt::ClientState::IDLE && this->state_ != espbt::ClientState::SEARCHING) + if (this->state_ != espbt::ClientState::IDLE) return false; this->log_event_("Found device"); @@ -168,8 +168,7 @@ void BLEClientBase::unconditional_disconnect() { this->log_gattc_warning_("esp_ble_gattc_close", err); } - if (this->state_ == espbt::ClientState::SEARCHING || this->state_ == espbt::ClientState::READY_TO_CONNECT || - this->state_ == espbt::ClientState::DISCOVERED) { + if (this->state_ == espbt::ClientState::READY_TO_CONNECT || this->state_ == espbt::ClientState::DISCOVERED) { this->set_address(0); this->set_state(espbt::ClientState::IDLE); } else { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 00bd1fe34c..0edde169eb 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -49,8 +49,6 @@ const char *client_state_to_string(ClientState state) { return "DISCONNECTING"; case ClientState::IDLE: return "IDLE"; - case ClientState::SEARCHING: - return "SEARCHING"; case ClientState::DISCOVERED: return "DISCOVERED"; case ClientState::READY_TO_CONNECT: @@ -136,9 +134,8 @@ void ESP32BLETracker::loop() { ClientStateCounts counts = this->count_client_states_(); if (counts != this->client_state_counts_) { this->client_state_counts_ = counts; - ESP_LOGD(TAG, "connecting: %d, discovered: %d, searching: %d, disconnecting: %d", - this->client_state_counts_.connecting, this->client_state_counts_.discovered, - this->client_state_counts_.searching, this->client_state_counts_.disconnecting); + ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d", this->client_state_counts_.connecting, + this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); } if (this->scanner_state_ == ScannerState::FAILED || @@ -158,10 +155,8 @@ void ESP32BLETracker::loop() { https://github.com/espressif/esp-idf/issues/6688 */ - bool promote_to_connecting = counts.discovered && !counts.searching && !counts.connecting; - if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && - !promote_to_connecting) { + if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && !counts.discovered) { #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE this->update_coex_preference_(false); #endif @@ -170,12 +165,11 @@ void ESP32BLETracker::loop() { } } // If there is a discovered client and no connecting - // clients and no clients using the scanner to search for - // devices, then promote the discovered client to ready to connect. + // clients, then promote the discovered client to ready to connect. // We check both RUNNING and IDLE states because: // - RUNNING: gap_scan_event_handler initiates stop_scan_() but promotion can happen immediately // - IDLE: Scanner has already stopped (naturally or by gap_scan_event_handler) - if (promote_to_connecting && + if (counts.discovered && !counts.connecting && (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::IDLE)) { this->try_promote_discovered_clients_(); } @@ -633,9 +627,8 @@ void ESP32BLETracker::dump_config() { this->scan_duration_, this->scan_interval_ * 0.625f, this->scan_window_ * 0.625f, this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); ESP_LOGCONFIG(TAG, " Scanner State: %s", this->scanner_state_to_string_(this->scanner_state_)); - ESP_LOGCONFIG(TAG, " Connecting: %d, discovered: %d, searching: %d, disconnecting: %d", - this->client_state_counts_.connecting, this->client_state_counts_.discovered, - this->client_state_counts_.searching, this->client_state_counts_.disconnecting); + ESP_LOGCONFIG(TAG, " Connecting: %d, discovered: %d, disconnecting: %d", this->client_state_counts_.connecting, + this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); if (this->scan_start_fail_count_) { ESP_LOGCONFIG(TAG, " Scan Start Fail Count: %d", this->scan_start_fail_count_); } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 763fa9f1c6..dd67156108 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -141,12 +141,10 @@ class ESPBTDeviceListener { struct ClientStateCounts { uint8_t connecting = 0; uint8_t discovered = 0; - uint8_t searching = 0; uint8_t disconnecting = 0; bool operator==(const ClientStateCounts &other) const { - return connecting == other.connecting && discovered == other.discovered && searching == other.searching && - disconnecting == other.disconnecting; + return connecting == other.connecting && discovered == other.discovered && disconnecting == other.disconnecting; } bool operator!=(const ClientStateCounts &other) const { return !(*this == other); } @@ -159,8 +157,6 @@ enum class ClientState : uint8_t { DISCONNECTING, // Connection is idle, no device detected. IDLE, - // Searching for device. - SEARCHING, // Device advertisement found. DISCOVERED, // Device is discovered and the scanner is stopped @@ -316,9 +312,6 @@ class ESP32BLETracker : public Component, case ClientState::DISCOVERED: counts.discovered++; break; - case ClientState::SEARCHING: - counts.searching++; - break; case ClientState::CONNECTING: case ClientState::READY_TO_CONNECT: counts.connecting++; From cde00a1f4c072ea170c0f5182dc2718dc989c61b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 Aug 2025 11:02:15 +1200 Subject: [PATCH 0756/1626] Bump esphome-dashboard from 20250814.0 to 20250828.0 (#10484) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2665211381..910f70fe45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyserial==3.5 platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 -esphome-dashboard==20250814.0 +esphome-dashboard==20250828.0 aioesphomeapi==39.0.0 zeroconf==0.147.0 puremagic==1.30 From bc960cf6d2b48ea5ace544dcd73fa9dd27fdea20 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 29 Aug 2025 10:52:37 +1000 Subject: [PATCH 0757/1626] [mapping] Use custom allocator (#9972) --- esphome/components/mapping/__init__.py | 18 +++-- esphome/components/mapping/mapping.h | 69 ++++++++++++++++++ tests/components/mapping/.gitattributes | 1 + tests/components/mapping/common.yaml | 19 ++++- tests/components/mapping/helvetica.ttf | Bin 0 -> 83644 bytes tests/components/mapping/test.esp32-ard.yaml | 18 ++--- .../components/mapping/test.esp32-c3-ard.yaml | 14 ++-- .../components/mapping/test.esp32-c3-idf.yaml | 14 ++-- tests/components/mapping/test.esp32-idf.yaml | 14 ++-- .../components/mapping/test.esp8266-ard.yaml | 14 ++-- tests/components/mapping/test.host.yaml | 16 ++-- tests/components/mapping/test.rp2040-ard.yaml | 14 ++-- 12 files changed, 152 insertions(+), 59 deletions(-) create mode 100644 esphome/components/mapping/mapping.h create mode 100644 tests/components/mapping/.gitattributes create mode 100644 tests/components/mapping/helvetica.ttf diff --git a/esphome/components/mapping/__init__.py b/esphome/components/mapping/__init__.py index 79657084fa..94c7c10a82 100644 --- a/esphome/components/mapping/__init__.py +++ b/esphome/components/mapping/__init__.py @@ -10,7 +10,8 @@ from esphome.loader import get_component CODEOWNERS = ["@clydebarrow"] MULTI_CONF = True -map_ = cg.std_ns.class_("map") +mapping_ns = cg.esphome_ns.namespace("mapping") +mapping_class = mapping_ns.class_("Mapping") CONF_ENTRIES = "entries" CONF_CLASS = "class" @@ -29,7 +30,11 @@ class IndexType: INDEX_TYPES = { "int": IndexType(cv.int_, cg.int_, int), - "string": IndexType(cv.string, cg.std_string, str), + "string": IndexType( + cv.string, + cg.std_string, + str, + ), } @@ -47,7 +52,7 @@ def to_schema(value): BASE_SCHEMA = cv.Schema( { - cv.Required(CONF_ID): cv.declare_id(map_), + cv.Required(CONF_ID): cv.declare_id(mapping_class), cv.Required(CONF_FROM): cv.one_of(*INDEX_TYPES, lower=True), cv.Required(CONF_TO): cv.string, }, @@ -123,12 +128,15 @@ async def to_code(config): if list(entries.values())[0].op != ".": value_type = value_type.operator("ptr") varid = config[CONF_ID] - varid.type = map_.template(index_type, value_type) + varid.type = mapping_class.template( + index_type, + value_type, + ) var = MockObj(varid, ".") decl = VariableDeclarationExpression(varid.type, "", varid) add_global(decl) CORE.register_variable(varid, var) for key, value in entries.items(): - cg.add(var.insert((key, value))) + cg.add(var.set(key, value)) return var diff --git a/esphome/components/mapping/mapping.h b/esphome/components/mapping/mapping.h new file mode 100644 index 0000000000..99c1f38829 --- /dev/null +++ b/esphome/components/mapping/mapping.h @@ -0,0 +1,69 @@ +#pragma once + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include +#include + +namespace esphome::mapping { + +using alloc_string_t = std::basic_string, RAMAllocator>; + +/** + * + * Mapping class with custom allocator. + * Additionally, when std::string is used as key or value, it will be replaced with a custom string type + * that uses RAMAllocator. + * @tparam K The type of the key in the mapping. + * @tparam V The type of the value in the mapping. Should be a basic type or pointer. + */ + +static const char *const TAG = "mapping"; + +template class Mapping { + public: + // Constructor + Mapping() = default; + + using key_t = const std::conditional_t, + alloc_string_t, // if K is std::string, custom string type + K>; + using value_t = std::conditional_t, + alloc_string_t, // if V is std::string, custom string type + V>; + + void set(const K &key, const V &value) { this->map_[key_t{key}] = value; } + + V get(const K &key) const { + auto it = this->map_.find(key_t{key}); + if (it != this->map_.end()) { + return V{it->second}; + } + if constexpr (std::is_pointer_v) { + esph_log_e(TAG, "Key '%p' not found in mapping", key); + } else if constexpr (std::is_same_v) { + esph_log_e(TAG, "Key '%s' not found in mapping", key.c_str()); + } else { + esph_log_e(TAG, "Key '%s' not found in mapping", to_string(key).c_str()); + } + return {}; + } + + // index map overload + V operator[](K key) { return this->get(key); } + + // convenience function for strings to get a C-style string + template, int> = 0> + const char *operator[](K key) const { + auto it = this->map_.find(key_t{key}); + if (it != this->map_.end()) { + return it->second.c_str(); // safe since value remains in map + } + return ""; + } + + protected: + std::map, RAMAllocator>> map_; +}; + +} // namespace esphome::mapping diff --git a/tests/components/mapping/.gitattributes b/tests/components/mapping/.gitattributes new file mode 100644 index 0000000000..9d74867fcf --- /dev/null +++ b/tests/components/mapping/.gitattributes @@ -0,0 +1 @@ +*.ttf -text diff --git a/tests/components/mapping/common.yaml b/tests/components/mapping/common.yaml index 07ca458146..7ffcfa4f67 100644 --- a/tests/components/mapping/common.yaml +++ b/tests/components/mapping/common.yaml @@ -50,6 +50,14 @@ mapping: red: red_id blue: blue_id green: green_id + - id: string_map_2 + from: string + to: string + entries: + one: "one" + two: "two" + three: "three" + seventy-seven: "seventy-seven" color: - id: red_id @@ -65,7 +73,14 @@ color: green: 0.0 blue: 1.0 +font: + - file: "$component_dir/helvetica.ttf" + id: font_id + size: 20 + display: lambda: |- - it.image(0, 0, id(weather_map)[0]); - it.image(0, 100, id(weather_map)[1]); + std::string value = id(int_map)[2]; + it.print(0, 0, id(font_id), TextAlign::TOP_LEFT, value.c_str()); + it.image(0, 0, id(weather_map)["clear-night"]); + it.image(0, 100, id(weather_map)["sunny"]); diff --git a/tests/components/mapping/helvetica.ttf b/tests/components/mapping/helvetica.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7aec6f3f3cc74a7138ce350def27a2209b44ad89 GIT binary patch literal 83644 zcmd?S2Y4LSy*_--%=S9lyR@rSv9z}3wJcl0vbDA(<3cgUCXxb)gk`V+n_|4l3Yj}864tp4Kc*DPz-_8EACAp&9+A0d(P~xMoF$YH;vl_5BwZ(;T03>_4_4lSGs9R&7vjZ%kcq{@Qh;)5%$=?q#g} zf{RvM(D>reVjMq!`XvYzZ>Dz;J#pwC)YsuHBHE|;7CB3BoAiR!pc4c^6m+aQk!c#19JkpR1?PI^$aU2FmZwUjrJp5%RgvsGCekmrlQfkohalyJW?>Rm;2cmyF_% zBuCYiE6%%UT0Oy+@jrnu`V-;>{jn#M5QAW0Cvc#ZumkOn5+T1=_$-th`KjG0;5&7A zTLUo?6EPDDu@W1xqqUtxA}-=49z54a{3JkvBt*g_LZT!_N=PY*lLRRvNm5QKNF}Kv z6G%0wAt^GEOd^v>Etx{nWGbm6(?~s;PG*n>(nyXYGf5MfMP`#`yxtr#m$Z;p5M~}Z zp3El;$U?G+oIp+_Cy|rMVsZ*uLQW-1$ue>p`37kx9i)?dlblY@Am1Y2CS7=?d&&Le zLGmzpk~~9RB7>x#Y$ZP++sO{Hle|h^A-l+r$!_u!@>B9U*+bqWZ;+pnx5!@d4%tWE zCGXLtDBZax{RJp7t>Q|JGqlQLT)FIl84A9@;&l6d5kUF0>gpL|L_ zBflh{lP}1x$-k3PN+_iQ6{(IIsf{|Qhx%y*H7=t`nxa!^n$DoJ@r)Buzi-lR{`Tk6 zwW#x5!cT;s3LlF@ac?{vFNv4ME8|n*$HiOY>*CKR$`X}jLYbk=S>`VDm4(Yn%2H+X z%eu-|RJ{J5|M-J&Pymk;@XjW**o(ZTJI2-Y$7rjun*NIXh76CZsU9`8QzvQ~0CibS zYv^RubUJNB3!H$OoyGLIeeP#3|^(ysB zb@j-G&l*3Q{#pHJ(>_aoHsQ1QXMsFEj>`!Oz@7jOH{$=}@esbRallCv#-@HG$ z-?rcU;rCrPxUMo>bCebUf&c&eClTXX<66T>`W^aT>o;M6rq$m7TeL&JPd8b25_I=p z1QC0a#bsi%FbhkyMy=7$(UR}09crCgN3%4p-yvM6zoJOVcgQ=!rMhY&gGZmlD)Z%M z&?-Za-hRm0cIco1NdG#r4SHz<wST1TI{X4w`Yyc7kO zJ%<+_cErCYvuNRh`Nz*|Yi*f3C(}H8R@2Pm8XIOzub);oH9e(v@}!BWn(7Hvl@;a5 zvP8VJBo>W?L&1RG=k>T(Gg#=L8iVeB|4X92JX-vh<9m8w#56i zC5gKh#`}`picVjdti*e69UWcTo=C_ff-C0r&FktrzVG<1ZaKc3Q;2pY+g7zsn%I_{ zm&DU5yJcNva{hdNq6^1eg=5rgL0s-#(ysJo`tW9FUcD-qY}*(~v~}Qlt;s|y-oLH2 ztrcG@2XVD*WX)qtCA(Lxz!eP*8hse?+mvc=DmgbXX<{~Rr*o67FT=T!;MnL|Ym~A? zUuzOIT69Ws(UMMlnYS(3n)Om|TNln!mpk4VuO-RmNfWn0(0kfWX-~>sr?$u2rR+>Hcvq1|AdNfQ|pB@z}?XF<<$Ttl=EcGTC0gTCb(aaOVED(0@2 zgDvfH90alTBzrb&&5$#bZE?A4aeE>Xmp9=PC1<+3Iyxe49ZIaWuQkH9AZ4o1 zd=WX8VilIyw!gV`rLuyxgI$?WrL{-(B5fuj7XahLJycEFkLfje9cC%1>B+Ae8Z72GhlCE0f7+^pGDy%lWKhu3OJcB65j zG1@_|ws^a2YFRFuTe>=0+xpu2+RuatG0yMM*q8A$+vDt(o}5bMR@7Ff=hnz+>`i5R zlP1n-CMyUtS!Y8jur1S+XA|afolR7}9pT$Ue0z{@5Af|j`1bF7yN_>w&bM##?OS}i zmv4W@w{P+-3vtfo>iKN)Z-mVG1CCzj=YGn!EQmWB=8tV(7xL{I28DuH^V-LBDZ?QSz8=l~29_QQd@$DwQeT;7(<=aR2_Hxwl@TXtQ&#&X#wS0RK z-y$T(mFM&AYJS&we19e1XSDBO-=y(tyF4u-*L85~SK!HHmVi53;(ZX1w$=!YRZZJ+ z=$=){zU54LGok6)N=m!>`ntE0cz0jCt#3o$M(l1#g8Nr>_sG?4iv zoHQ}5wsrK&Xh%l^I#6jeRU}jG1PZQCF|&Otm$6+~##BTxmeh2}{+4mq@j~r7c8lff zuH%Jc-}klGvG22teczGK1z~&7gkF22H3=V~6Lu^v-`bk&h|6E{{S*2A9em%;_v3Ij zKw<2&_v3`-YE=gv*aYpjYu7F`5iHMkqzbCYNo_q#=59yMpq?z&c0CCGPiDI>!`RNp zvJl$78^^ht#_Gnlxr{lw=Vt0W4c-~T{F&Z`NCV6h=g)UFyE4LpR`+6ZqU$8j$-4QA z+|!XW(DDWKhWU;9HYhu$Gvn=Xxw_>{xu#`#mt4XWy1wF)_{z8(M|;J)`ZjI|c67-4 z%0zq>8WK)GD|;q$&$h7?Mr0d_lhO{}$`g}wyW~m9x$->r-EDHJrK>OA(%G)SH{D96Kuc~KYx=;yU7b~`{!@+YBvSIfrsCuz03G_ z8mnGFyD5mY*Vs+0-t4BrCn&=A*(W<6#7!NAzezYLuo7ha4j{{BBs!U-%8+&8f9yOV zBR^p(mBPH>8gkHp&-)I-ujYhcQB0DY!0zW z_zDXRv9CY{m$Yvsz2S~*hWLlW=_wr^(N$OH5|e3ToiF*j+T}g9^($U~ot_~)M1QKz z7+G=qn|RaP#Q|YH;&eu(HSA3lN((P&D!w4)FKCb{e?fEc1r6*4x9FD|8|hN9v3a<8 zn7)i8q(O)og?qMe4_QpAOj0Dsyxr}1&1I=u_%=&_4or}cQ`B=VFH?t-il~-D%N@G#5!Y=uSri*#5$vn zuO5^MrL)!9*U$ci`xn(%XI(!_#rlE!gKDg^(-YKc{SE3OI-oAPPW|LMbt!$Gt=G}A z>*&D9>*^=845@xHcU00<8zv&pd>m;Lx-<##79=E9Xsmg~#!B(Va?2^+SjQI|%ZNpkhDuF#fGWoX9$&s zbCr!D1dNT1$`pz+12zM}0GTo+ppkWpKNO<1K@_5y&`7zqinNV2^gv~6Wuv_jr|?g= z!GJ#6AFHOJl(c?m*CDEu#-t&+RFX^gDe*YnNp>pnQiFrpu1CMb<;~>oe!WCPZhpC9 z(Bo=@BpdcAMkBx4Xkb?x$=&Sg45E)!r$SD<6nFCiBmf#K-6>YVfyPRSy0m;;$$01K^19Y^IrZH^sfQ*K*|=15?Q#>JAqt<`R$ zBvet^n8xc*VL=xEle7=8j3OPE+Kz6644ItkZEg#vQnIh5J?HkfHE$)8Gq&b7?75rq z;ciHE6tApL7p_dzrtA5Y^=a&-X5k|>cI9BN(T4v>{%fhhXyX?fZ4~U^gq7lA{>V^i zyn>g-D?%0V(%D!;DC?j;f50C|C)1Txl~pe6B6(yq7=6iloT#r)*QHsx3x$>S(`HPc zQSUMs4L&bB=b6qjQ7EZ$2`gV(-D`Tmy11!p?(WmSdHrQS>3-+JYZd(|bFT{T{r0!7 zzG&O(*~ibCby@e7Z6_SpxURx5ah<*MoNbq6(pFV+)pd0}`qN8Sn|J@zxnkX?*6%Nz zX%m9R`fr_a|Bee*ST9olW}4A_#gfK`68iqZC5GeXT-DV)yTm-AqK9Yk=qH9Z^qat{ zA@twWkQww@O{<9oL1`!oipT|}N*1}GG!_M=ifOfN=o0#&LhM_~5BnogLt_d%N_iUF zTr6C&sCj7Uis9fstYdq0FcOVKtHL69eeaM-iA0rHcq_Rq8wp2KB2~w|_#{~5lfhoQg#SXD8qEF>Z#RF)5b~Rwuo?<& zYPNa&TA2rX=4QXgW-gQwQS$II$wNGnI2$V|l!*qrq?OsRC(e@W2C-09UNvn7FPk=_ ze8#k@CamR!vZ|W&G_5R+y{hJE>6)rSSxI6thy|9OoGwXEPRzzyQYcH5rzUGBhL%_es=rqd-vXc`_F_)SMn>DUU}eS{VNA%M~^OG+P8CbzrI?( zjg+I0Zw`%Uyc8?&(%d31#W*kdv;rG#DKe9fF_RKr+^qN)H7hXO8FK{*S70GfCK>Z= zBF@Ib4oEH`R)?9Q%sHK67`m#K_E-B7Bqr^`$&9&{#A>lAI*L>W zurM8^RNHXEMyhRC_z5q&!b<{PEc`?wg%c@~NMYe>O)>bST^aLCVwj1Ae{Et0u9-m+ zGqCWREbJx=Nm;OHH%UHepX}VjjA6dzpe#HAD26)d1tU#X84O9*a5z&{SxKi(pE09K z%&QEK*B=OQrBRtIgKjds_Pu+yd~5z{)u*3q-DE$`?W6RUH=X{$b+7#Aw-$cSGWQhf zKWTH5<(%L3%xYh>$bVdI_1yG=(-$q8JXv?necN8VZN-{pbJg8p%SCJNx?^&c`jbuH zKJ(odpIG;Q%{_C!WlI+a6!rC;sAAU z@9cQ3l}VwMb;VXDyp_=z$u)+)*vJBFS$R;ziaRq6Oz z6*c8ptu#$Z@|tq2R)oK|wNd1?vRa6!tH^F((%=b98l{H zB3gL!&wrtBQeG`e7K;}OKh?hmCA>(Zm9x+q-Xg7>yfsAG$y>u$q?O1;j#z7JsqB<; zM$zhF9tY!5V|qrwm<|}L(#EP8>8crlbQS$K<0n;L+_~bW8&}-&Rezdduw$^Xu1w0Lwxce>6D)WCYJHbuYPR;-QYw;S_*yS-Q&Bd3wkAPQT_x}3>O zXf6EKLs)6fqBodvcF?L53HmqSd3=;F8#EeCUN4~wTE7|^g4?h2iWaMvNM8-%pS?+< zJ^}Vh_!6$9U{%}b%lFZj)wcWSR_#-5WA)CeZ`1YU7l=OdR4j6wt5|Q1rxCSrD!PmH z)}h}0!cwDi5oz8xoYS3wnoGMFU(cwotAmC%rt8xX-Wkbc_S|#MT-zM<=)`kZuO58p zq3E}*Gbf#n=XcUy2#bZgp-1yFRphNPnbLTR=6P#Orl{762>RA$MWMABBxC%IH}0f= zRV~8Z$Zp~dvALOifG3=IgiMV+;lGmFZRnZ^DI(r+cpI9)FEa<1y(8kd?7Q!N@ByLd z*?wR6rv3{&BQLi?ffw-%qZXD5oEM2K6f}cVJI^I#fQXF(h0oADL=}-B7!u)SgC8!( zo53|;?TKD~;hU=M7W$R`i+_A&upu@(Ve|uCmFCN!Vq9Ys=@%}xpGH4(o_>*H`j+>I?cgAMM6VmrB6F*Gy}c%lY-#KydiSzFAK=$HHk8}p58ch&9+(wr!o&CH2m zx8H}`zo&UC{ZiEIkKiVHm?t}!v3oL-f6PNEdKw0gWNyM*^iV`Y(L=$WILq%D^E8N9 z^lV7TFV4nV^ia&UqKAS#bF*aUo=u0ojJPCHH4ed*pazBTFYZ+D669b!xCZ3nu* z^a!4a&WRc($O~Ext%Vwv6?wzTYiN4jI77DWfEJW`A zlClITvi{O=#%v;16Bg!DFiwZ&U`ra3oSx>cn9d!%GMqrnfu;=jbLZNC^`;R z{*unPf6bGBco{@7l8YKdAH4OBhw= zL1rd_D)r<8jVh%Ds!T6ZrIb@8s1?`%04n5=f?Va;&}fm9o0-qIO^zVk^?D*)IFwYX zv346A!54;esi~={*@(@CXifwe$tE3AN+Sq{O*T;G*fHz_U-AOeSh#A^!c|a92?nLT z+mKv-U|5-4D(%Y#z077Imvc(HJnxbY*|`tBsD#|lbQ6U`tkG-CVEk*VWPA{bBQ+-@WXbg&C8mdsJ_*PHuvR zg8Qkf)jx)mXn>xs(YvS{gGG94y3t;scc@5jJEylE^oFp&ft4gXvTtrZ@&-EQ{SGN+ za0QN@}#4Y5Vw*GvpO-F!WE{hTZ|!g zquDOEXFPz<3;zxU*Cira>SjCNv(bxbP>_kvSE37ga;yS4MZ5joR=yv+F9 z!ii(vmI&jv#JJm5;Jz&J8F95@^hr(GjN8Y)-Eg34bWKFRpL%bzDa)`HN zs@Req&7Xp>`A=>pcrlh&+cKP!BtK!owhidk=ZCig9gA*e1j$r%nT9OE zE}90r9boS9xsFV{vNu^-@FXO}Fg#C?tdx)gXu>KLatI!OJgJq%vFB+{#{C|iLzt>% zuXsdhOoQrft``ND1RYpRC?MPvx_5kL%36BQeY*1`(@lH#{6qcY$HSu=`dXgo?R^64 zl2i1X-snGf-KJZHpHu(w-h1i+U}R6B7r*}cA^!$h-mpRGXoqy2z;)ma@D$G0+Qd+3 zld<^8z}v(m8}gn)!Qhy9n>b*HO^`4nQjb}KPOm9Dnsr!kVwa+GD1<4jti_@EiV3&J z4YsOJ_^ojWS@Ev!|1}1bG@S?Vt7B5zRufD0ip`r&~Z8pO!FA@d5>W%C#L5$4S;)0nrVDq38c4?EyTo}$StXq24J zqG2x%Gmb|J z&sW6N&(>_*xMt19dGprX`1I2^-nbbN{(*4S>fGyp{p;(wHI*T6@LLbQ_|7{oKKQLr z^VZC zgg8(5!4rgO6atL`!C(gK&4=x zs}!cy4`+c)WxO{TFSOhddSg~zZ}6P>5&C9!Uf&?fD0)@!q|ye281>WOssdAKG+cTbPa( zQ{#K@QG>cgeM?>S(MRI%iz)@}!lPPU5^`%Kmul@&jNHe13J;HK$9f8RHVXY9G?dl1 zB2&TQTh?C)(?=J?`xdvakTU=w8j}sOp8GB6Eiq6(oTaTS0G4Y9Fg(07hC389V(LY5@RatP&Vx{}NpqTx%2V*y z$>%CW;oNuMMYiJMhoaxI&YXM(e?N+G4w7!6_*WSD%eA*FW_rxUx6?8`rhKNyQhYnE z%?Ctc5{Rj`_M-(eGiGDPWW>TkpNv_T5p`I2YE@^ft9zaQ4` ze@-t0p~#`+=_O<^T0@{3z4RG_9t)?JUI4v7J7b<+qDc>Wfnv<_qV=E_Xr^0uC@2O& zvft9JYWouyD6#knwS5tz86aTdAN21*)5S@@Ml*du(6tLK`|ChNJTD=W6Fjqz>P z>n>T?nlm@X0WHXwkuQU~GxOTTNiCFL3E%;W+j~gSn^_R3mlRv+HtHv_eZx}59Lm^2 zSlIP)s8;bHsh=^sGce_D4?pRy#VxXapQ6)o7fPpRE|iY+e2H`m`bKz!JRJ@V>mD&0 z41r`N!aCmLCPGeymYZ*uWb2=PKJ?{Tmz^|Qly#lwFK(Ta`C|8OedB$bzxexy%F2P& z^X^%7&)((pEvHF33!nl1R378(|}0+*vv!ke-pQ6AgY5 z4anWk8PhBEb63BjX8)ufQ17GXQ_Focwq*So>LWi^2hIC{QjeYM!TC3io{t?S9*PZj^Zn_f#r{>X} z>P_l@eyBctd?4aH=j8`Kqw^ki-!}5VlosKvzdzM+#@%c0ewj%{3}Z%SqR&G@{BGcF zPo#oW1W8#w&m}Rb0Imm249HE07S3~Rt_Y3Ig8{!&FhHsAV$wMvVsHTus1cb1cSBYT zcsznLU}xT8?J&uCtR8FDDcDj2NNM|x9)$Y{5ENwiSa!@=nHrP;z>ugvHE6fm1JM2{ z#i-+j1{5+K3f-R?G+K;)FLtOxC222`15?5uh5L10FO?RyUh!2>2}tZq$*L@s5YSnr zSSstmMpXPd_Jw3+R@9-uqCT&VbndzLj`Tlx z*4e_{!}MpL>vtTOOP^Iw)BSp6ov@Ml*>JY?I#4t~%E%;|)#NEqpy_0i&}(_&0M|4w zIlyUJOG|9pc^CJoe2bdMfTfmw(uleA`R)XWKsq5lPZ|6oh4;qc2EY!&Q(?< z%GlL37YrrgB=_WKfClop$)O>1ftHZFawd}tu>N<^pbG_I()S8w^f%Paht1}-Bw+W+o})0?Qqwww!EdL2z{`h@;_oz3}d!KvksjPare#g#7x11ex zg{xL=f8#fLui9|&HEYfuxqoEz#@nufSwb(#===J$`d^b6ovZPSvA{1Sq(rCjg^}}% zT{d!lDJ}AgmO|4lMs6lVa>Ec)Bq1%+ap(azC=Lrt)EbQ}RpBR(C>GuNVTyd1O!g_U z7&kL9!puyJJc9Hj3LN_sr;`^r9jw5~3P?8YjG>KkBohPYfOpUiV^EgCUXLIDg?2Dn zib;O#8Ik0-7z@FX&R9(H>q34blKe*9_%?ESdYzua7g-!m4YT0%SUgTg6IM&%i-;~1 zOpm{dOy9CHvNNU;zQB* zp7~fk@aNw=_sa?)rv5?wH~Iv62bRt9Kwmxu>0SZpc7WIi zwYDnu3xtcZt@R6-^8Etnk;%)p7VYaBn-weM87+KP+a6*NIlj_L{s*e!NatEpI-_ zGQPC}-rj(>KxDnii)TX+ya9c{Yih#Eq=pAXCacSTJJbu+m*B!)^enB!TR17kvLj|By8i$@ zL*#E7HH%Ts7&Wz6!k6!#9HS-&R^-Sc*q>%OIJ6yI7Z{;{JUq+!ZifSZ77?UFh(U#X zddp5$;LtAFvjnzhi^u1dtag@S(H7`Nq$ z=G;~b7_=uRfH%feW!9{lW@hpLHV@*v`}NZX)gT zVS}B*Cp#hQ>JHr_#t(feUaH^G-mW%d^|Z5$6QkO*pqht#as<_YZOz*kjcQ_^YUmr! z^C0gV&(TH?d{|ioos4Xf0MH3S@6pVNjnTA>(r zn+#ksnb4u%gAT1C2Q-=#BccgK9jZk{ zmV8fXb&)1oZL_a6=q#p@1`x zLl#9Qhi)iX16hyDqg%0?K(E>`z>K?OizHk2DOM|2t5yqBt5&8~k5Hmccdyf35KGbE z1a%>nZYTD{S@i`14-taiVh?OZ=%=J zhCkA8mJCI|7<^CtE2Y2xe52nTOsI9jjaN;v4oslWVMzSxK&Afa=g@;W^-t<&>T6Gh zV^tfQ-J1{nb({SiX1y>BN1p;WM#xT$@{xk|iWa#s!mXE6j&SQ0qY1la)to$fu!)VX zFr3HcT{*vB!#yz+C%DjW^c%Bb=FFIkKIG|xqPyI*hiQ52&2n; zmiLU%U?VQi;Hpb`nLioz73s_%SCDJl^U|(8zYI)CxufO@J zdHcF^&$~vy#){``sHDZ#@j&!U%T~q5A)tx5yZ8l{m)nmZC%T!CQ(8 ztA+VS6p8`w!gTUDrZFKE4py=tyx<8N7G78pd|=SPc#ax$I&`Uz*!>B8$HPak?n zc<PUS7sv)f)0fL4A>KCK!TK$Szyrft^ zO^dtpTD-KVOBlQzIT`_qL@}b(om$HSnI4M@JZ*%~G=lN)AwVqIU(OP-hPY1An|S?= z;0YFl;#HWPCU`g`PrxqJE!*|t1(^hAa*|urFYLjf@ScrJ=4MSXjAH45N2fQ4QcF8V zjCE*AMNhN#0918PzvPw-?!(KnVYdj2yTux^WFn!kDJ<=NKMd*C*d%IVY+^A*Q`)g5 z!j2=+!f_DDM&=DtHy##Au2l6N-*xUuc06!GL89NslX`NM8d`?qDIfYv@7N z_y@c*rsXI?-8B71SNH9=(&TkY=ZQoA6QG2Q0}=ITB=Sgw0NFO^K=2gut; zv{LYES}AmRD{Zj~i=2&qw9-&B293ZM8Sr9;0yhSVus>y7dyH#@v8I7y7xh3{uUGsa z{5h;6QZd0iK;%G`bI>|ixPIMB6?7)y8%2{wMnA~9GHV4{70I1HXN8ZuGVAk3nL~CM zeKaM@2$QC#cXeHQneb^>*Xq?HR?uXb?m5upC3t>|;d}fJz6UVp$HHmlBeZyLA_Yr z@oO4ZfBoxUsrdZWm=Cg4eNz3tx=hB5Kx}rLMM#zXS^Y{K1pS#md;t9DhTaHi90?zQ z;TcVIOmDck-80GVBO+)mDr5FxTz(~r5tI4+iq|W$F-gd;*h%hC&TIGDvo7Y6SnXaf zS07^@hZ2RGp|I0biD?0wo90<@C}(#&7^r6#bVv{(8w=i8%ob#kHDb0Pi`jxP#B9NQ z%r~#7ny`+G*}Skutjufm22IWQU|o~r5*vnOxn?rZ ziJVVqq~+y8{M|qP@m=*dPb}~G3El9}9d|sWUaa5o-j271sW@_<=)Ykj%T_RMzk_qT zmsHUbMr|Z$3Zs#ViHk9(`|!lYnA5E#E|96%PD(&(3vgBudjh>8wY3V;8CfqWXMVU& z8V2r%x^z$t1AbpsK*!K%_@g&g?x z*;rXpqeEYnHWrwdRG zWWz%h)eZE{lsch6IR|^ z&s?psJRsW5(_vI+)8fPJa5A3k!b#)wS`8#7%mWP?Gu!$fwg?bGtf$ ziPLaj@A|vcq<+W0?b^9I;*(AsRKJ>goG<_psnFeC&@Lu>FSCBtpr!7zgMcMn_3*{mp{-(`qgM7{S*(Z%p^?$ za~Kpftb{yln4bwWdgtZP zJ|!IHo_{#RJpV9@aJK=TdRPAx$0U!$Z<3QkN_q-zNK0~hA23w>29jns5OUWMQ;e2K% zPFM~im(HO4h-7B2H&Id;Z6|q(LXSPEIZ?s^NX2n^MqS~`nn`Tr1K7x@!(PoSeAGY% z6m!o+*GqH-o|}{==zYM&1Q(NZ6V^$El$$3M)^t#~#8VOu&Bm(Xn$lpqJUT?LrKJ<` z!ic;g+_QpXO3Le|>H=w9)V}kH%`YBz+)I?aiR95+>WKR3SXxgp!eo{fE$?K4T{M44mO0(daglB#Om1_($z(9iP33G3DsDq2 z%7m;Z9KbP)O0x!LKzAt)h7s_(4GUVcCL}h$U=b)wuAxUrv0C^pVr1sPv7`~pb`-K| zfKWSBr|ucmiIB+Aj=uwc!kat}udNDi5+MJny-Cq{jd`(}@$%=h?qgo8pZC(35n=eS zii=^yoG?~EMk+?s1px$t9R*o%wu?j`;NBRB+0)OuZ6PzYJUUQ3Jbgy|#4qjTXY^U( zv4AhLZjS|HMsw&LK8igDd!07?7g$L&*x+Y?m2ALf+YGa@j!P32Q%IuIO`6?KElotx zaS5xWIky@jj_>QMkQVxb`YZMQ&#&?*`gpEz{h8}GdHhIkK)(=d~q zeiCTsB!A(wBgHsUA|>e1--?0yJdPxVkxVXHk{=PFq2qyh8?XqC2E9Ytv!{7T$D=I} z3(?jfz82Ra$cB@TEIA_Zap8F(Djx*n7)&S}aAICFjQM7f1A4EVb}P$1HY#@Kf*!eY zaeFRfuXIX419vETf`J#z*LM1DGhyKeaZCbN zQmP${%PI}BoGoP})|s&|5{IIkStFo-v;+$)4EeBN2Vhrp_hrmzb~f3QHuA3;6LQH@ z7=5d!5RJYiQFaxk4TxjWt}2Nau8L17;a4F)9A%@&(VL9fF(fS7l@6)UUQ(q)yRuxW zK)b@mn5m)zQ?1Mr${NlWWz|K)l^`4z^YL*7J5gOxh=(fxnOp@3W#QOzM7bAzoGY{O z@#u+b8-G$b*IT@F{!&54_y+g zx7w$Nb$th~iZs}*4Kdw~h&L~cHCXL4q6aZkFml~QUkmPv(R(!RDvnD^Bqig{qgo9WZmy9Js2GKno@UcP` zY_n9DBBHwkBCN-qfTapdatE1^0$Ti%}eI zq}w?d-vF4>%B;Y^_>#kvHE}S$R)l(@foV*OvPVntiJQAA^=Y{1R(XWCt; z2JVu8q(vqWU+CbewQQH!bd>0u%~mG*%t94JA37osJnF(gz{f5O2E$_)f-3+c$mi(_ zpZRH(q+#$cPZU;tF-Odg0}l3!ey|j=Unw9+@=a6HXpsJ`%@i zG@_+pO*LCpjBk&yx{8xI#W{q+g{TvD7wuE9yE&)CN&vU$x9YeBMsTHL@om;oLZvId z2qG=Q+aLM!ae2K#hRVY5xYVHDh=nz%8M$RNs1*%rHeq3fHU}2$P({E~FruiC&C+AA z8>AP5-7sa8DQgr!cD>dj_#U)~(}p6w5iMe4MIf}vcod;ccn%0{H0q0l2Azv!&Qse3 zgFk=dopl#OB|p7(?Z~^ZV<(6gjm!{!dT7H4=0OAM4PHzR^z@JqHF_3NuNYm*Bxm$s zRztl?dDJU<(DIVQoNJPEFESwyinN~FJvWtgqkoH>t+?^h7&C=TIU6sO^$-kZY0SGg zSrdcYorR1Z3t`w*7CQs~Wz80bi=kUACbMKf7bHr7fPxb=&cU%`^eZx`QYAbkv%u$5 z(TFEl7+q~eEr%&|a4f*QfiN>@-V^cxO{lp;^|j)LLqE{XI&`tvckt>LblvUkr+wq# zW7vW!*Qsj+qTf9>%C%_K$40pxo*f<=<;t^I1ESDq!3bsa1|HTY%F~s;#C7yqK%h}aW9Oa! z*nQ`*#$^_*tL|{?O2xc&#pu#}EAoEO0f9CWU}O2XOn7fY@In*;hURk3(4Yo-Oguo} z=HoIa7;OLeig8b)x`u8YPp4v*dW=q5wy!W^dW=rmcu=zi<3aOu(q#l47L87TcNnp7 zIvI^R9jDWPL2nif`dZLrJwwLnF|I<-E|E#CY~ct<^zHH9p!!4_(8udO{+M2?uDR3D za3|_J`h^e>Ki1!fF7ag=w--k!jA2@}5ehUvLSYQk%F&`ZQ=`7Mxj9EMZ!B{=fL9ew z)Bv_+`4m2OH4W2NCsfickEoBSk3T{O=jm@ebhq%z$ZT;nqZt_$bZbY?0Yd8kaCAK% zFWUprIwe06*oW~Cy0z*`HgR7}w*z>~Pw?&eQNZIlA&+9yP`l$eA%9-$5f_#bV3J|n zVYOro0hYzx{LSh-v|9aYe?J}&S3hO>uZ5$Z>t^dtf)1HWztHGeZ2y*G45YPxEZ0&ykA*=8PeOvj}NBI&_McfgB|>ZX58@`iz;!zO9PE zysTX@-~ix8FTjmc*{&{|@R=E!9%H0{O<*^%-8@b!n;CZoL~0Aur0KeXoy2`(CmMLxaj=(jKJfQPD)Y zfjzzfLqxK@4I+D|22>0$7)!GQuZG4%g|q1?4J0s^k8FM6slSv&l9ku5JO66?Q}$1O_UfKQ)KuyYALl)5 za*5p=UZ0&h>-*PVc4p^oXSVotf*9L;`yJ2vN=2i2dbRnq?z7L5m1iCw>5n9Iy6Y{r zhKklu;BxxobwP8`y=*~e_leV6?a>(c^HT69GEKx!D(T4@e-`n)Riq4~_*rzO;d!lj zJnsZr;?mCNqciZPDFYB$j6AED0^2a%w}XwXQN;RY)?I`C6zTnJCKh095(}^7-CmL9rCA%2EN^JBVM|9XaWK9BK^dq~y~$?;v- z5pzE9Nh`QG%)l1eUTc_raX5~|PB_!Rcmn(p_p_6}F|=+R;{la8d-~yM!Av058;%ut z!(?S1BqHZ<%oGb-o3Iv#@K_xIOx(lD0@$-Q(;wA-gWOS3l{=3@T(UDb^l-0->I)|@ZWgt zJ9~b*^B2RZaV zD8NVmD-<9m+B!B05CzDi9gPAUFMI!+P=HJzG3F2Om*RN9{Cq+)(&AguU;pSGl^Q?U z`D;v4)wlNMhwiv-)vW<~-Rt}4RQy_mNb7#SZS6H54()k!{Z){u`DhyzG8M!a+HW7B z^D%}tFH@S%m-0HlvWS!B9wXQH2G%4Y@|p|OeQ92>(Ah~LSO8UVr@aQ*Bcz%zc>^1|$z&`_HenbilQ9V+1GbX$*b$R4 z?-2Y3xok-GjgzsmD3`G)*S%3MWGIRU<%P^qc7%|XMR5T`4j&<7rJ>$vs32pafyL>N zqbP9yQE?VN#$~6XrCJ$q{!wu@*1WnET+@Ko1aMxvH4V?e%{TuJ!c*CaqWB7dx*B|MGr@cbzz}&kMw~0U9`Sxq?{`m0^zeFo8fo4oV z!a``pA06F_CQZh)RxHe?fsxR88RH&qp%s-HU}XB01d(FHDfB`kXsyB9JfkEG>GYjlvv+B`oCX!?oiC|KFC4%3krE#&{ zMO=1qHr70ska|AxhYu4;Fqk%n_tvEi1`iuo$)=OS==jFZ)kDYG%{Yv(|OW+k~6$J@xc+PF>HuO1OJL{m)^l zhBakytS{Nh!a`v6@q-rH+7nDsCAhk*GP38Nc!?4X zO1oGoN4Uo_OzVNaF*}pR>(dlzIeAR+`nabnlJl}&pHI`Ht|7UGyDLC=M#m8KQ6^q7 z@)*Rc=)u&mF%C+34eFRFw=4iahsk_iyLzP~v}+C2f>LvocI{1+#oj^W-hNXR+U?3V`bgd7`+4}C>xzrRI-@T(Ivo_$q#Y$jMuXEhaRWr=P>g9 z^_>^mpP_#`IQXuAt91Ew_iwm=!wsA6RNs2_4sMC4ba-qBtu{yy%f?v$8nlQqB_zT9)@nIYv~`!ALTUh7}PL zW?$TnZky$pe`XjNd}+4=OF|(kC9s4H-v^_}@O`Wo31=A+6mv1c{N?b&Pe_Jy&8X<3 zi1*!q{+yR%puVv#n5Y{qg{~$TxTY}%PK%q6Pjv|iU8t^)R zqt|@K0%niT0O9wse&e)D8#BxW?K_>Fr5DS}P-o{^Yj57v*$FRk|AbqHl#!$$f9K*K zZytG-$<-|&;~Y@Xj8JQfM#Cb;)KSz%MT{w4_3JREiV0-YH8%r0rjtI}!wFWV5sYC? zL9oNHrbr1$=)Ky9bPTYnHp}tJl_fNXnRH`fcsZPg!W~Hyem%~pp z=-l3E-T|}2X$b05Mf744^X2-I8#?hPY~8AxeDH1kjy*&e{Zw5>XX72&*y0zpcPx%Z z^A+EbjYeY*4MJPOG_F3caV5Q^FKS%HbPD?AfTVSM@rn$;idW?LRd_;{T{$9&dN#Hd z7*})}L4PYQ4}V2T?{STLMRxd4ykb5uVDQ$edNyTcn!Vx8ot?dRR40=s&Cp&mbMKA% zN7C~Qi}x^@K#S@q-ZnyY+PjX!O&6jxj+-8jZFOrt%9u*{|M+ImZ8C*5_?T6Go8UVMq#e=8G`Lkj(o zx_0E-LcCAKuT*e)(pg+W@-uc7<-=0soqtL`n5tniGLD?b5{&ee0kg$=1Y@J7ta9l5 z)3lzU3bt?>{Zwn=X$4DIU(|ZjxFrn8)40}~UetO4o+k=vqRO?Nhs6%1WVzx2US;`! z)u^kiL)Q%q6bsBTghXI-EwQ<<%kJu#%IF z`#W#2x~jUWDlm23MreWEt0c5SzVPeVC)7w zR0g-mM#HdTO#_%sC!EpYJSI(20@GpX#!e}?GOpl@;(g27yH0EG=NGS#+2x0oEp8u_ zoK@9H7)=?^h~Rnrh&}J0zA#eOq5U7J$fKpb(b9q<_xOu}iKjH`Z^FtD7<@pK)mcOf zStnLIffjP|#DJ#ced+u(KMdi;#%Uj=><`!c%TBAfeDfX4Dt)na-`l*Uv$Ln`;=az# zZ+_#l?{;?TCOF71sn`~;H+{YmEcb1(tL9?l=uekmJM$AX+HIusO)~mTmFkPp3_D>nDAj9FQO!LR8b49L zq@GVcrEc{j)ZGt#@`E4j-~WRjd;;&~Qnf>u1g(R> zjCN|eu-LaWp{NVBz9n0JQQKTmW7|TW3lq1+- z32UC7)Ny~mY?tgAmpX}-F@4u-i?U}#%XsRf;M?nX&SqSCGM_m)Iz1U0uZ7IEq82ij zMs3hSV{>`ioW-;X_H513kkeL7Px`p-Dds1+3SwS=-sMDs(tqi))6DCBs3j;3u&wzF zIXy*d!(zY3kvTBuSPtwkFP`;z95V;z zK^xl8hCBym{TFgzthXAimrs02z`Ya_Uy_rxo>P*Ui?85_CBCf4dhPn>vtG9U`&ln! zE7>Ibn&mh$>lJ+G;GcKBdXe4oOlPNg&C~bZa?dT-J$i2p*m-2Vs-JfIUObQzl2)X> zUU~b+TG|Vi0weT<&AjC{Y3-Ju`qD&0McE5-Kg1yixgQcP%ANy$2vr=2kO>PK6dTl= z&12F97Bx$|ce4p9kyA*-I+Pj!50($7vQEi??1kdPGvIDOs>THvbin4&8@(711CKID zF@Yct#dw#Z>|`C8MLUX46Qlrn;(^oR(PgIv!e`wZ3Rm3q7}MulQpsE3;Jx%Xw}o?& zpB=;-)*gkLoltPOrjU|+5`6-HRY{(}Uo~A+>3P)bg!Gg#)U0-(p=Ku(P_tm-R0bhv z6bNgEnhoSpv(JzeP_yM$5C547r8bpE%|6=iuS|I+9EO@@vkuAuNe=A8)So;l7Ywkh zK~Vdti&Fg5#bigSfSOGVDYcVvL#-s&j-h62*$w2VU%QZ4@;GNLmnN_d+5u}hTgFhc zveBwV48HcFaoUdP}1PEUe_otFF>0_M~Qf zuxa~_ftsC=DxzkwHvy>G)P%7s2LYMIf59(H)fQ2+DIKW=YL@@>1^3H5fuNRX4&sr) zz-+8Xp=RN`@*fr`WIFgy3!u*q|M_{?0%_s*rt4N;aB+3@726+fnft3(fBA{+1^fC9 zmtS37ebx5+Gnx0czWlLK_#bOgHTjpN+*!JAeMQ5Gf-oQ?j&&jO2Um4uQ z{QubKp9P1$6K#5vCU?dBVW`+o%=3rbxMUq(J z)Qv7908ZxuDZ7`?8?YSa3^Q}@qWyqDdNBB87ohSc_VXyaFz6VWME0{dZ1`XW;?v4{ zs1^nmV4}vb73jdM6C9`4T+)5dJ^jx-(>NtV9V@RDcHT`Vs{8I9X)fop<8Oe3{a!a2 zdA+Z+cL7{b%jzYHIwYFcAyF<^WyM&A=@6pCq28H_4?%q3b!J08VD{1Ht%KT7%m`B9 zVX~pTG30Wv8^^8#;eRqq_)uPSE+b*HUWudok{RNmoY!r}V6O9k9ku}_nAao-OX95a zI_EZ-(M{OYl(9IFHg{+6!v#Evb7Y4_ua4rsz+<5(#uu}6Gu4H3G&&or_NypbKQEhh zwJ>6?KFvCNHG%^1*Szv#=4mcF&Fx?N!r&m&Q6ndISc1KPosERU>~5A9C2LrMvw)p7 zV!+s%Y#3ulHB?y;KT5DBYc>{+k1V%g!jRQjf1pqvK;Po(tQ8PG?f$WHnl&2oIN59< z2%reZ&e}1JFvrtECu!JOPde?Xt7F?dVpdG2#UwpZ-Mf7G8*laWy!Syzr}|b`_s4H_ zfkmDdmmYdb>^%6k*m~#(Or`>msbTQSF_Ex&vwsv4HlIr?AYogNiG)S7emxQvg%}e2 zHAq+#emxQvh5rXg*wtT;gkAIXNZ8e1kAz+Ge-{Z`Y+sIm#fE(>4C@8@5j zK0w!9aH0Aa^#JDDnjjc3i_+C2e;fIbKA^7T?W~?8{toRNCVRLQBE???i4=7L|5XqY z&i^V%w5Sup+zJ^^X&!XgU51bgzbL`{Bi>7Z<~U{wRAE^&6e4Rj$l%Ta267EkwlLH( zb%dS#JG@SXjoDwFbzscCsMx~nMu-r_4?ksn;TV9^UQ^bN9Z|11(7Q?x!;e!6w1T0k z-XCB;j{~Cc(f#~W{GGb$TX)|*rK)=NtR>S#b?sSa(-K;C=5nOXU;5hv)b_NK_T1vq zWe#l+FNP#ELlSm^LdQh5GD#RcbTqOxF9}6tYwHon)^q;}Wb3*ABC@qo8@j`gt=K&_ zvUT+l$X4+G*CSh-h3iLN6kA5F5$-&6&8?z!(V}_BANmWnpvr0LIl^f`Y$nKcn)iYM z|C;wQ_FD-i?!6EhjsnI~j{Q~wM{MStjRe1OgIOGdCj2ss$;oH9*~1#2$|h-0w28J* z5M8hM0T*C2y_ii23&&`BYx@BcaK`vgyD&_l0Y9ULV4USs(7PJ&RibYd^T_ha##{i6 z%@9s|^104$e53QZC(k+l_{#b7D~~_^9Q7P~@A~0aU%%}2SBKa4+BcY{E`3w|<;sRo zZ2C&|mv1heYGU%SQzi5?16Y8Zq`hY`%K}7f9`C4SS-$2sjK(8kS!ecIq(9hC4zQmx z+Ig>vzU#lU=M?B!R-k9`ImsiQ6V7LI%8TPlxg6#^d3u^HVa&-8#;mPKH|OaYmIh1x zrGB#|QlO_J<9A@;^mNqrQ^2J41$uhw{U9VJ_nyHz{#_ugv3|PW$Iy2^)@_Y>+)f*# zqej`&mZ|4(64H{D(_^9kr@bqIkE*)*@4h!{GLxCiWG2fblgwlpNFX63A%rbM2y2iX zgvd_9zOUknD+mZy5fl;EDsJseCIL~g*rH;KwJNpRy0tE?O114*>qcI_|GDqY9;&vs z-`C%-3@`7^dvET$_rANFd(QuyfB(hIO;xxfrjGr7l=D)yMpGf~2V!JwLVe~Wl+&!1 zGs?fhFZJDBO@poL6ADk0}3#OZKp&4wvetELTInAv+y z*+~C}(bcP_mM`ty_ku$9-s}mUp6;TtBiGNKP*RcY9y^lkf`5@$u_rWr@M4nLQzOfb zD0`|B;1mWg-pHfusS3>3sj{b<>Hvxcd#V=V`A{MCG^kGj?~kyjLfYW$soGS|o(k$z zirCHBQ?-neO|0M_le~(Fd+Igk>RVQ8<#;Bir0F=0s3mjAd2O>m`j-IWRZH*`#p-BV{ty zm0)$akW3|j3qaE5>9{NPalbLwPYazqF4hHdD?;?G%?soZW}93P!67UDG*_Bg%w^2o zqbD*bs5F!6P6BKP;omrHCqr4cCs?h^%D0q70-1Y#5h^|$c|pbJ^Tw{g4P{#lW#QG4 z!<7bQ8dgUc$U=sr9y5}5tWGB4hLH;^K_oN9Gv^N+ zGjLPpBN?MJ^IrFruDRLyNbbka4(>PP{NXc(UB#^LRg}qRf1TsH;oh;iKCR&;SM{>T zH~1ZCXFoPxx#{T_nE8#rm5jd?P(2N+)}OsVGHYIc>iI<_*)%>i!i%~YoWhO5=Sn+K z$wv%qc%+?})$!iU+sTMXJK^Jf&;-|LH<9)E1h)?#8hV6ap${fw2^25j5kXv8K^lq= zT_WB*<3N1qOR#Y$0Nee|0D2t{m6w~y5gyTLEB)n_`0zojvLZysZ6-IsKh8{2?%-ZW zMxRPLVC@Lc=P+AOoIZialnSm=e3ZwO3z_BkC|@dtj7og?GyB8;FI4EQ02GZ3*;wnx z3@bh*Ot25#iz_?eAPiqM@a^#TK`eX-?yNF>W|HMf9h)@Gy$=k_pEPmgy!zT5y%(># z&Th1w-d$>QGVRN6eD$b)&lOv4_f$C96!~ptZz-!~S1&FZHF(5`TwimJztU=2TQ=doG_)(=hI__rH97?>*aARWG`r>~d|xxJx%xm9jEGC#)qd>f?37i<$u% z@02b7x3$8%-6bDo%l$)dy0;Q&y@Eg)~PA zJjEzNFZka|YA|UK30%LbelXdA8P4f&D)ewV>EH`{_x}9nd-uK|-xj=f$=qcN*u154 zmt7rfyK4E|B ziQo5n4u0h~774y0eE2V1xy401EqK3^FCgh+pr|8vFCKumR8y)1;3a1jNWyW>eJQCa zigO=L*?a-4L5_oIZb-d|^_P*In`3i}y?jN*f(Jno@oe?(WsC06|<9rj3QpEJ2ZR*&_eu0>{X>Z;^aXG z@2i~2YUB-z7qb@DvS_iqk@b_W)UYe%%Vy4!FJo6~#J%gq%`nN%=-j$q_wai82DWEzFB@ye5iA(MxejQ({%X?O}6%Zcy5dl-dFNe9&Uq8 zkv3S)`;{$F&imEaNWbE1e>K6E-zZVeP5 z0(hNZtHX!iIkFCSW;^N59zu2xeE6L<;-R4)ACLUTkj7*BcB2F)=}ToOr5%O}=!Bu% z!)Sq-3Z@8g@Iy~aKhQ5af5yri_Pq4SH6`V?*}r@^_&m<7oIQ8r&A0w_$2Go+l-Uax zvR_?&=aR7nc~e_{x@b&Z&Q$ID&-`HV+WF)AU*5E=V|8Ffex`lVg8ffE8(6w-{`jix z%{P2+)%}a|GfWGX?VmH}tFt37n^e;;&Ea1(a?Ykfb=C~9I093t`K35rd;d3TPdZf& zjkYJ1H=bqar3@yGX{3n%)Ov{UL7?#T;EaF$4A|y%4vpXt9q@2!q%+i>pdc>BHtDg) z*x1J&)87BmOD_rH**|r?Em^d0k>yQWHnkA#1j^c$5&;!DZ9hM^i z>V>t4K)qJMYL*6JBTz5b%QX3xq4F+h zOg<(*DGy`E5S|KuI|VEb*UGyPdg`z8ZnlP*AaBSMfwtSNISiepif@d>l7J_+I=(0Q zZm8$^Iw7Ee7#a!S97y3{;cp(lIgs~!H*OmN%k%$wpACQ_Nzwoz3YQyVgbI$xX1l$w z9XR^&$IspVi=1s4TNht(%Wa!mw`YsvZ+(Ore*S9L1AC^=d-S$FPqxky;PCx!*V{Tt z`xf&FOGR?E)Ch;v*TXFC6`pFvP9>8x=Rc3W7EO#++fGWD)i` z<}l*M7r6+b{U}Hh?{CYpkP{zZ=_X-Qi1^D@7d}|Z%TEV-9}n~d##wRU%QzhbcHvZL zXznmh45B#h3FS5Pz#2qb0&@a)9;rcA;Fy(LPKAnb+$-;01Cvas+~1oDMMJdeT31W) zX_Gzcu%_2j^HCnkzT-do^s&AP-ZDmSIisa1`OZ(LJTfV$?uKY-L7L%lA zjn3=nl{1EUdRmPs#@;q>2;4qQXenx7=JQn`BV(A41r-Tt}{>EgNCqIx=8e%Y})S7O+7pT$Z`= z9IFQ?&x2{{d?5ifFVez7AXp3&62e14ldi6FLXI3K^nDyMQ4opyX6A9pcuHT0zet~$O1!cG<=(#K=+awC>JIs1rAkMDIXcZ z@@}|i(%`vW6DGd5XT*q|$L3#f!Te*aNZNGynu|wNo8^na7A#-EJ{{52FeGM%DiqeJ zGt^q~1I$oss5fDT${QW5>VAf*qM4^Ey(TD%Q>F)`8iJx?s`8FrSn#ND9Qm*Ae#WY% z-&M!|4qT&{y_k@pmBhW;&6vmjS+9D8VpaIH;Z>g@Ua6HfZp1jZN*X1e(B6*KcxKqM zK7y)_^1BkM8to)tb$ zz3h=kF1z%RN7$H6)240QIBnV{?d|-;rI$S-|8m+UewN1I30-Hw$!g5J`n#;`M~Iaj!!Dy1-7PPB(2QVSiRA2vR7PxC=nW|jQ;N!_ zMdLMvUsoC1wV1bJS=;5|M5-m0?`5OqBYWi|lfazJz#}#oZ}*>Kcpe2UR&64~b3kEu z-iV-^|1iVzeud$AKLsxM`wY)mKE*OTLrTnaihGybcza!XD*I#S+M9klyRmt~%<0nA z;v=_RGj)pQmRT2#nb2U*ylndq7xkIwZx}Os;Z66C8ACOaBK8#bqed#i?9T4+h!)ii zxp`~MR4$kh947qA6-a%~7{E|!0i`b#o(SXsuVuI8@ToRfT1ul@nGiS=DlIE5Ys*pD zodcZR*`0$CO<{L-TLbRX!Tfv<+sbzn3M*f@4H{ltNM?6NO$EXK3mtKZaNC(+sT=Ih znRc6^iHT=--cO4zr8f{8c9`8+rJ;c!r21%u$1k{=V72P;OrLZ9oLMu@t2?i}T=RoL^^+%U+jGg1`9mG%i~(ydxpC(WGsd1ba?IGJ z9uMmEOU;ww^*nyYaHS_j>^a}iZbZ{W1#1&$fq(;Lu-mKHmxtewKUc$MFky#0h2w{^ zg@^eQga`&PYDwW3L_~~JFo;o0ih_E{29m>wEgkqF@Tat;TM*bej308QW8w&9XJr$n zFi2n-1tXMZQZPcKl#0a&2^=G2GJrUcV}w%uDL@HDF+xEn5~7z8!TnUF7ZT#fM?U`` zU`}WCYu0Q!*#6M^SC-Uev;*w3a`)~Nd&KEqy|n0Bu`W~gh<_CSNNu)QzDN2Jektj~ zUzEB9fq^m$N5w=9Z?ndLhL508F;Ve3Mx5+`;W#Knj)KN+#72sCZJk&T4{o?v>8;_( z_{P<&%}De=R)VEFfGc-|5Z?uSy(x&qu5p6`pVkyT<5Fh zCO>th+6KPyYLKG z-TF8h1wkr-Wl_N)Ciki#jXnXXWarSmv zPgh|*{kZn`TsM@*prJ3D&H=zmKBL-@crSZIvQYCGiH6>WS9DDEtI&ZR@b5cSkh|@L z!5kA7))WB2NYJNWP(L_;WbRM&uL!w}c`CJK9Qm}_4n0*^Nu@KYiYTjHXSW&$VT(~V zMHsGhcBce}tJt^(5r!)v+bMzJDpt9rD!!~{=)R2g*pYHeT?dm<1}=ngjus>-uuZo; z@W5@f&6q!b25s`oVsqzxQho65d)nIWxjVSzqSb3IzIe^*i%u^<+wOjK8X3}~!;YeY zwx}w@=&-}x3qw*Pyx{6XrbZs970XXap?9X$sfj|y0sG)j!Fl>_lmuFrDl1f)0bp3E z(K;O3;_$(59Gd!*~M_<;5Hq(|ON4rMu(%U;Bn$W&Rm(!8hNAMgQbVfAlL#Bbor_eBhz!6-uE3a$ zGXmxlSwc_?>TwJVeq*N9YStoTofal<4NWTuya`B4o5Uc|ZUo#I6pIB2qzZRnU?p`r ztwsZkG5upfo!NRCgU9fojyRdy{2ICu?7ZOO!egu0UF`aP{Vuy~>mN;$Zb-c-bpCnebI+}0L*<`ay1InDU8i*> zwCF zR`t#PcfA5FOho1Z`rtcRul#o${LdQ)XQH=UtoD}AcyHMzOya%eI_VSMTSVb8d4Z-@ zGXPRsfpBkvbROmCj^{6na&&Y4GN9Utqr0Ha2+_s=^&H(Mu$<+Q5j6kwSp=j`;OMsH z1L^a3IJvb%DM~Si6g?Ic9lk0j?8Y!0XFBa@?&6}|JI#mmfB981CwEu3l-!ynzrc(j zKZlx`$qFF9c zPDr%mWO;)*N<1N~T|gq^pApOgh#70YHIdgvWUr7zFR zccU99gl=FS=?2c3<6Ut%&USf&*$FvzCFd9ss?{9JuOP>z$gx2vRa%j*qC#jhXq)O^ zkA^TstXc+!b?fWL@t*`%J5eJdb8~)n&(^K?{A>J4#uEXeZ)3lj2} zmz+m;wmY212O+`DXIj{NJ(Nc%f(2yKPe@TfMx9nfb^u%y_%CFwI2JBi6^rYVAIzNF z>~1N}ZOSRENLi3QcY0<^FHe)Bu#YkG!i70Dv#p|6cpojtl7^%h#!oQ94i?2#A_iajGptCxB$aVFnJO-R{tt9f+?1 z)k=nBeYf4}FgodbJi1|v)r|lD7Yj!{SD<&da7-P)V#RpcdJh@Wo3?4n*YPVRD&Kn# ziMEK1j{1M zP*;{KtF71!TxFh%w0MM-^Yu1kElW!bC_P#`diFFI`*aE&eW<6Vr-t9(QQA}L4F#IQ zMjz$fJghic!Sh3>#fL-MQlVf7`izLBOTo)ijd1hO7{Man6Mw)Ykz*-@8VHyghuh)I zbGr+Td^v?+xilw%G!q{JH3=1My~{)_px~#81Z}XAfzpJN1ZO|WK$uGbd%%0vL@lgM>l`a00v&?`PL=Qw*OCq7eZy1{(4l*fL*2JfebxRQ)mQ#E*L-0F zXqKxuLptDz0{znqw~GHm2e=jhw(?^>KsJD9+d95h@XkiO^UUgmc;TK5Fj@UXx|vnq+yAnv_6I;SHJ+a+!kFdQ^7c<%XB3 z&ZyMnV=&Z>k4te^T#Bh?Z!k3>MRRhBDW()P#ja10;%GHRk-zO5Sbj_%Z5A&`s>8Zj zMZU2lr)W*JhVw|UV~?I{vJ7i+l9=`|H&Rq;QZjsP9>X=6VpVd9euICUCmzsjHki+f zETVpc?LCg8JFI?#?OkPrRHA#C!1`hXPdo@gOyG&PflUD8o&;(PK=u5R4j&prLUaTI z0uZlhD1v1!eM_FTfSGhmX9M#y*J-xAD5 zYnXu^eVrJF%k(4>^-He0$9QBsXNtSxQtY;`C#Q(<=-esZ5SL=N<56;o7?0vpq2y%_GkFi?DGGUPur^pMJvi~x5XE{^ zyFSMacBhuw@ls?#-pOGFO4qWa?U=SR+>h&78iyKOqv#JvG zvDH3SfWW4;B@$tIGvT@c3$Wlx2d69;Hd%?3C*Qu~RWQgdk{8Lp{?Q@-!w)jfG27!9 zjvpqPSt6KiWw){g+r>NNOKu`w+28N~YIFOe1CG7G8j1HlbY#F2zmOjz+F9*v;+uVM zKReE@xsjv}&K}2md2P6ti|rcDrg#P7$lt_!Oy6hD*JNr>OKvqx7|T49}HViG|Z7jYS==$$@H3?h+r0)T9Om zcrR?v@iV#;K{^s@((bTZZKz4iz5z&tWO2`c9;r>~QyJkt<%!I`8N5%W1~Pb`f*wg- ze{f4_NU8(P0m2XGfL8lZhe4;;OLj$uCp$vFOY#Tt0G42JM5YN!$(axs^DTnUfw5N-@0o-M|ba15kk#T;0SX$}lxz(^?*UTwd? zf)@6Z=RW)FIeGAc7U|LhyX6gR$L?|Cv`_w(o%zv|A2E5GRB-XNvM{UxW8QUL?`Xb< zDAxUj=assM;1B8|7Hz_h2-Wdf*r8Va=ne`Wzb%3x6APpQF& zz65Iuz}xG}w4OL(E%%lCd?}e!TjbEoYXza8DEexMB4(fhQ00CN9QQQ*w*(~(nh~SM zZ!Gj@6yl?#-`8*Mix0o2Zz0kz?F)TehBe^g?j4G^4xtPBYT(&HE{GJTjzBUsVWl(0 z_R+y|4ZU-%f(U?b41N3%>Aj=M9 zq(l7f^mfx@^mfTP?ug5A;|OoCF(Jnzl5;$4_%Joc{{p>TavqPw<*{j$H`tVr$5F|7 z9NjoNoX2~?@jer4gnv?RcQ5pIc`YTbCYP$WbGE8_yX>&OF1)ANMk&YBF|FfhTscNe z_6A2Jlw)#oIfhM8>-yiLw^MR`e@w0)=ebs9i)`U*TS!=f8#X$e@2=ThuL!R&9<@{W z57ygdo>Om^lc={V?N)Ec>sDuqsoP_5bvqj4#pr|<5jS3h>vqK05lY*ZsFmrSMHIb3 z!g!%s+^EbJ2qvsZqZcT%Mb~?6K0b4%1blqvoT&uR>w3P8OUS_@;PV7TX?J0-$G#NN zhT7Xau2IBzQUr077&9_m7|8{o_o|8Use z8|zF{-3LDdHCBVa568GQ21|Q`8mkmvRBEhxI@Gjz>UtAJMjiRo;1JKkVZ^5<=1iM^ zYBSwAwHb6B6a39Q**2lsjNbqzXXf9D)x%6N=7TmfZllx0>Y?03KcYNBA{Z{**(Ox` zAY7kmuk6z&#mD83z>w2{!N>i^G=HBoeE8%zBdy|is6fl_s0;Y4fx6Sd{{1;TwSOJK zQ^D5-6zYVDj|w-nrKY5S6@((Fu>fcb5P;C>hYz889qyyL z9F7vevnOo`eTnhx88Ub{dG-wP52oCMRAk}|LIzIsPoVFi<{1h>FJcpzoKzQ1Ke0nm zTFQwaF^nj{eZYe=YcZ$Wl9)Xr3|p$Xjpp=tzaf@Py;U4T2U78)7GIWl?K?m31RaQe z%!K_5Z%uFWeRTA#%L_7#YH#?({*DLk{$sC_e9u)1N!PF059>x8xNO6M3$C9x+@TRg z@iRli=$-q=)mk*;rh0lwcMx&NUCncdIOK++(waV-@85mn!x?UUilMSNRUz%_oN|>r z)oGj71dgu$qs-Yk?}A`t)p^@6J^#PcvksQ4I0uyvjgS86h2x@?4loCC_8cyuAn*v+ zlZC|dA`*&D@D+Q5zJw8{IC+I%(xW7-Ge}OcBQC`fSfff3QtXkOVzICI+$kQ3OR+b! z5WN#p>_!VwR#Fz9BF$mpQa{d9RJ9Po*XA)i&K8#`xr9Ux)DPQ)7){17jx%B%`IeFD zRVD`xuvFu7f8$8?KcPtV#Jzxb%Qe57`MZ54{B9XI6;n5g;H(z|^|)%-rr+(=P*x0>!Xzp*KX zSFN1hbWV$4x7T=& zBG@c^E$T4|)LGATIINZ|KBi)NP#B;{pQA(*&y}Fh3FW&49pcf1oR(bfRT#7Z@`|8a zxz?>AgRwaGEP_EGIA*5BnP$m!TAbHf2U!PgO*?t=NLHr9X|+)5%IApHoaM-sa7!9J zopXL_y#fyno=L;2PYX>9z|C9_lS!HwrqIfhV~<`Z4tNW}!lf4u0(pR>xF&TE`C?cxa=`qzxBu3vfHirFjA z$REis$cM!qhkx~}!-rp8zMMuR;H|(aLsIbswbdRK>mn^8)m_-f8!Sxd?R@}oj;@)w zy`rFZfhAwj!O+_)<85qWxF5u%_>bsrlT+-7OYv*9mvMt7T>;*q!%zrF5ONs;O? zT@!vw2LEiM1)+2}A#BhH zerP8*`_E9dGK%9GfwRNlekdZuX#*xs9Dq%(=Kfqd_3Ixe4y2=j6WRQI;^y!dtjz1W zUeYvaW}aLBtFjOz>3`9~C{C=Z|AnNT;ph5ag^l1F>wlH;;y+3M3oTD2d2d2n&h@{( zY5}o(NdHT^pLAj;$Yix8V#BW|ZQFIILjL09)tA*@yIcfU!E@JKQh)6#qAR$+oauiv ziT&pD7WU>7*Uvrs=4HF9Zn@2_ucS$eWg{j5Nt9c8IYl;?^XKa8YuKo)*@Z>dlfm)Ltq7bRcbaSpd z*;?<0?lhq$VI9k{DB51mEdgzBCire=E2Bch)*9uzRa_lXRab{|^4+?L?=~yKcN>yw zoCy0E)EdAwXGOmj`&qzC_o5! z2AV&ofH@VGR2TRqoMCN~?xFmiFj|2GOqrqbVX zC2S(SM04|oCz9DEKy3E=2fAgKKo3*e1+}$syLgYcsz+>zs*+hQ2%H?6z$nSj2nkpv6*fokz{iivZ__(TF7pR*WiG%>n}`N3NiK7K zQ9ds-a`=1XkZd(!#PYYx;kcw6CfDIdadr4DbBHp-cS+mI-*bujRz47SM&7eFHP@Rw zZ+j6LCOU5`qhf)}kjCfjvvSuvLOWXae>t@qAu$9T!>3bprT;@tZTaPI&8f}0Fvkkl zvsZ!nZAy^6(j(dqMfTFDvX?EQ?cni*LgExlT|IbQvD#um_~R28U#h99f-9^ZhFs`) zYby&YX3uWAXn-?ax`1t1@cp|sU?Bvg1!v&Og8X&5^^~MOaEo<&eT+!i(67)0XdS9X3k7a1Nd4A@~mkSY-Fnm*apo2 zTn$2h3g;q=C79O55=`?@Z7RWZ&;sNZgnk<^+gS*;R0#pYJlvNMz+qrM&Rd?>o3o}S(u)$ZX zSaHQyeJ(SD!|bV-o>4f=(9a3e^D&poed_%4n>Yk@PHwVaQ*JVXI?t6CMo=r(2a>PD zn*BH8Yz4(WZ?HH)a`7cwA9|GZP|<~5_h1b=OZWrU8wI=#izE|9Vp71O$AePv04YAy z)2zoH`@@SQPw=wo`ynUcvV9s?wr+KV$z==U%^?~7qd8IPj)$C`yUzJgGan$QO0#fGvDiznB;@6tE?g0HtKqw-94IUZr z@u0CL?Bnt2Kj)Dl9Kag}^bj@Ko1Ven@CJIr=;7CO-oiP~eZqO+YwTI|8j$yzv3x~Q zPS;S&yM(StEo%fzx;JP^sO9wJI!Uvp8CAcX>rl(NJjHRyg_7cj(GJP_RwGsMm-yf?HLj>2y_^&SdU1#TJlGQcy@}GQe*xkbx*q>EttqcwC77glb#X zZkC{wfaGI6ei4O`B1x*H&(1dR|zT0%OsBo&>?+DwR@a^i$PB{j{W70nXbn02cn zBppfBYSM5@vg$R!c@G+dIIysfz(oxlN`zmnOxM@cDnxm;4n6s+!8z@zr=D8S@<0D0 zejq-%Y~wOgm(*qPl2>{t3#RF0?BnK?DPDi*UD-4Fq#&8W{hy>DqK9 zqkyxgrKg3X;|oCpWIg!K22;@;m$jQj(LnLB)DmkEl^OCGO3dR?0+Wh(lz{Q4QEFgM zNKF$J%zH|TIZeY%qBcD}Qs@+oWX35mQfTnMr`zmlS=zE0RS5+yEZh+YG&j>Gb}ZPi zK-|;$3+&GRp5DLWaY8d&A9~-^?&Ca8y2Vg;<8hJ`@i@7L+d;|JK0!?}*2b9X_7r-9 zo`jxM*o__38?l2@pDyEVl;@GR`zzG8eNE>r(#L$(=1-$*>{rot4{w^-vV9O&ww!`w zDXk#6Y}xMYus2O|**=P~cBZ<$m}3(?=XxZU%~RqD+jzNu4j!o@@Bly^scM}2oJ_n)*>BYu9@>-tBv??K`j zD9Fm&D+#Y=2MKP3PA1!JiR#l+!cmliBmM{6rW?v{(vbgie!eRoUVl!Qq!tPJ7|YxB z8jS{L%&Fx6tRZJUyIqq{3tA#Jg8#GKYPYuKYXFdR*wd^uumua@AOt!FtVK$NLqVa> zQOpyCv^HjsVbZ=`C?N-Tn;w`<@>w=J&1nv&(^bM($=qkT)^D!Ghl35PYk}6!*TA#@O&`KtTb-H5F)|D_iX$wB$&@}J~acjwhqSBO3PEX}(aZ4U2TUyBi}Oz4l8 z?>m(@6d{U6%y-r5t`J3=RHA6ad{?dR3Lky3igV4w9=!C_$rS6mP>fC%EQMI!-tNik z>nc_;ui1HsAX+*E_||rhu~#lBCjf<|knqbw7E7^;Z?z1w3~MVUd~4mXG@>&HzIDhj z0+kLK2DE6Me;5Z&wh6;1a$_jXZ{gnwRB9pGr(lK!x6x?=l`1#Uk2t6_*rSIM7Edw! zTYFGWD_mznH8mz;iv~-P)n8-9hrj9uD{!S=t+fW1>W+e&=cYEZ$)uo18JtQ3*-`fD z>m(0511^_pTim~Xots-1O1uC8KtxQ**w!gfASMS#cWFLh0vd*^Q-DB zoCE) zuFT9R-1g=h@+a4Adt>K)_wC$y-{i^CQGhzH-!*kunr7UA+xn1ceDjGXRYx=0A*=6^BJ#NoqKnQKZ?kllDALalFL@rzv|p&`-sJst#5yCux~<& z{gca9*{_n94U&6D*IMBsHs(9l|N9d43k{KF{O_a7c&fj=&i$Hac-!trwT5+kFY$c2 z2aaHyK;EwZ*XJ#2n@G+Z=YRJJ)6~3`vtg7s$~RVe&+vB3^Nm%&2jZH@xM4_lAnK7L z;Z3+ecOKRB@OGP#onaxbN2I6&+x+jf$I+6eb$viJA$%e>OVDND8uu;bGH)&A1W)@- zoF{0vn1*CeP*=7q?6s-jmq_LfV}ss^LmPFw=14{x6?u!oXd|9SJw>7k=W%bGZ)vxY zXkzmig8+%kCHImE=kiz_g2{*RCK0n9huaFLSyY;kPm+h#e6pOle0IBj>n@)$KCq}w zjhl}-YCa)_5*qBEL0a7nVfjUW&0?W6F-?ppbK0`f0H&eX`VX9;wav1iJ=Q zwZMTOK5knvD?EPuL@*y=9s)Thf_e%r&y=6dbp;A$1ak^0xtu~ImlJbd;WVn0L=mn^ zwV5QF+Ce>npxJO#0`Gwys~TeYgcm;)i+;zx%qi-);R1iI zs4tp4ZlWjcFHG`wu9DxE-z&INoBcFP7su_~`->L~?nMNOt&dOM3G`7J;`U#Txc!DO zn@@y-2oWVz>!?CO1o~ON&P<6|-#O*)*G0u+;i!15T?LtF-&Dimb@TKHmr(TS3jc;q z;=q;`1}fpcN& zKzkK~tz%2$&~=)p!tgrE!+V-vvOBc*p_Kn@O#C?eBQg8E2S&g9UPRy9uh}EobS54P z?+?mqI8xFmJA+bEqgv7^JA=Ye@E;dBPoW;`E^;25mgs`3*^}&pi+8hFt`^`u{L>TLOY}`Dc{(R! zd3PZwrE`X$)SuInUGuB33wwBvwg#z28pN4K{`HzaVj&^T=75OePoX2>noYd7)m^{W z+$%jNc!f)qnJS`VO<=JXR60i!D7C^bR?k5v`W%F%uzy*P6kR3qJu96G*s`=*)p9_Z-6qxR$D0X zNT0QKnlV;iaM#UjQu9xHyXBV)bUmEzdjGKU+CA%S{RTETd@s*#S~XQ{zjGPWYVLhV z{%L5z$c<~}u#9&**Do2}e4)6!9>N{0OYD!Dwc-`u!HC(YVyDwFVrtfYwM)EWkCMJY z_|DTe2%jTSO8*+^Y1XX$IYa?9s^qt|7XxbsbMcvg=Z;Ce?S5>j* zWlQI;tIx7a;xbL&6^9SpmODFjVEHWMVujSs8nnlvxrnrysMY%KBi{5=#Jc*kJQo@e z=0tLlU{xD^gRbmXTy(gdO_z<<&p zCca8-;t5t5tyVTEdK~+nQP{{UM`>%Mc5xmO%3F7+V|}!=4jL+sWKFTx z@i%{_`_ekNZANfuVoU4D^{2;|){&m3Ayu(b+N35^ocIQ7q6LgCY;1dq6c$YjWtCqP zXY!Wi5w<1FYu~V}{uI30f+pU?(8BOP=B@riFkQ9*j4hSPsx{S zEbNE!#Qk42X*y!^?NXVv82pQr2pv%$NC&qqnF9_k^C$Q~BDPgJ<=bG)ftOE^5kFOn5zGx}Iw2m@|%c_=_U4b%t*zNLyJ3BADO`gvV-X?BC$&1*J4a#isE-zKssI zJ&vjo&On`(A-skoiXC`iLa&HET^RvV)Da;1bRHSys9kV(uhnyDKb7QGW}3hrk@-cpNGde$kn%)7YX%6tv`6a7Yu~*}th?z0M93ukg?ugeQgV$Pjep z2J>==^ymlpY|Yx%Y6+M?QO-ym#7;cLFq%4+3WC60Xc)s>~b@HMxm9Q6q& zyz)wSWw~bvHh1`%r>Lx4xmJcF&*1X1B2W0*kfGxc%LTt1H*v_saYF}T8xp=Yw6STN za;*tRL+i&iH4Y75>pyT5-VJ^?s;Pg|sDVSUfpbhL!@wcKM=95a<7nXEQNxD}#I<;C zD(+yb*xX7AC050XV*?q*U5#5{#a|hB)v;q&UG?OXSB+n{Zv2FG>n3cIidUZfjd&pO zy!b}q8Oe6+N;(J2aQY?Fre1Q%)M=M|`MCDSUk=GlzR>we@;Qtn?ZO-EZ=AiknR;?O zcXL#Ap(swIB*h6IN>uLV7}93eF2H|?Sl<-pz%VN_`&$AV^Eq-55h$WLP==E*z#~C< zqVjW54pd?;#*YQE8S@c0%)Hf}z_^7`@c)o$>%Z(R|6%McXs<%oYnmp^E0yeaW$ug2 zynQ2ci8AwIR^>8BRb(zvXje7k5znNz5IPeCXyrsOqd&gp35U)EeGnyDLp={d&ao)FW$+KeFe5BCt^;>zwEP^+vmkhn>QV+k+p9&67iYrB* z88Z;6%4()dbfJ*{iY`Qc*WE?E-t({fL&qoX zlL3$?V2>})=HZ(`{FO=d+ z&8Bf1kp?&_moQZjHwd6hY!CvTi_(fXv_ag*VFn6*6nI^K$ZC_cV_LRvAJejPQbU^| zkJcjXS_$5))?w2JxlYVb)*D*ZX3libHLK04v&CO)%dt_6pd+c-DSlUWjv>c-^3@z< zN8wy23(}Id6hm&A@;wF1h9zyT-0=4-bX|~On8V*w;f21WEjuf>3`UbIaOyd-M+4R| zdS0MlK|wIvwY`6#YAFFLJI4vzD-qKj?n6pQ*Gh(Xx0UjbM~_*%?&A;Ft{Xi@{-U;4 z{z6M3j)G~ z>8$}LXw;cAy#}o7@QV=OXQFvjyA8eY0{&(p=Blfx=!j;fRg?f|GcB)75O%`4KReg` zS>Z+;%GT$DAN zWXUYAd}RCfhiiLe&1Cyc+Z&|8C#NiZx#`8PG_w5c_|waWoSe+g-)utQ@k!-l_AoZ% z?iV$hpWi)Be*3EPmB7T%cdMOt_!bHs`Ru(D0;&A71 zLHCJZ6Q|lNMhD89gr=gvy|o1AqUtF0iW6C8Tc3IT9ZgenhuZUc_83%?SMa?t04U0DytC)nlhggn zZy7gk+0E>V57~+W?~`xWmDc;_*A1B_{}i8{a><7mtv$$EIxoCX4pNDWVbw0xuELR4 zXi-WWiTw^#tg4mgvEN~$RNuHhQexF$R9D}w)i4Sj@%nMlih@9)og73&^qs*73Lnxk zjaYH2;uHRQqh$%@kWjPNbdSaAXcI(1s9 zd*-q0(d7#19blS6S7Udwk7T!IPWg!Re!;ut6>P0MSTt?FyU{sfgZ#8KLTZ<9O!Mv9 z(3#t5T6jfIhg^gAw3a=p86b|sx_^Z7o+9J_x3Mv<4@x45GE~XX@2r(hp6xG=+l%!7 z9gNrg$DKCas%fA9UaDCFEedl9H|u^a{SID| zg+PR?NXSi}gxsja+$L5T$&CWMl^hs%dt0D%!rKn{j2?*}l)5DcO|2|cWXi(mV?7lr z1)~a`qV7mx!ZGmBUm{tC1y&h;l&Bme9qF_*@aXhB=m~Ru@W4gE^T*sj`_`ZQ_{JrN zs+Qh$*$@7-YJ1Cy2b$_{Ub^m}=C_;XPq}1J&+BK*UoX~${m~bK@OrT5nx3Z&T~WmgQ?{jQEq>N6T~`p_g!NLZ63+vpW7LeI9BbKH-!{3hv|+ zj=dB#*x4Stlv2uPm{aUXkuTS2w(3w^-uREV6_|BgaP^V297JSU2(tybgnnq-qMRdN zVI|TkuM&q|=fq?LxV29N{93IX!q?I$Y`57kTcNhZKh>5L_BFCJQ*~i>QG>~|dT+zb zii*-wt7tKwr)%t&>&aX+VdQ-IsnWq!MT3lH7P$6D?DWm-A+~?YplZx;DMJh<`73#! z{BGygA6|Ij6pYI3pzyx#25BBd-YQ{MLakIM)JoW1Sre%h9?{KjD#Qf5dJkaJ< zaMC=`=7Bnog?#25ZX=-g&)A7W9jrS!z4pn?jkot6bVIM= zK7ob#d&b^;@}HP_Lr=ZGqHdD1D2iu=0m(e2b6!l?L& z(i0-{QFK&P<|EY^%EY3>A~&D}Q$P_UOsMz6T}i7mr5g0c6e8hPCIP?2j>ZpSWs642 z&}HZ(igOp4AXtZ8&?DdpO&Py9*(U-#p%yC9QYQWb#XwYGsB`wvCn8ExT zYEsMK>}|&efe2k+qVCW3vmx@2SjU;pITC!h@0Fis{pG1=#C>N!m47^ot>4Nrwz5_7 z_qNJsw#qx{&EF*mx|=jFLxN2a)+%ovB09Bu1B;GJDO`_j45aY>5FM5j%z-|o;#4~m zBuxs;s}(2PMXin-g6itp3UpdRpzGi$h9%n!)*0-rku^!pr&dI!X0NfNo$Zk-ntmGOgOw zOe{6hnSlBNm5fjayMUyDfO87VDva_NV~k+u)@uYUtO2KJL#72}`Uv!3R%R8fs9>sN zs?{onnSlO@wv}v;ynLm+oc&C`hs|QMw7Wa|ch-r|imjcub>1p2!tD2+u9e-P?}ynn z5vvfLU6m10R7b=pf`@0fd*6B$+oJFHBA!vMn<(C=U;J;z@aQJSVtBxzCSI@m8Or&c zBb9VN15yd73f)9$kbVuG@PC1-&`k_GRAHX`|A>kaX3ABf4fW&-OQ5Q7VPH|4nZhfq z3z+y=6SbLfq}sQoJ}mw_)Q9ZaZ_a&)Jnz?jF52{~|BKF2++)ElT<&%?A8g-wiJhq1?VCgq!*Z30fUN+6>0090VNd zA^eY0qKL-6XG9iIH1-}=GJswC0 zDmz$Oe3WGgE82WMTx1nUi3E|bCJ`L$mBbL0>F{Cra8y<~>`Gt)P-;nNg{-x?dC#7m zJI9Z#?jL%HLgI^$J}TXMc9C@J&4)K#DBUd$8*=5%XBSbReC?B`Xs)H&-K5s;PZH|3 z_#Aaxa_+jN+T-;bO0~Lc>f@@oLqJ7RC6m+|%+06p1rjoQze%U~1iT}30|F*xR?)z_Re>BDl@NCl5 zn_j4zV;S_RkZQmQK?oLJJ~zBS`=`!Z3<4x(^e}!uUBK`3%jKbh!H_RMB0qv(QA#oY z*cIaK%2%-tLqVPJsQ4>kw`QGChpj=MCrr>z6PnppVYB$4umjr&3FkBq3lnf}8@@O5 zeO=d^xPL6R7qHb}8-c9~TLZRn*ytMVrz!UfZAfE2rNj5@gms2|)*H{t!*+}IS)m!* zeK-zi-Vp-2I$=J(AH;7T(+EN#&L2Qp2Xqe#dvX2%u2Vkgz6to=j^h^mE)U0dNUCit-B;d}+sxk5yqv9FV!>UtMx2`Eb*_G7SZ!ZQYAKMLC<+(YLlVY`MsD_qB( z?Rp#6u@|nx^RJ=v*y!F(v`0DzalJwP-3}aEk&is=Gu5pghMs9+hVT^aQT|JDFFpS+ z`2AvRjo7IEb#xE*({XK*Ae{Y^AjlK7?{&Q^{!OUF`DdyAa1Bsse(eaq_MngEapwj-F-HJb+7JCwcrin@PL-FK?j>u8TQXT@(mMIAKbdmwhhJ9r=Yc?|ncv3KDf0ULjx z^e$sJDv$bp@5VjRO=&aKP7p0geh%dvgS;J5_VO$0{$6$8sa~H=d$e!dD}RdjLhqa2 zab!b&exl}&_B?;MPVYJVzUer+QCoh8zqcJYAKmD^D{U$h&pN5pjeHX4=-DUK?| zJ?e43df!<(#(lTbca-xF*j`839oSN_J%-;Lz%#GI^#j!AWA|tm&*Qs*K0*1TK6y>z zp57VGk3!x*q+{%VN8iyd#-Lx0!u{gw)e2@$F^8}usC`Q4kMA!1XX2l*KZtD~w!3K4+$3CvbSa;dU&{9YY{)O~-;{Th ziRXQia?J1f5@o0HD6%0lu>Rw76L4fdkcMkkXysRR?GAuDX zW*lxjoAN?x$aII;|6}Z>GO)yT*P;#(GDtOLQ^e88~*#|Bmn8a23h@C`$@4tcTuhxMQNw+t;BI;-KrhF=b|4jVe`r;QsLpJ+TY ze8BLBMpTb@dt~~^U84#|O&t{)tsVXBn4&TB$Fw(H-*iXQpT}M_cISEh$EA-e9Cv8k z)8jrK?;byL{Oa+yjz2xVb3(<0857=^sGs*edS|j}a?Rw~lebNo zHRakVKb-RFR5rDA>XfMmrfr^f_q3l)`|J7c^GBZl^XXSlziawWr++!4aK_jf8)gJ% zyw;rBT+@8C`JI^uX1+V~FSA^;8fUGTwQttjvyHR+&c0ywHFE~eX_<5VoWpb8nroQb zckY3CtLNQ1@5l2#n(vrDWPxWv(}INyb}x8v!OIIeTS{6cv}|s9q~-CJPg*)#OIpXb zZfd==^yVShYwY2BbNlPzYdhgQbmzkHj zmd#kUXxREB3DV(TYE;_}fa&%HAud zt-O5Yp_L!6%33vR)z;PLueo&1eQRD=^TwKw)@H07ymsN*-D_`O`_S6cYdhBsUe~m4 z$GThB{dnCk)_t+wyMFBYjqC4R|I_uau77`nbwk~T`5Sg@Xy5S0M%~6IHh#5fz@}xJ z9@+HPg%@A=^k&EA3pT%ek#tejMH??VdeIjbufF*GOPVh^d}-CCt1mrq`T3XMa`|gp zGPnHcO5K&quNr*SjH_O~dg9f$Tw}Rr`85Zw`T4c>Yj50|yLJDz>}@Ny{rEc9b?05T z`?}-Xb=xOw|G|ziJFeUD+D^~TxjXmneD?Z{yY#yb?;c5#5xW9h4SGbD(qL6YqfQgX zRYBZ{6#&Iv?kDbw$WP=+uEhNr6u(#Q(cm8PZl!-(j9oXY$2y#E zL+VUJPcR9OtH&C_COpfJwK#9(IBllUA~QDO1Abn|@Bd0Y#(8+PQ#yLOpJl7ZbiOA) zhV?_($jG;pY2X}#^MK&+V^K(BKT(e*;Y#+RdW_$SlhtD_#+6p}Scmg>@M9pl1(T?x z16u^nALHj!aXw9aK|QAPZ}4LizyA~Un9eKtPowjanV*N_i(r$K{97p?#~D1GRq8RFznC8*KCWPuu2+xg{O$bM$?yN6dQ9gZ=f~MNpC&!8 z9@F{X@Z&sw{~y(3I{yVf&c}I!CRIJgd5xVP_on+b#p*GgucTuGf39YjdQ9iX)3J%y zr)HjdOy^hdV_t8X%hh8#zhl^{)tlEYS+sb4L5~GJ3o7gC`u47?Eg03hX?g4V^}Q#| zTd-{2+LrQyq05&S@Y~iEtZiM_x^`o0OZlkQRzkUt@legb!@|& zwz|f;;e}<5NBT}0II6KuXD9_KX^ubrmX^J(hg$AyeYE9p>&cd9S`F*`+EFc^wTSEe zdS~k`E$yw+hQQF-ev7_UzfiwOe@GwF->AP`zb;^$J?AKN%q_zk9>e&wL|8d|J3fyJ zi-$i3jqGyf;@=;~B;O+R!flNW!9F!*)5~0*83YB#37%>Fp{0*5J-Jj`=Pw@FI&$I2 zMI#T542@i{uyx_Wg^Ly*S{PdRz{2)L3${M6wf+7F?r*mtU;CEtJGk#AX~P^Sf`>0D zoL{hg!S?M7pa*8I?SYXSmoyeGX+*@t#exmt^_K9zEBN1)_#1?0;^c Date: Fri, 29 Aug 2025 02:53:54 +0200 Subject: [PATCH 0758/1626] Update mcp4461.cpp (#10479) --- esphome/components/mcp4461/mcp4461.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 55ce9b7899..191fbae366 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -198,7 +198,7 @@ uint16_t Mcp4461Component::get_wiper_level_(Mcp4461WiperIdx wiper) { uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) { uint8_t addr = this->get_wiper_address_(wiper_idx); - uint8_t reg = addr | static_cast(Mcp4461Commands::INCREMENT); + uint8_t reg = addr | static_cast(Mcp4461Commands::READ); if (wiper_idx > 3) { if (!this->is_eeprom_ready_for_writing_(true)) { return 0; From ef98f67b41dab7bd50e71217b3afa607928f955c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 29 Aug 2025 11:58:58 +1000 Subject: [PATCH 0759/1626] [lvgl] Replace spinbox step with selected_digit (#10349) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/lvgl/defines.py | 1 + esphome/components/lvgl/widgets/__init__.py | 1 - esphome/components/lvgl/widgets/spinbox.py | 31 +++++++++++++-------- tests/components/lvgl/lvgl-package.yaml | 2 +- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 8f09a3a6d0..baee403b57 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -451,6 +451,7 @@ CONF_GRID_ROWS = "grid_rows" CONF_HEADER_MODE = "header_mode" CONF_HOME = "home" CONF_INITIAL_FOCUS = "initial_focus" +CONF_SELECTED_DIGIT = "selected_digit" CONF_KEY_CODE = "key_code" CONF_KEYPADS = "keypads" CONF_LAYOUT = "layout" diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index bb6155234c..1f9cdde0a0 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -67,7 +67,6 @@ class Widget: self.type = wtype self.config = config self.scale = 1.0 - self.step = 1.0 self.range_from = -sys.maxsize self.range_to = sys.maxsize if wtype.is_compound(): diff --git a/esphome/components/lvgl/widgets/spinbox.py b/esphome/components/lvgl/widgets/spinbox.py index b84dc7cd23..26ad149c6f 100644 --- a/esphome/components/lvgl/widgets/spinbox.py +++ b/esphome/components/lvgl/widgets/spinbox.py @@ -11,6 +11,7 @@ from ..defines import ( CONF_ROLLOVER, CONF_SCROLLBAR, CONF_SELECTED, + CONF_SELECTED_DIGIT, CONF_TEXTAREA_PLACEHOLDER, ) from ..lv_validation import lv_bool, lv_float @@ -38,18 +39,24 @@ def validate_spinbox(config): min_val = -1 - max_val range_from = int(config[CONF_RANGE_FROM]) range_to = int(config[CONF_RANGE_TO]) - step = int(config[CONF_STEP]) + step = config[CONF_SELECTED_DIGIT] + digits = config[CONF_DIGITS] if ( range_from > max_val or range_from < min_val or range_to > max_val or range_to < min_val ): - raise cv.Invalid("Range outside allowed limits") - if step <= 0 or step >= (range_to - range_from) / 2: - raise cv.Invalid("Invalid step value") - if config[CONF_DIGITS] <= config[CONF_DECIMAL_PLACES]: - raise cv.Invalid("Number of digits must exceed number of decimal places") + raise cv.Invalid("Range outside allowed limits", path=[CONF_RANGE_FROM]) + if digits <= config[CONF_DECIMAL_PLACES]: + raise cv.Invalid( + "Number of digits must exceed number of decimal places", path=[CONF_DIGITS] + ) + if step >= digits: + raise cv.Invalid( + "Initial selected digit must be less than number of digits", + path=[CONF_SELECTED_DIGIT], + ) return config @@ -59,7 +66,10 @@ SPINBOX_SCHEMA = cv.Schema( cv.Optional(CONF_RANGE_FROM, default=0): cv.float_, cv.Optional(CONF_RANGE_TO, default=100): cv.float_, cv.Optional(CONF_DIGITS, default=4): cv.int_range(1, 10), - cv.Optional(CONF_STEP, default=1.0): cv.positive_float, + cv.Optional(CONF_STEP): cv.invalid( + f"{CONF_STEP} has been replaced by {CONF_SELECTED_DIGIT}" + ), + cv.Optional(CONF_SELECTED_DIGIT, default=0): cv.positive_int, cv.Optional(CONF_DECIMAL_PLACES, default=0): cv.int_range(0, 6), cv.Optional(CONF_ROLLOVER, default=False): lv_bool, } @@ -93,13 +103,12 @@ class SpinboxType(WidgetType): scale = 10 ** config[CONF_DECIMAL_PLACES] range_from = int(config[CONF_RANGE_FROM]) * scale range_to = int(config[CONF_RANGE_TO]) * scale - step = int(config[CONF_STEP]) * scale + step = config[CONF_SELECTED_DIGIT] w.scale = scale - w.step = step w.range_to = range_to w.range_from = range_from lv.spinbox_set_range(w.obj, range_from, range_to) - await w.set_property(CONF_STEP, step) + await w.set_property("step", 10**step) await w.set_property(CONF_ROLLOVER, config) lv.spinbox_set_digit_format( w.obj, digits, digits - config[CONF_DECIMAL_PLACES] @@ -120,7 +129,7 @@ class SpinboxType(WidgetType): return config[CONF_RANGE_FROM] def get_step(self, config: dict): - return config[CONF_STEP] + return 10 ** config[CONF_SELECTED_DIGIT] spinbox_spec = SpinboxType() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index feee96672c..582531e943 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -684,7 +684,7 @@ lvgl: width: 120 range_from: -10 range_to: 1000 - step: 5.0 + selected_digit: 2 rollover: false digits: 6 decimal_places: 2 From dea68bebd8f41c9ab4d0ee3d3fa38fc7b679685e Mon Sep 17 00:00:00 2001 From: Ben Curtis Date: Thu, 28 Aug 2025 22:00:54 -0400 Subject: [PATCH 0760/1626] Adjust sen5x to match VOC/NOX datasheet (#9894) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/sen5x/sensor.py | 111 +++++++++++++++++------------ 1 file changed, 67 insertions(+), 44 deletions(-) diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index f52de5fe85..9668a253c0 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -65,26 +65,47 @@ ACCELERATION_MODES = { "high": RhtAccelerationMode.HIGH_ACCELERATION, } -GAS_SENSOR = cv.Schema( - { - cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema( - { - cv.Optional(CONF_INDEX_OFFSET, default=100): cv.int_range(1, 250), - cv.Optional(CONF_LEARNING_TIME_OFFSET_HOURS, default=12): cv.int_range( - 1, 1000 - ), - cv.Optional(CONF_LEARNING_TIME_GAIN_HOURS, default=12): cv.int_range( - 1, 1000 - ), - cv.Optional( - CONF_GATING_MAX_DURATION_MINUTES, default=720 - ): cv.int_range(0, 3000), - cv.Optional(CONF_STD_INITIAL, default=50): cv.int_, - cv.Optional(CONF_GAIN_FACTOR, default=230): cv.int_range(1, 1000), - } - ) - } -) + +def _gas_sensor( + *, + index_offset: int, + learning_time_offset: int, + learning_time_gain: int, + gating_max_duration: int, + std_initial: int, + gain_factor: int, +) -> cv.Schema: + return sensor.sensor_schema( + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_AQI, + state_class=STATE_CLASS_MEASUREMENT, + ).extend( + { + cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema( + { + cv.Optional(CONF_INDEX_OFFSET, default=index_offset): cv.int_range( + 1, 250 + ), + cv.Optional( + CONF_LEARNING_TIME_OFFSET_HOURS, default=learning_time_offset + ): cv.int_range(1, 1000), + cv.Optional( + CONF_LEARNING_TIME_GAIN_HOURS, default=learning_time_gain + ): cv.int_range(1, 1000), + cv.Optional( + CONF_GATING_MAX_DURATION_MINUTES, default=gating_max_duration + ): cv.int_range(0, 3000), + cv.Optional(CONF_STD_INITIAL, default=std_initial): cv.int_range( + 10, 5000 + ), + cv.Optional(CONF_GAIN_FACTOR, default=gain_factor): cv.int_range( + 1, 1000 + ), + } + ) + } + ) def float_previously_pct(value): @@ -127,18 +148,22 @@ CONFIG_SCHEMA = ( state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_AUTO_CLEANING_INTERVAL): cv.update_interval, - cv.Optional(CONF_VOC): sensor.sensor_schema( - icon=ICON_RADIATOR, - accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, - state_class=STATE_CLASS_MEASUREMENT, - ).extend(GAS_SENSOR), - cv.Optional(CONF_NOX): sensor.sensor_schema( - icon=ICON_RADIATOR, - accuracy_decimals=0, - device_class=DEVICE_CLASS_AQI, - state_class=STATE_CLASS_MEASUREMENT, - ).extend(GAS_SENSOR), + cv.Optional(CONF_VOC): _gas_sensor( + index_offset=100, + learning_time_offset=12, + learning_time_gain=12, + gating_max_duration=180, + std_initial=50, + gain_factor=230, + ), + cv.Optional(CONF_NOX): _gas_sensor( + index_offset=1, + learning_time_offset=12, + learning_time_gain=12, + gating_max_duration=720, + std_initial=50, + gain_factor=230, + ), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, cv.Optional(CONF_VOC_BASELINE): cv.hex_uint16_t, cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( @@ -194,16 +219,15 @@ async def to_code(config): await i2c.register_i2c_device(var, config) for key, funcName in SETTING_MAP.items(): - if key in config: - cg.add(getattr(var, funcName)(config[key])) + if cfg := config.get(key): + cg.add(getattr(var, funcName)(cfg)) for key, funcName in SENSOR_MAP.items(): - if key in config: - sens = await sensor.new_sensor(config[key]) + if cfg := config.get(key): + sens = await sensor.new_sensor(cfg) cg.add(getattr(var, funcName)(sens)) - if CONF_VOC in config and CONF_ALGORITHM_TUNING in config[CONF_VOC]: - cfg = config[CONF_VOC][CONF_ALGORITHM_TUNING] + if cfg := config.get(CONF_VOC, {}).get(CONF_ALGORITHM_TUNING): cg.add( var.set_voc_algorithm_tuning( cfg[CONF_INDEX_OFFSET], @@ -214,8 +238,7 @@ async def to_code(config): cfg[CONF_GAIN_FACTOR], ) ) - if CONF_NOX in config and CONF_ALGORITHM_TUNING in config[CONF_NOX]: - cfg = config[CONF_NOX][CONF_ALGORITHM_TUNING] + if cfg := config.get(CONF_NOX, {}).get(CONF_ALGORITHM_TUNING): cg.add( var.set_nox_algorithm_tuning( cfg[CONF_INDEX_OFFSET], @@ -225,12 +248,12 @@ async def to_code(config): cfg[CONF_GAIN_FACTOR], ) ) - if CONF_TEMPERATURE_COMPENSATION in config: + if cfg := config.get(CONF_TEMPERATURE_COMPENSATION): cg.add( var.set_temperature_compensation( - config[CONF_TEMPERATURE_COMPENSATION][CONF_OFFSET], - config[CONF_TEMPERATURE_COMPENSATION][CONF_NORMALIZED_OFFSET_SLOPE], - config[CONF_TEMPERATURE_COMPENSATION][CONF_TIME_CONSTANT], + cfg[CONF_OFFSET], + cfg[CONF_NORMALIZED_OFFSET_SLOPE], + cfg[CONF_TIME_CONSTANT], ) ) From ca72286386aa0f47e609bd3774cab65ccc4047c5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 29 Aug 2025 15:42:39 +1000 Subject: [PATCH 0761/1626] [lvgl] Update hello world (#10469) --- esphome/components/lvgl/hello_world.py | 129 ++++++++++++++++++------- 1 file changed, 96 insertions(+), 33 deletions(-) diff --git a/esphome/components/lvgl/hello_world.py b/esphome/components/lvgl/hello_world.py index 2c2ec6732c..f85da9d8e4 100644 --- a/esphome/components/lvgl/hello_world.py +++ b/esphome/components/lvgl/hello_world.py @@ -4,49 +4,112 @@ from esphome.yaml_util import parse_yaml CONFIG = """ - obj: - radius: 0 + id: hello_world_card_ pad_all: 12 - bg_color: 0xFFFFFF + bg_color: white height: 100% width: 100% + scrollable: false widgets: - - spinner: - id: hello_world_spinner_ - align: center - indicator: - arc_color: tomato - height: 100 - width: 100 - spin_time: 2s - arc_length: 60deg - - label: - id: hello_world_label_ - text: "Hello World!" + - obj: + align: top_mid + outline_width: 0 + border_width: 0 + pad_all: 4 + scrollable: false + height: size_content + width: 100% + layout: + type: flex + flex_flow: row + flex_align_cross: center + flex_align_track: start + flex_align_main: space_between + widgets: + - button: + checkable: true + radius: 4 + text_font: montserrat_20 + on_click: + lvgl.label.update: + id: hello_world_label_ + text: "Clicked!" + widgets: + - label: + text: "Button" + - label: + id: hello_world_title_ + text: ESPHome + text_font: montserrat_20 + width: 100% + text_align: center + on_boot: + lvgl.widget.refresh: hello_world_title_ + hidden: !lambda |- + return lv_obj_get_width(lv_scr_act()) < 400; + - checkbox: + text: Checkbox + id: hello_world_checkbox_ + on_boot: + lvgl.widget.refresh: hello_world_checkbox_ + hidden: !lambda |- + return lv_obj_get_width(lv_scr_act()) < 240; + on_click: + lvgl.label.update: + id: hello_world_label_ + text: "Checked!" + - obj: + id: hello_world_container_ align: center + y: 14 + pad_all: 0 + outline_width: 0 + border_width: 0 + width: 100% + height: size_content + scrollable: false on_click: lvgl.spinner.update: id: hello_world_spinner_ arc_color: springgreen - - checkbox: - pad_all: 8 - text: Checkbox - align: top_right - on_click: - lvgl.label.update: - id: hello_world_label_ - text: "Checked!" - - button: - pad_all: 8 - checkable: true - align: top_left - text_font: montserrat_20 - on_click: - lvgl.label.update: - id: hello_world_label_ - text: "Clicked!" + layout: + type: flex + flex_flow: row_wrap + flex_align_cross: center + flex_align_track: center + flex_align_main: space_evenly widgets: - - label: - text: "Button" + - spinner: + id: hello_world_spinner_ + indicator: + arc_color: tomato + height: 100 + width: 100 + spin_time: 2s + arc_length: 60deg + widgets: + - label: + id: hello_world_label_ + text: "Hello World!" + align: center + - obj: + id: hello_world_qrcode_ + outline_width: 0 + border_width: 0 + hidden: !lambda |- + return lv_obj_get_width(lv_scr_act()) < 300 && lv_obj_get_height(lv_scr_act()) < 400; + widgets: + - label: + text_font: montserrat_14 + text: esphome.io + align: top_mid + - qrcode: + text: "https://esphome.io" + size: 80 + align: bottom_mid + on_boot: + lvgl.widget.refresh: hello_world_qrcode_ + - slider: width: 80% align: bottom_mid From a6eaf59effc816e9eaabbd39b6d0da9300f872e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 29 Aug 2025 08:59:09 -0500 Subject: [PATCH 0762/1626] [bluetooth_proxy] Expose configured scanning mode in API responses --- esphome/components/api/api.proto | 1 + esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 3 ++- esphome/components/api/api_pb2_dump.cpp | 1 + esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 6 ++++++ esphome/components/bluetooth_proxy/bluetooth_proxy.h | 3 ++- 6 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 6b19f2026a..9707e714e7 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1712,6 +1712,7 @@ message BluetoothScannerStateResponse { BluetoothScannerState state = 1; BluetoothScannerMode mode = 2; + BluetoothScannerMode configured_mode = 3; } message BluetoothScannerSetModeRequest { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 476e3c88d0..de60ed3fdb 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2153,10 +2153,12 @@ void BluetoothDeviceClearCacheResponse::calculate_size(ProtoSize &size) const { void BluetoothScannerStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, static_cast(this->state)); buffer.encode_uint32(2, static_cast(this->mode)); + buffer.encode_uint32(3, static_cast(this->configured_mode)); } void BluetoothScannerStateResponse::calculate_size(ProtoSize &size) const { size.add_uint32(1, static_cast(this->state)); size.add_uint32(1, static_cast(this->mode)); + size.add_uint32(1, static_cast(this->configured_mode)); } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index abdf0e6121..3f2c2ea763 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -2214,12 +2214,13 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage { class BluetoothScannerStateResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 126; - static constexpr uint8_t ESTIMATED_SIZE = 4; + static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_scanner_state_response"; } #endif enums::BluetoothScannerState state{}; enums::BluetoothScannerMode mode{}; + enums::BluetoothScannerMode configured_mode{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 7af322f96d..3e7df9195b 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1704,6 +1704,7 @@ void BluetoothScannerStateResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothScannerStateResponse"); dump_field(out, "state", static_cast(this->state)); dump_field(out, "mode", static_cast(this->mode)); + dump_field(out, "configured_mode", static_cast(this->configured_mode)); } void BluetoothScannerSetModeRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothScannerSetModeRequest"); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 80b7fbe960..532aff550e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -24,6 +24,9 @@ void BluetoothProxy::setup() { this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; + // Capture the configured scan mode from YAML before any API changes + this->configured_scan_active_ = this->parent_->get_scan_active(); + this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { this->send_bluetooth_scanner_state_(state); @@ -36,6 +39,9 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta resp.state = static_cast(state); resp.mode = this->parent_->get_scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; + resp.configured_mode = this->configured_scan_active_ + ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE + : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; this->api_connection_->send_message(resp, api::BluetoothScannerStateResponse::MESSAGE_TYPE); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index c81c8c9532..4b262dbe86 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -161,7 +161,8 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ // Group 4: 1-byte types grouped together bool active_; uint8_t connection_count_{0}; - // 2 bytes used, 2 bytes padding + bool configured_scan_active_{false}; // Configured scan mode from YAML + // 3 bytes used, 1 byte padding }; extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) From fd568d9af36a8d17fd795c2b5b89a674bb502070 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 Aug 2025 10:14:15 -0500 Subject: [PATCH 0763/1626] Bump aioesphomeapi from 39.0.0 to 39.0.1 (#10491) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 910f70fe45..32fdfabcda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250828.0 -aioesphomeapi==39.0.0 +aioesphomeapi==39.0.1 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 8be40bf1caaf159e89d71397f79f89125c4bfad5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 29 Aug 2025 18:21:41 -0500 Subject: [PATCH 0764/1626] Fix incorrect entity count when lambdas are present (priority ordering issue) --- esphome/core/config.py | 2 +- tests/unit_tests/core/test_config.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 90768a4b09..b6ff1d8afd 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -423,7 +423,7 @@ async def _add_automations(config): DATETIME_SUBTYPES = {"date", "time", "datetime"} -@coroutine_with_priority(-100.0) +@coroutine_with_priority(-1000.0) async def _add_platform_defines() -> None: # Generate compile-time defines for platforms that have actual entities # Only add USE_* and count defines when there are entities diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 46e3b513d7..f5ba5221ed 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -8,6 +8,7 @@ import pytest from esphome import config_validation as cv, core from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES +from esphome.core import config from esphome.core.config import Area, validate_area_config from .common import load_config_from_fixture @@ -223,3 +224,24 @@ def test_device_duplicate_id( # Check for the specific error message from IDPassValidationStep captured = capsys.readouterr() assert "ID duplicate_device redefined!" in captured.out + + +def test_add_platform_defines_priority() -> None: + """Test that _add_platform_defines runs after globals. + + This ensures the fix for issue #10431 where sensor counts were incorrect + when lambdas were present. The function must run at a lower priority than + globals (-100.0) to ensure all components (including those using globals + in lambdas) have registered their entities before the count defines are + generated. + + Regression test for https://github.com/esphome/esphome/issues/10431 + """ + # Import globals to check its priority + from esphome.components.globals import to_code as globals_to_code + + # _add_platform_defines must run AFTER globals (lower priority number = runs later) + assert config._add_platform_defines.priority < globals_to_code.priority, ( + f"_add_platform_defines priority ({config._add_platform_defines.priority}) must be lower than " + f"globals priority ({globals_to_code.priority}) to fix issue #10431 (sensor count bug with lambdas)" + ) From 983b3cb87924f9145c9f0f895e78f927b81d9ede Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 30 Aug 2025 18:43:26 +1200 Subject: [PATCH 0765/1626] [mipi] Add type to models for better type hinting downstream (#10475) --- esphome/components/mipi/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 570a021cff..a9ecb9d79a 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -2,7 +2,7 @@ # Various configuration constants for MIPI displays # Various utility functions for MIPI DBI configuration -from typing import Any +from typing import Any, Self from esphome.components.const import CONF_COLOR_DEPTH from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns @@ -222,7 +222,7 @@ def delay(ms): class DriverChip: - models = {} + models: dict[str, Self] = {} def __init__( self, From e29f0ee7f860e513adaad75b9b9ea890bacf60ea Mon Sep 17 00:00:00 2001 From: DT-art1 <81360462+DT-art1@users.noreply.github.com> Date: Sun, 31 Aug 2025 16:26:15 +0200 Subject: [PATCH 0766/1626] Add JPEG encoder support via new camera_encoder component (#9459) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/camera/buffer.h | 18 ++++ esphome/components/camera/buffer_impl.cpp | 20 +++++ esphome/components/camera/buffer_impl.h | 26 ++++++ esphome/components/camera/camera.h | 43 ++++++++++ esphome/components/camera/encoder.h | 69 ++++++++++++++++ esphome/components/camera_encoder/__init__.py | 62 ++++++++++++++ .../camera_encoder/encoder_buffer_impl.cpp | 23 ++++++ .../camera_encoder/encoder_buffer_impl.h | 25 ++++++ .../esp32_camera_jpeg_encoder.cpp | 82 +++++++++++++++++++ .../esp32_camera_jpeg_encoder.h | 39 +++++++++ tests/components/camera_encoder/common.yaml | 5 ++ .../camera_encoder/test.esp32-ard.yaml | 1 + .../camera_encoder/test.esp32-idf.yaml | 1 + 14 files changed, 415 insertions(+) create mode 100644 esphome/components/camera/buffer.h create mode 100644 esphome/components/camera/buffer_impl.cpp create mode 100644 esphome/components/camera/buffer_impl.h create mode 100644 esphome/components/camera/encoder.h create mode 100644 esphome/components/camera_encoder/__init__.py create mode 100644 esphome/components/camera_encoder/encoder_buffer_impl.cpp create mode 100644 esphome/components/camera_encoder/encoder_buffer_impl.h create mode 100644 esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp create mode 100644 esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h create mode 100644 tests/components/camera_encoder/common.yaml create mode 100644 tests/components/camera_encoder/test.esp32-ard.yaml create mode 100644 tests/components/camera_encoder/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index a1a74e9c99..116f35f3b6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -89,6 +89,7 @@ esphome/components/bp5758d/* @Cossid esphome/components/button/* @esphome/core esphome/components/bytebuffer/* @clydebarrow esphome/components/camera/* @DT-art1 @bdraco +esphome/components/camera_encoder/* @DT-art1 esphome/components/canbus/* @danielschramm @mvturnho esphome/components/cap1188/* @mreditor97 esphome/components/captive_portal/* @esphome/core diff --git a/esphome/components/camera/buffer.h b/esphome/components/camera/buffer.h new file mode 100644 index 0000000000..f860877b94 --- /dev/null +++ b/esphome/components/camera/buffer.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace esphome::camera { + +/// Interface for a generic buffer that stores image data. +class Buffer { + public: + /// Returns a pointer to the buffer's data. + virtual uint8_t *get_data_buffer() = 0; + /// Returns the length of the buffer in bytes. + virtual size_t get_data_length() = 0; + virtual ~Buffer() = default; +}; + +} // namespace esphome::camera diff --git a/esphome/components/camera/buffer_impl.cpp b/esphome/components/camera/buffer_impl.cpp new file mode 100644 index 0000000000..d17a4e2707 --- /dev/null +++ b/esphome/components/camera/buffer_impl.cpp @@ -0,0 +1,20 @@ +#include "buffer_impl.h" + +namespace esphome::camera { + +BufferImpl::BufferImpl(size_t size) { + this->data_ = this->allocator_.allocate(size); + this->size_ = size; +} + +BufferImpl::BufferImpl(CameraImageSpec *spec) { + this->data_ = this->allocator_.allocate(spec->bytes_per_image()); + this->size_ = spec->bytes_per_image(); +} + +BufferImpl::~BufferImpl() { + if (this->data_ != nullptr) + this->allocator_.deallocate(this->data_, this->size_); +} + +} // namespace esphome::camera diff --git a/esphome/components/camera/buffer_impl.h b/esphome/components/camera/buffer_impl.h new file mode 100644 index 0000000000..46398295fa --- /dev/null +++ b/esphome/components/camera/buffer_impl.h @@ -0,0 +1,26 @@ +#pragma once + +#include "buffer.h" +#include "camera.h" + +namespace esphome::camera { + +/// Default implementation of Buffer Interface. +/// Uses a RAMAllocator for memory reservation. +class BufferImpl : public Buffer { + public: + explicit BufferImpl(size_t size); + explicit BufferImpl(CameraImageSpec *spec); + // -------- Buffer -------- + uint8_t *get_data_buffer() override { return data_; } + size_t get_data_length() override { return size_; } + // ------------------------ + ~BufferImpl() override; + + protected: + RAMAllocator allocator_; + size_t size_{}; + uint8_t *data_{}; +}; + +} // namespace esphome::camera diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index fb9da58cc1..c28a756a06 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -15,6 +15,26 @@ namespace camera { */ enum CameraRequester : uint8_t { IDLE, API_REQUESTER, WEB_REQUESTER }; +/// Enumeration of different pixel formats. +enum PixelFormat : uint8_t { + PIXEL_FORMAT_GRAYSCALE = 0, ///< 8-bit grayscale. + PIXEL_FORMAT_RGB565, ///< 16-bit RGB (5-6-5). + PIXEL_FORMAT_BGR888, ///< RGB pixel data in 8-bit format, stored as B, G, R (1 byte each). +}; + +/// Returns string name for a given PixelFormat. +inline const char *to_string(PixelFormat format) { + switch (format) { + case PIXEL_FORMAT_GRAYSCALE: + return "PIXEL_FORMAT_GRAYSCALE"; + case PIXEL_FORMAT_RGB565: + return "PIXEL_FORMAT_RGB565"; + case PIXEL_FORMAT_BGR888: + return "PIXEL_FORMAT_BGR888"; + } + return "PIXEL_FORMAT_UNKNOWN"; +} + /** Abstract camera image base class. * Encapsulates the JPEG encoded data and it is shared among * all connected clients. @@ -43,6 +63,29 @@ class CameraImageReader { virtual ~CameraImageReader() {} }; +/// Specification of a caputured camera image. +/// This struct defines the format and size details for images captured +/// or processed by a camera component. +struct CameraImageSpec { + uint16_t width; + uint16_t height; + PixelFormat format; + size_t bytes_per_pixel() { + switch (format) { + case PIXEL_FORMAT_GRAYSCALE: + return 1; + case PIXEL_FORMAT_RGB565: + return 2; + case PIXEL_FORMAT_BGR888: + return 3; + } + + return 1; + } + size_t bytes_per_row() { return bytes_per_pixel() * width; } + size_t bytes_per_image() { return bytes_per_pixel() * width * height; } +}; + /** Abstract camera base class. Collaborates with API. * 1) API server starts and installs callback (add_image_callback) * which is called by the camera when a new image is available. diff --git a/esphome/components/camera/encoder.h b/esphome/components/camera/encoder.h new file mode 100644 index 0000000000..17ce828d23 --- /dev/null +++ b/esphome/components/camera/encoder.h @@ -0,0 +1,69 @@ +#pragma once + +#include "buffer.h" +#include "camera.h" + +namespace esphome::camera { + +/// Result codes from the encoder used to control camera pipeline flow. +enum EncoderError : uint8_t { + ENCODER_ERROR_SUCCESS = 0, ///< Encoding succeeded, continue pipeline normally. + ENCODER_ERROR_SKIP_FRAME, ///< Skip current frame, try again on next frame. + ENCODER_ERROR_RETRY_FRAME, ///< Retry current frame, after buffer growth or for incremental encoding. + ENCODER_ERROR_CONFIGURATION ///< Fatal config error, shut down pipeline. +}; + +/// Converts EncoderError to string. +inline const char *to_string(EncoderError error) { + switch (error) { + case ENCODER_ERROR_SUCCESS: + return "ENCODER_ERROR_SUCCESS"; + case ENCODER_ERROR_SKIP_FRAME: + return "ENCODER_ERROR_SKIP_FRAME"; + case ENCODER_ERROR_RETRY_FRAME: + return "ENCODER_ERROR_RETRY_FRAME"; + case ENCODER_ERROR_CONFIGURATION: + return "ENCODER_ERROR_CONFIGURATION"; + } + return "ENCODER_ERROR_INVALID"; +} + +/// Interface for an encoder buffer supporting resizing and variable-length data. +class EncoderBuffer { + public: + /// Sets logical buffer size, reallocates if needed. + /// @param size Required size in bytes. + /// @return true on success, false on allocation failure. + virtual bool set_buffer_size(size_t size) = 0; + + /// Returns a pointer to the buffer data. + virtual uint8_t *get_data() const = 0; + + /// Returns number of bytes currently used. + virtual size_t get_size() const = 0; + + /// Returns total allocated buffer size. + virtual size_t get_max_size() const = 0; + + virtual ~EncoderBuffer() = default; +}; + +/// Interface for image encoders used in a camera pipeline. +class Encoder { + public: + /// Encodes pixel data from a previous camera pipeline stage. + /// @param spec Specification of the input pixel data. + /// @param pixels Image pixels in RGB or grayscale format, as specified in @p spec. + /// @return EncoderError Indicating the result of the encoding operation. + virtual EncoderError encode_pixels(CameraImageSpec *spec, Buffer *pixels) = 0; + + /// Returns the encoder's output buffer. + /// @return Pointer to an EncoderBuffer containing encoded data. + virtual EncoderBuffer *get_output_buffer() = 0; + + /// Prints the encoder's configuration to the log. + virtual void dump_config() = 0; + virtual ~Encoder() = default; +}; + +} // namespace esphome::camera diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py new file mode 100644 index 0000000000..c0f0ca2fe0 --- /dev/null +++ b/esphome/components/camera_encoder/__init__.py @@ -0,0 +1,62 @@ +import esphome.codegen as cg +from esphome.components.esp32 import add_idf_component +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE +from esphome.core import CORE +from esphome.types import ConfigType + +CODEOWNERS = ["@DT-art1"] + +AUTO_LOAD = ["camera"] + +CONF_BUFFER_EXPAND_SIZE = "buffer_expand_size" +CONF_ENCODER_BUFFER_ID = "encoder_buffer_id" +CONF_QUALITY = "quality" + +ESP32_CAMERA_ENCODER = "esp32_camera" + +camera_ns = cg.esphome_ns.namespace("camera") +camera_encoder_ns = cg.esphome_ns.namespace("camera_encoder") + +Encoder = camera_ns.class_("Encoder") +EncoderBufferImpl = camera_encoder_ns.class_("EncoderBufferImpl") + +ESP32CameraJPEGEncoder = camera_encoder_ns.class_("ESP32CameraJPEGEncoder", Encoder) + +MAX_JPEG_BUFFER_SIZE_2MB = 2 * 1024 * 1024 + +ESP32_CAMERA_ENCODER_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ESP32CameraJPEGEncoder), + cv.Optional(CONF_QUALITY, default=80): cv.int_range(1, 100), + cv.Optional(CONF_BUFFER_SIZE, default=4096): cv.int_range( + 1024, MAX_JPEG_BUFFER_SIZE_2MB + ), + cv.Optional(CONF_BUFFER_EXPAND_SIZE, default=1024): cv.int_range( + 0, MAX_JPEG_BUFFER_SIZE_2MB + ), + cv.GenerateID(CONF_ENCODER_BUFFER_ID): cv.declare_id(EncoderBufferImpl), + } +) + +CONFIG_SCHEMA = cv.typed_schema( + { + ESP32_CAMERA_ENCODER: ESP32_CAMERA_ENCODER_SCHEMA, + }, + default_type=ESP32_CAMERA_ENCODER, +) + + +async def to_code(config: ConfigType) -> None: + buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID]) + cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) + if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: + if CORE.using_esp_idf: + add_idf_component(name="espressif/esp32-camera", ref="2.1.0") + cg.add_build_flag("-DUSE_ESP32_CAMERA_JPEG_ENCODER") + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_QUALITY], + buffer, + ) + cg.add(var.set_buffer_expand_size(config[CONF_BUFFER_EXPAND_SIZE])) diff --git a/esphome/components/camera_encoder/encoder_buffer_impl.cpp b/esphome/components/camera_encoder/encoder_buffer_impl.cpp new file mode 100644 index 0000000000..db84026496 --- /dev/null +++ b/esphome/components/camera_encoder/encoder_buffer_impl.cpp @@ -0,0 +1,23 @@ +#include "encoder_buffer_impl.h" + +namespace esphome::camera_encoder { + +bool EncoderBufferImpl::set_buffer_size(size_t size) { + if (size > this->capacity_) { + uint8_t *p = this->allocator_.reallocate(this->data_, size); + if (p == nullptr) + return false; + + this->data_ = p; + this->capacity_ = size; + } + this->size_ = size; + return true; +} + +EncoderBufferImpl::~EncoderBufferImpl() { + if (this->data_ != nullptr) + this->allocator_.deallocate(this->data_, this->capacity_); +} + +} // namespace esphome::camera_encoder diff --git a/esphome/components/camera_encoder/encoder_buffer_impl.h b/esphome/components/camera_encoder/encoder_buffer_impl.h new file mode 100644 index 0000000000..13eccb7d56 --- /dev/null +++ b/esphome/components/camera_encoder/encoder_buffer_impl.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/camera/encoder.h" +#include "esphome/core/helpers.h" + +namespace esphome::camera_encoder { + +class EncoderBufferImpl : public camera::EncoderBuffer { + public: + // --- EncoderBuffer --- + bool set_buffer_size(size_t size) override; + uint8_t *get_data() const override { return this->data_; } + size_t get_size() const override { return this->size_; } + size_t get_max_size() const override { return this->capacity_; } + // ---------------------- + ~EncoderBufferImpl() override; + + protected: + RAMAllocator allocator_; + size_t capacity_{}; + size_t size_{}; + uint8_t *data_{}; +}; + +} // namespace esphome::camera_encoder diff --git a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp new file mode 100644 index 0000000000..7e21122087 --- /dev/null +++ b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp @@ -0,0 +1,82 @@ +#ifdef USE_ESP32_CAMERA_JPEG_ENCODER + +#include "esp32_camera_jpeg_encoder.h" + +namespace esphome::camera_encoder { + +static const char *const TAG = "camera_encoder"; + +ESP32CameraJPEGEncoder::ESP32CameraJPEGEncoder(uint8_t quality, camera::EncoderBuffer *output) { + this->quality_ = quality; + this->output_ = output; +} + +camera::EncoderError ESP32CameraJPEGEncoder::encode_pixels(camera::CameraImageSpec *spec, camera::Buffer *pixels) { + this->bytes_written_ = 0; + this->out_of_output_memory_ = false; + bool success = fmt2jpg_cb(pixels->get_data_buffer(), pixels->get_data_length(), spec->width, spec->height, + to_internal_(spec->format), this->quality_, callback_, this); + + if (!success) + return camera::ENCODER_ERROR_CONFIGURATION; + + if (this->out_of_output_memory_) { + if (this->buffer_expand_size_ <= 0) + return camera::ENCODER_ERROR_SKIP_FRAME; + + size_t current_size = this->output_->get_max_size(); + size_t new_size = this->output_->get_max_size() + this->buffer_expand_size_; + if (!this->output_->set_buffer_size(new_size)) { + ESP_LOGE(TAG, "Failed to expand output buffer."); + this->buffer_expand_size_ = 0; + return camera::ENCODER_ERROR_SKIP_FRAME; + } + + ESP_LOGD(TAG, "Output buffer expanded (%u -> %u).", current_size, this->output_->get_max_size()); + return camera::ENCODER_ERROR_RETRY_FRAME; + } + + this->output_->set_buffer_size(this->bytes_written_); + return camera::ENCODER_ERROR_SUCCESS; +} + +void ESP32CameraJPEGEncoder::dump_config() { + ESP_LOGCONFIG(TAG, + "ESP32 Camera JPEG Encoder:\n" + " Size: %zu\n" + " Quality: %d\n" + " Expand: %d\n", + this->output_->get_max_size(), this->quality_, this->buffer_expand_size_); +} + +size_t ESP32CameraJPEGEncoder::callback_(void *arg, size_t index, const void *data, size_t len) { + ESP32CameraJPEGEncoder *that = reinterpret_cast(arg); + uint8_t *buffer = that->output_->get_data(); + size_t buffer_length = that->output_->get_max_size(); + if (index + len > buffer_length) { + that->out_of_output_memory_ = true; + return 0; + } + + std::memcpy(&buffer[index], data, len); + that->bytes_written_ += len; + return len; +} + +pixformat_t ESP32CameraJPEGEncoder::to_internal_(camera::PixelFormat format) { + switch (format) { + case camera::PIXEL_FORMAT_GRAYSCALE: + return PIXFORMAT_GRAYSCALE; + case camera::PIXEL_FORMAT_RGB565: + return PIXFORMAT_RGB565; + // Internal representation for RGB is in byte order: B, G, R + case camera::PIXEL_FORMAT_BGR888: + return PIXFORMAT_RGB888; + } + + return PIXFORMAT_GRAYSCALE; +} + +} // namespace esphome::camera_encoder + +#endif diff --git a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h new file mode 100644 index 0000000000..b585252584 --- /dev/null +++ b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h @@ -0,0 +1,39 @@ +#pragma once + +#ifdef USE_ESP32_CAMERA_JPEG_ENCODER + +#include + +#include "esphome/components/camera/encoder.h" + +namespace esphome::camera_encoder { + +/// Encoder that uses the software-based JPEG implementation from Espressif's esp32-camera component. +class ESP32CameraJPEGEncoder : public camera::Encoder { + public: + /// Constructs a ESP32CameraJPEGEncoder instance. + /// @param quality Sets the quality of the encoded image (1-100). + /// @param output Pointer to preallocated output buffer. + ESP32CameraJPEGEncoder(uint8_t quality, camera::EncoderBuffer *output); + /// Sets the number of bytes to expand the output buffer on underflow during encoding. + /// @param buffer_expand_size Number of bytes to expand the buffer. + void set_buffer_expand_size(size_t buffer_expand_size) { this->buffer_expand_size_ = buffer_expand_size; } + // -------- Encoder -------- + camera::EncoderError encode_pixels(camera::CameraImageSpec *spec, camera::Buffer *pixels) override; + camera::EncoderBuffer *get_output_buffer() override { return output_; } + void dump_config() override; + // ------------------------- + protected: + static size_t callback_(void *arg, size_t index, const void *data, size_t len); + pixformat_t to_internal_(camera::PixelFormat format); + + camera::EncoderBuffer *output_{}; + size_t buffer_expand_size_{}; + size_t bytes_written_{}; + uint8_t quality_{}; + bool out_of_output_memory_{}; +}; + +} // namespace esphome::camera_encoder + +#endif diff --git a/tests/components/camera_encoder/common.yaml b/tests/components/camera_encoder/common.yaml new file mode 100644 index 0000000000..8fd7a8ce47 --- /dev/null +++ b/tests/components/camera_encoder/common.yaml @@ -0,0 +1,5 @@ +camera_encoder: + id: jpeg_encoder + quality: 80 + buffer_size: 4096 + buffer_expand_size: 1024 diff --git a/tests/components/camera_encoder/test.esp32-ard.yaml b/tests/components/camera_encoder/test.esp32-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/camera_encoder/test.esp32-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/camera_encoder/test.esp32-idf.yaml b/tests/components/camera_encoder/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/camera_encoder/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From da21174c6d2181e27fec5c6ba6f14a4bb59de318 Mon Sep 17 00:00:00 2001 From: Felix Kaechele Date: Sun, 31 Aug 2025 17:02:56 -0400 Subject: [PATCH 0767/1626] [sntp] Use callbacks to trigger `on_time_sync` for ESP32 and ESP8266 (#10390) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/sntp/sntp_component.cpp | 45 +++++++++++++++++----- esphome/components/sntp/sntp_component.h | 7 ++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/esphome/components/sntp/sntp_component.cpp b/esphome/components/sntp/sntp_component.cpp index ccd9af3153..1cca5e8043 100644 --- a/esphome/components/sntp/sntp_component.cpp +++ b/esphome/components/sntp/sntp_component.cpp @@ -14,8 +14,13 @@ namespace sntp { static const char *const TAG = "sntp"; +#if defined(USE_ESP32) +SNTPComponent *SNTPComponent::instance = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + void SNTPComponent::setup() { #if defined(USE_ESP32) + SNTPComponent::instance = this; if (esp_sntp_enabled()) { esp_sntp_stop(); } @@ -25,6 +30,11 @@ void SNTPComponent::setup() { esp_sntp_setservername(i++, server.c_str()); } esp_sntp_set_sync_interval(this->get_update_interval()); + esp_sntp_set_time_sync_notification_cb([](struct timeval *tv) { + if (SNTPComponent::instance != nullptr) { + SNTPComponent::instance->defer([]() { SNTPComponent::instance->time_synced(); }); + } + }); esp_sntp_init(); #else sntp_stop(); @@ -34,6 +44,14 @@ void SNTPComponent::setup() { for (auto &server : this->servers_) { sntp_setservername(i++, server.c_str()); } + +#if defined(USE_ESP8266) + settimeofday_cb([this](bool from_sntp) { + if (from_sntp) + this->time_synced(); + }); +#endif + sntp_init(); #endif } @@ -46,7 +64,8 @@ void SNTPComponent::dump_config() { } void SNTPComponent::update() { #if !defined(USE_ESP32) - // force resync + // Some platforms currently cannot set the sync interval at runtime so we need + // to do the re-sync by hand for now. if (sntp_enabled()) { sntp_stop(); this->has_time_ = false; @@ -55,23 +74,31 @@ void SNTPComponent::update() { #endif } void SNTPComponent::loop() { +// The loop is used to infer whether we have valid time on platforms where we +// cannot tell whether SNTP has succeeded. +// One limitation of this approach is that we cannot tell if it was the SNTP +// component that set the time. +// ESP-IDF and ESP8266 use callbacks from the SNTP task to trigger the +// `on_time_sync` trigger on successful sync events. +#if defined(USE_ESP32) || defined(USE_ESP8266) + this->disable_loop(); +#endif + if (this->has_time_) return; + this->time_synced(); +} + +void SNTPComponent::time_synced() { auto time = this->now(); - if (!time.is_valid()) + this->has_time_ = time.is_valid(); + if (!this->has_time_) return; ESP_LOGD(TAG, "Synchronized time: %04d-%02d-%02d %02d:%02d:%02d", time.year, time.month, time.day_of_month, time.hour, time.minute, time.second); this->time_sync_callback_.call(); - this->has_time_ = true; - -#ifdef USE_ESP_IDF - // On ESP-IDF, time sync is permanent and update() doesn't force resync - // Time is now synchronized, no need to check anymore - this->disable_loop(); -#endif } } // namespace sntp diff --git a/esphome/components/sntp/sntp_component.h b/esphome/components/sntp/sntp_component.h index a4e8267383..dd4c71e082 100644 --- a/esphome/components/sntp/sntp_component.h +++ b/esphome/components/sntp/sntp_component.h @@ -26,9 +26,16 @@ class SNTPComponent : public time::RealTimeClock { void update() override; void loop() override; + void time_synced(); + protected: std::vector servers_; bool has_time_{false}; + +#if defined(USE_ESP32) + private: + static SNTPComponent *instance; +#endif }; } // namespace sntp From a25b544c3bdcc8233acc199830a1ce2f9cae64c6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 1 Sep 2025 09:22:11 +1200 Subject: [PATCH 0768/1626] [display] Allow page actions to have auto generated display id (#10460) --- esphome/components/display/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 8021a8f9b1..e55afcebbf 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -176,7 +176,7 @@ async def display_page_show_to_code(config, action_id, template_arg, args): DisplayPageShowNextAction, maybe_simple_id( { - cv.Required(CONF_ID): cv.templatable(cv.use_id(Display)), + cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), } ), ) @@ -190,7 +190,7 @@ async def display_page_show_next_to_code(config, action_id, template_arg, args): DisplayPageShowPrevAction, maybe_simple_id( { - cv.Required(CONF_ID): cv.templatable(cv.use_id(Display)), + cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), } ), ) From 905e2906fec68b46fe13b6d8ced986dd2bd3ff19 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 1 Sep 2025 00:54:35 +0200 Subject: [PATCH 0769/1626] [nrf52] add dfu (#9319) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 45 +++++++++++++++- esphome/components/nrf52/const.py | 1 + esphome/components/nrf52/dfu.cpp | 51 +++++++++++++++++++ esphome/components/nrf52/dfu.h | 24 +++++++++ esphome/core/defines.h | 4 ++ .../components/nrf52/test.nrf52-adafruit.yaml | 7 +++ 6 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 esphome/components/nrf52/dfu.cpp create mode 100644 esphome/components/nrf52/dfu.h create mode 100644 tests/components/nrf52/test.nrf52-adafruit.yaml diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index e75bf8192c..c388bb0f5e 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -2,10 +2,13 @@ from __future__ import annotations from pathlib import Path +from esphome import pins import esphome.codegen as cg from esphome.components.zephyr import ( copy_files as zephyr_copy_files, zephyr_add_pm_static, + zephyr_add_prj_conf, + zephyr_data, zephyr_set_core_data, zephyr_to_code, ) @@ -18,6 +21,8 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BOARD, CONF_FRAMEWORK, + CONF_ID, + CONF_RESET_PIN, KEY_CORE, KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, @@ -90,18 +95,43 @@ def _detect_bootloader(config: ConfigType) -> ConfigType: return config +nrf52_ns = cg.esphome_ns.namespace("nrf52") +DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component) + +CONF_DFU = "dfu" + CONFIG_SCHEMA = cv.All( + set_core_data, cv.Schema( { cv.Required(CONF_BOARD): cv.string_strict, cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True), + cv.Optional(CONF_DFU): cv.Schema( + { + cv.GenerateID(): cv.declare_id(DeviceFirmwareUpdate), + cv.Required(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ), } ), _detect_bootloader, - set_core_data, ) +def _validate_mcumgr(config): + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader == BOOTLOADER_MCUBOOT: + raise cv.Invalid(f"'{bootloader}' bootloader does not support DFU") + + +def _final_validate(config): + if CONF_DFU in config: + _validate_mcumgr(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + @coroutine_with_priority(1000) async def to_code(config: ConfigType) -> None: """Convert the configuration to code.""" @@ -136,6 +166,19 @@ async def to_code(config: ConfigType) -> None: zephyr_to_code(config) + if dfu_config := config.get(CONF_DFU): + CORE.add_job(_dfu_to_code, dfu_config) + + +@coroutine_with_priority(90) +async def _dfu_to_code(dfu_config): + cg.add_define("USE_NRF52_DFU") + var = cg.new_Pvariable(dfu_config[CONF_ID]) + pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN]) + cg.add(var.set_reset_pin(pin)) + zephyr_add_prj_conf("CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", True) + await cg.register_component(var, dfu_config) + def copy_files() -> None: """Copy files to the build directory.""" diff --git a/esphome/components/nrf52/const.py b/esphome/components/nrf52/const.py index 715d527a66..977ca2252a 100644 --- a/esphome/components/nrf52/const.py +++ b/esphome/components/nrf52/const.py @@ -2,6 +2,7 @@ BOOTLOADER_ADAFRUIT = "adafruit" BOOTLOADER_ADAFRUIT_NRF52_SD132 = "adafruit_nrf52_sd132" BOOTLOADER_ADAFRUIT_NRF52_SD140_V6 = "adafruit_nrf52_sd140_v6" BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 = "adafruit_nrf52_sd140_v7" + EXTRA_ADC = [ "VDD", "VDDHDIV5", diff --git a/esphome/components/nrf52/dfu.cpp b/esphome/components/nrf52/dfu.cpp new file mode 100644 index 0000000000..9e49373467 --- /dev/null +++ b/esphome/components/nrf52/dfu.cpp @@ -0,0 +1,51 @@ +#include "dfu.h" + +#ifdef USE_NRF52_DFU + +#include +#include +#include +#include "esphome/core/log.h" + +namespace esphome { +namespace nrf52 { + +static const char *const TAG = "dfu"; + +volatile bool goto_dfu = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static const uint32_t DFU_DBL_RESET_MAGIC = 0x5A1AD5; // SALADS + +#define DEVICE_AND_COMMA(node_id) DEVICE_DT_GET(node_id), + +static void cdc_dte_rate_callback(const struct device * /*unused*/, uint32_t rate) { + if (rate == 1200) { + goto_dfu = true; + } +} +void DeviceFirmwareUpdate::setup() { + this->reset_pin_->setup(); + const struct device *cdc_dev[] = {DT_FOREACH_STATUS_OKAY(zephyr_cdc_acm_uart, DEVICE_AND_COMMA)}; + for (auto &idx : cdc_dev) { + cdc_acm_dte_rate_callback_set(idx, cdc_dte_rate_callback); + } +} + +void DeviceFirmwareUpdate::loop() { + if (goto_dfu) { + goto_dfu = false; + volatile uint32_t *dbl_reset_mem = (volatile uint32_t *) 0x20007F7C; + (*dbl_reset_mem) = DFU_DBL_RESET_MAGIC; + this->reset_pin_->digital_write(true); + } +} + +void DeviceFirmwareUpdate::dump_config() { + ESP_LOGCONFIG(TAG, "DFU:"); + LOG_PIN(" RESET Pin: ", this->reset_pin_); +} + +} // namespace nrf52 +} // namespace esphome + +#endif diff --git a/esphome/components/nrf52/dfu.h b/esphome/components/nrf52/dfu.h new file mode 100644 index 0000000000..979a4567cf --- /dev/null +++ b/esphome/components/nrf52/dfu.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_NRF52_DFU +#include "esphome/core/component.h" +#include "esphome/core/gpio.h" + +namespace esphome { +namespace nrf52 { +class DeviceFirmwareUpdate : public Component { + public: + void setup() override; + void loop() override; + void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } + void dump_config() override; + + protected: + GPIOPin *reset_pin_; +}; + +} // namespace nrf52 +} // namespace esphome + +#endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5df3bcf475..9a7e090b83 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -240,6 +240,10 @@ #define USE_SOCKET_SELECT_SUPPORT #endif +#ifdef USE_NRF52 +#define USE_NRF52_DFU +#endif + // Disabled feature flags // #define USE_BSEC // Requires a library with proprietary license // #define USE_BSEC2 // Requires a library with proprietary license diff --git a/tests/components/nrf52/test.nrf52-adafruit.yaml b/tests/components/nrf52/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..3fe80209b6 --- /dev/null +++ b/tests/components/nrf52/test.nrf52-adafruit.yaml @@ -0,0 +1,7 @@ +nrf52: + dfu: + reset_pin: + number: 14 + inverted: true + mode: + output: true From 6d834c019d710117eaf414b28f49bbb8eca8da77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 31 Aug 2025 20:01:15 -0500 Subject: [PATCH 0770/1626] Fix incorrect entity count due to undefined execution order with globals (#10497) --- esphome/core/config.py | 2 +- tests/unit_tests/core/test_config.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 90768a4b09..b6ff1d8afd 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -423,7 +423,7 @@ async def _add_automations(config): DATETIME_SUBTYPES = {"date", "time", "datetime"} -@coroutine_with_priority(-100.0) +@coroutine_with_priority(-1000.0) async def _add_platform_defines() -> None: # Generate compile-time defines for platforms that have actual entities # Only add USE_* and count defines when there are entities diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 46e3b513d7..f5ba5221ed 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -8,6 +8,7 @@ import pytest from esphome import config_validation as cv, core from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES +from esphome.core import config from esphome.core.config import Area, validate_area_config from .common import load_config_from_fixture @@ -223,3 +224,24 @@ def test_device_duplicate_id( # Check for the specific error message from IDPassValidationStep captured = capsys.readouterr() assert "ID duplicate_device redefined!" in captured.out + + +def test_add_platform_defines_priority() -> None: + """Test that _add_platform_defines runs after globals. + + This ensures the fix for issue #10431 where sensor counts were incorrect + when lambdas were present. The function must run at a lower priority than + globals (-100.0) to ensure all components (including those using globals + in lambdas) have registered their entities before the count defines are + generated. + + Regression test for https://github.com/esphome/esphome/issues/10431 + """ + # Import globals to check its priority + from esphome.components.globals import to_code as globals_to_code + + # _add_platform_defines must run AFTER globals (lower priority number = runs later) + assert config._add_platform_defines.priority < globals_to_code.priority, ( + f"_add_platform_defines priority ({config._add_platform_defines.priority}) must be lower than " + f"globals priority ({globals_to_code.priority}) to fix issue #10431 (sensor count bug with lambdas)" + ) From 6daeffcefda4f91eb1e0ee1e720073dce496068e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 31 Aug 2025 20:07:29 -0500 Subject: [PATCH 0771/1626] [bluetooth_proxy] Expose configured scanning mode in API responses (#10490) --- esphome/components/api/api.proto | 1 + esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 3 ++- esphome/components/api/api_pb2_dump.cpp | 1 + esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 6 ++++++ esphome/components/bluetooth_proxy/bluetooth_proxy.h | 3 ++- 6 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 6b19f2026a..9707e714e7 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1712,6 +1712,7 @@ message BluetoothScannerStateResponse { BluetoothScannerState state = 1; BluetoothScannerMode mode = 2; + BluetoothScannerMode configured_mode = 3; } message BluetoothScannerSetModeRequest { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 476e3c88d0..de60ed3fdb 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2153,10 +2153,12 @@ void BluetoothDeviceClearCacheResponse::calculate_size(ProtoSize &size) const { void BluetoothScannerStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, static_cast(this->state)); buffer.encode_uint32(2, static_cast(this->mode)); + buffer.encode_uint32(3, static_cast(this->configured_mode)); } void BluetoothScannerStateResponse::calculate_size(ProtoSize &size) const { size.add_uint32(1, static_cast(this->state)); size.add_uint32(1, static_cast(this->mode)); + size.add_uint32(1, static_cast(this->configured_mode)); } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index abdf0e6121..3f2c2ea763 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -2214,12 +2214,13 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage { class BluetoothScannerStateResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 126; - static constexpr uint8_t ESTIMATED_SIZE = 4; + static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_scanner_state_response"; } #endif enums::BluetoothScannerState state{}; enums::BluetoothScannerMode mode{}; + enums::BluetoothScannerMode configured_mode{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 7af322f96d..3e7df9195b 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1704,6 +1704,7 @@ void BluetoothScannerStateResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothScannerStateResponse"); dump_field(out, "state", static_cast(this->state)); dump_field(out, "mode", static_cast(this->mode)); + dump_field(out, "configured_mode", static_cast(this->configured_mode)); } void BluetoothScannerSetModeRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "BluetoothScannerSetModeRequest"); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 80b7fbe960..532aff550e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -24,6 +24,9 @@ void BluetoothProxy::setup() { this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; + // Capture the configured scan mode from YAML before any API changes + this->configured_scan_active_ = this->parent_->get_scan_active(); + this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { if (this->api_connection_ != nullptr) { this->send_bluetooth_scanner_state_(state); @@ -36,6 +39,9 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta resp.state = static_cast(state); resp.mode = this->parent_->get_scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; + resp.configured_mode = this->configured_scan_active_ + ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE + : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; this->api_connection_->send_message(resp, api::BluetoothScannerStateResponse::MESSAGE_TYPE); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index c81c8c9532..4b262dbe86 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -161,7 +161,8 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ // Group 4: 1-byte types grouped together bool active_; uint8_t connection_count_{0}; - // 2 bytes used, 2 bytes padding + bool configured_scan_active_{false}; // Configured scan mode from YAML + // 3 bytes used, 1 byte padding }; extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) From 77dbe7711752f4e273cd432cb2436a051c83a373 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 1 Sep 2025 19:30:02 +0200 Subject: [PATCH 0772/1626] [nrf52] fix missing bootloader (#10519) --- esphome/components/nrf52/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index c388bb0f5e..a4e387b77a 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -101,6 +101,7 @@ DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component) CONF_DFU = "dfu" CONFIG_SCHEMA = cv.All( + _detect_bootloader, set_core_data, cv.Schema( { @@ -114,7 +115,6 @@ CONFIG_SCHEMA = cv.All( ), } ), - _detect_bootloader, ) From d0b4bc48e465419a2070a83389113a9cc35dc736 Mon Sep 17 00:00:00 2001 From: Mischa Siekmann <45062894+gnumpi@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:51:03 +0200 Subject: [PATCH 0773/1626] [wifi] Guard wifi error cases introduced in IDF5.2 by a version check (#10466) --- esphome/components/wifi/wifi_component_esp_idf.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index d465b346b3..31ee712a48 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -654,12 +654,14 @@ const char *get_disconnect_reason_str(uint8_t reason) { return "Association comeback time too long"; case WIFI_REASON_SA_QUERY_TIMEOUT: return "SA query timeout"; +#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 2) case WIFI_REASON_NO_AP_FOUND_W_COMPATIBLE_SECURITY: return "No AP found with compatible security"; case WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD: return "No AP found in auth mode threshold"; case WIFI_REASON_NO_AP_FOUND_IN_RSSI_THRESHOLD: return "No AP found in RSSI threshold"; +#endif case WIFI_REASON_UNSPECIFIED: default: return "Unspecified"; From 2ddd8c72d6ddab304e872f615cc876c629f27d15 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 2 Sep 2025 06:51:31 +1000 Subject: [PATCH 0774/1626] [mipi_dsi] Fix config for Guition screen (#10464) --- esphome/components/mipi_dsi/models/guition.py | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index fd3fbf6160..5f7db4ebda 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -16,7 +16,6 @@ DriverChip( lane_bit_rate="750Mbps", swap_xy=cv.UNDEFINED, color_order="RGB", - reset_pin=27, initsequence=[ (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x00), (0x06, 0x00), (0x0B, 0x11), (0x17, 0x00), (0x20, 0x04), (0x1F, 0x05), (0x23, 0x00), (0x25, 0x19), (0x28, 0x18), (0x29, 0x04), (0x2A, 0x01), From ed48282d094b082ea6b8801e8976f7116346b986 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 2 Sep 2025 06:53:03 +1000 Subject: [PATCH 0775/1626] [mcp4461] Fix read transaction (#10465) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/mcp4461/mcp4461.cpp | 22 ++++++++++++++++++---- esphome/components/mcp4461/mcp4461.h | 1 + 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 191fbae366..2f2c75e05a 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -122,7 +122,7 @@ uint8_t Mcp4461Component::get_status_register_() { uint8_t addr = static_cast(Mcp4461Addresses::MCP4461_STATUS); uint8_t reg = addr | static_cast(Mcp4461Commands::READ); uint16_t buf; - if (!this->read_byte_16(reg, &buf)) { + if (!this->read_16_(reg, &buf)) { this->error_code_ = MCP4461_STATUS_REGISTER_ERROR; this->mark_failed(); return 0; @@ -148,6 +148,20 @@ void Mcp4461Component::read_status_register_to_log() { ((status_register_value >> 3) & 0x01), ((status_register_value >> 2) & 0x01), ((status_register_value >> 1) & 0x01), ((status_register_value >> 0) & 0x01)); } +bool Mcp4461Component::read_16_(uint8_t address, uint16_t *buf) { + // read 16 bits and convert from big endian to host, + // Do this as two separate operations to ensure a stop condition between the write and read + i2c::ErrorCode err = this->write(&address, 1); + if (err != i2c::ERROR_OK) { + return false; + } + err = this->read(reinterpret_cast(buf), 2); + if (err != i2c::ERROR_OK) { + return false; + } + *buf = convert_big_endian(*buf); + return true; +} uint8_t Mcp4461Component::get_wiper_address_(uint8_t wiper) { uint8_t addr; @@ -205,7 +219,7 @@ uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) { } } uint16_t buf = 0; - if (!(this->read_byte_16(reg, &buf))) { + if (!(this->read_16_(reg, &buf))) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); ESP_LOGW(TAG, "Error fetching %swiper %u value", (wiper_idx > 3) ? "nonvolatile " : "", wiper_idx); @@ -392,7 +406,7 @@ uint8_t Mcp4461Component::get_terminal_register_(Mcp4461TerminalIdx terminal_con : static_cast(Mcp4461Addresses::MCP4461_TCON1); reg |= static_cast(Mcp4461Commands::READ); uint16_t buf; - if (this->read_byte_16(reg, &buf)) { + if (this->read_16_(reg, &buf)) { return static_cast(buf & 0x00ff); } else { this->error_code_ = MCP4461_STATUS_I2C_ERROR; @@ -517,7 +531,7 @@ uint16_t Mcp4461Component::get_eeprom_value(Mcp4461EepromLocation location) { if (!this->is_eeprom_ready_for_writing_(true)) { return 0; } - if (!this->read_byte_16(reg, &buf)) { + if (!this->read_16_(reg, &buf)) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); ESP_LOGW(TAG, "Error fetching EEPROM location value"); diff --git a/esphome/components/mcp4461/mcp4461.h b/esphome/components/mcp4461/mcp4461.h index 9b7f60f201..59f6358a56 100644 --- a/esphome/components/mcp4461/mcp4461.h +++ b/esphome/components/mcp4461/mcp4461.h @@ -96,6 +96,7 @@ class Mcp4461Component : public Component, public i2c::I2CDevice { protected: friend class Mcp4461Wiper; + bool read_16_(uint8_t address, uint16_t *buf); void update_write_protection_status_(); uint8_t get_wiper_address_(uint8_t wiper); uint16_t read_wiper_level_(uint8_t wiper); From f75a50206f9cf4be6407bdbba7f2de1302d45356 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Sep 2025 22:28:43 -0500 Subject: [PATCH 0776/1626] [core] Optimize fnv1_hash to avoid string allocations for static entities --- esphome/core/entity_base.cpp | 14 +++++++++++--- esphome/core/entity_base.h | 3 +++ esphome/core/helpers.cpp | 10 ++++++---- esphome/core/helpers.h | 3 ++- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 411a877bbf..4883c72cf1 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -45,10 +45,15 @@ void EntityBase::set_icon(const char *icon) { #endif } +// Check if the object_id is dynamic (changes with MAC suffix) +bool EntityBase::is_object_id_dynamic_() const { + return !this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled(); +} + // Entity Object ID std::string EntityBase::get_object_id() const { // Check if `App.get_friendly_name()` is constant or dynamic. - if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { + if (this->is_object_id_dynamic_()) { // `App.get_friendly_name()` is dynamic. return str_sanitize(str_snake_case(App.get_friendly_name())); } @@ -58,7 +63,7 @@ std::string EntityBase::get_object_id() const { StringRef EntityBase::get_object_id_ref_for_api_() const { static constexpr auto EMPTY_STRING = StringRef::from_lit(""); // Return empty for dynamic case (MAC suffix) - if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { + if (this->is_object_id_dynamic_()) { return EMPTY_STRING; } // For static case, return the string or empty if null @@ -70,7 +75,10 @@ void EntityBase::set_object_id(const char *object_id) { } // Calculate Object ID Hash from Entity Name -void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash(this->get_object_id()); } +void EntityBase::calc_object_id_() { + this->object_id_hash_ = + fnv1_hash(this->is_object_id_dynamic_() ? this->get_object_id().c_str() : this->object_id_c_str_); +} uint32_t EntityBase::get_object_id_hash() { return this->object_id_hash_; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 8a65a9627a..4a6460e708 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -126,6 +126,9 @@ class EntityBase { virtual uint32_t hash_base() { return 0L; } void calc_object_id_(); + /// Check if the object_id is dynamic (changes with MAC suffix) + bool is_object_id_dynamic_() const; + StringRef name_; const char *object_id_c_str_{nullptr}; #ifdef USE_ENTITY_ICON diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 44e9193994..43d6f1153c 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -142,11 +142,13 @@ uint16_t crc16be(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t poly, return refout ? (crc ^ 0xffff) : crc; } -uint32_t fnv1_hash(const std::string &str) { +uint32_t fnv1_hash(const char *str) { uint32_t hash = 2166136261UL; - for (char c : str) { - hash *= 16777619UL; - hash ^= c; + if (str) { + while (*str) { + hash *= 16777619UL; + hash ^= *str++; + } } return hash; } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 53ec7a2a5a..a6741925d0 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -155,7 +155,8 @@ uint16_t crc16be(const uint8_t *data, uint16_t len, uint16_t crc = 0, uint16_t p bool refout = false); /// Calculate a FNV-1 hash of \p str. -uint32_t fnv1_hash(const std::string &str); +uint32_t fnv1_hash(const char *str); +inline uint32_t fnv1_hash(const std::string &str) { return fnv1_hash(str.c_str()); } /// Return a random 32-bit unsigned integer. uint32_t random_uint32(); From ae46100af8e901835cd490afb6b2cabf450a1c86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Sep 2025 23:01:11 -0500 Subject: [PATCH 0777/1626] [core] Use get_icon_ref() in entity platform logging to avoid string allocations --- esphome/components/button/button.cpp | 4 ++-- esphome/components/datetime/date_entity.h | 4 ++-- esphome/components/datetime/datetime_entity.h | 4 ++-- esphome/components/datetime/time_entity.h | 4 ++-- esphome/components/event/event.h | 4 ++-- esphome/components/lock/lock.h | 4 ++-- esphome/components/number/number.cpp | 4 ++-- esphome/components/select/select.h | 4 ++-- esphome/components/sensor/sensor.cpp | 4 ++-- esphome/components/switch/switch.cpp | 4 ++-- esphome/components/text/text.h | 4 ++-- esphome/components/text_sensor/text_sensor.h | 4 ++-- 12 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/button/button.cpp b/esphome/components/button/button.cpp index 63d71dcb8a..c968d31088 100644 --- a/esphome/components/button/button.cpp +++ b/esphome/components/button/button.cpp @@ -14,8 +14,8 @@ void log_button(const char *tag, const char *prefix, const char *type, Button *o ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); - if (!obj->get_icon().empty()) { - ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + if (!obj->get_icon_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon_ref().c_str()); } } diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index ce43c5639d..fcbb46cf17 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -16,8 +16,8 @@ namespace datetime { #define LOG_DATETIME_DATE(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ } diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index 27db84cf7e..275eedfd3b 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -16,8 +16,8 @@ namespace datetime { #define LOG_DATETIME_DATETIME(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ } diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index f7e0a7ddd9..e79b8c225d 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -16,8 +16,8 @@ namespace datetime { #define LOG_DATETIME_TIME(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ } diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index 03c3c8d95a..0f35c0657d 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -13,8 +13,8 @@ namespace event { #define LOG_EVENT(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ if (!(obj)->get_device_class().empty()) { \ ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 2173c84903..04c4cd71cd 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -15,8 +15,8 @@ class Lock; #define LOG_LOCK(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ if ((obj)->traits.get_assumed_state()) { \ ESP_LOGCONFIG(TAG, "%s Assumed State: YES", prefix); \ diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index 4769c1ed12..e0f9fd89de 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -14,8 +14,8 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); - if (!obj->get_icon().empty()) { - ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + if (!obj->get_icon_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon_ref().c_str()); } if (!obj->traits.get_unit_of_measurement().empty()) { diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 3ab651b241..902b8a78ce 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -12,8 +12,8 @@ namespace select { #define LOG_SELECT(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ } diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 6df6347c18..91bf965584 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -24,8 +24,8 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); } - if (!obj->get_icon().empty()) { - ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + if (!obj->get_icon_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon_ref().c_str()); } if (obj->get_force_update()) { diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 49acd274b2..bfb9a277a2 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -91,8 +91,8 @@ void log_switch(const char *tag, const char *prefix, const char *type, Switch *o LOG_STR_ARG(onoff)); // Add optional fields separately - if (!obj->get_icon().empty()) { - ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + if (!obj->get_icon_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon_ref().c_str()); } if (obj->assumed_state()) { ESP_LOGCONFIG(tag, "%s Assumed State: YES", prefix); diff --git a/esphome/components/text/text.h b/esphome/components/text/text.h index 3cc0cefc3e..74d08eda8a 100644 --- a/esphome/components/text/text.h +++ b/esphome/components/text/text.h @@ -12,8 +12,8 @@ namespace text { #define LOG_TEXT(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ } diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index b54f75155b..d68078b244 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -17,8 +17,8 @@ namespace text_sensor { if (!(obj)->get_device_class().empty()) { \ ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ } \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ } From 4da18133f48e65d295c9164f234159fa76e50d46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Sep 2025 23:07:33 -0500 Subject: [PATCH 0778/1626] [core] Use get_device_class_ref() in entity platform logging to avoid string allocations --- esphome/components/binary_sensor/binary_sensor.cpp | 4 ++-- esphome/components/cover/cover.h | 4 ++-- esphome/components/event/event.h | 4 ++-- esphome/components/number/number.cpp | 4 ++-- esphome/components/sensor/sensor.cpp | 4 ++-- esphome/components/switch/switch.cpp | 4 ++-- esphome/components/text_sensor/text_sensor.h | 4 ++-- esphome/components/valve/valve.h | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index e652d302b6..39319d3c1c 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -15,8 +15,8 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); - if (!obj->get_device_class().empty()) { - ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); + if (!obj->get_device_class_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class_ref().c_str()); } } diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index 8b6f5b8a72..ada5953d57 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -19,8 +19,8 @@ const extern float COVER_CLOSED; if (traits_.get_is_assumed_state()) { \ ESP_LOGCONFIG(TAG, "%s Assumed State: YES", prefix); \ } \ - if (!(obj)->get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ + if (!(obj)->get_device_class_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class_ref().c_str()); \ } \ } diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index 03c3c8d95a..251396177f 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -16,8 +16,8 @@ namespace event { if (!(obj)->get_icon().empty()) { \ ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ } \ - if (!(obj)->get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ + if (!(obj)->get_device_class_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class_ref().c_str()); \ } \ } diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index 4769c1ed12..374a7df916 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -22,8 +22,8 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj->traits.get_unit_of_measurement().c_str()); } - if (!obj->traits.get_device_class().empty()) { - ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->traits.get_device_class().c_str()); + if (!obj->traits.get_device_class_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->traits.get_device_class_ref().c_str()); } } diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 6df6347c18..381199978f 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -20,8 +20,8 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()).c_str(), prefix, obj->get_unit_of_measurement().c_str(), prefix, obj->get_accuracy_decimals()); - if (!obj->get_device_class().empty()) { - ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); + if (!obj->get_device_class_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class_ref().c_str()); } if (!obj->get_icon().empty()) { diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 49acd274b2..ee9585531d 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -100,8 +100,8 @@ void log_switch(const char *tag, const char *prefix, const char *type, Switch *o if (obj->is_inverted()) { ESP_LOGCONFIG(tag, "%s Inverted: YES", prefix); } - if (!obj->get_device_class().empty()) { - ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); + if (!obj->get_device_class_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class_ref().c_str()); } } } diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index b54f75155b..23cdbf1c93 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -14,8 +14,8 @@ namespace text_sensor { #define LOG_TEXT_SENSOR(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ + if (!(obj)->get_device_class_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class_ref().c_str()); \ } \ if (!(obj)->get_icon().empty()) { \ ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index 0e14a8d8f0..ab7ff5abe1 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -19,8 +19,8 @@ const extern float VALVE_CLOSED; if (traits_.get_is_assumed_state()) { \ ESP_LOGCONFIG(TAG, "%s Assumed State: YES", prefix); \ } \ - if (!(obj)->get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ + if (!(obj)->get_device_class_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class_ref().c_str()); \ } \ } From 4746eb65f78fc76571fe09935bc63afbd4356101 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Sep 2025 23:11:47 -0500 Subject: [PATCH 0779/1626] [core] Use get_unit_of_measurement_ref() in entity logging to avoid string allocations --- esphome/components/number/number.cpp | 4 ++-- esphome/components/sensor/sensor.cpp | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index 4769c1ed12..66d95df3ed 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -18,8 +18,8 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); } - if (!obj->traits.get_unit_of_measurement().empty()) { - ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj->traits.get_unit_of_measurement().c_str()); + if (!obj->traits.get_unit_of_measurement_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj->traits.get_unit_of_measurement_ref().c_str()); } if (!obj->traits.get_device_class().empty()) { diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 6df6347c18..29455d089a 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -18,7 +18,7 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o "%s Unit of Measurement: '%s'\n" "%s Accuracy Decimals: %d", prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()).c_str(), - prefix, obj->get_unit_of_measurement().c_str(), prefix, obj->get_accuracy_decimals()); + prefix, obj->get_unit_of_measurement_ref().c_str(), prefix, obj->get_accuracy_decimals()); if (!obj->get_device_class().empty()) { ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); @@ -128,7 +128,7 @@ void Sensor::internal_send_state_to_frontend(float state) { this->set_has_state(true); this->state = state; ESP_LOGD(TAG, "'%s': Sending state %.5f %s with %d decimals of accuracy", this->get_name().c_str(), state, - this->get_unit_of_measurement().c_str(), this->get_accuracy_decimals()); + this->get_unit_of_measurement_ref().c_str(), this->get_accuracy_decimals()); this->callback_.call(state); } From 48070be82925375accb306e152570f4827c28c43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Sep 2025 23:24:37 -0500 Subject: [PATCH 0780/1626] [sensor] Change state_class_to_string() to return const char* to avoid allocations --- esphome/components/sensor/sensor.cpp | 6 +++--- esphome/components/sensor/sensor.h | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 6df6347c18..ac9c873f83 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -17,8 +17,8 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o "%s State Class: '%s'\n" "%s Unit of Measurement: '%s'\n" "%s Accuracy Decimals: %d", - prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()).c_str(), - prefix, obj->get_unit_of_measurement().c_str(), prefix, obj->get_accuracy_decimals()); + prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()), prefix, + obj->get_unit_of_measurement().c_str(), prefix, obj->get_accuracy_decimals()); if (!obj->get_device_class().empty()) { ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); @@ -33,7 +33,7 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o } } -std::string state_class_to_string(StateClass state_class) { +const char *state_class_to_string(StateClass state_class) { switch (state_class) { case STATE_CLASS_MEASUREMENT: return "measurement"; diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index b3206d8dab..507cb326b2 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -33,7 +33,7 @@ enum StateClass : uint8_t { STATE_CLASS_TOTAL = 3, }; -std::string state_class_to_string(StateClass state_class); +const char *state_class_to_string(StateClass state_class); /** Base-class for all sensors. * From f286bc57f3fda499f270c37e4390c2189a3980ec Mon Sep 17 00:00:00 2001 From: Eyal <109809+eyal0@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:45:25 -0600 Subject: [PATCH 0781/1626] [core] Fix timezone offset calculation (#10426) --- esphome/core/time.cpp | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index f9652b5329..fe6f50158c 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -203,27 +203,13 @@ void ESPTime::recalc_timestamp_local() { } int32_t ESPTime::timezone_offset() { - int32_t offset = 0; time_t now = ::time(nullptr); - auto local = ESPTime::from_epoch_local(now); - auto utc = ESPTime::from_epoch_utc(now); - bool negative = utc.hour > local.hour && local.day_of_year <= utc.day_of_year; - - if (utc.minute > local.minute) { - local.minute += 60; - local.hour -= 1; - } - offset += (local.minute - utc.minute) * 60; - - if (negative) { - offset -= (utc.hour - local.hour) * 3600; - } else { - if (utc.hour > local.hour) { - local.hour += 24; - } - offset += (local.hour - utc.hour) * 3600; - } - return offset; + struct tm local_tm = *::localtime(&now); + local_tm.tm_isdst = 0; // Cause mktime to ignore daylight saving time because we want to include it in the offset. + time_t local_time = mktime(&local_tm); + struct tm utc_tm = *::gmtime(&now); + time_t utc_time = mktime(&utc_tm); + return static_cast(local_time - utc_time); } bool ESPTime::operator<(const ESPTime &other) const { return this->timestamp < other.timestamp; } From 98c1b01fe792d6642aec332654d5ecae651ebc8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 08:47:07 -0500 Subject: [PATCH 0782/1626] pool scheduler items --- esphome/core/scheduler.cpp | 81 ++++++++++++++++++++++++-------------- esphome/core/scheduler.h | 54 +++++++++++++++++++------ 2 files changed, 93 insertions(+), 42 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a907b89b02..8077d3b81b 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -79,8 +79,22 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } + // Get fresh timestamp BEFORE taking lock - millis_64_ may need to acquire lock itself + const uint64_t now = this->millis_64_(millis()); + + // Take lock early to protect scheduler_item_pool_ access + LockGuard guard{this->lock_}; + // Create and populate the scheduler item - auto item = make_unique(); + std::unique_ptr item; + if (!this->scheduler_item_pool_.empty()) { + // Reuse from pool + item = std::move(this->scheduler_item_pool_.back()); + this->scheduler_item_pool_.pop_back(); + } else { + // Allocate new if pool is empty + item = make_unique(); + } item->component = component; item->set_name(name_cstr, !is_static_string); item->type = type; @@ -99,7 +113,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Single-core platforms don't need thread-safe defer handling if (delay == 0 && type == SchedulerItem::TIMEOUT) { // Put in defer queue for guaranteed FIFO execution - LockGuard guard{this->lock_}; if (!skip_cancel) { this->cancel_item_locked_(component, name_cstr, type); } @@ -108,9 +121,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } #endif /* not ESPHOME_THREAD_SINGLE */ - // Get fresh timestamp for new timer/interval - ensures accurate scheduling - const auto now = this->millis_64_(millis()); // Fresh millis() call - // Type-specific setup if (type == SchedulerItem::INTERVAL) { item->interval = delay; @@ -142,8 +152,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } #endif /* ESPHOME_DEBUG_SCHEDULER */ - LockGuard guard{this->lock_}; - // For retries, check if there's a cancelled timeout first if (is_retry && name_cstr != nullptr && type == SchedulerItem::TIMEOUT && (has_cancelled_timeout_in_container_(this->items_, component, name_cstr, /* match_retry= */ true) || @@ -335,11 +343,11 @@ void HOT Scheduler::call(uint32_t now) { #ifdef ESPHOME_THREAD_MULTI_ATOMICS const auto last_dbg = this->last_millis_.load(std::memory_order_relaxed); const auto major_dbg = this->millis_major_.load(std::memory_order_relaxed); - ESP_LOGD(TAG, "Items: count=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), now_64, - major_dbg, last_dbg); + ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), + this->scheduler_item_pool_.size(), now_64, major_dbg, last_dbg); #else /* not ESPHOME_THREAD_MULTI_ATOMICS */ - ESP_LOGD(TAG, "Items: count=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), now_64, - this->millis_major_, this->last_millis_); + ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), + this->scheduler_item_pool_.size(), now_64, this->millis_major_, this->last_millis_); #endif /* else ESPHOME_THREAD_MULTI_ATOMICS */ // Cleanup before debug output this->cleanup_(); @@ -380,10 +388,13 @@ void HOT Scheduler::call(uint32_t now) { std::vector> valid_items; - // Move all non-removed items to valid_items + // Move all non-removed items to valid_items, recycle removed ones for (auto &item : this->items_) { - if (!item->remove) { + if (!is_item_removed_(item.get())) { valid_items.push_back(std::move(item)); + } else { + // Recycle removed items + this->recycle_item_(std::move(item)); } } @@ -469,6 +480,9 @@ void HOT Scheduler::call(uint32_t now) { // Add new item directly to to_add_ // since we have the lock held this->to_add_.push_back(std::move(item)); + } else { + // Timeout completed - recycle it + this->recycle_item_(std::move(item)); } } } @@ -518,6 +532,10 @@ size_t HOT Scheduler::cleanup_() { } void HOT Scheduler::pop_raw_() { std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); + + // Instead of destroying, recycle the item + this->recycle_item_(std::move(this->items_.back())); + this->items_.pop_back(); } @@ -552,18 +570,14 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Check all containers for matching items #ifndef ESPHOME_THREAD_SINGLE - // Only check defer queue for timeouts (intervals never go there) + // Cancel and immediately recycle items in defer queue if (type == SchedulerItem::TIMEOUT) { - for (auto &item : this->defer_queue_) { - if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - this->mark_item_removed_(item.get()); - total_cancelled++; - } - } + total_cancelled += + this->cancel_and_recycle_from_container_(this->defer_queue_, component, name_cstr, type, match_retry); } #endif /* not ESPHOME_THREAD_SINGLE */ - // Cancel items in the main heap + // Cancel items in the main heap (can't recycle immediately due to heap structure) for (auto &item : this->items_) { if (this->matches_item_(item, component, name_cstr, type, match_retry)) { this->mark_item_removed_(item.get()); @@ -572,14 +586,8 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c } } - // Cancel items in to_add_ - for (auto &item : this->to_add_) { - if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - this->mark_item_removed_(item.get()); - total_cancelled++; - // Don't track removals for to_add_ items - } - } + // Cancel and immediately recycle items in to_add_ since they're not in heap yet + total_cancelled += this->cancel_and_recycle_from_container_(this->to_add_, component, name_cstr, type, match_retry); return total_cancelled > 0; } @@ -747,4 +755,19 @@ bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr &a, return a->next_execution_ > b->next_execution_; } +void Scheduler::recycle_item_(std::unique_ptr item) { + if (!item) + return; + + static constexpr size_t MAX_POOL_SIZE = 16; + if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) { + // Clear callback to release captured resources + item->callback = nullptr; + // Clear dynamic name if any + item->clear_dynamic_name(); + this->scheduler_item_pool_.push_back(std::move(item)); + } + // else: unique_ptr will delete the item when it goes out of scope +} + } // namespace esphome diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index f469a60d5c..70d114c488 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -5,6 +5,7 @@ #include #include #include +#include #ifdef ESPHOME_THREAD_MULTI_ATOMICS #include #endif @@ -142,11 +143,7 @@ class Scheduler { } // Destructor to clean up dynamic names - ~SchedulerItem() { - if (name_is_dynamic) { - delete[] name_.dynamic_name; - } - } + ~SchedulerItem() { clear_dynamic_name(); } // Delete copy operations to prevent accidental copies SchedulerItem(const SchedulerItem &) = delete; @@ -159,13 +156,19 @@ class Scheduler { // Helper to get the name regardless of storage type const char *get_name() const { return name_is_dynamic ? name_.dynamic_name : name_.static_name; } + // Helper to clear dynamic name if allocated + void clear_dynamic_name() { + if (name_is_dynamic && name_.dynamic_name) { + delete[] name_.dynamic_name; + name_.dynamic_name = nullptr; + name_is_dynamic = false; + } + } + // Helper to set name with proper ownership void set_name(const char *name, bool make_copy = false) { // Clean up old dynamic name if any - if (name_is_dynamic && name_.dynamic_name) { - delete[] name_.dynamic_name; - name_is_dynamic = false; - } + clear_dynamic_name(); if (!name) { // nullptr case - no name provided @@ -240,10 +243,13 @@ class Scheduler { void execute_item_(SchedulerItem *item, uint32_t now); // Helper to check if item should be skipped - bool should_skip_item_(const SchedulerItem *item) const { - return item->remove || (item->component != nullptr && item->component->is_failed()); + bool should_skip_item_(SchedulerItem *item) const { + return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); } + // Helper to recycle a SchedulerItem + void recycle_item_(std::unique_ptr item); + // Helper to check if item is marked for removal (platform-specific) // Returns true if item should be skipped, handles platform-specific synchronization // For ESPHOME_THREAD_MULTI_NO_ATOMICS platforms, the caller must hold the scheduler lock before calling this @@ -280,14 +286,33 @@ class Scheduler { bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr, bool match_retry) const { for (const auto &item : container) { - if (item->remove && this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry, - /* skip_removed= */ false)) { + if (is_item_removed_(item.get()) && + this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry, + /* skip_removed= */ false)) { return true; } } return false; } + // Template helper to cancel and recycle items from a container + template + size_t cancel_and_recycle_from_container_(Container &container, Component *component, const char *name_cstr, + SchedulerItem::Type type, bool match_retry) { + size_t cancelled = 0; + for (auto it = container.begin(); it != container.end();) { + if (this->matches_item_(*it, component, name_cstr, type, match_retry)) { + // Recycle the cancelled item immediately + this->recycle_item_(std::move(*it)); + it = container.erase(it); + cancelled++; + } else { + ++it; + } + } + return cancelled; + } + Mutex lock_; std::vector> items_; std::vector> to_add_; @@ -297,6 +322,9 @@ class Scheduler { #endif /* ESPHOME_THREAD_SINGLE */ uint32_t to_remove_{0}; + // Memory pool for recycling SchedulerItem objects + std::vector> scheduler_item_pool_; + #ifdef ESPHOME_THREAD_MULTI_ATOMICS /* * Multi-threaded platforms with atomic support: last_millis_ needs atomic for lock-free updates From 98b8f15576e92a66363f4863a4f1e006342d8612 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 08:48:39 -0500 Subject: [PATCH 0783/1626] pool scheduler items --- esphome/core/scheduler.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 8077d3b81b..3616bbc70a 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -759,7 +759,7 @@ void Scheduler::recycle_item_(std::unique_ptr item) { if (!item) return; - static constexpr size_t MAX_POOL_SIZE = 16; + static constexpr size_t MAX_POOL_SIZE = 8; if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) { // Clear callback to release captured resources item->callback = nullptr; From ce4d422da8341851a331633705911e78d71f2003 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 10:06:51 -0500 Subject: [PATCH 0784/1626] comments --- esphome/core/scheduler.cpp | 5 +++++ esphome/core/scheduler.h | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 3616bbc70a..7e2a805793 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -759,6 +759,11 @@ void Scheduler::recycle_item_(std::unique_ptr item) { if (!item) return; + // Pool size of 8 is a balance between memory usage and performance: + // - Small enough to not waste memory on simple configs (1-2 timers) + // - Large enough to handle complex setups with multiple sensors/components + // - Prevents system-wide stalls from heap allocation/deallocation that can + // disrupt task synchronization and cause dropped events static constexpr size_t MAX_POOL_SIZE = 8; if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) { // Clear callback to release captured resources diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 70d114c488..50cc8da366 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -322,7 +322,14 @@ class Scheduler { #endif /* ESPHOME_THREAD_SINGLE */ uint32_t to_remove_{0}; - // Memory pool for recycling SchedulerItem objects + // Memory pool for recycling SchedulerItem objects to reduce heap churn. + // Design decisions: + // - std::vector is used instead of a fixed array because many systems only need 1-2 scheduler items + // - The vector grows dynamically up to MAX_POOL_SIZE (8) only when needed, saving memory on simple setups + // - This approach balances memory efficiency for simple configs with performance for complex ones + // - The pool significantly reduces heap fragmentation which is critical because heap allocation/deallocation + // can stall the entire system, causing timing issues and dropped events for any components that need + // to synchronize between tasks (see https://github.com/esphome/backlog/issues/52) std::vector> scheduler_item_pool_; #ifdef ESPHOME_THREAD_MULTI_ATOMICS From 4c121502003dd1c8505eab3d443908acd3738c0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 10:31:27 -0500 Subject: [PATCH 0785/1626] debug logging --- esphome/core/scheduler.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 7e2a805793..a8cebf3e9e 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -91,9 +91,15 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Reuse from pool item = std::move(this->scheduler_item_pool_.back()); this->scheduler_item_pool_.pop_back(); +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGVV(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_.size()); +#endif } else { // Allocate new if pool is empty item = make_unique(); +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGVV(TAG, "Allocated new item (pool empty)"); +#endif } item->component = component; item->set_name(name_cstr, !is_static_string); @@ -771,6 +777,13 @@ void Scheduler::recycle_item_(std::unique_ptr item) { // Clear dynamic name if any item->clear_dynamic_name(); this->scheduler_item_pool_.push_back(std::move(item)); +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGVV(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_.size()); +#endif + } else { +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGVV(TAG, "Pool full (size: %zu), deleting item", this->scheduler_item_pool_.size()); +#endif } // else: unique_ptr will delete the item when it goes out of scope } From 440053577589a023a9114479e2efe672a3a13e0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 10:45:59 -0500 Subject: [PATCH 0786/1626] some tests --- esphome/components/api/api_pb2_dump.cpp | 2 +- .../integration/fixtures/scheduler_pool.yaml | 215 ++++++++++++++++++ tests/integration/test_scheduler_pool.py | 194 ++++++++++++++++ 3 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/scheduler_pool.yaml create mode 100644 tests/integration/test_scheduler_pool.py diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3e7df9195b..1d7d315419 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1135,7 +1135,7 @@ void ExecuteServiceArgument::dump_to(std::string &out) const { dump_field(out, "string_", this->string_); dump_field(out, "int_", this->int_); for (const auto it : this->bool_array) { - dump_field(out, "bool_array", it, 4); + dump_field(out, "bool_array", static_cast(it), 4); } for (const auto &it : this->int_array) { dump_field(out, "int_array", it, 4); diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml new file mode 100644 index 0000000000..196724c021 --- /dev/null +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -0,0 +1,215 @@ +esphome: + name: scheduler-pool-test + on_boot: + priority: -100 + then: + - logger.log: "Starting scheduler pool tests" + debug_scheduler: true # Enable scheduler debug logging + +host: +api: + services: + - service: run_phase_1 + then: + - script.execute: test_pool_recycling + - service: run_phase_2 + then: + - script.execute: test_sensor_polling + - service: run_phase_3 + then: + - script.execute: test_communication_patterns + - service: run_phase_4 + then: + - script.execute: test_defer_patterns + - service: run_phase_5 + then: + - script.execute: test_pool_reuse_verification + - service: run_complete + then: + - script.execute: complete_test +logger: + level: VERY_VERBOSE # Need VERY_VERBOSE to see pool debug messages + +globals: + - id: create_count + type: int + initial_value: '0' + - id: cancel_count + type: int + initial_value: '0' + - id: interval_counter + type: int + initial_value: '0' + - id: pool_test_done + type: bool + initial_value: 'false' + +script: + - id: test_pool_recycling + then: + - logger.log: "Testing scheduler pool recycling with realistic usage patterns" + - lambda: |- + auto *component = id(test_sensor); + + // Simulate realistic component behavior with timeouts that complete naturally + ESP_LOGI("test", "Phase 1: Simulating normal component lifecycle"); + + // Sensor update timeouts (common pattern) + App.scheduler.set_timeout(component, "sensor_init", 100, []() { + ESP_LOGD("test", "Sensor initialized"); + id(create_count)++; + }); + + // Retry timeout (gets cancelled if successful) + App.scheduler.set_timeout(component, "retry_timeout", 500, []() { + ESP_LOGD("test", "Retry timeout executed"); + id(create_count)++; + }); + + // Simulate successful operation - cancel retry + App.scheduler.set_timeout(component, "success_sim", 200, []() { + ESP_LOGD("test", "Operation succeeded, cancelling retry"); + App.scheduler.cancel_timeout(id(test_sensor), "retry_timeout"); + id(cancel_count)++; + }); + + id(create_count) += 3; + ESP_LOGI("test", "Phase 1 complete"); + + - id: test_sensor_polling + then: + - lambda: |- + // Simulate sensor polling pattern + ESP_LOGI("test", "Phase 2: Simulating sensor polling patterns"); + auto *component = id(test_sensor); + + // Multiple sensors with different update intervals + App.scheduler.set_interval(component, "temp_sensor", 1000, []() { + ESP_LOGD("test", "Temperature sensor update"); + id(interval_counter)++; + if (id(interval_counter) >= 3) { + App.scheduler.cancel_interval(id(test_sensor), "temp_sensor"); + ESP_LOGD("test", "Temperature sensor stopped"); + } + }); + + App.scheduler.set_interval(component, "humidity_sensor", 1500, []() { + ESP_LOGD("test", "Humidity sensor update"); + id(interval_counter)++; + if (id(interval_counter) >= 5) { + App.scheduler.cancel_interval(id(test_sensor), "humidity_sensor"); + ESP_LOGD("test", "Humidity sensor stopped"); + } + }); + + id(create_count) += 2; + ESP_LOGI("test", "Phase 2 complete"); + + - id: test_communication_patterns + then: + - lambda: |- + // Simulate communication patterns (WiFi/API reconnects, etc) + ESP_LOGI("test", "Phase 3: Simulating communication patterns"); + auto *component = id(test_sensor); + + // Connection timeout pattern + App.scheduler.set_timeout(component, "connect_timeout", 2000, []() { + ESP_LOGD("test", "Connection timeout - would retry"); + id(create_count)++; + + // Schedule retry + App.scheduler.set_timeout(id(test_sensor), "connect_retry", 1000, []() { + ESP_LOGD("test", "Retrying connection"); + id(create_count)++; + }); + }); + + // Heartbeat pattern + App.scheduler.set_interval(component, "heartbeat", 500, []() { + ESP_LOGD("test", "Heartbeat"); + id(interval_counter)++; + if (id(interval_counter) >= 10) { + App.scheduler.cancel_interval(id(test_sensor), "heartbeat"); + ESP_LOGD("test", "Heartbeat stopped"); + } + }); + + id(create_count) += 2; + ESP_LOGI("test", "Phase 3 complete"); + + - id: test_defer_patterns + then: + - lambda: |- + // Simulate defer patterns (state changes, async operations) + ESP_LOGI("test", "Phase 4: Simulating defer patterns"); + + class TestComponent : public Component { + public: + void simulate_state_changes() { + // Defer state changes (common in switches, lights, etc) + this->defer("state_change_1", []() { + ESP_LOGD("test", "State change 1 applied"); + id(create_count)++; + }); + + // Another state change + this->defer("state_change_2", []() { + ESP_LOGD("test", "State change 2 applied"); + id(create_count)++; + }); + + // Cleanup operation + this->defer("cleanup", []() { + ESP_LOGD("test", "Cleanup executed"); + id(create_count)++; + }); + } + }; + + static TestComponent test_comp; + test_comp.simulate_state_changes(); + ESP_LOGI("test", "Phase 4 complete"); + + - id: test_pool_reuse_verification + then: + - lambda: |- + ESP_LOGI("test", "Phase 5: Verifying pool reuse after everything settles"); + + // First, ensure any remaining intervals are cancelled to recycle to pool + auto *component = id(test_sensor); + App.scheduler.cancel_interval(component, "temp_sensor"); + App.scheduler.cancel_interval(component, "humidity_sensor"); + App.scheduler.cancel_interval(component, "heartbeat"); + + // Give a moment for items to be recycled + ESP_LOGD("test", "Cancelled any remaining intervals to build up pool"); + + // Now create 6 new timeouts - they should all reuse from pool + int reuse_test_count = 6; + int initial_pool_reused = 0; + + for (int i = 0; i < reuse_test_count; i++) { + std::string name = "reuse_test_" + std::to_string(i); + App.scheduler.set_timeout(component, name, 100 + i * 50, [i]() { + ESP_LOGD("test", "Reuse test %d completed", i); + }); + } + + ESP_LOGI("test", "Created %d items for reuse verification", reuse_test_count); + id(create_count) += reuse_test_count; + ESP_LOGI("test", "Phase 5 complete"); + + - id: complete_test + then: + - lambda: |- + ESP_LOGI("test", "Pool recycling test complete - created %d items, cancelled %d, intervals %d", + id(create_count), id(cancel_count), id(interval_counter)); + +sensor: + - platform: template + name: Test Sensor + id: test_sensor + lambda: return 1.0; + update_interval: never + +# No interval - tests will be triggered from Python via API services diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py new file mode 100644 index 0000000000..bd604e053f --- /dev/null +++ b/tests/integration/test_scheduler_pool.py @@ -0,0 +1,194 @@ +"""Integration test for scheduler memory pool functionality.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_pool( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that the scheduler memory pool is working correctly with realistic usage. + + This test simulates real-world scheduler usage patterns and verifies that: + 1. Items are recycled to the pool when timeouts complete naturally + 2. Items are recycled when intervals/timeouts are cancelled + 3. Items are reused from the pool for new scheduler operations + 4. The pool grows gradually based on actual usage patterns + 5. Pool operations are logged correctly with debug scheduler enabled + """ + # Track log messages to verify pool behavior + log_lines: list[str] = [] + pool_reuse_count = 0 + pool_recycle_count = 0 + pool_full_count = 0 + new_alloc_count = 0 + + # Patterns to match pool operations + reuse_pattern = re.compile(r"Reused item from pool \(pool size now: (\d+)\)") + recycle_pattern = re.compile(r"Recycled item to pool \(pool size now: (\d+)\)") + pool_full_pattern = re.compile(r"Pool full \(size: (\d+)\), deleting item") + new_alloc_pattern = re.compile(r"Allocated new item \(pool empty\)") + + # Futures to track when test phases complete + loop = asyncio.get_running_loop() + test_complete_future: asyncio.Future[bool] = loop.create_future() + phase_futures = { + 1: loop.create_future(), + 2: loop.create_future(), + 3: loop.create_future(), + 4: loop.create_future(), + 5: loop.create_future(), + } + + def check_output(line: str) -> None: + """Check log output for pool operations and phase completion.""" + nonlocal pool_reuse_count, pool_recycle_count, pool_full_count, new_alloc_count + log_lines.append(line) + + # Track pool operations + if reuse_pattern.search(line): + pool_reuse_count += 1 + + elif recycle_pattern.search(line): + pool_recycle_count += 1 + + elif pool_full_pattern.search(line): + pool_full_count += 1 + + elif new_alloc_pattern.search(line): + new_alloc_count += 1 + + # Track phase completion + for phase_num in range(1, 6): + if ( + f"Phase {phase_num} complete" in line + and not phase_futures[phase_num].done() + ): + phase_futures[phase_num].set_result(True) + + # Check for test completion + if "Pool recycling test complete" in line and not test_complete_future.done(): + test_complete_future.set_result(True) + + # Run the test with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device is running + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-pool-test" + + # Get list of services + entities, services = await client.list_entities_services() + service_names = {s.name for s in services} + + # Verify all test services are available + expected_services = { + "run_phase_1", + "run_phase_2", + "run_phase_3", + "run_phase_4", + "run_phase_5", + "run_complete", + } + assert expected_services.issubset(service_names), ( + f"Missing services: {expected_services - service_names}" + ) + + # Get service objects + phase_services = { + num: next(s for s in services if s.name == f"run_phase_{num}") + for num in range(1, 6) + } + complete_service = next(s for s in services if s.name == "run_complete") + + try: + # Phase 1: Component lifecycle + client.execute_service(phase_services[1], {}) + await asyncio.wait_for(phase_futures[1], timeout=3.0) + await asyncio.sleep(0.5) # Let timeouts complete + + # Phase 2: Sensor polling + client.execute_service(phase_services[2], {}) + await asyncio.wait_for(phase_futures[2], timeout=3.0) + await asyncio.sleep(1.0) # Let intervals run a bit + + # Phase 3: Communication patterns + client.execute_service(phase_services[3], {}) + await asyncio.wait_for(phase_futures[3], timeout=3.0) + await asyncio.sleep(1.0) # Let heartbeat run + + # Phase 4: Defer patterns + client.execute_service(phase_services[4], {}) + await asyncio.wait_for(phase_futures[4], timeout=3.0) + await asyncio.sleep(2.0) # Let everything settle and recycle + + # Phase 5: Pool reuse verification + client.execute_service(phase_services[5], {}) + await asyncio.wait_for(phase_futures[5], timeout=3.0) + await asyncio.sleep(0.5) # Let reuse tests complete + + # Complete test + client.execute_service(complete_service, {}) + await asyncio.wait_for(test_complete_future, timeout=2.0) + + except TimeoutError as e: + # Print debug info if test times out + recent_logs = "\n".join(log_lines[-30:]) + phases_completed = [num for num, fut in phase_futures.items() if fut.done()] + pytest.fail( + f"Test timed out waiting for phase/completion. Error: {e}\n" + f" Phases completed: {phases_completed}\n" + f" Pool stats:\n" + f" Reuse count: {pool_reuse_count}\n" + f" Recycle count: {pool_recycle_count}\n" + f" Pool full count: {pool_full_count}\n" + f" New alloc count: {new_alloc_count}\n" + f"Recent logs:\n{recent_logs}" + ) + + # Verify all test phases ran + for phase_num in range(1, 6): + assert phase_futures[phase_num].done(), f"Phase {phase_num} did not complete" + + # Verify pool behavior + assert pool_recycle_count > 0, "Should have recycled items to pool" + + # Check pool metrics + if pool_recycle_count > 0: + max_pool_size = 0 + for line in log_lines: + if match := recycle_pattern.search(line): + size = int(match.group(1)) + max_pool_size = max(max_pool_size, size) + + # Pool can grow up to its maximum of 8 + assert max_pool_size <= 8, f"Pool grew beyond maximum ({max_pool_size})" + + # Log summary for debugging + print("\nScheduler Pool Test Summary (Python Orchestrated):") + print(f" Items recycled to pool: {pool_recycle_count}") + print(f" Items reused from pool: {pool_reuse_count}") + print(f" Pool full events: {pool_full_count}") + print(f" New allocations: {new_alloc_count}") + print(" All phases completed successfully") + + # Verify reuse happened + if pool_reuse_count == 0 and pool_recycle_count > 3: + pytest.fail("Pool had items recycled but none were reused") + + # Success - pool is working + assert pool_recycle_count > 0 or new_alloc_count < 15, ( + "Pool should either recycle items or limit new allocations" + ) From 50f5728c765decaa40d4d6fa61d49eb9ef99caf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 11:00:18 -0500 Subject: [PATCH 0787/1626] preen --- tests/integration/fixtures/scheduler_pool.yaml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index 196724c021..0541421c26 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -175,22 +175,26 @@ script: - lambda: |- ESP_LOGI("test", "Phase 5: Verifying pool reuse after everything settles"); - // First, ensure any remaining intervals are cancelled to recycle to pool + // Cancel any remaining intervals auto *component = id(test_sensor); App.scheduler.cancel_interval(component, "temp_sensor"); App.scheduler.cancel_interval(component, "humidity_sensor"); App.scheduler.cancel_interval(component, "heartbeat"); - // Give a moment for items to be recycled - ESP_LOGD("test", "Cancelled any remaining intervals to build up pool"); + ESP_LOGD("test", "Cancelled any remaining intervals"); - // Now create 6 new timeouts - they should all reuse from pool - int reuse_test_count = 6; - int initial_pool_reused = 0; + // The pool should have items from completed timeouts in earlier phases. + // Phase 1 had 3 timeouts that completed and were recycled. + // Phase 3 had 1 timeout that completed and was recycled. + // Phase 4 had 3 defers that completed and were recycled. + // So we should have a decent pool size already from naturally completed items. + + // Now create 8 new timeouts - they should reuse from pool when available + int reuse_test_count = 8; for (int i = 0; i < reuse_test_count; i++) { std::string name = "reuse_test_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 100 + i * 50, [i]() { + App.scheduler.set_timeout(component, name, 50 + i * 10, [i]() { ESP_LOGD("test", "Reuse test %d completed", i); }); } From 154023f0177c956b896afb0152cc18137b64c9f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 11:04:42 -0500 Subject: [PATCH 0788/1626] preen --- esphome/core/scheduler.cpp | 6 +++--- esphome/core/scheduler.h | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a8cebf3e9e..78024a206f 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -14,7 +14,7 @@ namespace esphome { static const char *const TAG = "scheduler"; -static const uint32_t MAX_LOGICALLY_DELETED_ITEMS = 10; +static const uint32_t MAX_LOGICALLY_DELETED_ITEMS = 6; // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; // max delay to start an interval sequence @@ -765,12 +765,12 @@ void Scheduler::recycle_item_(std::unique_ptr item) { if (!item) return; - // Pool size of 8 is a balance between memory usage and performance: + // Pool size of 10 is a balance between memory usage and performance: // - Small enough to not waste memory on simple configs (1-2 timers) // - Large enough to handle complex setups with multiple sensors/components // - Prevents system-wide stalls from heap allocation/deallocation that can // disrupt task synchronization and cause dropped events - static constexpr size_t MAX_POOL_SIZE = 8; + static constexpr size_t MAX_POOL_SIZE = 10; if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) { // Clear callback to release captured resources item->callback = nullptr; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 50cc8da366..300e12117d 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -325,7 +325,7 @@ class Scheduler { // Memory pool for recycling SchedulerItem objects to reduce heap churn. // Design decisions: // - std::vector is used instead of a fixed array because many systems only need 1-2 scheduler items - // - The vector grows dynamically up to MAX_POOL_SIZE (8) only when needed, saving memory on simple setups + // - The vector grows dynamically up to MAX_POOL_SIZE (10) only when needed, saving memory on simple setups // - This approach balances memory efficiency for simple configs with performance for complex ones // - The pool significantly reduces heap fragmentation which is critical because heap allocation/deallocation // can stall the entire system, causing timing issues and dropped events for any components that need From 3115c6fdbfaf067149efb4101ea2de1ad89abfe6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 11:05:42 -0500 Subject: [PATCH 0789/1626] preen --- tests/integration/test_scheduler_pool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py index bd604e053f..eee9ab5e65 100644 --- a/tests/integration/test_scheduler_pool.py +++ b/tests/integration/test_scheduler_pool.py @@ -173,8 +173,8 @@ async def test_scheduler_pool( size = int(match.group(1)) max_pool_size = max(max_pool_size, size) - # Pool can grow up to its maximum of 8 - assert max_pool_size <= 8, f"Pool grew beyond maximum ({max_pool_size})" + # Pool can grow up to its maximum of 10 + assert max_pool_size <= 10, f"Pool grew beyond maximum ({max_pool_size})" # Log summary for debugging print("\nScheduler Pool Test Summary (Python Orchestrated):") From ef33f630c2ccdb7678ecb152decd73375efa92a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 11:07:13 -0500 Subject: [PATCH 0790/1626] preen --- .../integration/fixtures/scheduler_pool.yaml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index 0541421c26..eb1446a710 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -24,6 +24,9 @@ api: - service: run_phase_5 then: - script.execute: test_pool_reuse_verification + - service: run_phase_6 + then: + - script.execute: test_full_pool_reuse - service: run_complete then: - script.execute: complete_test @@ -203,6 +206,29 @@ script: id(create_count) += reuse_test_count; ESP_LOGI("test", "Phase 5 complete"); + - id: test_full_pool_reuse + then: + - lambda: |- + ESP_LOGI("test", "Phase 6: Testing full pool reuse after Phase 5 items complete"); + + // At this point, all Phase 5 timeouts should have completed and been recycled. + // The pool should be at or near its maximum size (10). + // Creating 10 new items should reuse all from the pool. + + auto *component = id(test_sensor); + int full_reuse_count = 10; + + for (int i = 0; i < full_reuse_count; i++) { + std::string name = "full_reuse_" + std::to_string(i); + App.scheduler.set_timeout(component, name, 50 + i * 10, [i]() { + ESP_LOGD("test", "Full reuse test %d completed", i); + }); + } + + ESP_LOGI("test", "Created %d items for full pool reuse verification", full_reuse_count); + id(create_count) += full_reuse_count; + ESP_LOGI("test", "Phase 6 complete"); + - id: complete_test then: - lambda: |- From 05c71bda91586d70a0a43e747f9754701bc7f929 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 11:10:46 -0500 Subject: [PATCH 0791/1626] preen --- esphome/core/scheduler.cpp | 17 +++++++++++------ tests/integration/test_scheduler_pool.py | 15 +++++++++++---- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 78024a206f..33efa52eb3 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -14,7 +14,18 @@ namespace esphome { static const char *const TAG = "scheduler"; +// Memory pool configuration constants +// Pool size of 10 is a balance between memory usage and performance: +// - Small enough to not waste memory on simple configs (1-2 timers) +// - Large enough to handle complex setups with multiple sensors/components +// - Prevents system-wide stalls from heap allocation/deallocation that can +// disrupt task synchronization and cause dropped events +static constexpr size_t MAX_POOL_SIZE = 10; +// Maximum number of cancelled items to keep in the heap before forcing a cleanup. +// Set to 6 to trigger cleanup relatively frequently, ensuring cancelled items are +// recycled to the pool in a timely manner to maintain pool efficiency. static const uint32_t MAX_LOGICALLY_DELETED_ITEMS = 6; + // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; // max delay to start an interval sequence @@ -765,12 +776,6 @@ void Scheduler::recycle_item_(std::unique_ptr item) { if (!item) return; - // Pool size of 10 is a balance between memory usage and performance: - // - Small enough to not waste memory on simple configs (1-2 timers) - // - Large enough to handle complex setups with multiple sensors/components - // - Prevents system-wide stalls from heap allocation/deallocation that can - // disrupt task synchronization and cause dropped events - static constexpr size_t MAX_POOL_SIZE = 10; if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) { // Clear callback to release captured resources item->callback = nullptr; diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py index eee9ab5e65..9da6ba7f10 100644 --- a/tests/integration/test_scheduler_pool.py +++ b/tests/integration/test_scheduler_pool.py @@ -47,6 +47,7 @@ async def test_scheduler_pool( 3: loop.create_future(), 4: loop.create_future(), 5: loop.create_future(), + 6: loop.create_future(), } def check_output(line: str) -> None: @@ -68,7 +69,7 @@ async def test_scheduler_pool( new_alloc_count += 1 # Track phase completion - for phase_num in range(1, 6): + for phase_num in range(1, 7): if ( f"Phase {phase_num} complete" in line and not phase_futures[phase_num].done() @@ -100,6 +101,7 @@ async def test_scheduler_pool( "run_phase_3", "run_phase_4", "run_phase_5", + "run_phase_6", "run_complete", } assert expected_services.issubset(service_names), ( @@ -109,7 +111,7 @@ async def test_scheduler_pool( # Get service objects phase_services = { num: next(s for s in services if s.name == f"run_phase_{num}") - for num in range(1, 6) + for num in range(1, 7) } complete_service = next(s for s in services if s.name == "run_complete") @@ -137,7 +139,12 @@ async def test_scheduler_pool( # Phase 5: Pool reuse verification client.execute_service(phase_services[5], {}) await asyncio.wait_for(phase_futures[5], timeout=3.0) - await asyncio.sleep(0.5) # Let reuse tests complete + await asyncio.sleep(1.0) # Let Phase 5 timeouts complete and recycle + + # Phase 6: Full pool reuse verification + client.execute_service(phase_services[6], {}) + await asyncio.wait_for(phase_futures[6], timeout=3.0) + await asyncio.sleep(1.0) # Let Phase 6 timeouts complete # Complete test client.execute_service(complete_service, {}) @@ -159,7 +166,7 @@ async def test_scheduler_pool( ) # Verify all test phases ran - for phase_num in range(1, 6): + for phase_num in range(1, 7): assert phase_futures[phase_num].done(), f"Phase {phase_num} did not complete" # Verify pool behavior From c4efdf57667a60abb18c233ce96ac57c6caba49f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 11:14:43 -0500 Subject: [PATCH 0792/1626] preen --- esphome/core/scheduler.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 33efa52eb3..162f40e8e6 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -26,6 +26,11 @@ static constexpr size_t MAX_POOL_SIZE = 10; // recycled to the pool in a timely manner to maintain pool efficiency. static const uint32_t MAX_LOGICALLY_DELETED_ITEMS = 6; +// Ensure MAX_LOGICALLY_DELETED_ITEMS is at least 4 smaller than MAX_POOL_SIZE +// This guarantees we have room in the pool for recycled items when cleanup occurs +static_assert(MAX_LOGICALLY_DELETED_ITEMS + 4 <= MAX_POOL_SIZE, + "MAX_LOGICALLY_DELETED_ITEMS must be at least 4 smaller than MAX_POOL_SIZE"); + // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; // max delay to start an interval sequence From b009a0f967d76a687fd06eca31e0548a00815ab8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 16:10:28 -0500 Subject: [PATCH 0793/1626] improve pool hit rate --- esphome/core/scheduler.cpp | 2 + .../integration/fixtures/scheduler_pool.yaml | 52 +++++++++++-------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 162f40e8e6..7df1334aec 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -349,6 +349,8 @@ void HOT Scheduler::call(uint32_t now) { if (!this->should_skip_item_(item.get())) { this->execute_item_(item.get(), now); } + // Recycle the defer item after execution + this->recycle_item_(std::move(item)); } #endif /* not ESPHOME_THREAD_SINGLE */ diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index eb1446a710..e3b5c0f42f 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -144,33 +144,39 @@ script: then: - lambda: |- // Simulate defer patterns (state changes, async operations) - ESP_LOGI("test", "Phase 4: Simulating defer patterns"); + ESP_LOGI("test", "Phase 4: Simulating heavy defer patterns like ratgdo"); - class TestComponent : public Component { - public: - void simulate_state_changes() { - // Defer state changes (common in switches, lights, etc) - this->defer("state_change_1", []() { - ESP_LOGD("test", "State change 1 applied"); - id(create_count)++; - }); + auto *component = id(test_sensor); - // Another state change - this->defer("state_change_2", []() { - ESP_LOGD("test", "State change 2 applied"); - id(create_count)++; - }); + // Simulate a burst of defer operations like ratgdo does with state updates + // These should execute immediately and recycle quickly to the pool + for (int i = 0; i < 10; i++) { + std::string defer_name = "defer_" + std::to_string(i); + App.scheduler.set_timeout(component, defer_name, 0, [i]() { + ESP_LOGD("test", "Defer %d executed", i); + // Force a small delay between defer executions to see recycling + if (i == 5) { + ESP_LOGI("test", "Half of defers executed, checking pool status"); + } + }); + } - // Cleanup operation - this->defer("cleanup", []() { - ESP_LOGD("test", "Cleanup executed"); - id(create_count)++; - }); - } - }; + id(create_count) += 10; + ESP_LOGD("test", "Created 10 defer operations (0ms timeouts)"); + + // Also create some named defers that might get replaced + App.scheduler.set_timeout(component, "state_update", 0, []() { + ESP_LOGD("test", "State update 1"); + }); + + // Replace the same named defer (should cancel previous) + App.scheduler.set_timeout(component, "state_update", 0, []() { + ESP_LOGD("test", "State update 2 (replaced)"); + }); + + id(create_count) += 2; + id(cancel_count) += 1; // One cancelled due to replacement - static TestComponent test_comp; - test_comp.simulate_state_changes(); ESP_LOGI("test", "Phase 4 complete"); - id: test_pool_reuse_verification From e0e8a982d57f86975c2d2856c7ba8218f3e8c78c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 16:12:59 -0500 Subject: [PATCH 0794/1626] improve pool hit rate --- .../integration/fixtures/scheduler_pool.yaml | 20 ++++++++-------- tests/integration/test_scheduler_pool.py | 24 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index e3b5c0f42f..e488f38e2e 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -58,19 +58,19 @@ script: ESP_LOGI("test", "Phase 1: Simulating normal component lifecycle"); // Sensor update timeouts (common pattern) - App.scheduler.set_timeout(component, "sensor_init", 100, []() { + App.scheduler.set_timeout(component, "sensor_init", 10, []() { ESP_LOGD("test", "Sensor initialized"); id(create_count)++; }); // Retry timeout (gets cancelled if successful) - App.scheduler.set_timeout(component, "retry_timeout", 500, []() { + App.scheduler.set_timeout(component, "retry_timeout", 50, []() { ESP_LOGD("test", "Retry timeout executed"); id(create_count)++; }); // Simulate successful operation - cancel retry - App.scheduler.set_timeout(component, "success_sim", 200, []() { + App.scheduler.set_timeout(component, "success_sim", 20, []() { ESP_LOGD("test", "Operation succeeded, cancelling retry"); App.scheduler.cancel_timeout(id(test_sensor), "retry_timeout"); id(cancel_count)++; @@ -87,7 +87,7 @@ script: auto *component = id(test_sensor); // Multiple sensors with different update intervals - App.scheduler.set_interval(component, "temp_sensor", 1000, []() { + App.scheduler.set_interval(component, "temp_sensor", 100, []() { ESP_LOGD("test", "Temperature sensor update"); id(interval_counter)++; if (id(interval_counter) >= 3) { @@ -96,7 +96,7 @@ script: } }); - App.scheduler.set_interval(component, "humidity_sensor", 1500, []() { + App.scheduler.set_interval(component, "humidity_sensor", 150, []() { ESP_LOGD("test", "Humidity sensor update"); id(interval_counter)++; if (id(interval_counter) >= 5) { @@ -116,19 +116,19 @@ script: auto *component = id(test_sensor); // Connection timeout pattern - App.scheduler.set_timeout(component, "connect_timeout", 2000, []() { + App.scheduler.set_timeout(component, "connect_timeout", 200, []() { ESP_LOGD("test", "Connection timeout - would retry"); id(create_count)++; // Schedule retry - App.scheduler.set_timeout(id(test_sensor), "connect_retry", 1000, []() { + App.scheduler.set_timeout(id(test_sensor), "connect_retry", 100, []() { ESP_LOGD("test", "Retrying connection"); id(create_count)++; }); }); // Heartbeat pattern - App.scheduler.set_interval(component, "heartbeat", 500, []() { + App.scheduler.set_interval(component, "heartbeat", 50, []() { ESP_LOGD("test", "Heartbeat"); id(interval_counter)++; if (id(interval_counter) >= 10) { @@ -203,7 +203,7 @@ script: for (int i = 0; i < reuse_test_count; i++) { std::string name = "reuse_test_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 50 + i * 10, [i]() { + App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { ESP_LOGD("test", "Reuse test %d completed", i); }); } @@ -226,7 +226,7 @@ script: for (int i = 0; i < full_reuse_count; i++) { std::string name = "full_reuse_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 50 + i * 10, [i]() { + App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { ESP_LOGD("test", "Full reuse test %d completed", i); }); } diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py index 9da6ba7f10..98e5904ce6 100644 --- a/tests/integration/test_scheduler_pool.py +++ b/tests/integration/test_scheduler_pool.py @@ -118,33 +118,33 @@ async def test_scheduler_pool( try: # Phase 1: Component lifecycle client.execute_service(phase_services[1], {}) - await asyncio.wait_for(phase_futures[1], timeout=3.0) - await asyncio.sleep(0.5) # Let timeouts complete + await asyncio.wait_for(phase_futures[1], timeout=1.0) + await asyncio.sleep(0.05) # Let timeouts complete # Phase 2: Sensor polling client.execute_service(phase_services[2], {}) - await asyncio.wait_for(phase_futures[2], timeout=3.0) - await asyncio.sleep(1.0) # Let intervals run a bit + await asyncio.wait_for(phase_futures[2], timeout=1.0) + await asyncio.sleep(0.1) # Let intervals run a bit # Phase 3: Communication patterns client.execute_service(phase_services[3], {}) - await asyncio.wait_for(phase_futures[3], timeout=3.0) - await asyncio.sleep(1.0) # Let heartbeat run + await asyncio.wait_for(phase_futures[3], timeout=1.0) + await asyncio.sleep(0.1) # Let heartbeat run # Phase 4: Defer patterns client.execute_service(phase_services[4], {}) - await asyncio.wait_for(phase_futures[4], timeout=3.0) - await asyncio.sleep(2.0) # Let everything settle and recycle + await asyncio.wait_for(phase_futures[4], timeout=1.0) + await asyncio.sleep(0.2) # Let everything settle and recycle # Phase 5: Pool reuse verification client.execute_service(phase_services[5], {}) - await asyncio.wait_for(phase_futures[5], timeout=3.0) - await asyncio.sleep(1.0) # Let Phase 5 timeouts complete and recycle + await asyncio.wait_for(phase_futures[5], timeout=1.0) + await asyncio.sleep(0.1) # Let Phase 5 timeouts complete and recycle # Phase 6: Full pool reuse verification client.execute_service(phase_services[6], {}) - await asyncio.wait_for(phase_futures[6], timeout=3.0) - await asyncio.sleep(1.0) # Let Phase 6 timeouts complete + await asyncio.wait_for(phase_futures[6], timeout=1.0) + await asyncio.sleep(0.1) # Let Phase 6 timeouts complete # Complete test client.execute_service(complete_service, {}) From f72f80ed7bbd30770ab48a67a5570b080186ff1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 16:13:51 -0500 Subject: [PATCH 0795/1626] cleanup --- tests/integration/test_scheduler_pool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py index 98e5904ce6..bd878be180 100644 --- a/tests/integration/test_scheduler_pool.py +++ b/tests/integration/test_scheduler_pool.py @@ -148,7 +148,7 @@ async def test_scheduler_pool( # Complete test client.execute_service(complete_service, {}) - await asyncio.wait_for(test_complete_future, timeout=2.0) + await asyncio.wait_for(test_complete_future, timeout=0.5) except TimeoutError as e: # Print debug info if test times out From d1276dc6df07f4af76cba228c063c3faea6c132b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 16:41:50 -0500 Subject: [PATCH 0796/1626] [core] Replace magic coroutine priority numbers with self-documenting CoroPriority enum (#10518) --- .../alarm_control_panel/__init__.py | 4 +- esphome/components/api/__init__.py | 4 +- esphome/components/async_tcp/__init__.py | 4 +- esphome/components/audio_adc/__init__.py | 4 +- esphome/components/audio_dac/__init__.py | 4 +- esphome/components/binary_sensor/__init__.py | 4 +- esphome/components/button/__init__.py | 4 +- esphome/components/captive_portal/__init__.py | 4 +- esphome/components/climate/__init__.py | 4 +- esphome/components/cover/__init__.py | 4 +- esphome/components/datetime/__init__.py | 4 +- esphome/components/display/__init__.py | 4 +- .../components/esp32_ble_tracker/__init__.py | 4 +- esphome/components/esp8266/__init__.py | 4 +- esphome/components/esp8266/gpio.py | 4 +- esphome/components/esphome/ota/__init__.py | 4 +- esphome/components/ethernet/__init__.py | 9 +- esphome/components/event/__init__.py | 4 +- esphome/components/fan/__init__.py | 4 +- esphome/components/globals/__init__.py | 4 +- .../components/http_request/ota/__init__.py | 4 +- esphome/components/i2c/__init__.py | 4 +- esphome/components/json/__init__.py | 4 +- esphome/components/light/__init__.py | 4 +- esphome/components/lock/__init__.py | 4 +- esphome/components/logger/__init__.py | 4 +- esphome/components/mdns/__init__.py | 4 +- esphome/components/media_player/__init__.py | 4 +- esphome/components/microphone/__init__.py | 4 +- esphome/components/mqtt/__init__.py | 4 +- esphome/components/network/__init__.py | 4 +- esphome/components/nrf52/__init__.py | 6 +- esphome/components/number/__init__.py | 4 +- esphome/components/ota/__init__.py | 4 +- esphome/components/rp2040/__init__.py | 4 +- esphome/components/safe_mode/__init__.py | 4 +- esphome/components/select/__init__.py | 4 +- esphome/components/sensor/__init__.py | 4 +- esphome/components/speaker/__init__.py | 4 +- esphome/components/spi/__init__.py | 4 +- esphome/components/status_led/__init__.py | 4 +- esphome/components/stepper/__init__.py | 4 +- esphome/components/switch/__init__.py | 4 +- esphome/components/text/__init__.py | 4 +- esphome/components/text_sensor/__init__.py | 4 +- esphome/components/time/__init__.py | 4 +- esphome/components/touchscreen/__init__.py | 4 +- esphome/components/update/__init__.py | 4 +- esphome/components/valve/__init__.py | 4 +- esphome/components/web_server/__init__.py | 4 +- esphome/components/web_server/ota/__init__.py | 4 +- .../components/web_server_base/__init__.py | 4 +- esphome/components/wifi/__init__.py | 4 +- esphome/core/__init__.py | 1 + esphome/core/config.py | 14 +- esphome/coroutine.py | 83 ++++++- tests/unit_tests/test_coroutine.py | 204 ++++++++++++++++++ 57 files changed, 404 insertions(+), 117 deletions(-) create mode 100644 tests/unit_tests/test_coroutine.py diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 058e061d1e..174a9d9e0a 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -13,7 +13,7 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_WEB_SERVER, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -345,6 +345,6 @@ async def alarm_control_panel_is_armed_to_code( return cg.new_Pvariable(condition_id, template_arg, paren) -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(alarm_control_panel_ns.using) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 2672ea1edb..5fb84d3c21 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -24,7 +24,7 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_VARIABLES, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority DOMAIN = "api" DEPENDENCIES = ["network"] @@ -134,7 +134,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(40.0) +@coroutine_with_priority(CoroPriority.WEB) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/async_tcp/__init__.py b/esphome/components/async_tcp/__init__.py index 942d5bc8e5..f2d8895b39 100644 --- a/esphome/components/async_tcp/__init__.py +++ b/esphome/components/async_tcp/__init__.py @@ -8,7 +8,7 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RTL87XX, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority CODEOWNERS = ["@esphome/core"] @@ -27,7 +27,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(200.0) +@coroutine_with_priority(CoroPriority.NETWORK_TRANSPORT) async def to_code(config): if CORE.is_esp32 or CORE.is_libretiny: # https://github.com/ESP32Async/AsyncTCP diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index dd3c958821..2f95a039f5 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MIC_GAIN -from esphome.core import coroutine_with_priority +from esphome.core import CoroPriority, coroutine_with_priority CODEOWNERS = ["@kbx81"] IS_PLATFORM_COMPONENT = True @@ -35,7 +35,7 @@ async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_define("USE_AUDIO_ADC") cg.add_global(audio_adc_ns.using) diff --git a/esphome/components/audio_dac/__init__.py b/esphome/components/audio_dac/__init__.py index 978ed195bd..92e6cb18fa 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -3,7 +3,7 @@ from esphome.automation import maybe_simple_id import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_VOLUME -from esphome.core import coroutine_with_priority +from esphome.core import CoroPriority, coroutine_with_priority CODEOWNERS = ["@kbx81"] IS_PLATFORM_COMPONENT = True @@ -51,7 +51,7 @@ async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_define("USE_AUDIO_DAC") cg.add_global(audio_dac_ns.using) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index b56fde1ffd..6aa97d6e05 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -59,7 +59,7 @@ from esphome.const import ( DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass from esphome.util import Registry @@ -652,7 +652,7 @@ async def binary_sensor_is_off_to_code(config, condition_id, template_arg, args) return cg.new_Pvariable(condition_id, template_arg, paren, False) -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(binary_sensor_ns.using) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index a23958989e..e1ac875cb0 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -17,7 +17,7 @@ from esphome.const import ( DEVICE_CLASS_RESTART, DEVICE_CLASS_UPDATE, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -134,6 +134,6 @@ async def button_press_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(button_ns.using) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index cd69b67c78..39cafc7cb4 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -10,7 +10,7 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RTL87XX, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority AUTO_LOAD = ["web_server_base", "ota.web_server"] DEPENDENCIES = ["wifi"] @@ -40,7 +40,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(64.0) +@coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 4af3a619b5..c0c33d7242 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -47,7 +47,7 @@ from esphome.const import ( CONF_VISUAL, CONF_WEB_SERVER, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -517,6 +517,6 @@ async def climate_control_to_code(config, action_id, template_arg, args): return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(climate_ns.using) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 383cfaf8fb..bec6dcbdac 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -32,7 +32,7 @@ from esphome.const import ( DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -263,6 +263,6 @@ async def cover_control_to_code(config, action_id, template_arg, args): return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(cover_ns.using) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 1d84b75f26..602db3827a 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -21,7 +21,7 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_YEAR, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -172,7 +172,7 @@ async def new_datetime(config, *args): return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(datetime_ns.using) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index e55afcebbf..ccbeedcd2f 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, SCHEDULER_DONT_RUN, ) -from esphome.core import coroutine_with_priority +from esphome.core import CoroPriority, coroutine_with_priority IS_PLATFORM_COMPONENT = True @@ -218,7 +218,7 @@ async def display_is_displaying_page_to_code(config, condition_id, template_arg, return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(display_ns.using) cg.add_define("USE_DISPLAY") diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 9ad2f3b25f..558143b007 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -30,7 +30,7 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType @@ -368,7 +368,7 @@ async def to_code(config): # This needs to be run as a job with very low priority so that all components have # chance to call register_ble_tracker and register_client before the list is checked # and added to the global defines list. -@coroutine_with_priority(-1000) +@coroutine_with_priority(CoroPriority.FINAL) async def _add_ble_features(): # Add feature-specific defines based on what's needed if BLEFeatures.ESP_BT_DEVICE in _required_features: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 33a4149571..b85314214e 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -17,7 +17,7 @@ from esphome.const import ( PLATFORM_ESP8266, ThreadModel, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.helpers import copy_file_if_changed from .boards import BOARDS, ESP8266_LD_SCRIPTS @@ -176,7 +176,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(1000) +@coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config): cg.add(esp8266_ns.setup_preferences()) diff --git a/esphome/components/esp8266/gpio.py b/esphome/components/esp8266/gpio.py index 050efaacae..2bc2291117 100644 --- a/esphome/components/esp8266/gpio.py +++ b/esphome/components/esp8266/gpio.py @@ -17,7 +17,7 @@ from esphome.const import ( CONF_PULLUP, PLATFORM_ESP8266, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from . import boards from .const import KEY_BOARD, KEY_ESP8266, KEY_PIN_INITIAL_STATES, esp8266_ns @@ -188,7 +188,7 @@ async def esp8266_pin_to_code(config): return var -@coroutine_with_priority(-999.0) +@coroutine_with_priority(CoroPriority.WORKAROUNDS) async def add_pin_initial_states_array(): # Add includes at the very end, so that they override everything initial_states: list[PinInitialState] = CORE.data[KEY_ESP8266][ diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 9facdc3bc6..7b579501ed 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,7 +16,7 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import coroutine_with_priority +from esphome.core import CoroPriority, coroutine_with_priority import esphome.final_validate as fv _LOGGER = logging.getLogger(__name__) @@ -121,7 +121,7 @@ CONFIG_SCHEMA = ( FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate -@coroutine_with_priority(52.0) +@coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_port(config[CONF_PORT])) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 7a412a643d..a26238553c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -38,7 +38,12 @@ from esphome.const import ( KEY_CORE, KEY_FRAMEWORK_VERSION, ) -from esphome.core import CORE, TimePeriodMilliseconds, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + TimePeriodMilliseconds, + coroutine_with_priority, +) import esphome.final_validate as fv CONFLICTS_WITH = ["wifi"] @@ -289,7 +294,7 @@ def phy_register(address: int, value: int, page: int): ) -@coroutine_with_priority(60.0) +@coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 1948570ecd..449cc48625 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -17,7 +17,7 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_MOTION, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -143,6 +143,6 @@ async def event_fire_to_code(config, action_id, template_arg, args): return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(event_ns.using) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 3fb217a24e..da8bf850c7 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -31,7 +31,7 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_WEB_SERVER, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity IS_PLATFORM_COMPONENT = True @@ -398,6 +398,6 @@ async def fan_is_on_off_to_code(config, condition_id, template_arg, args): return cg.new_Pvariable(condition_id, template_arg, paren) -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(fan_ns.using) diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index e4bce99b0b..633ccea66b 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -8,7 +8,7 @@ from esphome.const import ( CONF_TYPE, CONF_VALUE, ) -from esphome.core import coroutine_with_priority +from esphome.core import CoroPriority, coroutine_with_priority CODEOWNERS = ["@esphome/core"] globals_ns = cg.esphome_ns.namespace("globals") @@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.Schema( # Run with low priority so that namespaces are registered first -@coroutine_with_priority(-100.0) +@coroutine_with_priority(CoroPriority.LATE) async def to_code(config): type_ = cg.RawExpression(config[CONF_TYPE]) restore = config[CONF_RESTORE_VALUE] diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index a3f6d5840c..fd542e594a 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -3,7 +3,7 @@ import esphome.codegen as cg from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_URL, CONF_USERNAME -from esphome.core import coroutine_with_priority +from esphome.core import CoroPriority, coroutine_with_priority from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns @@ -40,7 +40,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(52.0) +@coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 35b9fab9e4..3cfec1e94d 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -18,7 +18,7 @@ from esphome.const import ( PLATFORM_RP2040, PlatformFramework, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority import esphome.final_validate as fv LOGGER = logging.getLogger(__name__) @@ -74,7 +74,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(1.0) +@coroutine_with_priority(CoroPriority.BUS) async def to_code(config): cg.add_global(i2c_ns.using) cg.add_define("USE_I2C") diff --git a/esphome/components/json/__init__.py b/esphome/components/json/__init__.py index 87aa823c0d..4cd737c60d 100644 --- a/esphome/components/json/__init__.py +++ b/esphome/components/json/__init__.py @@ -1,6 +1,6 @@ import esphome.codegen as cg import esphome.config_validation as cv -from esphome.core import coroutine_with_priority +from esphome.core import CoroPriority, coroutine_with_priority CODEOWNERS = ["@esphome/core"] json_ns = cg.esphome_ns.namespace("json") @@ -10,7 +10,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(1.0) +@coroutine_with_priority(CoroPriority.BUS) async def to_code(config): cg.add_library("bblanchon/ArduinoJson", "7.4.2") cg.add_define("USE_JSON") diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index fa39721ee2..f1089ad64f 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -37,7 +37,7 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_WHITE, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -283,6 +283,6 @@ async def new_light(config, *args): return output_var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(light_ns.using) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 7977efd264..04c1586ddd 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -13,7 +13,7 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_WEB_SERVER, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -155,6 +155,6 @@ async def lock_is_off_to_code(config, condition_id, template_arg, args): return cg.new_Pvariable(condition_id, template_arg, paren, False) -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(lock_ns.using) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index d8c95d75f2..2865355278 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -51,7 +51,7 @@ from esphome.const import ( PLATFORM_RTL87XX, PlatformFramework, ) -from esphome.core import CORE, Lambda, coroutine_with_priority +from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority CODEOWNERS = ["@esphome/core"] logger_ns = cg.esphome_ns.namespace("logger") @@ -275,7 +275,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(90.0) +@coroutine_with_priority(CoroPriority.DIAGNOSTICS) async def to_code(config): baud_rate = config[CONF_BAUD_RATE] level = config[CONF_LEVEL] diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 469fe8ada6..a21ef9d97b 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -11,7 +11,7 @@ from esphome.const import ( CONF_SERVICES, PlatformFramework, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] @@ -72,7 +72,7 @@ def mdns_service( ) -@coroutine_with_priority(55.0) +@coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): if config[CONF_DISABLED] is True: return diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index d288e70cba..70c7cf7a56 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -14,7 +14,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity -from esphome.coroutine import coroutine_with_priority +from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@jesserockz"] @@ -303,7 +303,7 @@ async def media_player_volume_set_action(config, action_id, template_arg, args): return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(media_player_ns.using) cg.add_define("USE_MEDIA_PLAYER") diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index 29bdcfa3f3..1fc0df88a3 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -12,7 +12,7 @@ from esphome.const import ( CONF_TRIGGER_ID, ) from esphome.core import CORE -from esphome.coroutine import coroutine_with_priority +from esphome.coroutine import CoroPriority, coroutine_with_priority AUTO_LOAD = ["audio"] CODEOWNERS = ["@jesserockz", "@kahrendt"] @@ -213,7 +213,7 @@ automation.register_condition( )(microphone_action) -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(microphone_ns.using) cg.add_define("USE_MICROPHONE") diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 52d3181780..814fb566d4 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -57,7 +57,7 @@ from esphome.const import ( PLATFORM_ESP8266, PlatformFramework, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority DEPENDENCIES = ["network"] @@ -321,7 +321,7 @@ def exp_mqtt_message(config): ) -@coroutine_with_priority(40.0) +@coroutine_with_priority(CoroPriority.WEB) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index b04fca7a1c..9679961b15 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option import esphome.config_validation as cv from esphome.const import CONF_ENABLE_IPV6, CONF_MIN_IPV6_ADDR_COUNT -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority CODEOWNERS = ["@esphome/core"] AUTO_LOAD = ["mdns"] @@ -36,7 +36,7 @@ CONFIG_SCHEMA = cv.Schema( ) -@coroutine_with_priority(201.0) +@coroutine_with_priority(CoroPriority.NETWORK) async def to_code(config): cg.add_define("USE_NETWORK") if CORE.using_arduino and CORE.is_esp32: diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index a4e387b77a..84e505a90a 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -30,7 +30,7 @@ from esphome.const import ( PLATFORM_NRF52, ThreadModel, ) -from esphome.core import CORE, EsphomeError, coroutine_with_priority +from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -132,7 +132,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -@coroutine_with_priority(1000) +@coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config: ConfigType) -> None: """Convert the configuration to code.""" cg.add_platformio_option("board", config[CONF_BOARD]) @@ -170,7 +170,7 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(_dfu_to_code, dfu_config) -@coroutine_with_priority(90) +@coroutine_with_priority(CoroPriority.DIAGNOSTICS) async def _dfu_to_code(dfu_config): cg.add_define("USE_NRF52_DFU") var = cg.new_Pvariable(dfu_config[CONF_ID]) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 4a83d5fc5f..c2cad2f7f1 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -76,7 +76,7 @@ from esphome.const import ( DEVICE_CLASS_WIND_DIRECTION, DEVICE_CLASS_WIND_SPEED, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -321,7 +321,7 @@ async def number_in_range_to_code(config, condition_id, template_arg, args): return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(number_ns.using) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 4d5b8a61e2..cf814fb1ee 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -10,7 +10,7 @@ from esphome.const import ( CONF_TRIGGER_ID, PlatformFramework, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority CODEOWNERS = ["@esphome/core"] AUTO_LOAD = ["md5", "safe_mode"] @@ -82,7 +82,7 @@ BASE_OTA_SCHEMA = cv.Schema( ) -@coroutine_with_priority(54.0) +@coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): cg.add_define("USE_OTA") diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 46eabb5325..1ec38e0159 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -18,7 +18,7 @@ from esphome.const import ( PLATFORM_RP2040, ThreadModel, ) -from esphome.core import CORE, EsphomeError, coroutine_with_priority +from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.helpers import copy_file_if_changed, mkdir_p, read_file, write_file from .const import KEY_BOARD, KEY_PIO_FILES, KEY_RP2040, rp2040_ns @@ -159,7 +159,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(1000) +@coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config): cg.add(rp2040_ns.setup_preferences()) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 991747b089..9944d71722 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -10,7 +10,7 @@ from esphome.const import ( CONF_TRIGGER_ID, KEY_PAST_SAFE_MODE, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.cpp_generator import RawExpression CODEOWNERS = ["@paulmonigatti", "@jsuanet", "@kbx81"] @@ -53,7 +53,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(50.0) +@coroutine_with_priority(CoroPriority.APPLICATION) async def to_code(config): if not config[CONF_DISABLED]: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 756e98c906..c7146df9fb 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -16,7 +16,7 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_WEB_SERVER, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -124,7 +124,7 @@ async def new_select(config, *args, options: list[str]): return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(select_ns.using) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 277718e46c..fe9822b3ca 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -101,7 +101,7 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass from esphome.util import Registry @@ -1142,6 +1142,6 @@ def _lstsq(a, b): return _mat_dot(_mat_dot(x, a_t), b) -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(sensor_ns.using) diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index 2ac1ca0cb9..5f1ba94ee6 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -4,7 +4,7 @@ from esphome.components import audio, audio_dac import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_ID, CONF_VOLUME from esphome.core import CORE -from esphome.coroutine import coroutine_with_priority +from esphome.coroutine import CoroPriority, coroutine_with_priority AUTO_LOAD = ["audio"] CODEOWNERS = ["@jesserockz", "@kahrendt"] @@ -138,7 +138,7 @@ async def speaker_mute_action_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(speaker_ns.using) cg.add_define("USE_SPEAKER") diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index a436bc6dab..894c6d1878 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -35,7 +35,7 @@ from esphome.const import ( PLATFORM_RP2040, PlatformFramework, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority import esphome.final_validate as fv CODEOWNERS = ["@esphome/core", "@clydebarrow"] @@ -351,7 +351,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(1.0) +@coroutine_with_priority(CoroPriority.BUS) async def to_code(configs): cg.add_define("USE_SPI") cg.add_global(spi_ns.using) diff --git a/esphome/components/status_led/__init__.py b/esphome/components/status_led/__init__.py index b299ae7ff7..b0fce37126 100644 --- a/esphome/components/status_led/__init__.py +++ b/esphome/components/status_led/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN -from esphome.core import coroutine_with_priority +from esphome.core import CoroPriority, coroutine_with_priority status_led_ns = cg.esphome_ns.namespace("status_led") StatusLED = status_led_ns.class_("StatusLED", cg.Component) @@ -15,7 +15,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -@coroutine_with_priority(80.0) +@coroutine_with_priority(CoroPriority.STATUS) async def to_code(config): pin = await cg.gpio_pin_expression(config[CONF_PIN]) rhs = StatusLED.new(pin) diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index c234388e7e..62bc71f2d1 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -10,7 +10,7 @@ from esphome.const import ( CONF_SPEED, CONF_TARGET, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority IS_PLATFORM_COMPONENT = True @@ -178,6 +178,6 @@ async def stepper_set_deceleration_to_code(config, action_id, template_arg, args return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(stepper_ns.using) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index f495dbc0b4..0e7b35b373 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -21,7 +21,7 @@ from esphome.const import ( DEVICE_CLASS_OUTLET, DEVICE_CLASS_SWITCH, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -230,6 +230,6 @@ async def switch_is_off_to_code(config, condition_id, template_arg, args): return cg.new_Pvariable(condition_id, template_arg, paren, False) -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(switch_ns.using) diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index aa831d1f06..1baacc239f 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -13,7 +13,7 @@ from esphome.const import ( CONF_VALUE, CONF_WEB_SERVER, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -149,7 +149,7 @@ async def new_text( return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(text_ns.using) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index e4aa701a7b..f7b3b5c55e 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -20,7 +20,7 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_TIMESTAMP, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass from esphome.util import Registry @@ -230,7 +230,7 @@ async def new_text_sensor(config, *args): return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(text_sensor_ns.using) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index a38ad4eae3..a20d79b857 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -26,7 +26,7 @@ from esphome.const import ( CONF_TIMEZONE, CONF_TRIGGER_ID, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority _LOGGER = logging.getLogger(__name__) @@ -340,7 +340,7 @@ async def register_time(time_var, config): await setup_time_core_(time_var, config) -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): if CORE.using_zephyr: zephyr_add_prj_conf("POSIX_CLOCK", True) diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index 01a271a34e..4a5c03ace4 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -13,7 +13,7 @@ from esphome.const import ( CONF_SWAP_XY, CONF_TRANSFORM, ) -from esphome.core import coroutine_with_priority +from esphome.core import CoroPriority, coroutine_with_priority CODEOWNERS = ["@jesserockz", "@nielsnl68"] DEPENDENCIES = ["display"] @@ -152,7 +152,7 @@ async def register_touchscreen(var, config): ) -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(touchscreen_ns.using) cg.add_define("USE_TOUCHSCREEN") diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 50d8aaf139..35fc4eaf1d 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -14,7 +14,7 @@ from esphome.const import ( DEVICE_CLASS_FIRMWARE, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -124,7 +124,7 @@ async def new_update(config): return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(update_ns.using) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 8185bd6ea2..6f31fc3a20 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -21,7 +21,7 @@ from esphome.const import ( DEVICE_CLASS_GAS, DEVICE_CLASS_WATER, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass @@ -233,6 +233,6 @@ async def valve_control_to_code(config, action_id, template_arg, args): return var -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(valve_ns.using) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index be193bbab8..288d928e80 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -31,7 +31,7 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RTL87XX, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.types import ConfigType @@ -269,7 +269,7 @@ def add_resource_as_progmem( cg.add_global(cg.RawExpression(size_t)) -@coroutine_with_priority(40.0) +@coroutine_with_priority(CoroPriority.WEB) async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 3af14fd453..3ec7e65e1d 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -3,7 +3,7 @@ from esphome.components.esp32 import add_idf_component from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network", "web_server_base"] @@ -22,7 +22,7 @@ CONFIG_SCHEMA = ( ) -@coroutine_with_priority(52.0) +@coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 50ae6b92fa..8add2f051f 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] @@ -26,7 +26,7 @@ CONFIG_SCHEMA = cv.Schema( ) -@coroutine_with_priority(65.0) +@coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 4013e8f400..7943911021 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -44,7 +44,7 @@ from esphome.const import ( CONF_USERNAME, PlatformFramework, ) -from esphome.core import CORE, HexInt, coroutine_with_priority +from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority import esphome.final_validate as fv from . import wpa2_eap @@ -370,7 +370,7 @@ def wifi_network(config, ap, static_ip): return ap -@coroutine_with_priority(60.0) +@coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 8a9630735e..89e3eff7d8 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -29,6 +29,7 @@ from esphome.const import ( # pylint: disable=unused-import from esphome.coroutine import ( # noqa: F401 + CoroPriority, FakeAwaitable as _FakeAwaitable, FakeEventLoop as _FakeEventLoop, coroutine, diff --git a/esphome/core/config.py b/esphome/core/config.py index b6ff1d8afd..96b9e23861 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -39,7 +39,7 @@ from esphome.const import ( PlatformFramework, __version__ as ESPHOME_VERSION, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.helpers import ( copy_file_if_changed, fnv1a_32bit_hash, @@ -359,7 +359,7 @@ ARDUINO_GLUE_CODE = """\ """ -@coroutine_with_priority(-999.0) +@coroutine_with_priority(CoroPriority.WORKAROUNDS) async def add_arduino_global_workaround(): # The Arduino framework defined these itself in the global # namespace. For the esphome codebase that is not a problem, @@ -376,7 +376,7 @@ async def add_arduino_global_workaround(): cg.add_global(cg.RawStatement(line)) -@coroutine_with_priority(-1000.0) +@coroutine_with_priority(CoroPriority.FINAL) async def add_includes(includes): # Add includes at the very end, so that the included files can access global variables for include in includes: @@ -392,7 +392,7 @@ async def add_includes(includes): include_file(path, basename) -@coroutine_with_priority(-1000.0) +@coroutine_with_priority(CoroPriority.FINAL) async def _add_platformio_options(pio_options): # Add includes at the very end, so that they override everything for key, val in pio_options.items(): @@ -401,7 +401,7 @@ async def _add_platformio_options(pio_options): cg.add_platformio_option(key, val) -@coroutine_with_priority(30.0) +@coroutine_with_priority(CoroPriority.AUTOMATION) async def _add_automations(config): for conf in config.get(CONF_ON_BOOT, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf.get(CONF_PRIORITY)) @@ -423,7 +423,7 @@ async def _add_automations(config): DATETIME_SUBTYPES = {"date", "time", "datetime"} -@coroutine_with_priority(-1000.0) +@coroutine_with_priority(CoroPriority.FINAL) async def _add_platform_defines() -> None: # Generate compile-time defines for platforms that have actual entities # Only add USE_* and count defines when there are entities @@ -442,7 +442,7 @@ async def _add_platform_defines() -> None: cg.add_define(f"USE_{platform_name.upper()}") -@coroutine_with_priority(100.0) +@coroutine_with_priority(CoroPriority.CORE) async def to_code(config: ConfigType) -> None: cg.add_global(cg.global_ns.namespace("esphome").using) # These can be used by user lambdas, put them to default scope diff --git a/esphome/coroutine.py b/esphome/coroutine.py index 8d952246f3..741a0c7c0c 100644 --- a/esphome/coroutine.py +++ b/esphome/coroutine.py @@ -42,7 +42,10 @@ Here everything is combined in `yield` expressions. You await other coroutines u the last `yield` expression defines what is returned. """ +from __future__ import annotations + from collections.abc import Awaitable, Callable, Generator, Iterator +import enum import functools import heapq import inspect @@ -53,6 +56,79 @@ from typing import Any _LOGGER = logging.getLogger(__name__) +class CoroPriority(enum.IntEnum): + """Execution priority stages for ESPHome code generation. + + Higher values run first. These stages ensure proper dependency + resolution during code generation. + """ + + # Platform initialization - must run first + # Examples: esp32, esp8266, rp2040 + PLATFORM = 1000 + + # Network infrastructure setup + # Examples: network (201) + NETWORK = 201 + + # Network transport layer + # Examples: async_tcp (200) + NETWORK_TRANSPORT = 200 + + # Core system components + # Examples: esphome core, most entity base components (cover, update, datetime, + # valve, alarm_control_panel, lock, event, binary_sensor, button, climate, fan, + # light, media_player, number, select, sensor, switch, text_sensor, text), + # microphone, speaker, audio_dac, touchscreen, stepper + CORE = 100 + + # Diagnostic and debugging systems + # Examples: logger (90) + DIAGNOSTICS = 90 + + # Status and monitoring systems + # Examples: status_led (80) + STATUS = 80 + + # Communication protocols and services + # Examples: web_server_base (65), captive_portal (64), wifi (60), ethernet (60), + # mdns (55), ota_updates (54), web_server_ota (52) + COMMUNICATION = 60 + + # Application-level services + # Examples: safe_mode (50) + APPLICATION = 50 + + # Web and UI services + # Examples: web_server (40) + WEB = 40 + + # Automations and user logic + # Examples: esphome core automations (30) + AUTOMATION = 30 + + # Bus and peripheral setup + # Examples: i2c (1) + BUS = 1 + + # Standard component priority (default) + # Components without explicit priority run at 0 + COMPONENT = 0 + + # Components that need others to be registered first + # Examples: globals (-100) + LATE = -100 + + # Platform-specific workarounds and fixes + # Examples: add_arduino_global_workaround (-999), esp8266 pin states (-999) + WORKAROUNDS = -999 + + # Final setup that requires all components to be registered + # Examples: add_includes, _add_platformio_options, _add_platform_defines (all -1000), + # esp32_ble_tracker feature defines (-1000) + FINAL = -1000 + + def coroutine(func: Callable[..., Any]) -> Callable[..., Awaitable[Any]]: """Decorator to apply to methods to convert them to ESPHome coroutines.""" if getattr(func, "_esphome_coroutine", False): @@ -95,15 +171,16 @@ def coroutine(func: Callable[..., Any]) -> Callable[..., Awaitable[Any]]: return coro -def coroutine_with_priority(priority: float): +def coroutine_with_priority(priority: float | CoroPriority): """Decorator to apply to functions to convert them to ESPHome coroutines. :param priority: priority with which to schedule the coroutine, higher priorities run first. + Can be a float or a CoroPriority enum value. """ def decorator(func): coro = coroutine(func) - coro.priority = priority + coro.priority = float(priority) return coro return decorator @@ -173,7 +250,7 @@ class _Task: self.iterator = iterator self.original_function = original_function - def with_priority(self, priority: float) -> "_Task": + def with_priority(self, priority: float) -> _Task: return _Task(priority, self.id_number, self.iterator, self.original_function) @property diff --git a/tests/unit_tests/test_coroutine.py b/tests/unit_tests/test_coroutine.py new file mode 100644 index 0000000000..138b08edb5 --- /dev/null +++ b/tests/unit_tests/test_coroutine.py @@ -0,0 +1,204 @@ +"""Tests for the coroutine module.""" + +import pytest + +from esphome.coroutine import CoroPriority, FakeEventLoop, coroutine_with_priority + + +def test_coro_priority_enum_values() -> None: + """Test that CoroPriority enum values match expected priorities.""" + assert CoroPriority.PLATFORM == 1000 + assert CoroPriority.NETWORK == 201 + assert CoroPriority.NETWORK_TRANSPORT == 200 + assert CoroPriority.CORE == 100 + assert CoroPriority.DIAGNOSTICS == 90 + assert CoroPriority.STATUS == 80 + assert CoroPriority.COMMUNICATION == 60 + assert CoroPriority.APPLICATION == 50 + assert CoroPriority.WEB == 40 + assert CoroPriority.AUTOMATION == 30 + assert CoroPriority.BUS == 1 + assert CoroPriority.COMPONENT == 0 + assert CoroPriority.LATE == -100 + assert CoroPriority.WORKAROUNDS == -999 + assert CoroPriority.FINAL == -1000 + + +def test_coroutine_with_priority_accepts_float() -> None: + """Test that coroutine_with_priority accepts float values.""" + + @coroutine_with_priority(100.0) + def test_func() -> None: + pass + + assert hasattr(test_func, "priority") + assert test_func.priority == 100.0 + + +def test_coroutine_with_priority_accepts_enum() -> None: + """Test that coroutine_with_priority accepts CoroPriority enum values.""" + + @coroutine_with_priority(CoroPriority.CORE) + def test_func() -> None: + pass + + assert hasattr(test_func, "priority") + assert test_func.priority == 100.0 + + +def test_float_and_enum_are_interchangeable() -> None: + """Test that float and CoroPriority enum values produce the same priority.""" + + @coroutine_with_priority(100.0) + def func_with_float() -> None: + pass + + @coroutine_with_priority(CoroPriority.CORE) + def func_with_enum() -> None: + pass + + assert func_with_float.priority == func_with_enum.priority + assert func_with_float.priority == 100.0 + + +@pytest.mark.parametrize( + ("enum_value", "float_value"), + [ + (CoroPriority.PLATFORM, 1000.0), + (CoroPriority.NETWORK, 201.0), + (CoroPriority.NETWORK_TRANSPORT, 200.0), + (CoroPriority.CORE, 100.0), + (CoroPriority.DIAGNOSTICS, 90.0), + (CoroPriority.STATUS, 80.0), + (CoroPriority.COMMUNICATION, 60.0), + (CoroPriority.APPLICATION, 50.0), + (CoroPriority.WEB, 40.0), + (CoroPriority.AUTOMATION, 30.0), + (CoroPriority.BUS, 1.0), + (CoroPriority.COMPONENT, 0.0), + (CoroPriority.LATE, -100.0), + (CoroPriority.WORKAROUNDS, -999.0), + (CoroPriority.FINAL, -1000.0), + ], +) +def test_all_priority_values_are_interchangeable( + enum_value: CoroPriority, float_value: float +) -> None: + """Test that all CoroPriority values work correctly with coroutine_with_priority.""" + + @coroutine_with_priority(enum_value) + def func_with_enum() -> None: + pass + + @coroutine_with_priority(float_value) + def func_with_float() -> None: + pass + + assert func_with_enum.priority == float_value + assert func_with_float.priority == float_value + assert func_with_enum.priority == func_with_float.priority + + +def test_execution_order_with_enum_priorities() -> None: + """Test that execution order is correct when using enum priorities.""" + execution_order: list[str] = [] + + @coroutine_with_priority(CoroPriority.PLATFORM) + async def platform_func() -> None: + execution_order.append("platform") + + @coroutine_with_priority(CoroPriority.CORE) + async def core_func() -> None: + execution_order.append("core") + + @coroutine_with_priority(CoroPriority.FINAL) + async def final_func() -> None: + execution_order.append("final") + + # Create event loop and add jobs + loop = FakeEventLoop() + loop.add_job(platform_func) + loop.add_job(core_func) + loop.add_job(final_func) + + # Run all tasks + loop.flush_tasks() + + # Check execution order (higher priority runs first) + assert execution_order == ["platform", "core", "final"] + + +def test_mixed_float_and_enum_priorities() -> None: + """Test that mixing float and enum priorities works correctly.""" + execution_order: list[str] = [] + + @coroutine_with_priority(1000.0) # Same as PLATFORM + async def func1() -> None: + execution_order.append("func1") + + @coroutine_with_priority(CoroPriority.CORE) + async def func2() -> None: + execution_order.append("func2") + + @coroutine_with_priority(-1000.0) # Same as FINAL + async def func3() -> None: + execution_order.append("func3") + + # Create event loop and add jobs + loop = FakeEventLoop() + loop.add_job(func2) + loop.add_job(func3) + loop.add_job(func1) + + # Run all tasks + loop.flush_tasks() + + # Check execution order + assert execution_order == ["func1", "func2", "func3"] + + +def test_enum_priority_comparison() -> None: + """Test that enum priorities can be compared directly.""" + assert CoroPriority.PLATFORM > CoroPriority.NETWORK + assert CoroPriority.NETWORK > CoroPriority.NETWORK_TRANSPORT + assert CoroPriority.NETWORK_TRANSPORT > CoroPriority.CORE + assert CoroPriority.CORE > CoroPriority.DIAGNOSTICS + assert CoroPriority.DIAGNOSTICS > CoroPriority.STATUS + assert CoroPriority.STATUS > CoroPriority.COMMUNICATION + assert CoroPriority.COMMUNICATION > CoroPriority.APPLICATION + assert CoroPriority.APPLICATION > CoroPriority.WEB + assert CoroPriority.WEB > CoroPriority.AUTOMATION + assert CoroPriority.AUTOMATION > CoroPriority.BUS + assert CoroPriority.BUS > CoroPriority.COMPONENT + assert CoroPriority.COMPONENT > CoroPriority.LATE + assert CoroPriority.LATE > CoroPriority.WORKAROUNDS + assert CoroPriority.WORKAROUNDS > CoroPriority.FINAL + + +def test_custom_priority_between_enum_values() -> None: + """Test that custom float priorities between enum values work correctly.""" + execution_order: list[str] = [] + + @coroutine_with_priority(CoroPriority.CORE) # 100 + async def core_func() -> None: + execution_order.append("core") + + @coroutine_with_priority(95.0) # Between CORE and DIAGNOSTICS + async def custom_func() -> None: + execution_order.append("custom") + + @coroutine_with_priority(CoroPriority.DIAGNOSTICS) # 90 + async def diag_func() -> None: + execution_order.append("diagnostics") + + # Create event loop and add jobs + loop = FakeEventLoop() + loop.add_job(diag_func) + loop.add_job(core_func) + loop.add_job(custom_func) + + # Run all tasks + loop.flush_tasks() + + # Check execution order + assert execution_order == ["core", "custom", "diagnostics"] From 43634257f62488baa5365884bbc9faf3351dfc66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 18:43:38 -0500 Subject: [PATCH 0797/1626] fix defer churn --- esphome/core/scheduler.cpp | 16 ++++++++++++++ esphome/core/scheduler.h | 45 +++++++++++++++++++++++++++----------- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 7df1334aec..be4301e19b 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -101,6 +101,22 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Take lock early to protect scheduler_item_pool_ access LockGuard guard{this->lock_}; + // Optimization: if we're updating a defer that hasn't executed yet, just update its callback + // This avoids allocating a new item and cancelling/re-adding + if (delay == 0 && type == SchedulerItem::TIMEOUT && !skip_cancel && name_cstr != nullptr) { +#ifdef ESPHOME_THREAD_SINGLE + // Single-threaded: check to_add_ for defers that haven't been moved to heap yet + if (this->try_update_defer_in_container_(this->to_add_, component, name_cstr, std::move(func))) { + return; + } +#else + // Multi-threaded: check defer_queue_ + if (this->try_update_defer_in_container_(this->defer_queue_, component, name_cstr, std::move(func))) { + return; + } +#endif + } + // Create and populate the scheduler item std::unique_ptr item; if (!this->scheduler_item_pool_.empty()) { diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 300e12117d..34e1725963 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -217,6 +217,15 @@ class Scheduler { // Common implementation for cancel operations bool cancel_item_(Component *component, bool is_static_string, const void *name_ptr, SchedulerItem::Type type); + // Helper to check if two scheduler item names match + inline bool HOT names_match_(const char *name1, const char *name2) const { + // Check pointer equality first (common for static strings), then string contents + // The core ESPHome codebase uses static strings (const char*) for component names, + // making pointer comparison effective. The std::string overloads exist only for + // compatibility with external components but are rarely used in practice. + return (name1 != nullptr && name2 != nullptr) && ((name1 == name2) || (strcmp(name1, name2) == 0)); + } + // Helper function to check if item matches criteria for cancellation inline bool HOT matches_item_(const std::unique_ptr &item, Component *component, const char *name_cstr, SchedulerItem::Type type, bool match_retry, bool skip_removed = true) const { @@ -224,19 +233,7 @@ class Scheduler { (match_retry && !item->is_retry)) { return false; } - const char *item_name = item->get_name(); - if (item_name == nullptr) { - return false; - } - // Fast path: if pointers are equal - // This is effective because the core ESPHome codebase uses static strings (const char*) - // for component names. The std::string overloads exist only for compatibility with - // external components, but are rarely used in practice. - if (item_name == name_cstr) { - return true; - } - // Slow path: compare string contents - return strcmp(name_cstr, item_name) == 0; + return this->names_match_(item->get_name(), name_cstr); } // Helper to execute a scheduler item @@ -313,6 +310,28 @@ class Scheduler { return cancelled; } + // Template helper to try updating a defer in a container instead of allocating a new one + // Returns true if the defer was updated, false if not found + template + bool try_update_defer_in_container_(Container &container, Component *component, const char *name_cstr, + std::function &&func) { + if (container.empty()) { + return false; + } + + auto &last_item = container.back(); + + // Check if last item is a matching defer (timeout with 0 delay) and names match + if (last_item->component != component || last_item->type != SchedulerItem::TIMEOUT || last_item->interval != 0 || + is_item_removed_(last_item.get()) || !this->names_match_(last_item->get_name(), name_cstr)) { + return false; + } + + // Same defer at the end - just update the callback, no allocation needed + last_item->callback = std::move(func); + return true; + } + Mutex lock_; std::vector> items_; std::vector> to_add_; From e3fb9c2a7855d7c961c94a849ceb4c69e0489c35 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 2 Sep 2025 19:51:17 -0400 Subject: [PATCH 0798/1626] [esp32] Remove hardcoding of ulp (#10535) --- esphome/components/esp32/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ac236f4eb3..47832a08ae 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -855,11 +855,6 @@ async def to_code(config): cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) - # platformio/toolchain-esp32ulp does not support linux_aarch64 yet and has not been updated for over 2 years - # This is espressif's own published version which is more up to date. - cg.add_platformio_option( - "platform_packages", ["espressif/toolchain-esp32ulp@2.35.0-20220830"] - ) add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True) add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True From 0c5b63c382f91e767bf2434640751dc143501b38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 18:59:54 -0500 Subject: [PATCH 0799/1626] preen --- esphome/core/scheduler.cpp | 45 ++++++++++++------- esphome/core/scheduler.h | 4 +- .../integration/fixtures/scheduler_pool.yaml | 41 ++++++++++++++--- tests/integration/test_scheduler_pool.py | 18 +++++--- 4 files changed, 80 insertions(+), 28 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index be4301e19b..20739dedbb 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -15,21 +15,24 @@ namespace esphome { static const char *const TAG = "scheduler"; // Memory pool configuration constants -// Pool size of 10 is a balance between memory usage and performance: -// - Small enough to not waste memory on simple configs (1-2 timers) -// - Large enough to handle complex setups with multiple sensors/components -// - Prevents system-wide stalls from heap allocation/deallocation that can -// disrupt task synchronization and cause dropped events -static constexpr size_t MAX_POOL_SIZE = 10; -// Maximum number of cancelled items to keep in the heap before forcing a cleanup. -// Set to 6 to trigger cleanup relatively frequently, ensuring cancelled items are -// recycled to the pool in a timely manner to maintain pool efficiency. -static const uint32_t MAX_LOGICALLY_DELETED_ITEMS = 6; +// Pool size of 5 matches typical usage patterns (2-4 active timers) +// - Minimal memory overhead (~250 bytes on ESP32) +// - Sufficient for most configs with a couple sensors/components +// - Still prevents heap fragmentation and allocation stalls +// - Complex setups with many timers will just allocate beyond the pool +// See https://github.com/esphome/backlog/issues/52 +static constexpr size_t MAX_POOL_SIZE = 5; -// Ensure MAX_LOGICALLY_DELETED_ITEMS is at least 4 smaller than MAX_POOL_SIZE -// This guarantees we have room in the pool for recycled items when cleanup occurs -static_assert(MAX_LOGICALLY_DELETED_ITEMS + 4 <= MAX_POOL_SIZE, - "MAX_LOGICALLY_DELETED_ITEMS must be at least 4 smaller than MAX_POOL_SIZE"); +// Cleanup is performed when cancelled items exceed this percentage of total items. +// Using integer math: cleanup when (cancelled * 100 / total) > 50 +// This balances cleanup frequency with performance - we avoid O(n) cleanup +// on every cancellation but don't let cancelled items accumulate excessively. +static constexpr uint32_t CLEANUP_PERCENTAGE = 50; + +// Minimum number of cancelled items before considering cleanup. +// Even if the fraction is exceeded, we need at least this many cancelled items +// to make the O(n) cleanup operation worthwhile. +static constexpr uint32_t MIN_CANCELLED_ITEMS_FOR_CLEANUP = 3; // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; @@ -417,8 +420,18 @@ void HOT Scheduler::call(uint32_t now) { } #endif /* ESPHOME_DEBUG_SCHEDULER */ - // If we have too many items to remove - if (this->to_remove_ > MAX_LOGICALLY_DELETED_ITEMS) { + // Check if we should perform cleanup based on percentage of cancelled items + // Cleanup when: cancelled items >= MIN_CANCELLED_ITEMS_FOR_CLEANUP AND + // cancelled percentage > CLEANUP_PERCENTAGE + size_t total_items = this->items_.size(); + bool should_cleanup = false; + + if (this->to_remove_ >= MIN_CANCELLED_ITEMS_FOR_CLEANUP && total_items > 0) { + // Use integer math to avoid floating point: (cancelled * 100 / total) > CLEANUP_PERCENTAGE + should_cleanup = (this->to_remove_ * 100) > (total_items * CLEANUP_PERCENTAGE); + } + + if (should_cleanup) { // We hold the lock for the entire cleanup operation because: // 1. We're rebuilding the entire items_ list, so we need exclusive access throughout // 2. Other threads must see either the old state or the new state, not intermediate states diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 34e1725963..f16a3814ec 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -344,8 +344,8 @@ class Scheduler { // Memory pool for recycling SchedulerItem objects to reduce heap churn. // Design decisions: // - std::vector is used instead of a fixed array because many systems only need 1-2 scheduler items - // - The vector grows dynamically up to MAX_POOL_SIZE (10) only when needed, saving memory on simple setups - // - This approach balances memory efficiency for simple configs with performance for complex ones + // - The vector grows dynamically up to MAX_POOL_SIZE (5) only when needed, saving memory on simple setups + // - Pool size of 5 matches typical usage (2-4 timers) while keeping memory overhead low (~250 bytes on ESP32) // - The pool significantly reduces heap fragmentation which is critical because heap allocation/deallocation // can stall the entire system, causing timing issues and dropped events for any components that need // to synchronize between tasks (see https://github.com/esphome/backlog/issues/52) diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index e488f38e2e..5389125188 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -27,6 +27,9 @@ api: - service: run_phase_6 then: - script.execute: test_full_pool_reuse + - service: run_phase_7 + then: + - script.execute: test_same_defer_optimization - service: run_complete then: - script.execute: complete_test @@ -87,7 +90,8 @@ script: auto *component = id(test_sensor); // Multiple sensors with different update intervals - App.scheduler.set_interval(component, "temp_sensor", 100, []() { + // These should only allocate once and reuse the same item for each interval execution + App.scheduler.set_interval(component, "temp_sensor", 10, []() { ESP_LOGD("test", "Temperature sensor update"); id(interval_counter)++; if (id(interval_counter) >= 3) { @@ -96,7 +100,7 @@ script: } }); - App.scheduler.set_interval(component, "humidity_sensor", 150, []() { + App.scheduler.set_interval(component, "humidity_sensor", 15, []() { ESP_LOGD("test", "Humidity sensor update"); id(interval_counter)++; if (id(interval_counter) >= 5) { @@ -105,7 +109,9 @@ script: } }); + // Only 2 allocations for the intervals, no matter how many times they execute id(create_count) += 2; + ESP_LOGD("test", "Created 2 intervals - they will reuse same items for each execution"); ESP_LOGI("test", "Phase 2 complete"); - id: test_communication_patterns @@ -215,11 +221,14 @@ script: - id: test_full_pool_reuse then: - lambda: |- - ESP_LOGI("test", "Phase 6: Testing full pool reuse after Phase 5 items complete"); + ESP_LOGI("test", "Phase 6: Testing pool size limits after Phase 5 items complete"); // At this point, all Phase 5 timeouts should have completed and been recycled. - // The pool should be at or near its maximum size (10). - // Creating 10 new items should reuse all from the pool. + // The pool should be at its maximum size (5). + // Creating 10 new items tests that: + // - First 5 items reuse from the pool + // - Remaining 5 items allocate new (pool empty) + // - Pool doesn't grow beyond MAX_POOL_SIZE of 5 auto *component = id(test_sensor); int full_reuse_count = 10; @@ -235,6 +244,28 @@ script: id(create_count) += full_reuse_count; ESP_LOGI("test", "Phase 6 complete"); + - id: test_same_defer_optimization + then: + - lambda: |- + ESP_LOGI("test", "Phase 7: Testing same-named defer optimization"); + + auto *component = id(test_sensor); + + // Create 10 defers with the same name - should optimize to update callback in-place + // This pattern is common in components like ratgdo that repeatedly defer state updates + for (int i = 0; i < 10; i++) { + App.scheduler.set_timeout(component, "repeated_defer", 0, [i]() { + ESP_LOGD("test", "Repeated defer executed with value: %d", i); + }); + } + + // Only the first should allocate, the rest should update in-place + // We expect only 1 allocation for all 10 operations + id(create_count) += 1; // Only count 1 since others should be optimized + + ESP_LOGD("test", "Created 10 same-named defers (should only allocate once)"); + ESP_LOGI("test", "Phase 7 complete"); + - id: complete_test then: - lambda: |- diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py index bd878be180..b5f9f12631 100644 --- a/tests/integration/test_scheduler_pool.py +++ b/tests/integration/test_scheduler_pool.py @@ -48,6 +48,7 @@ async def test_scheduler_pool( 4: loop.create_future(), 5: loop.create_future(), 6: loop.create_future(), + 7: loop.create_future(), } def check_output(line: str) -> None: @@ -69,9 +70,10 @@ async def test_scheduler_pool( new_alloc_count += 1 # Track phase completion - for phase_num in range(1, 7): + for phase_num in range(1, 8): if ( f"Phase {phase_num} complete" in line + and phase_num in phase_futures and not phase_futures[phase_num].done() ): phase_futures[phase_num].set_result(True) @@ -102,6 +104,7 @@ async def test_scheduler_pool( "run_phase_4", "run_phase_5", "run_phase_6", + "run_phase_7", "run_complete", } assert expected_services.issubset(service_names), ( @@ -111,7 +114,7 @@ async def test_scheduler_pool( # Get service objects phase_services = { num: next(s for s in services if s.name == f"run_phase_{num}") - for num in range(1, 7) + for num in range(1, 8) } complete_service = next(s for s in services if s.name == "run_complete") @@ -146,6 +149,11 @@ async def test_scheduler_pool( await asyncio.wait_for(phase_futures[6], timeout=1.0) await asyncio.sleep(0.1) # Let Phase 6 timeouts complete + # Phase 7: Same-named defer optimization + client.execute_service(phase_services[7], {}) + await asyncio.wait_for(phase_futures[7], timeout=1.0) + await asyncio.sleep(0.05) # Let the single defer execute + # Complete test client.execute_service(complete_service, {}) await asyncio.wait_for(test_complete_future, timeout=0.5) @@ -166,7 +174,7 @@ async def test_scheduler_pool( ) # Verify all test phases ran - for phase_num in range(1, 7): + for phase_num in range(1, 8): assert phase_futures[phase_num].done(), f"Phase {phase_num} did not complete" # Verify pool behavior @@ -180,8 +188,8 @@ async def test_scheduler_pool( size = int(match.group(1)) max_pool_size = max(max_pool_size, size) - # Pool can grow up to its maximum of 10 - assert max_pool_size <= 10, f"Pool grew beyond maximum ({max_pool_size})" + # Pool can grow up to its maximum of 5 + assert max_pool_size <= 5, f"Pool grew beyond maximum ({max_pool_size})" # Log summary for debugging print("\nScheduler Pool Test Summary (Python Orchestrated):") From 6e14050351047b9cd74ec7722468a33c4e56a4ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 19:11:14 -0500 Subject: [PATCH 0800/1626] preen --- esphome/core/scheduler.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index f16a3814ec..ceb47e294c 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -5,7 +5,6 @@ #include #include #include -#include #ifdef ESPHOME_THREAD_MULTI_ATOMICS #include #endif From be4c8956ad6134ea1ed11e48100dd78a6e0a304b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 19:39:49 -0500 Subject: [PATCH 0801/1626] debug --- esphome/core/scheduler.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 20739dedbb..5df5a0fa75 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -127,13 +127,13 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type item = std::move(this->scheduler_item_pool_.back()); this->scheduler_item_pool_.pop_back(); #ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGVV(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_.size()); + ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_.size()); #endif } else { // Allocate new if pool is empty item = make_unique(); #ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGVV(TAG, "Allocated new item (pool empty)"); + ESP_LOGD(TAG, "Allocated new item (pool empty)"); #endif } item->component = component; @@ -819,11 +819,11 @@ void Scheduler::recycle_item_(std::unique_ptr item) { item->clear_dynamic_name(); this->scheduler_item_pool_.push_back(std::move(item)); #ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGVV(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_.size()); + ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_.size()); #endif } else { #ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGVV(TAG, "Pool full (size: %zu), deleting item", this->scheduler_item_pool_.size()); + ESP_LOGD(TAG, "Pool full (size: %zu), deleting item", this->scheduler_item_pool_.size()); #endif } // else: unique_ptr will delete the item when it goes out of scope From 41628d219352742ce1819711c18edef25170be19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 19:47:15 -0500 Subject: [PATCH 0802/1626] improve debug logging --- esphome/core/scheduler.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 5df5a0fa75..6b35a9e057 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -403,9 +403,10 @@ void HOT Scheduler::call(uint32_t now) { } const char *name = item->get_name(); - ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64, + bool is_cancelled = is_item_removed_(item.get()); + ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64 "%s", item->get_type_str(), item->get_source(), name ? name : "(null)", item->interval, - item->next_execution_ - now_64, item->next_execution_); + item->next_execution_ - now_64, item->next_execution_, is_cancelled ? " [CANCELLED]" : ""); old_items.push_back(std::move(item)); } From c8a4a3b752bc35e0eebc025786a07b0db107ce3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 19:52:44 -0500 Subject: [PATCH 0803/1626] more churn --- esphome/core/scheduler.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 6b35a9e057..1d3ecc33af 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -120,6 +120,24 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type #endif } + // Optimization: if we're updating a timeout that's still in to_add_, just update it in-place + // This is common when timers are rapidly rescheduled (like api_reboot on connect/disconnect) + if (delay != 0 && type == SchedulerItem::TIMEOUT && !skip_cancel && name_cstr != nullptr && !this->to_add_.empty()) { + auto &last_item = this->to_add_.back(); + // Check if last item in to_add_ matches and can be updated + if (last_item->component == component && last_item->type == SchedulerItem::TIMEOUT && + !is_item_removed_(last_item.get()) && this->names_match_(last_item->get_name(), name_cstr)) { + // Same timeout at the end of to_add_ - update it instead of creating new + last_item->callback = std::move(func); + last_item->next_execution_ = now + delay; +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGD(TAG, "Updated existing timeout in to_add_ for '%s/%s'", component->get_component_source(), + name_cstr ? name_cstr : "(null)"); +#endif + return; + } + } + // Create and populate the scheduler item std::unique_ptr item; if (!this->scheduler_item_pool_.empty()) { From e90ae09354b155eff86419790f9ee9212655fe92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 19:54:01 -0500 Subject: [PATCH 0804/1626] preen --- esphome/core/scheduler.cpp | 53 +++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 1d3ecc33af..3702bd0ae2 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -104,37 +104,42 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Take lock early to protect scheduler_item_pool_ access LockGuard guard{this->lock_}; - // Optimization: if we're updating a defer that hasn't executed yet, just update its callback + // Optimization: if we're updating a timeout that hasn't been added to heap yet, just update it in-place // This avoids allocating a new item and cancelling/re-adding - if (delay == 0 && type == SchedulerItem::TIMEOUT && !skip_cancel && name_cstr != nullptr) { -#ifdef ESPHOME_THREAD_SINGLE - // Single-threaded: check to_add_ for defers that haven't been moved to heap yet - if (this->try_update_defer_in_container_(this->to_add_, component, name_cstr, std::move(func))) { - return; - } -#else - // Multi-threaded: check defer_queue_ - if (this->try_update_defer_in_container_(this->defer_queue_, component, name_cstr, std::move(func))) { + if (type == SchedulerItem::TIMEOUT && !skip_cancel && name_cstr != nullptr) { +#ifndef ESPHOME_THREAD_SINGLE + // Multi-threaded: defers go to defer_queue_ + if (delay == 0 && this->try_update_defer_in_container_(this->defer_queue_, component, name_cstr, std::move(func))) { return; } #endif - } - // Optimization: if we're updating a timeout that's still in to_add_, just update it in-place - // This is common when timers are rapidly rescheduled (like api_reboot on connect/disconnect) - if (delay != 0 && type == SchedulerItem::TIMEOUT && !skip_cancel && name_cstr != nullptr && !this->to_add_.empty()) { - auto &last_item = this->to_add_.back(); - // Check if last item in to_add_ matches and can be updated - if (last_item->component == component && last_item->type == SchedulerItem::TIMEOUT && - !is_item_removed_(last_item.get()) && this->names_match_(last_item->get_name(), name_cstr)) { - // Same timeout at the end of to_add_ - update it instead of creating new - last_item->callback = std::move(func); - last_item->next_execution_ = now + delay; + // Check if we can update an existing timeout in to_add_ + if (!this->to_add_.empty()) { + auto &last_item = this->to_add_.back(); + // Check if last item in to_add_ matches and can be updated + if (last_item->component == component && last_item->type == SchedulerItem::TIMEOUT && + !is_item_removed_(last_item.get()) && this->names_match_(last_item->get_name(), name_cstr)) { + // For defers (delay==0), only update callback + if (delay == 0 && last_item->interval == 0) { + last_item->callback = std::move(func); #ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Updated existing timeout in to_add_ for '%s/%s'", component->get_component_source(), - name_cstr ? name_cstr : "(null)"); + ESP_LOGD(TAG, "Updated existing defer in to_add_ for '%s/%s'", component->get_component_source(), + name_cstr ? name_cstr : "(null)"); #endif - return; + return; + } + // For regular timeouts, update callback and execution time + if (delay != 0) { + last_item->callback = std::move(func); + last_item->next_execution_ = now + delay; +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGD(TAG, "Updated existing timeout in to_add_ for '%s/%s'", component->get_component_source(), + name_cstr ? name_cstr : "(null)"); +#endif + return; + } + } } } From 979a021a27664b3b59d099457c778289482fecbf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 19:55:38 -0500 Subject: [PATCH 0805/1626] preen --- esphome/core/scheduler.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 3702bd0ae2..0e6c1245b9 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -120,7 +120,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Check if last item in to_add_ matches and can be updated if (last_item->component == component && last_item->type == SchedulerItem::TIMEOUT && !is_item_removed_(last_item.get()) && this->names_match_(last_item->get_name(), name_cstr)) { - // For defers (delay==0), only update callback +#ifdef ESPHOME_THREAD_SINGLE + // Single-threaded: defers can be in to_add_, only update callback if (delay == 0 && last_item->interval == 0) { last_item->callback = std::move(func); #ifdef ESPHOME_DEBUG_SCHEDULER @@ -129,6 +130,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type #endif return; } +#endif // For regular timeouts, update callback and execution time if (delay != 0) { last_item->callback = std::move(func); From 3066afef24e62f9db743b43401e759fdc3cc3f65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 20:07:47 -0500 Subject: [PATCH 0806/1626] fix churn on last itme --- esphome/core/scheduler.cpp | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 0e6c1245b9..5dfd241814 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -656,12 +656,23 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c } #endif /* not ESPHOME_THREAD_SINGLE */ - // Cancel items in the main heap (can't recycle immediately due to heap structure) - for (auto &item : this->items_) { - if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - this->mark_item_removed_(item.get()); + // Cancel items in the main heap + // Special case: if the last item in the heap matches, we can remove it immediately + // (removing the last element doesn't break heap structure) + if (!this->items_.empty()) { + auto &last_item = this->items_.back(); + if (this->matches_item_(last_item, component, name_cstr, type, match_retry)) { + this->recycle_item_(std::move(this->items_.back())); + this->items_.pop_back(); total_cancelled++; - this->to_remove_++; // Track removals for heap items + } + // For other items in heap, we can only mark for removal (can't remove from middle of heap) + for (auto &item : this->items_) { + if (this->matches_item_(item, component, name_cstr, type, match_retry)) { + this->mark_item_removed_(item.get()); + total_cancelled++; + this->to_remove_++; // Track removals for heap items + } } } From 91eabc983ea3c64c693a78ad7a3ddd6517b1e5a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 20:20:02 -0500 Subject: [PATCH 0807/1626] cleanup --- esphome/core/scheduler.cpp | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 5dfd241814..56806f2fa4 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -23,16 +23,10 @@ static const char *const TAG = "scheduler"; // See https://github.com/esphome/backlog/issues/52 static constexpr size_t MAX_POOL_SIZE = 5; -// Cleanup is performed when cancelled items exceed this percentage of total items. -// Using integer math: cleanup when (cancelled * 100 / total) > 50 -// This balances cleanup frequency with performance - we avoid O(n) cleanup -// on every cancellation but don't let cancelled items accumulate excessively. -static constexpr uint32_t CLEANUP_PERCENTAGE = 50; - -// Minimum number of cancelled items before considering cleanup. -// Even if the fraction is exceeded, we need at least this many cancelled items -// to make the O(n) cleanup operation worthwhile. -static constexpr uint32_t MIN_CANCELLED_ITEMS_FOR_CLEANUP = 3; +// Maximum number of logically deleted (cancelled) items before forcing cleanup. +// Set to 5 to match the pool size - when we have as many cancelled items as our +// pool can hold, it's time to clean up and recycle them. +static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 5; // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; @@ -446,18 +440,11 @@ void HOT Scheduler::call(uint32_t now) { } #endif /* ESPHOME_DEBUG_SCHEDULER */ - // Check if we should perform cleanup based on percentage of cancelled items - // Cleanup when: cancelled items >= MIN_CANCELLED_ITEMS_FOR_CLEANUP AND - // cancelled percentage > CLEANUP_PERCENTAGE - size_t total_items = this->items_.size(); - bool should_cleanup = false; - - if (this->to_remove_ >= MIN_CANCELLED_ITEMS_FOR_CLEANUP && total_items > 0) { - // Use integer math to avoid floating point: (cancelled * 100 / total) > CLEANUP_PERCENTAGE - should_cleanup = (this->to_remove_ * 100) > (total_items * CLEANUP_PERCENTAGE); - } - - if (should_cleanup) { + // Check if we should perform cleanup based on number of cancelled items + // Cleanup when we have accumulated MAX_LOGICALLY_DELETED_ITEMS cancelled items + // This simple threshold ensures we don't waste memory on cancelled items + // regardless of how many intervals are running + if (this->to_remove_ >= MAX_LOGICALLY_DELETED_ITEMS) { // We hold the lock for the entire cleanup operation because: // 1. We're rebuilding the entire items_ list, so we need exclusive access throughout // 2. Other threads must see either the old state or the new state, not intermediate states From 5aa54bfff480c3c28e33592510a05a86191554a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 20:30:16 -0500 Subject: [PATCH 0808/1626] preen --- esphome/core/scheduler.cpp | 41 -------------------------------------- esphome/core/scheduler.h | 22 -------------------- 2 files changed, 63 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 56806f2fa4..f463e87996 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -98,47 +98,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Take lock early to protect scheduler_item_pool_ access LockGuard guard{this->lock_}; - // Optimization: if we're updating a timeout that hasn't been added to heap yet, just update it in-place - // This avoids allocating a new item and cancelling/re-adding - if (type == SchedulerItem::TIMEOUT && !skip_cancel && name_cstr != nullptr) { -#ifndef ESPHOME_THREAD_SINGLE - // Multi-threaded: defers go to defer_queue_ - if (delay == 0 && this->try_update_defer_in_container_(this->defer_queue_, component, name_cstr, std::move(func))) { - return; - } -#endif - - // Check if we can update an existing timeout in to_add_ - if (!this->to_add_.empty()) { - auto &last_item = this->to_add_.back(); - // Check if last item in to_add_ matches and can be updated - if (last_item->component == component && last_item->type == SchedulerItem::TIMEOUT && - !is_item_removed_(last_item.get()) && this->names_match_(last_item->get_name(), name_cstr)) { -#ifdef ESPHOME_THREAD_SINGLE - // Single-threaded: defers can be in to_add_, only update callback - if (delay == 0 && last_item->interval == 0) { - last_item->callback = std::move(func); -#ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Updated existing defer in to_add_ for '%s/%s'", component->get_component_source(), - name_cstr ? name_cstr : "(null)"); -#endif - return; - } -#endif - // For regular timeouts, update callback and execution time - if (delay != 0) { - last_item->callback = std::move(func); - last_item->next_execution_ = now + delay; -#ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Updated existing timeout in to_add_ for '%s/%s'", component->get_component_source(), - name_cstr ? name_cstr : "(null)"); -#endif - return; - } - } - } - } - // Create and populate the scheduler item std::unique_ptr item; if (!this->scheduler_item_pool_.empty()) { diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index ceb47e294c..e241e7a4ec 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -309,28 +309,6 @@ class Scheduler { return cancelled; } - // Template helper to try updating a defer in a container instead of allocating a new one - // Returns true if the defer was updated, false if not found - template - bool try_update_defer_in_container_(Container &container, Component *component, const char *name_cstr, - std::function &&func) { - if (container.empty()) { - return false; - } - - auto &last_item = container.back(); - - // Check if last item is a matching defer (timeout with 0 delay) and names match - if (last_item->component != component || last_item->type != SchedulerItem::TIMEOUT || last_item->interval != 0 || - is_item_removed_(last_item.get()) || !this->names_match_(last_item->get_name(), name_cstr)) { - return false; - } - - // Same defer at the end - just update the callback, no allocation needed - last_item->callback = std::move(func); - return true; - } - Mutex lock_; std::vector> items_; std::vector> to_add_; From 1a054299d4813a9a367b3367d8516fdd92cb23fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 21:17:14 -0500 Subject: [PATCH 0809/1626] [core] Optimize fnv1_hash to avoid string allocations for static entities (#10529) --- esphome/core/entity_base.cpp | 14 +++++++++++--- esphome/core/entity_base.h | 3 +++ esphome/core/helpers.cpp | 10 ++++++---- esphome/core/helpers.h | 3 ++- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 411a877bbf..4883c72cf1 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -45,10 +45,15 @@ void EntityBase::set_icon(const char *icon) { #endif } +// Check if the object_id is dynamic (changes with MAC suffix) +bool EntityBase::is_object_id_dynamic_() const { + return !this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled(); +} + // Entity Object ID std::string EntityBase::get_object_id() const { // Check if `App.get_friendly_name()` is constant or dynamic. - if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { + if (this->is_object_id_dynamic_()) { // `App.get_friendly_name()` is dynamic. return str_sanitize(str_snake_case(App.get_friendly_name())); } @@ -58,7 +63,7 @@ std::string EntityBase::get_object_id() const { StringRef EntityBase::get_object_id_ref_for_api_() const { static constexpr auto EMPTY_STRING = StringRef::from_lit(""); // Return empty for dynamic case (MAC suffix) - if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) { + if (this->is_object_id_dynamic_()) { return EMPTY_STRING; } // For static case, return the string or empty if null @@ -70,7 +75,10 @@ void EntityBase::set_object_id(const char *object_id) { } // Calculate Object ID Hash from Entity Name -void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash(this->get_object_id()); } +void EntityBase::calc_object_id_() { + this->object_id_hash_ = + fnv1_hash(this->is_object_id_dynamic_() ? this->get_object_id().c_str() : this->object_id_c_str_); +} uint32_t EntityBase::get_object_id_hash() { return this->object_id_hash_; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 8a65a9627a..4a6460e708 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -126,6 +126,9 @@ class EntityBase { virtual uint32_t hash_base() { return 0L; } void calc_object_id_(); + /// Check if the object_id is dynamic (changes with MAC suffix) + bool is_object_id_dynamic_() const; + StringRef name_; const char *object_id_c_str_{nullptr}; #ifdef USE_ENTITY_ICON diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 44e9193994..43d6f1153c 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -142,11 +142,13 @@ uint16_t crc16be(const uint8_t *data, uint16_t len, uint16_t crc, uint16_t poly, return refout ? (crc ^ 0xffff) : crc; } -uint32_t fnv1_hash(const std::string &str) { +uint32_t fnv1_hash(const char *str) { uint32_t hash = 2166136261UL; - for (char c : str) { - hash *= 16777619UL; - hash ^= c; + if (str) { + while (*str) { + hash *= 16777619UL; + hash ^= *str++; + } } return hash; } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 53ec7a2a5a..a6741925d0 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -155,7 +155,8 @@ uint16_t crc16be(const uint8_t *data, uint16_t len, uint16_t crc = 0, uint16_t p bool refout = false); /// Calculate a FNV-1 hash of \p str. -uint32_t fnv1_hash(const std::string &str); +uint32_t fnv1_hash(const char *str); +inline uint32_t fnv1_hash(const std::string &str) { return fnv1_hash(str.c_str()); } /// Return a random 32-bit unsigned integer. uint32_t random_uint32(); From 83fbd77c4a4922411c1a5b79f83f108c9fd101da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 21:23:46 -0500 Subject: [PATCH 0810/1626] [core] Use get_icon_ref() in entity platform logging to avoid string allocations (#10530) --- esphome/components/button/button.cpp | 4 ++-- esphome/components/datetime/date_entity.h | 4 ++-- esphome/components/datetime/datetime_entity.h | 4 ++-- esphome/components/datetime/time_entity.h | 4 ++-- esphome/components/event/event.h | 4 ++-- esphome/components/lock/lock.h | 4 ++-- esphome/components/number/number.cpp | 4 ++-- esphome/components/select/select.h | 4 ++-- esphome/components/sensor/sensor.cpp | 4 ++-- esphome/components/switch/switch.cpp | 4 ++-- esphome/components/text/text.h | 4 ++-- esphome/components/text_sensor/text_sensor.h | 4 ++-- 12 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/button/button.cpp b/esphome/components/button/button.cpp index 63d71dcb8a..c968d31088 100644 --- a/esphome/components/button/button.cpp +++ b/esphome/components/button/button.cpp @@ -14,8 +14,8 @@ void log_button(const char *tag, const char *prefix, const char *type, Button *o ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); - if (!obj->get_icon().empty()) { - ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + if (!obj->get_icon_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon_ref().c_str()); } } diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index ce43c5639d..fcbb46cf17 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -16,8 +16,8 @@ namespace datetime { #define LOG_DATETIME_DATE(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ } diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index 27db84cf7e..275eedfd3b 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -16,8 +16,8 @@ namespace datetime { #define LOG_DATETIME_DATETIME(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ } diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index f7e0a7ddd9..e79b8c225d 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -16,8 +16,8 @@ namespace datetime { #define LOG_DATETIME_TIME(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ } diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index 03c3c8d95a..0f35c0657d 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -13,8 +13,8 @@ namespace event { #define LOG_EVENT(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ if (!(obj)->get_device_class().empty()) { \ ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 2173c84903..04c4cd71cd 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -15,8 +15,8 @@ class Lock; #define LOG_LOCK(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ if ((obj)->traits.get_assumed_state()) { \ ESP_LOGCONFIG(TAG, "%s Assumed State: YES", prefix); \ diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index 4769c1ed12..e0f9fd89de 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -14,8 +14,8 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); - if (!obj->get_icon().empty()) { - ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + if (!obj->get_icon_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon_ref().c_str()); } if (!obj->traits.get_unit_of_measurement().empty()) { diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 3ab651b241..902b8a78ce 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -12,8 +12,8 @@ namespace select { #define LOG_SELECT(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ } diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 6df6347c18..91bf965584 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -24,8 +24,8 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); } - if (!obj->get_icon().empty()) { - ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + if (!obj->get_icon_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon_ref().c_str()); } if (obj->get_force_update()) { diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 49acd274b2..bfb9a277a2 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -91,8 +91,8 @@ void log_switch(const char *tag, const char *prefix, const char *type, Switch *o LOG_STR_ARG(onoff)); // Add optional fields separately - if (!obj->get_icon().empty()) { - ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon().c_str()); + if (!obj->get_icon_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon_ref().c_str()); } if (obj->assumed_state()) { ESP_LOGCONFIG(tag, "%s Assumed State: YES", prefix); diff --git a/esphome/components/text/text.h b/esphome/components/text/text.h index 3cc0cefc3e..74d08eda8a 100644 --- a/esphome/components/text/text.h +++ b/esphome/components/text/text.h @@ -12,8 +12,8 @@ namespace text { #define LOG_TEXT(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ } diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index b54f75155b..d68078b244 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -17,8 +17,8 @@ namespace text_sensor { if (!(obj)->get_device_class().empty()) { \ ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ } \ - if (!(obj)->get_icon().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon().c_str()); \ + if (!(obj)->get_icon_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ } From d2b23ba3a72fedb29d557c639db9b4f9be078789 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 21:24:16 -0500 Subject: [PATCH 0811/1626] [sensor] Change state_class_to_string() to return const char* to avoid allocations (#10533) --- esphome/components/sensor/sensor.cpp | 6 +++--- esphome/components/sensor/sensor.h | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 91bf965584..81e55cf05f 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -17,8 +17,8 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o "%s State Class: '%s'\n" "%s Unit of Measurement: '%s'\n" "%s Accuracy Decimals: %d", - prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()).c_str(), - prefix, obj->get_unit_of_measurement().c_str(), prefix, obj->get_accuracy_decimals()); + prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()), prefix, + obj->get_unit_of_measurement().c_str(), prefix, obj->get_accuracy_decimals()); if (!obj->get_device_class().empty()) { ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); @@ -33,7 +33,7 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o } } -std::string state_class_to_string(StateClass state_class) { +const char *state_class_to_string(StateClass state_class) { switch (state_class) { case STATE_CLASS_MEASUREMENT: return "measurement"; diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index b3206d8dab..507cb326b2 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -33,7 +33,7 @@ enum StateClass : uint8_t { STATE_CLASS_TOTAL = 3, }; -std::string state_class_to_string(StateClass state_class); +const char *state_class_to_string(StateClass state_class); /** Base-class for all sensors. * From 5ba1c32242dd58611c3050b26f3d359191a9bf2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 21:26:43 -0500 Subject: [PATCH 0812/1626] [host] Fix memory allocation in preferences load() method (#10506) --- esphome/components/host/preferences.h | 5 +- .../fixtures/host_preferences_save_load.yaml | 110 ++++++++++++ tests/integration/test_host_preferences.py | 167 ++++++++++++++++++ 3 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/host_preferences_save_load.yaml create mode 100644 tests/integration/test_host_preferences.py diff --git a/esphome/components/host/preferences.h b/esphome/components/host/preferences.h index 6707366517..6b2e7eb8f9 100644 --- a/esphome/components/host/preferences.h +++ b/esphome/components/host/preferences.h @@ -42,9 +42,10 @@ class HostPreferences : public ESPPreferences { if (len > 255) return false; this->setup_(); - if (this->data.count(key) == 0) + auto it = this->data.find(key); + if (it == this->data.end()) return false; - auto vec = this->data[key]; + const auto &vec = it->second; if (vec.size() != len) return false; memcpy(data, vec.data(), len); diff --git a/tests/integration/fixtures/host_preferences_save_load.yaml b/tests/integration/fixtures/host_preferences_save_load.yaml new file mode 100644 index 0000000000..929c5f7ff0 --- /dev/null +++ b/tests/integration/fixtures/host_preferences_save_load.yaml @@ -0,0 +1,110 @@ +esphome: + name: test_device + on_boot: + - lambda: |- + ESP_LOGD("test", "Host preferences test starting"); + +host: + +logger: + level: DEBUG + +api: + +preferences: + flash_write_interval: 0s # Disable automatic saving for test control + +switch: + - platform: template + name: "Test Switch" + id: test_switch + optimistic: true + restore_mode: DISABLED # Don't auto-restore for test control + +number: + - platform: template + name: "Test Number" + id: test_number + min_value: 0 + max_value: 100 + step: 0.1 + optimistic: true + restore_value: false # Don't auto-restore for test control + +button: + - platform: template + name: "Save Preferences" + on_press: + - lambda: |- + // Save current values to preferences + ESPPreferenceObject switch_pref = global_preferences->make_preference(0x1234); + ESPPreferenceObject number_pref = global_preferences->make_preference(0x5678); + + bool switch_value = id(test_switch).state; + float number_value = id(test_number).state; + + if (switch_pref.save(&switch_value)) { + ESP_LOGI("test", "Preference saved: key=switch, value=%.1f", switch_value ? 1.0 : 0.0); + } + if (number_pref.save(&number_value)) { + ESP_LOGI("test", "Preference saved: key=number, value=%.1f", number_value); + } + + // Force sync to disk + global_preferences->sync(); + + - platform: template + name: "Load Preferences" + on_press: + - lambda: |- + // Load values from preferences + ESPPreferenceObject switch_pref = global_preferences->make_preference(0x1234); + ESPPreferenceObject number_pref = global_preferences->make_preference(0x5678); + + // Also try to load non-existent preferences (tests our fix) + ESPPreferenceObject fake_pref1 = global_preferences->make_preference(0x9999); + ESPPreferenceObject fake_pref2 = global_preferences->make_preference(0xAAAA); + + bool switch_value = false; + float number_value = 0.0; + uint32_t fake_value = 0; + int loaded_count = 0; + + // These should not exist and shouldn't create map entries + fake_pref1.load(&fake_value); + fake_pref2.load(&fake_value); + + if (switch_pref.load(&switch_value)) { + id(test_switch).publish_state(switch_value); + ESP_LOGI("test", "Preference loaded: key=switch, value=%.1f", switch_value ? 1.0 : 0.0); + loaded_count++; + } else { + ESP_LOGW("test", "Failed to load switch preference"); + } + + if (number_pref.load(&number_value)) { + id(test_number).publish_state(number_value); + ESP_LOGI("test", "Preference loaded: key=number, value=%.1f", number_value); + loaded_count++; + } else { + ESP_LOGW("test", "Failed to load number preference"); + } + + // Log completion message for the test to detect + ESP_LOGI("test", "Final load test: loaded %d preferences successfully", loaded_count); + + - platform: template + name: "Verify Preferences" + on_press: + - lambda: |- + // Verify current values match what we expect + bool switch_value = id(test_switch).state; + float number_value = id(test_number).state; + + // After loading, switch should be true (1.0) and number should be 42.5 + if (switch_value == true && number_value == 42.5) { + ESP_LOGI("test", "Preferences verified: values match!"); + } else { + ESP_LOGE("test", "Preferences mismatch: switch=%d, number=%.1f", + switch_value, number_value); + } diff --git a/tests/integration/test_host_preferences.py b/tests/integration/test_host_preferences.py new file mode 100644 index 0000000000..38c6460cf1 --- /dev/null +++ b/tests/integration/test_host_preferences.py @@ -0,0 +1,167 @@ +"""Test host preferences save and load functionality.""" + +from __future__ import annotations + +import asyncio +import re +from typing import Any + +from aioesphomeapi import ButtonInfo, EntityInfo, NumberInfo, SwitchInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +def find_entity_by_name( + entities: list[EntityInfo], entity_type: type, name: str +) -> Any: + """Helper to find an entity by type and name.""" + return next( + (e for e in entities if isinstance(e, entity_type) and e.name == name), None + ) + + +@pytest.mark.asyncio +async def test_host_preferences_save_load( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that preferences are correctly saved and loaded after our optimization fix.""" + loop = asyncio.get_running_loop() + log_lines: list[str] = [] + preferences_saved = loop.create_future() + preferences_loaded = loop.create_future() + values_match = loop.create_future() + final_load_complete = loop.create_future() + + # Patterns to match preference logs + save_pattern = re.compile(r"Preference saved: key=(\w+), value=([0-9.]+)") + load_pattern = re.compile(r"Preference loaded: key=(\w+), value=([0-9.]+)") + verify_pattern = re.compile(r"Preferences verified: values match!") + final_load_success_pattern = re.compile( + r"Final load test: loaded \d+ preferences successfully" + ) + + saved_values: dict[str, float] = {} + loaded_values: dict[str, float] = {} + + def check_output(line: str) -> None: + """Check log output for preference operations.""" + log_lines.append(line) + + # Look for save operations + match = save_pattern.search(line) + if match: + key = match.group(1) + value = float(match.group(2)) + saved_values[key] = value + if len(saved_values) >= 2 and not preferences_saved.done(): + preferences_saved.set_result(True) + + # Look for load operations + match = load_pattern.search(line) + if match: + key = match.group(1) + value = float(match.group(2)) + loaded_values[key] = value + if len(loaded_values) >= 2 and not preferences_loaded.done(): + preferences_loaded.set_result(True) + + # Look for verification + if verify_pattern.search(line) and not values_match.done(): + values_match.set_result(True) + + # Look for final load test completion + if final_load_success_pattern.search(line) and not final_load_complete.done(): + final_load_complete.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get entity list + entities, _ = await client.list_entities_services() + + # Find our test entities using helper + test_switch = find_entity_by_name(entities, SwitchInfo, "Test Switch") + test_number = find_entity_by_name(entities, NumberInfo, "Test Number") + save_button = find_entity_by_name(entities, ButtonInfo, "Save Preferences") + load_button = find_entity_by_name(entities, ButtonInfo, "Load Preferences") + verify_button = find_entity_by_name(entities, ButtonInfo, "Verify Preferences") + + assert test_switch is not None, "Test Switch not found" + assert test_number is not None, "Test Number not found" + assert save_button is not None, "Save Preferences button not found" + assert load_button is not None, "Load Preferences button not found" + assert verify_button is not None, "Verify Preferences button not found" + + # Set initial values + client.switch_command(test_switch.key, True) + client.number_command(test_number.key, 42.5) + + # Save preferences + client.button_command(save_button.key) + + # Wait for save to complete + try: + await asyncio.wait_for(preferences_saved, timeout=5.0) + except TimeoutError: + pytest.fail("Preferences not saved within timeout") + + # Verify we saved the expected values + assert "switch" in saved_values, f"Switch preference not saved: {saved_values}" + assert "number" in saved_values, f"Number preference not saved: {saved_values}" + assert saved_values["switch"] == 1.0, ( + f"Switch value incorrect: {saved_values['switch']}" + ) + assert saved_values["number"] == 42.5, ( + f"Number value incorrect: {saved_values['number']}" + ) + + # Change the values to something else + client.switch_command(test_switch.key, False) + client.number_command(test_number.key, 13.7) + + # Load preferences (should restore the saved values) + client.button_command(load_button.key) + + # Wait for load to complete + try: + await asyncio.wait_for(preferences_loaded, timeout=5.0) + except TimeoutError: + pytest.fail("Preferences not loaded within timeout") + + # Verify loaded values match saved values + assert "switch" in loaded_values, ( + f"Switch preference not loaded: {loaded_values}" + ) + assert "number" in loaded_values, ( + f"Number preference not loaded: {loaded_values}" + ) + assert loaded_values["switch"] == saved_values["switch"], ( + f"Loaded switch value {loaded_values['switch']} doesn't match saved {saved_values['switch']}" + ) + assert loaded_values["number"] == saved_values["number"], ( + f"Loaded number value {loaded_values['number']} doesn't match saved {saved_values['number']}" + ) + + # Verify the values were actually restored + client.button_command(verify_button.key) + + # Wait for verification + try: + await asyncio.wait_for(values_match, timeout=5.0) + except TimeoutError: + pytest.fail("Preference verification failed within timeout") + + # Test that non-existent preferences don't crash (tests our fix) + # This will trigger load attempts for keys that don't exist + # Our fix should prevent map entries from being created + client.button_command(load_button.key) + + # Wait for the final load test to complete + try: + await asyncio.wait_for(final_load_complete, timeout=5.0) + except TimeoutError: + pytest.fail("Final load test did not complete within timeout") From 086f1982fa176bcabb31a09ca65f5b233f8097e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 21:26:53 -0500 Subject: [PATCH 0813/1626] [core] Use get_device_class_ref() in entity platform logging to avoid string allocations (#10531) --- esphome/components/binary_sensor/binary_sensor.cpp | 4 ++-- esphome/components/cover/cover.h | 4 ++-- esphome/components/event/event.h | 4 ++-- esphome/components/number/number.cpp | 4 ++-- esphome/components/sensor/sensor.cpp | 4 ++-- esphome/components/switch/switch.cpp | 4 ++-- esphome/components/text_sensor/text_sensor.h | 4 ++-- esphome/components/valve/valve.h | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index e652d302b6..39319d3c1c 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -15,8 +15,8 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); - if (!obj->get_device_class().empty()) { - ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); + if (!obj->get_device_class_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class_ref().c_str()); } } diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index 8b6f5b8a72..ada5953d57 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -19,8 +19,8 @@ const extern float COVER_CLOSED; if (traits_.get_is_assumed_state()) { \ ESP_LOGCONFIG(TAG, "%s Assumed State: YES", prefix); \ } \ - if (!(obj)->get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ + if (!(obj)->get_device_class_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class_ref().c_str()); \ } \ } diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index 0f35c0657d..a90c8ebe05 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -16,8 +16,8 @@ namespace event { if (!(obj)->get_icon_ref().empty()) { \ ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ } \ - if (!(obj)->get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ + if (!(obj)->get_device_class_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class_ref().c_str()); \ } \ } diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index e0f9fd89de..8f5cd9bdeb 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -22,8 +22,8 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj->traits.get_unit_of_measurement().c_str()); } - if (!obj->traits.get_device_class().empty()) { - ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->traits.get_device_class().c_str()); + if (!obj->traits.get_device_class_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->traits.get_device_class_ref().c_str()); } } diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 81e55cf05f..ae45498949 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -20,8 +20,8 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()), prefix, obj->get_unit_of_measurement().c_str(), prefix, obj->get_accuracy_decimals()); - if (!obj->get_device_class().empty()) { - ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); + if (!obj->get_device_class_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class_ref().c_str()); } if (!obj->get_icon_ref().empty()) { diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index bfb9a277a2..02cee91a76 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -100,8 +100,8 @@ void log_switch(const char *tag, const char *prefix, const char *type, Switch *o if (obj->is_inverted()) { ESP_LOGCONFIG(tag, "%s Inverted: YES", prefix); } - if (!obj->get_device_class().empty()) { - ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class().c_str()); + if (!obj->get_device_class_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class_ref().c_str()); } } } diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index d68078b244..3ab88e2d91 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -14,8 +14,8 @@ namespace text_sensor { #define LOG_TEXT_SENSOR(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ + if (!(obj)->get_device_class_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class_ref().c_str()); \ } \ if (!(obj)->get_icon_ref().empty()) { \ ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index 0e14a8d8f0..ab7ff5abe1 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -19,8 +19,8 @@ const extern float VALVE_CLOSED; if (traits_.get_is_assumed_state()) { \ ESP_LOGCONFIG(TAG, "%s Assumed State: YES", prefix); \ } \ - if (!(obj)->get_device_class().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class().c_str()); \ + if (!(obj)->get_device_class_ref().empty()) { \ + ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class_ref().c_str()); \ } \ } From 1a5402f35c674015d64b1e35816e4d61e310d073 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 22:27:40 -0500 Subject: [PATCH 0814/1626] preen --- esphome/core/scheduler.cpp | 24 ++++++++++++++++++------ esphome/core/scheduler.h | 18 ------------------ 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index f463e87996..4745cf9ecc 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -517,7 +517,9 @@ void HOT Scheduler::call(uint32_t now) { void HOT Scheduler::process_to_add() { LockGuard guard{this->lock_}; for (auto &it : this->to_add_) { - if (it->remove) { + if (is_item_removed_(it.get())) { + // Recycle cancelled items + this->recycle_item_(std::move(it)); continue; } @@ -595,10 +597,14 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Check all containers for matching items #ifndef ESPHOME_THREAD_SINGLE - // Cancel and immediately recycle items in defer queue + // Mark items in defer queue as cancelled (they'll be skipped when processed) if (type == SchedulerItem::TIMEOUT) { - total_cancelled += - this->cancel_and_recycle_from_container_(this->defer_queue_, component, name_cstr, type, match_retry); + for (auto &item : this->defer_queue_) { + if (this->matches_item_(item, component, name_cstr, type, match_retry)) { + this->mark_item_removed_(item.get()); + total_cancelled++; + } + } } #endif /* not ESPHOME_THREAD_SINGLE */ @@ -622,8 +628,14 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c } } - // Cancel and immediately recycle items in to_add_ since they're not in heap yet - total_cancelled += this->cancel_and_recycle_from_container_(this->to_add_, component, name_cstr, type, match_retry); + // Cancel items in to_add_ + for (auto &item : this->to_add_) { + if (this->matches_item_(item, component, name_cstr, type, match_retry)) { + this->mark_item_removed_(item.get()); + total_cancelled++; + // Don't track removals for to_add_ items + } + } return total_cancelled > 0; } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index e241e7a4ec..85cfaab2e0 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -291,24 +291,6 @@ class Scheduler { return false; } - // Template helper to cancel and recycle items from a container - template - size_t cancel_and_recycle_from_container_(Container &container, Component *component, const char *name_cstr, - SchedulerItem::Type type, bool match_retry) { - size_t cancelled = 0; - for (auto it = container.begin(); it != container.end();) { - if (this->matches_item_(*it, component, name_cstr, type, match_retry)) { - // Recycle the cancelled item immediately - this->recycle_item_(std::move(*it)); - it = container.erase(it); - cancelled++; - } else { - ++it; - } - } - return cancelled; - } - Mutex lock_; std::vector> items_; std::vector> to_add_; From af10a809de6604a3ae2564fef96f0bb2aa790f16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Sep 2025 22:43:26 -0500 Subject: [PATCH 0815/1626] cleanup --- esphome/core/scheduler.cpp | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 4745cf9ecc..1d84a207af 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -399,10 +399,12 @@ void HOT Scheduler::call(uint32_t now) { } #endif /* ESPHOME_DEBUG_SCHEDULER */ - // Check if we should perform cleanup based on number of cancelled items - // Cleanup when we have accumulated MAX_LOGICALLY_DELETED_ITEMS cancelled items - // This simple threshold ensures we don't waste memory on cancelled items - // regardless of how many intervals are running + // Cleanup removed items before processing + // First try to clean items from the top of the heap (fast path) + this->cleanup_(); + + // If we still have too many cancelled items, do a full cleanup + // This only happens if cancelled items are stuck in the middle/bottom of the heap if (this->to_remove_ >= MAX_LOGICALLY_DELETED_ITEMS) { // We hold the lock for the entire cleanup operation because: // 1. We're rebuilding the entire items_ list, so we need exclusive access throughout @@ -429,9 +431,6 @@ void HOT Scheduler::call(uint32_t now) { std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); this->to_remove_ = 0; } - - // Cleanup removed items before processing - this->cleanup_(); while (!this->items_.empty()) { // use scoping to indicate visibility of `item` variable { From 68628a85b15659c9bacf0059739774886bf6989f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 01:08:57 -0500 Subject: [PATCH 0816/1626] [core] Use get_unit_of_measurement_ref() in entity logging to avoid string allocations (#10532) --- esphome/components/number/number.cpp | 4 ++-- esphome/components/sensor/sensor.cpp | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index 8f5cd9bdeb..da08faf655 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -18,8 +18,8 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon_ref().c_str()); } - if (!obj->traits.get_unit_of_measurement().empty()) { - ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj->traits.get_unit_of_measurement().c_str()); + if (!obj->traits.get_unit_of_measurement_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Unit of Measurement: '%s'", prefix, obj->traits.get_unit_of_measurement_ref().c_str()); } if (!obj->traits.get_device_class_ref().empty()) { diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index ae45498949..e2e8302d8b 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -18,7 +18,7 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o "%s Unit of Measurement: '%s'\n" "%s Accuracy Decimals: %d", prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()), prefix, - obj->get_unit_of_measurement().c_str(), prefix, obj->get_accuracy_decimals()); + obj->get_unit_of_measurement_ref().c_str(), prefix, obj->get_accuracy_decimals()); if (!obj->get_device_class_ref().empty()) { ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class_ref().c_str()); @@ -128,7 +128,7 @@ void Sensor::internal_send_state_to_frontend(float state) { this->set_has_state(true); this->state = state; ESP_LOGD(TAG, "'%s': Sending state %.5f %s with %d decimals of accuracy", this->get_name().c_str(), state, - this->get_unit_of_measurement().c_str(), this->get_accuracy_decimals()); + this->get_unit_of_measurement_ref().c_str(), this->get_accuracy_decimals()); this->callback_.call(state); } From 4d681ffe3d1629cc5e07e1bf3af8c23caeded9a6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Sep 2025 11:47:51 -0400 Subject: [PATCH 0817/1626] [esp32] Rebuild when idf_component.yml changes (#10540) --- esphome/components/esp32/__init__.py | 7 ++++++- esphome/writer.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 47832a08ae..54f83ab6e8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -40,6 +40,7 @@ from esphome.cpp_generator import RawExpression import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, mkdir_p, write_file_if_changed from esphome.types import ConfigType +from esphome.writer import clean_cmake_cache from .boards import BOARDS, STANDARD_BOARDS from .const import ( # noqa @@ -1074,7 +1075,11 @@ def _write_idf_component_yml(): contents = yaml_util.dump({"dependencies": dependencies}) else: contents = "" - write_file_if_changed(yml_path, contents) + if write_file_if_changed(yml_path, contents): + dependencies_lock = CORE.relative_build_path("dependencies.lock") + if os.path.isfile(dependencies_lock): + os.remove(dependencies_lock) + clean_cmake_cache() # Called by writer.py diff --git a/esphome/writer.py b/esphome/writer.py index 4b25a25f7e..b8fe44abdd 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -310,6 +310,10 @@ def clean_build(): if os.path.isdir(piolibdeps): _LOGGER.info("Deleting %s", piolibdeps) shutil.rmtree(piolibdeps) + dependencies_lock = CORE.relative_build_path("dependencies.lock") + if os.path.isfile(dependencies_lock): + _LOGGER.info("Deleting %s", dependencies_lock) + os.remove(dependencies_lock) GITIGNORE_CONTENT = """# Gitignore settings for ESPHome From c3359edb33f6e403efa5b8970bd60ebcb1e80079 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Wed, 3 Sep 2025 19:18:26 +0200 Subject: [PATCH 0818/1626] [i2c] Fix bug write_register16 (#10547) --- esphome/components/i2c/i2c.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index e66ab8ba73..48e1cf8aca 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -47,9 +47,9 @@ ErrorCode I2CDevice::write_register(uint8_t a_register, const uint8_t *data, siz ErrorCode I2CDevice::write_register16(uint16_t a_register, const uint8_t *data, size_t len) const { std::vector v(len + 2); - v.push_back(a_register >> 8); - v.push_back(a_register); - v.insert(v.end(), data, data + len); + v[0] = a_register >> 8; + v[1] = a_register; + std::copy(data, data + len, v.begin() + 2); return bus_->write_readv(this->address_, v.data(), v.size(), nullptr, 0); } From 8aeb6d3ba2e3eda1726ec1e2acaf8d5060518ca0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 14:27:39 -0500 Subject: [PATCH 0819/1626] [bluetooth_proxy] Change default for active connections to true (#10546) --- esphome/components/bluetooth_proxy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 112faa27e5..f21b5028c7 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -80,7 +80,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(BluetoothProxy), - cv.Optional(CONF_ACTIVE, default=False): cv.boolean, + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.SplitDefault(CONF_CACHE_SERVICES, esp32_idf=True): cv.All( cv.only_with_esp_idf, cv.boolean ), From 0ab65c225e9152ad0930a885515803f3f6c14f64 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:58:42 -0400 Subject: [PATCH 0820/1626] [wifi] Check for esp32_hosted on no wifi variants (#10528) --- esphome/components/wifi/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 7943911021..c63a12f879 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -51,7 +51,7 @@ from . import wpa2_eap AUTO_LOAD = ["network"] -NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2] +NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] CONF_SAVE = "save" wifi_ns = cg.esphome_ns.namespace("wifi") @@ -179,8 +179,8 @@ WIFI_NETWORK_STA = WIFI_NETWORK_BASE.extend( def validate_variant(_): if CORE.is_esp32: variant = get_esp32_variant() - if variant in NO_WIFI_VARIANTS: - raise cv.Invalid(f"{variant} does not support WiFi") + if variant in NO_WIFI_VARIANTS and "esp32_hosted" not in fv.full_config.get(): + raise cv.Invalid(f"WiFi requires component esp32_hosted on {variant}") def final_validate(config): From 5759692627d7679f5f2693585a779b14a6d1a3c0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:59:47 -0400 Subject: [PATCH 0821/1626] [esp32] Clear IDF environment variables (#10527) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 54f83ab6e8..12d84dd4b3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -841,6 +841,9 @@ async def to_code(config): if conf[CONF_ADVANCED][CONF_IGNORE_EFUSE_CUSTOM_MAC]: cg.add_define("USE_ESP32_IGNORE_EFUSE_CUSTOM_MAC") + for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"): + os.environ.pop(clean_var, None) + add_extra_script( "post", "post_build.py", From 23c66509029dc8a32f7669901de69947f4ef778a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 15:07:13 -0500 Subject: [PATCH 0822/1626] [api] Fix VERY_VERBOSE logging compilation error with bool arrays (#10539) --- esphome/components/api/api_pb2_dump.cpp | 2 +- script/api_protobuf/api_protobuf.py | 4 +++- tests/integration/fixtures/parallel_script_delays.yaml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3e7df9195b..1d7d315419 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1135,7 +1135,7 @@ void ExecuteServiceArgument::dump_to(std::string &out) const { dump_field(out, "string_", this->string_); dump_field(out, "int_", this->int_); for (const auto it : this->bool_array) { - dump_field(out, "bool_array", it, 4); + dump_field(out, "bool_array", static_cast(it), 4); } for (const auto &it : this->int_array) { dump_field(out, "int_array", it, 4); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 511d70d3ec..205bac4937 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1059,7 +1059,9 @@ def _generate_array_dump_content( # Check if underlying type can use dump_field if ti.can_use_dump_field(): # For types that have dump_field overloads, use them with extra indent - o += f' dump_field(out, "{name}", {ti.dump_field_value("it")}, 4);\n' + # std::vector iterators return proxy objects, need explicit cast + value_expr = "static_cast(it)" if is_bool else ti.dump_field_value("it") + o += f' dump_field(out, "{name}", {value_expr}, 4);\n' else: # For complex types (messages, bytes), use the old pattern o += f' out.append(" {name}: ");\n' diff --git a/tests/integration/fixtures/parallel_script_delays.yaml b/tests/integration/fixtures/parallel_script_delays.yaml index 6887045913..71d5b904e9 100644 --- a/tests/integration/fixtures/parallel_script_delays.yaml +++ b/tests/integration/fixtures/parallel_script_delays.yaml @@ -4,7 +4,7 @@ esphome: host: logger: - level: DEBUG + level: VERY_VERBOSE api: actions: From 950299e52bb40bb6c82ed7844f4e006023659df5 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Fri, 29 Aug 2025 02:53:54 +0200 Subject: [PATCH 0823/1626] Update mcp4461.cpp (#10479) --- esphome/components/mcp4461/mcp4461.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 55ce9b7899..191fbae366 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -198,7 +198,7 @@ uint16_t Mcp4461Component::get_wiper_level_(Mcp4461WiperIdx wiper) { uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) { uint8_t addr = this->get_wiper_address_(wiper_idx); - uint8_t reg = addr | static_cast(Mcp4461Commands::INCREMENT); + uint8_t reg = addr | static_cast(Mcp4461Commands::READ); if (wiper_idx > 3) { if (!this->is_eeprom_ready_for_writing_(true)) { return 0; From de998f2f39b7f90bdd3538e5a587be55dcd0887f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 31 Aug 2025 20:01:15 -0500 Subject: [PATCH 0824/1626] Fix incorrect entity count due to undefined execution order with globals (#10497) --- esphome/core/config.py | 2 +- tests/unit_tests/core/test_config.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 90768a4b09..b6ff1d8afd 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -423,7 +423,7 @@ async def _add_automations(config): DATETIME_SUBTYPES = {"date", "time", "datetime"} -@coroutine_with_priority(-100.0) +@coroutine_with_priority(-1000.0) async def _add_platform_defines() -> None: # Generate compile-time defines for platforms that have actual entities # Only add USE_* and count defines when there are entities diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 46e3b513d7..f5ba5221ed 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -8,6 +8,7 @@ import pytest from esphome import config_validation as cv, core from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES +from esphome.core import config from esphome.core.config import Area, validate_area_config from .common import load_config_from_fixture @@ -223,3 +224,24 @@ def test_device_duplicate_id( # Check for the specific error message from IDPassValidationStep captured = capsys.readouterr() assert "ID duplicate_device redefined!" in captured.out + + +def test_add_platform_defines_priority() -> None: + """Test that _add_platform_defines runs after globals. + + This ensures the fix for issue #10431 where sensor counts were incorrect + when lambdas were present. The function must run at a lower priority than + globals (-100.0) to ensure all components (including those using globals + in lambdas) have registered their entities before the count defines are + generated. + + Regression test for https://github.com/esphome/esphome/issues/10431 + """ + # Import globals to check its priority + from esphome.components.globals import to_code as globals_to_code + + # _add_platform_defines must run AFTER globals (lower priority number = runs later) + assert config._add_platform_defines.priority < globals_to_code.priority, ( + f"_add_platform_defines priority ({config._add_platform_defines.priority}) must be lower than " + f"globals priority ({globals_to_code.priority}) to fix issue #10431 (sensor count bug with lambdas)" + ) From c55bc93f70dc788341b33ea258abb719e78740a2 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 2 Sep 2025 06:51:31 +1000 Subject: [PATCH 0825/1626] [mipi_dsi] Fix config for Guition screen (#10464) --- esphome/components/mipi_dsi/models/guition.py | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index fd3fbf6160..5f7db4ebda 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -16,7 +16,6 @@ DriverChip( lane_bit_rate="750Mbps", swap_xy=cv.UNDEFINED, color_order="RGB", - reset_pin=27, initsequence=[ (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x00), (0x06, 0x00), (0x0B, 0x11), (0x17, 0x00), (0x20, 0x04), (0x1F, 0x05), (0x23, 0x00), (0x25, 0x19), (0x28, 0x18), (0x29, 0x04), (0x2A, 0x01), From a1a336783e2739239bdb4ed95bbd459d15e3fdcc Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 2 Sep 2025 06:53:03 +1000 Subject: [PATCH 0826/1626] [mcp4461] Fix read transaction (#10465) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/mcp4461/mcp4461.cpp | 22 ++++++++++++++++++---- esphome/components/mcp4461/mcp4461.h | 1 + 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 191fbae366..2f2c75e05a 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -122,7 +122,7 @@ uint8_t Mcp4461Component::get_status_register_() { uint8_t addr = static_cast(Mcp4461Addresses::MCP4461_STATUS); uint8_t reg = addr | static_cast(Mcp4461Commands::READ); uint16_t buf; - if (!this->read_byte_16(reg, &buf)) { + if (!this->read_16_(reg, &buf)) { this->error_code_ = MCP4461_STATUS_REGISTER_ERROR; this->mark_failed(); return 0; @@ -148,6 +148,20 @@ void Mcp4461Component::read_status_register_to_log() { ((status_register_value >> 3) & 0x01), ((status_register_value >> 2) & 0x01), ((status_register_value >> 1) & 0x01), ((status_register_value >> 0) & 0x01)); } +bool Mcp4461Component::read_16_(uint8_t address, uint16_t *buf) { + // read 16 bits and convert from big endian to host, + // Do this as two separate operations to ensure a stop condition between the write and read + i2c::ErrorCode err = this->write(&address, 1); + if (err != i2c::ERROR_OK) { + return false; + } + err = this->read(reinterpret_cast(buf), 2); + if (err != i2c::ERROR_OK) { + return false; + } + *buf = convert_big_endian(*buf); + return true; +} uint8_t Mcp4461Component::get_wiper_address_(uint8_t wiper) { uint8_t addr; @@ -205,7 +219,7 @@ uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) { } } uint16_t buf = 0; - if (!(this->read_byte_16(reg, &buf))) { + if (!(this->read_16_(reg, &buf))) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); ESP_LOGW(TAG, "Error fetching %swiper %u value", (wiper_idx > 3) ? "nonvolatile " : "", wiper_idx); @@ -392,7 +406,7 @@ uint8_t Mcp4461Component::get_terminal_register_(Mcp4461TerminalIdx terminal_con : static_cast(Mcp4461Addresses::MCP4461_TCON1); reg |= static_cast(Mcp4461Commands::READ); uint16_t buf; - if (this->read_byte_16(reg, &buf)) { + if (this->read_16_(reg, &buf)) { return static_cast(buf & 0x00ff); } else { this->error_code_ = MCP4461_STATUS_I2C_ERROR; @@ -517,7 +531,7 @@ uint16_t Mcp4461Component::get_eeprom_value(Mcp4461EepromLocation location) { if (!this->is_eeprom_ready_for_writing_(true)) { return 0; } - if (!this->read_byte_16(reg, &buf)) { + if (!this->read_16_(reg, &buf)) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); ESP_LOGW(TAG, "Error fetching EEPROM location value"); diff --git a/esphome/components/mcp4461/mcp4461.h b/esphome/components/mcp4461/mcp4461.h index 9b7f60f201..59f6358a56 100644 --- a/esphome/components/mcp4461/mcp4461.h +++ b/esphome/components/mcp4461/mcp4461.h @@ -96,6 +96,7 @@ class Mcp4461Component : public Component, public i2c::I2CDevice { protected: friend class Mcp4461Wiper; + bool read_16_(uint8_t address, uint16_t *buf); void update_write_protection_status_(); uint8_t get_wiper_address_(uint8_t wiper); uint16_t read_wiper_level_(uint8_t wiper); From 6f188d128491782ca9b4f8071eca871a11609c8d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Sep 2025 11:47:51 -0400 Subject: [PATCH 0827/1626] [esp32] Rebuild when idf_component.yml changes (#10540) --- esphome/components/esp32/__init__.py | 7 ++++++- esphome/writer.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ac236f4eb3..ca10f36c1b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -40,6 +40,7 @@ from esphome.cpp_generator import RawExpression import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, mkdir_p, write_file_if_changed from esphome.types import ConfigType +from esphome.writer import clean_cmake_cache from .boards import BOARDS, STANDARD_BOARDS from .const import ( # noqa @@ -1079,7 +1080,11 @@ def _write_idf_component_yml(): contents = yaml_util.dump({"dependencies": dependencies}) else: contents = "" - write_file_if_changed(yml_path, contents) + if write_file_if_changed(yml_path, contents): + dependencies_lock = CORE.relative_build_path("dependencies.lock") + if os.path.isfile(dependencies_lock): + os.remove(dependencies_lock) + clean_cmake_cache() # Called by writer.py diff --git a/esphome/writer.py b/esphome/writer.py index 4b25a25f7e..b8fe44abdd 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -310,6 +310,10 @@ def clean_build(): if os.path.isdir(piolibdeps): _LOGGER.info("Deleting %s", piolibdeps) shutil.rmtree(piolibdeps) + dependencies_lock = CORE.relative_build_path("dependencies.lock") + if os.path.isfile(dependencies_lock): + _LOGGER.info("Deleting %s", dependencies_lock) + os.remove(dependencies_lock) GITIGNORE_CONTENT = """# Gitignore settings for ESPHome From 1b8978a89a3c8f79f12f7139d1de29faa8779b6d Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Wed, 3 Sep 2025 19:18:26 +0200 Subject: [PATCH 0828/1626] [i2c] Fix bug write_register16 (#10547) --- esphome/components/i2c/i2c.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index e66ab8ba73..48e1cf8aca 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -47,9 +47,9 @@ ErrorCode I2CDevice::write_register(uint8_t a_register, const uint8_t *data, siz ErrorCode I2CDevice::write_register16(uint16_t a_register, const uint8_t *data, size_t len) const { std::vector v(len + 2); - v.push_back(a_register >> 8); - v.push_back(a_register); - v.insert(v.end(), data, data + len); + v[0] = a_register >> 8; + v[1] = a_register; + std::copy(data, data + len, v.begin() + 2); return bus_->write_readv(this->address_, v.data(), v.size(), nullptr, 0); } From 9fe94f1201899ba5b414848029cd96cdb60d69a7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:59:47 -0400 Subject: [PATCH 0829/1626] [esp32] Clear IDF environment variables (#10527) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ca10f36c1b..e2359a8470 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -841,6 +841,9 @@ async def to_code(config): if conf[CONF_ADVANCED][CONF_IGNORE_EFUSE_CUSTOM_MAC]: cg.add_define("USE_ESP32_IGNORE_EFUSE_CUSTOM_MAC") + for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"): + os.environ.pop(clean_var, None) + add_extra_script( "post", "post_build.py", From a29fef166bc5daa7b139d39b15af9be1bf0fa144 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 15:07:13 -0500 Subject: [PATCH 0830/1626] [api] Fix VERY_VERBOSE logging compilation error with bool arrays (#10539) --- esphome/components/api/api_pb2_dump.cpp | 2 +- script/api_protobuf/api_protobuf.py | 4 +++- tests/integration/fixtures/parallel_script_delays.yaml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 7af322f96d..e5008c93d8 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1135,7 +1135,7 @@ void ExecuteServiceArgument::dump_to(std::string &out) const { dump_field(out, "string_", this->string_); dump_field(out, "int_", this->int_); for (const auto it : this->bool_array) { - dump_field(out, "bool_array", it, 4); + dump_field(out, "bool_array", static_cast(it), 4); } for (const auto &it : this->int_array) { dump_field(out, "int_array", it, 4); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 3396e5ad05..53180b13c3 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1059,7 +1059,9 @@ def _generate_array_dump_content( # Check if underlying type can use dump_field if ti.can_use_dump_field(): # For types that have dump_field overloads, use them with extra indent - o += f' dump_field(out, "{name}", {ti.dump_field_value("it")}, 4);\n' + # std::vector iterators return proxy objects, need explicit cast + value_expr = "static_cast(it)" if is_bool else ti.dump_field_value("it") + o += f' dump_field(out, "{name}", {value_expr}, 4);\n' else: # For complex types (messages, bytes), use the old pattern o += f' out.append(" {name}: ");\n' diff --git a/tests/integration/fixtures/parallel_script_delays.yaml b/tests/integration/fixtures/parallel_script_delays.yaml index 6887045913..71d5b904e9 100644 --- a/tests/integration/fixtures/parallel_script_delays.yaml +++ b/tests/integration/fixtures/parallel_script_delays.yaml @@ -4,7 +4,7 @@ esphome: host: logger: - level: DEBUG + level: VERY_VERBOSE api: actions: From 2d3cdf60bab17fce88ab6ba1a606d920acf9f597 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 4 Sep 2025 09:06:00 +1200 Subject: [PATCH 0831/1626] Bump version to 2025.8.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 6e0a1a42e8..ab6a0e8252 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.8.2 +PROJECT_NUMBER = 2025.8.3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index e0e72a6758..5ed9092454 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.8.2" +__version__ = "2025.8.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From d505f5ecaab0eee337c910579fefc843bffec711 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 16:13:07 -0500 Subject: [PATCH 0832/1626] [scheduler] Reduce SchedulerItem memory usage by 7.4% on 32-bit platforms --- esphome/core/scheduler.cpp | 27 ++++++++++++++++----------- esphome/core/scheduler.h | 31 +++++++++++++++++++++---------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a907b89b02..fabf3f6e5d 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -14,7 +14,7 @@ namespace esphome { static const char *const TAG = "scheduler"; -static const uint32_t MAX_LOGICALLY_DELETED_ITEMS = 10; +static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 10; // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; // max delay to start an interval sequence @@ -117,12 +117,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // first execution happens immediately after a random smallish offset // Calculate random offset (0 to min(interval/2, 5s)) uint32_t offset = (uint32_t) (std::min(delay / 2, MAX_INTERVAL_DELAY) * random_float()); - item->next_execution_ = now + offset; + item->set_next_execution(now + offset); ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms", name_cstr ? name_cstr : "", delay, offset); } else { item->interval = 0; - item->next_execution_ = now + delay; + item->set_next_execution(now + delay); } #ifdef ESPHOME_DEBUG_SCHEDULER @@ -138,7 +138,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type name_cstr ? name_cstr : "(null)", type_str, delay); } else { ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ", offset=%" PRIu32 ")", type_str, item->get_source(), - name_cstr ? name_cstr : "(null)", type_str, delay, static_cast(item->next_execution_ - now)); + name_cstr ? name_cstr : "(null)", type_str, delay, + static_cast(item->get_next_execution() - now)); } #endif /* ESPHOME_DEBUG_SCHEDULER */ @@ -285,9 +286,10 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { auto &item = this->items_[0]; // Convert the fresh timestamp from caller (usually Application::loop()) to 64-bit const auto now_64 = this->millis_64_(now); // 'now' from parameter - fresh from caller - if (item->next_execution_ < now_64) + const uint64_t next_exec = item->get_next_execution(); + if (next_exec < now_64) return 0; - return item->next_execution_ - now_64; + return next_exec - now_64; } void HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE @@ -354,7 +356,7 @@ void HOT Scheduler::call(uint32_t now) { const char *name = item->get_name(); ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64, item->get_type_str(), item->get_source(), name ? name : "(null)", item->interval, - item->next_execution_ - now_64, item->next_execution_); + item->get_next_execution() - now_64, item->get_next_execution()); old_items.push_back(std::move(item)); } @@ -401,7 +403,7 @@ void HOT Scheduler::call(uint32_t now) { { // Don't copy-by value yet auto &item = this->items_[0]; - if (item->next_execution_ > now_64) { + if (item->get_next_execution() > now_64) { // Not reached timeout yet, done for this call break; } @@ -440,7 +442,7 @@ void HOT Scheduler::call(uint32_t now) { const char *item_name = item->get_name(); ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")", item->get_type_str(), item->get_source(), item_name ? item_name : "(null)", item->interval, - item->next_execution_, now_64); + item->get_next_execution(), now_64); #endif /* ESPHOME_DEBUG_SCHEDULER */ // Warning: During callback(), a lot of stuff can happen, including: @@ -465,7 +467,7 @@ void HOT Scheduler::call(uint32_t now) { } if (item->type == SchedulerItem::INTERVAL) { - item->next_execution_ = now_64 + item->interval; + item->set_next_execution(now_64 + item->interval); // Add new item directly to to_add_ // since we have the lock held this->to_add_.push_back(std::move(item)); @@ -744,7 +746,10 @@ uint64_t Scheduler::millis_64_(uint32_t now) { bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr &a, const std::unique_ptr &b) { - return a->next_execution_ > b->next_execution_; + // High bits are almost always equal (change only on 32-bit rollover ~49 days) + // Optimize for common case: check low bits first when high bits are equal + return (a->next_execution_high_ == b->next_execution_high_) ? (a->next_execution_low_ > b->next_execution_low_) + : (a->next_execution_high_ > b->next_execution_high_); } } // namespace esphome diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index f469a60d5c..4a35dd88b0 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -88,19 +88,19 @@ class Scheduler { struct SchedulerItem { // Ordered by size to minimize padding Component *component; - uint32_t interval; - // 64-bit time to handle millis() rollover. The scheduler combines the 32-bit millis() - // with a 16-bit rollover counter to create a 64-bit time that won't roll over for - // billions of years. This ensures correct scheduling even when devices run for months. - uint64_t next_execution_; - // Optimized name storage using tagged union union { const char *static_name; // For string literals (no allocation) char *dynamic_name; // For allocated strings } name_; - + uint32_t interval; + // Split 64-bit time to handle millis() rollover. The scheduler combines the 32-bit millis() + // with a 16-bit rollover counter to create a 64-bit time that won't roll over for + // billions of years. This ensures correct scheduling even when devices run for months. + // Split into two fields for better memory alignment on 32-bit systems. + uint32_t next_execution_low_; // Lower 32 bits of next execution time std::function callback; + uint16_t next_execution_high_; // Upper 16 bits of next execution time #ifdef ESPHOME_THREAD_MULTI_ATOMICS // Multi-threaded with atomics: use atomic for lock-free access @@ -126,7 +126,8 @@ class Scheduler { SchedulerItem() : component(nullptr), interval(0), - next_execution_(0), + next_execution_low_(0), + next_execution_high_(0), #ifdef ESPHOME_THREAD_MULTI_ATOMICS // remove is initialized in the member declaration as std::atomic{false} type(TIMEOUT), @@ -157,7 +158,7 @@ class Scheduler { SchedulerItem &operator=(SchedulerItem &&) = delete; // Helper to get the name regardless of storage type - const char *get_name() const { return name_is_dynamic ? name_.dynamic_name : name_.static_name; } + constexpr const char *get_name() const { return name_is_dynamic ? name_.dynamic_name : name_.static_name; } // Helper to set name with proper ownership void set_name(const char *name, bool make_copy = false) { @@ -183,7 +184,17 @@ class Scheduler { } static bool cmp(const std::unique_ptr &a, const std::unique_ptr &b); - const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } + + // Helper methods to work with split execution time (constexpr for optimization) + constexpr uint64_t get_next_execution() const { + return (static_cast(next_execution_high_) << 32) | next_execution_low_; + } + + constexpr void set_next_execution(uint64_t value) { + next_execution_low_ = static_cast(value); + next_execution_high_ = static_cast(value >> 32); + } + constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } const char *get_source() const { return component ? component->get_component_source() : "unknown"; } }; From 191e9dedc553271739e97e016117d32188e4663f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 16:30:37 -0500 Subject: [PATCH 0833/1626] Update esphome/core/scheduler.h --- esphome/core/scheduler.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 4a35dd88b0..c4bbbf1035 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -185,7 +185,6 @@ class Scheduler { static bool cmp(const std::unique_ptr &a, const std::unique_ptr &b); - // Helper methods to work with split execution time (constexpr for optimization) constexpr uint64_t get_next_execution() const { return (static_cast(next_execution_high_) << 32) | next_execution_low_; } From ca0029e0024edc913486933b53c97676fa47abe7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 16:34:47 -0500 Subject: [PATCH 0834/1626] explain why its safe --- esphome/core/scheduler.h | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 4a35dd88b0..e44d7501cc 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -94,13 +94,15 @@ class Scheduler { char *dynamic_name; // For allocated strings } name_; uint32_t interval; - // Split 64-bit time to handle millis() rollover. The scheduler combines the 32-bit millis() - // with a 16-bit rollover counter to create a 64-bit time that won't roll over for - // billions of years. This ensures correct scheduling even when devices run for months. - // Split into two fields for better memory alignment on 32-bit systems. - uint32_t next_execution_low_; // Lower 32 bits of next execution time + // Split time to handle millis() rollover. The scheduler combines the 32-bit millis() + // with a 16-bit rollover counter to create a 48-bit time space (stored as 64-bit + // for compatibility). With 49.7 days per 32-bit rollover, the 16-bit counter + // supports 49.7 days × 65536 = ~8900 years. This ensures correct scheduling + // even when devices run for months. Split into two fields for better memory + // alignment on 32-bit systems. + uint32_t next_execution_low_; // Lower 32 bits of execution time (millis value) std::function callback; - uint16_t next_execution_high_; // Upper 16 bits of next execution time + uint16_t next_execution_high_; // Upper 16 bits (millis_major counter) #ifdef ESPHOME_THREAD_MULTI_ATOMICS // Multi-threaded with atomics: use atomic for lock-free access @@ -186,12 +188,17 @@ class Scheduler { static bool cmp(const std::unique_ptr &a, const std::unique_ptr &b); // Helper methods to work with split execution time (constexpr for optimization) + // Note: We use 48 bits total (32 + 16), stored in a 64-bit value for API compatibility. + // The upper 16 bits of the 64-bit value are always zero, which is fine since + // millis_major_ is also 16 bits and they must match. constexpr uint64_t get_next_execution() const { return (static_cast(next_execution_high_) << 32) | next_execution_low_; } constexpr void set_next_execution(uint64_t value) { next_execution_low_ = static_cast(value); + // Cast to uint16_t intentionally truncates to lower 16 bits of the upper 32 bits. + // This is correct because millis_major_ that creates these values is also 16 bits. next_execution_high_ = static_cast(value >> 32); } constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } From 26e0151fee24fee795a25b2f84c47b701beeea41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 16:36:49 -0500 Subject: [PATCH 0835/1626] Update esphome/core/scheduler.h Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/scheduler.h | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7ca0bc1064..6ae6d9af57 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -95,9 +95,10 @@ class Scheduler { } name_; uint32_t interval; // Split time to handle millis() rollover. The scheduler combines the 32-bit millis() - // with a 16-bit rollover counter to create a 48-bit time space (stored as 64-bit - // for compatibility). With 49.7 days per 32-bit rollover, the 16-bit counter - // supports 49.7 days × 65536 = ~8900 years. This ensures correct scheduling + // with a 16-bit rollover counter to create a 48-bit time space (using 32+16 bits). + // This is intentionally limited to 48 bits, not stored as a full 64-bit value. + // With 49.7 days per 32-bit rollover, the 16-bit counter supports + // 49.7 days × 65536 = ~8900 years. This ensures correct scheduling // even when devices run for months. Split into two fields for better memory // alignment on 32-bit systems. uint32_t next_execution_low_; // Lower 32 bits of execution time (millis value) From 1298268937ab15b98d4579b09b4ba8fa1b30ce4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 16:37:01 -0500 Subject: [PATCH 0836/1626] Update esphome/core/scheduler.h Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- 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 6ae6d9af57..a4d20b9948 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -161,7 +161,7 @@ class Scheduler { SchedulerItem &operator=(SchedulerItem &&) = delete; // Helper to get the name regardless of storage type - constexpr const char *get_name() const { return name_is_dynamic ? name_.dynamic_name : name_.static_name; } + const char *get_name() const { return name_is_dynamic ? name_.dynamic_name : name_.static_name; } // Helper to set name with proper ownership void set_name(const char *name, bool make_copy = false) { From 7249716a3c4802f8892ac3f27e38b20dfb4b9507 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 17:53:48 -0500 Subject: [PATCH 0837/1626] [esp32] Reduce GPIO memory usage by 50% through bit-packing --- esphome/components/esp32/gpio.cpp | 36 ++++++++++++++++--------------- esphome/components/esp32/gpio.h | 33 ++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 27572063ca..ceb0710e32 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -54,13 +54,13 @@ struct ISRPinArg { ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) - arg->pin = this->pin_; + arg->pin = this->get_pin_num(); arg->flags = gpio::FLAG_NONE; - arg->inverted = inverted_; + arg->inverted = pin_flags_.inverted; #if defined(USE_ESP32_VARIANT_ESP32) - arg->use_rtc = rtc_gpio_is_valid_gpio(this->pin_); + arg->use_rtc = rtc_gpio_is_valid_gpio(this->get_pin_num()); if (arg->use_rtc) - arg->rtc_pin = rtc_io_number_get(this->pin_); + arg->rtc_pin = rtc_io_number_get(this->get_pin_num()); #endif return ISRInternalGPIOPin((void *) arg); } @@ -69,23 +69,23 @@ void ESP32InternalGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpi gpio_int_type_t idf_type = GPIO_INTR_ANYEDGE; switch (type) { case gpio::INTERRUPT_RISING_EDGE: - idf_type = inverted_ ? GPIO_INTR_NEGEDGE : GPIO_INTR_POSEDGE; + idf_type = pin_flags_.inverted ? GPIO_INTR_NEGEDGE : GPIO_INTR_POSEDGE; break; case gpio::INTERRUPT_FALLING_EDGE: - idf_type = inverted_ ? GPIO_INTR_POSEDGE : GPIO_INTR_NEGEDGE; + idf_type = pin_flags_.inverted ? GPIO_INTR_POSEDGE : GPIO_INTR_NEGEDGE; break; case gpio::INTERRUPT_ANY_EDGE: idf_type = GPIO_INTR_ANYEDGE; break; case gpio::INTERRUPT_LOW_LEVEL: - idf_type = inverted_ ? GPIO_INTR_HIGH_LEVEL : GPIO_INTR_LOW_LEVEL; + idf_type = pin_flags_.inverted ? GPIO_INTR_HIGH_LEVEL : GPIO_INTR_LOW_LEVEL; break; case gpio::INTERRUPT_HIGH_LEVEL: - idf_type = inverted_ ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL; + idf_type = pin_flags_.inverted ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL; break; } - gpio_set_intr_type(pin_, idf_type); - gpio_intr_enable(pin_); + gpio_set_intr_type(get_pin_num(), idf_type); + gpio_intr_enable(get_pin_num()); if (!isr_service_installed) { auto res = gpio_install_isr_service(ESP_INTR_FLAG_LEVEL3); if (res != ESP_OK) { @@ -94,7 +94,7 @@ void ESP32InternalGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpi } isr_service_installed = true; } - gpio_isr_handler_add(pin_, func, arg); + gpio_isr_handler_add(get_pin_num(), func, arg); } std::string ESP32InternalGPIOPin::dump_summary() const { @@ -112,13 +112,13 @@ void ESP32InternalGPIOPin::setup() { conf.intr_type = GPIO_INTR_DISABLE; gpio_config(&conf); if (flags_ & gpio::FLAG_OUTPUT) { - gpio_set_drive_capability(pin_, drive_strength_); + gpio_set_drive_capability(get_pin_num(), get_drive_strength()); } } void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { // can't call gpio_config here because that logs in esp-idf which may cause issues - gpio_set_direction(pin_, flags_to_mode(flags)); + gpio_set_direction(get_pin_num(), flags_to_mode(flags)); gpio_pull_mode_t pull_mode = GPIO_FLOATING; if ((flags & gpio::FLAG_PULLUP) && (flags & gpio::FLAG_PULLDOWN)) { pull_mode = GPIO_PULLUP_PULLDOWN; @@ -127,12 +127,14 @@ void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { } else if (flags & gpio::FLAG_PULLDOWN) { pull_mode = GPIO_PULLDOWN_ONLY; } - gpio_set_pull_mode(pin_, pull_mode); + gpio_set_pull_mode(get_pin_num(), pull_mode); } -bool ESP32InternalGPIOPin::digital_read() { return bool(gpio_get_level(pin_)) != inverted_; } -void ESP32InternalGPIOPin::digital_write(bool value) { gpio_set_level(pin_, value != inverted_ ? 1 : 0); } -void ESP32InternalGPIOPin::detach_interrupt() const { gpio_intr_disable(pin_); } +bool ESP32InternalGPIOPin::digital_read() { return bool(gpio_get_level(get_pin_num())) != pin_flags_.inverted; } +void ESP32InternalGPIOPin::digital_write(bool value) { + gpio_set_level(get_pin_num(), value != pin_flags_.inverted ? 1 : 0); +} +void ESP32InternalGPIOPin::detach_interrupt() const { gpio_intr_disable(get_pin_num()); } } // namespace esp32 diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index 0fefc1c058..312ded5d7f 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -7,11 +7,17 @@ namespace esphome { namespace esp32 { +// Static assertions to ensure our bit-packed fields can hold the enum values +static_assert(GPIO_NUM_MAX <= 256, "gpio_num_t has too many values for uint8_t"); +static_assert(GPIO_DRIVE_CAP_MAX <= 4, "gpio_drive_cap_t has too many values for 2-bit field"); + class ESP32InternalGPIOPin : public InternalGPIOPin { public: - void set_pin(gpio_num_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_drive_strength(gpio_drive_cap_t drive_strength) { drive_strength_ = drive_strength; } + void set_pin(gpio_num_t pin) { pin_ = static_cast(pin); } + void set_inverted(bool inverted) { pin_flags_.inverted = inverted; } + void set_drive_strength(gpio_drive_cap_t drive_strength) { + pin_flags_.drive_strength = static_cast(drive_strength); + } void set_flags(gpio::Flags flags) { flags_ = flags; } void setup() override; @@ -21,17 +27,26 @@ class ESP32InternalGPIOPin : public InternalGPIOPin { std::string dump_summary() const override; void detach_interrupt() const override; ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return (uint8_t) pin_; } + uint8_t get_pin() const override { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const override { return pin_flags_.inverted; } + gpio_num_t get_pin_num() const { return static_cast(pin_); } + gpio_drive_cap_t get_drive_strength() const { return static_cast(pin_flags_.drive_strength); } protected: void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; - gpio_num_t pin_; - gpio_drive_cap_t drive_strength_; - gpio::Flags flags_; - bool inverted_; + // Memory layout: 8 bytes total on 32-bit systems + // - 3 bytes for members below + // - 1 byte padding for alignment + // - 4 bytes for vtable pointer + uint8_t pin_; // GPIO pin number (0-255, actual max ~48 on ESP32) + gpio::Flags flags_; // GPIO flags (1 byte) + struct PinFlags { + uint8_t inverted : 1; // Invert pin logic (1 bit) + uint8_t drive_strength : 2; // Drive strength 0-3 (2 bits) + uint8_t reserved : 5; // Reserved for future use (5 bits) + } pin_flags_; // Total: 1 byte // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static bool isr_service_installed; }; From 63cd8a6a5802675abbdac20ffcf42e8b95a67434 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 18:40:28 -0500 Subject: [PATCH 0838/1626] [esp8266] Reduce preference memory usage by 40% through field optimization --- esphome/components/esp8266/preferences.cpp | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index bb7e436bea..da6c2fdb86 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -119,16 +119,16 @@ static bool load_from_rtc(size_t offset, uint32_t *data, size_t len) { class ESP8266PreferenceBackend : public ESPPreferenceBackend { public: - size_t offset = 0; uint32_t type = 0; + uint16_t offset = 0; + uint8_t length_words = 0; // Max 255 words (1020 bytes of data) bool in_flash = false; - size_t length_words = 0; bool save(const uint8_t *data, size_t len) override { if (bytes_to_words(len) != length_words) { return false; } - size_t buffer_size = length_words + 1; + size_t buffer_size = static_cast(length_words) + 1; std::unique_ptr buffer(new uint32_t[buffer_size]()); // Note the () for zero-initialization memcpy(buffer.get(), data, len); buffer[length_words] = calculate_crc(buffer.get(), buffer.get() + length_words, type); @@ -142,7 +142,7 @@ class ESP8266PreferenceBackend : public ESPPreferenceBackend { if (bytes_to_words(len) != length_words) { return false; } - size_t buffer_size = length_words + 1; + size_t buffer_size = static_cast(length_words) + 1; std::unique_ptr buffer(new uint32_t[buffer_size]()); bool ret = in_flash ? load_from_flash(offset, buffer.get(), buffer_size) : load_from_rtc(offset, buffer.get(), buffer_size); @@ -176,15 +176,19 @@ class ESP8266Preferences : public ESPPreferences { ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) override { uint32_t length_words = bytes_to_words(length); + if (length_words > 255) { + ESP_LOGE(TAG, "Preference too large: %u words > 255", length_words); + return {}; + } if (in_flash) { uint32_t start = current_flash_offset; uint32_t end = start + length_words + 1; if (end > ESP8266_FLASH_STORAGE_SIZE) return {}; auto *pref = new ESP8266PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->offset = start; + pref->offset = static_cast(start); pref->type = type; - pref->length_words = length_words; + pref->length_words = static_cast(length_words); pref->in_flash = true; current_flash_offset = end; return {pref}; @@ -210,9 +214,9 @@ class ESP8266Preferences : public ESPPreferences { uint32_t rtc_offset = in_normal ? start + 32 : start - 96; auto *pref = new ESP8266PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->offset = rtc_offset; + pref->offset = static_cast(rtc_offset); pref->type = type; - pref->length_words = length_words; + pref->length_words = static_cast(length_words); pref->in_flash = false; current_offset += length_words + 1; return pref; From 3b4ed0a51fb7ad832682e8dd7c2e738594330121 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 19:04:21 -0500 Subject: [PATCH 0839/1626] preen --- esphome/components/esp8266/preferences.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index da6c2fdb86..a26e9cc498 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP8266 #include +#include extern "C" { #include "spi_flash.h" } @@ -177,7 +178,7 @@ class ESP8266Preferences : public ESPPreferences { ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) override { uint32_t length_words = bytes_to_words(length); if (length_words > 255) { - ESP_LOGE(TAG, "Preference too large: %u words > 255", length_words); + ESP_LOGE(TAG, "Preference too large: %" PRIu32 " words > 255", length_words); return {}; } if (in_flash) { From c03d978b46e96bccd08d0e2d595e9f66594b09ad Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 4 Sep 2025 04:02:49 +0200 Subject: [PATCH 0840/1626] [wizard] extend the wizard dashboard API to allow upload and empty config options (#10203) --- esphome/dashboard/web_server.py | 77 ++++++++++++++++++++++++++++----- esphome/wizard.py | 59 +++++++++++++++---------- tests/unit_tests/test_wizard.py | 61 ++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 33 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index fd16667d8a..294a180794 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import base64 +import binascii from collections.abc import Callable, Iterable import datetime import functools @@ -490,7 +491,17 @@ class WizardRequestHandler(BaseHandler): kwargs = { k: v for k, v in json.loads(self.request.body.decode()).items() - if k in ("name", "platform", "board", "ssid", "psk", "password") + if k + in ( + "type", + "name", + "platform", + "board", + "ssid", + "psk", + "password", + "file_content", + ) } if not kwargs["name"]: self.set_status(422) @@ -498,19 +509,65 @@ class WizardRequestHandler(BaseHandler): self.write(json.dumps({"error": "Name is required"})) return + if "type" not in kwargs: + # Default to basic wizard type for backwards compatibility + kwargs["type"] = "basic" + kwargs["friendly_name"] = kwargs["name"] kwargs["name"] = friendly_name_slugify(kwargs["friendly_name"]) - - kwargs["ota_password"] = secrets.token_hex(16) - noise_psk = secrets.token_bytes(32) - kwargs["api_encryption_key"] = base64.b64encode(noise_psk).decode() + if kwargs["type"] == "basic": + kwargs["ota_password"] = secrets.token_hex(16) + noise_psk = secrets.token_bytes(32) + kwargs["api_encryption_key"] = base64.b64encode(noise_psk).decode() + elif kwargs["type"] == "upload": + try: + kwargs["file_text"] = base64.b64decode(kwargs["file_content"]).decode( + "utf-8" + ) + except (binascii.Error, UnicodeDecodeError): + self.set_status(422) + self.set_header("content-type", "application/json") + self.write( + json.dumps({"error": "The uploaded file is not correctly encoded."}) + ) + return + elif kwargs["type"] != "empty": + self.set_status(422) + self.set_header("content-type", "application/json") + self.write( + json.dumps( + {"error": f"Invalid wizard type specified: {kwargs['type']}"} + ) + ) + return filename = f"{kwargs['name']}.yaml" destination = settings.rel_path(filename) - wizard.wizard_write(path=destination, **kwargs) - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps({"configuration": filename})) - self.finish() + + # Check if destination file already exists + if os.path.exists(destination): + self.set_status(409) # Conflict status code + self.set_header("content-type", "application/json") + self.write( + json.dumps({"error": f"Configuration file '{filename}' already exists"}) + ) + self.finish() + return + + success = wizard.wizard_write(path=destination, **kwargs) + if success: + self.set_status(200) + self.set_header("content-type", "application/json") + self.write(json.dumps({"configuration": filename})) + self.finish() + else: + self.set_status(500) + self.set_header("content-type", "application/json") + self.write( + json.dumps( + {"error": "Failed to write configuration, see logs for details"} + ) + ) + self.finish() class ImportRequestHandler(BaseHandler): diff --git a/esphome/wizard.py b/esphome/wizard.py index 8602e90222..cb599df59a 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -189,32 +189,45 @@ def wizard_write(path, **kwargs): from esphome.components.rtl87xx import boards as rtl87xx_boards name = kwargs["name"] - board = kwargs["board"] + if kwargs["type"] == "empty": + file_text = "" + # Will be updated later after editing the file + hardware = "UNKNOWN" + elif kwargs["type"] == "upload": + file_text = kwargs["file_text"] + hardware = "UNKNOWN" + else: # "basic" + board = kwargs["board"] - for key in ("ssid", "psk", "password", "ota_password"): - if key in kwargs: - kwargs[key] = sanitize_double_quotes(kwargs[key]) + for key in ("ssid", "psk", "password", "ota_password"): + if key in kwargs: + kwargs[key] = sanitize_double_quotes(kwargs[key]) + if "platform" not in kwargs: + if board in esp8266_boards.BOARDS: + platform = "ESP8266" + elif board in esp32_boards.BOARDS: + platform = "ESP32" + elif board in rp2040_boards.BOARDS: + platform = "RP2040" + elif board in bk72xx_boards.BOARDS: + platform = "BK72XX" + elif board in ln882x_boards.BOARDS: + platform = "LN882X" + elif board in rtl87xx_boards.BOARDS: + platform = "RTL87XX" + else: + safe_print(color(AnsiFore.RED, f'The board "{board}" is unknown.')) + return False + kwargs["platform"] = platform + hardware = kwargs["platform"] + file_text = wizard_file(**kwargs) - if "platform" not in kwargs: - if board in esp8266_boards.BOARDS: - platform = "ESP8266" - elif board in esp32_boards.BOARDS: - platform = "ESP32" - elif board in rp2040_boards.BOARDS: - platform = "RP2040" - elif board in bk72xx_boards.BOARDS: - platform = "BK72XX" - elif board in ln882x_boards.BOARDS: - platform = "LN882X" - elif board in rtl87xx_boards.BOARDS: - platform = "RTL87XX" - else: - safe_print(color(AnsiFore.RED, f'The board "{board}" is unknown.')) - return False - kwargs["platform"] = platform - hardware = kwargs["platform"] + # Check if file already exists to prevent overwriting + if os.path.exists(path) and os.path.isfile(path): + safe_print(color(AnsiFore.RED, f'The file "{path}" already exists.')) + return False - write_file(path, wizard_file(**kwargs)) + write_file(path, file_text) storage = StorageJSON.from_wizard(name, name, f"{name}.local", hardware) storage_path = ext_storage_path(os.path.basename(path)) storage.save(storage_path) diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index ab20b2abb5..fea2fb5558 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -17,6 +17,7 @@ import esphome.wizard as wz @pytest.fixture def default_config(): return { + "type": "basic", "name": "test-name", "platform": "ESP8266", "board": "esp01_1m", @@ -125,6 +126,47 @@ def test_wizard_write_sets_platform(default_config, tmp_path, monkeypatch): assert "esp8266:" in generated_config +def test_wizard_empty_config(tmp_path, monkeypatch): + """ + The wizard should be able to create an empty configuration + """ + # Given + empty_config = { + "type": "empty", + "name": "test-empty", + } + monkeypatch.setattr(wz, "write_file", MagicMock()) + monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + + # When + wz.wizard_write(tmp_path, **empty_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert generated_config == "" + + +def test_wizard_upload_config(tmp_path, monkeypatch): + """ + The wizard should be able to import an base64 encoded configuration + """ + # Given + empty_config = { + "type": "upload", + "name": "test-upload", + "file_text": "# imported file 📁\n\n", + } + monkeypatch.setattr(wz, "write_file", MagicMock()) + monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + + # When + wz.wizard_write(tmp_path, **empty_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert generated_config == "# imported file 📁\n\n" + + def test_wizard_write_defaults_platform_from_board_esp8266( default_config, tmp_path, monkeypatch ): @@ -471,3 +513,22 @@ def test_wizard_requires_valid_ssid(tmpdir, monkeypatch, wizard_answers): # Then assert retval == 0 + + +def test_wizard_write_protects_existing_config(tmpdir, default_config, monkeypatch): + """ + The wizard_write function should not overwrite existing config files and return False + """ + # Given + config_file = tmpdir.join("test.yaml") + original_content = "# Original config content\n" + config_file.write(original_content) + + monkeypatch.setattr(CORE, "config_path", str(tmpdir)) + + # When + result = wz.wizard_write(str(config_file), **default_config) + + # Then + assert result is False # Should return False when file exists + assert config_file.read() == original_content From ea01cc598bc5cbd1f2d242783120a54f76a7106b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 21:56:47 -0500 Subject: [PATCH 0841/1626] [esp8266] Store component source strings in PROGMEM to save RAM --- esphome/core/component.cpp | 15 +++++++++++++++ esphome/cpp_helpers.py | 8 ++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 40cda17ca3..ca8d31e922 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -12,6 +12,9 @@ #ifdef USE_RUNTIME_STATS #include "esphome/components/runtime_stats/runtime_stats.h" #endif +#ifdef USE_ESP8266 +#include +#endif namespace esphome { @@ -185,7 +188,19 @@ void Component::call() { const char *Component::get_component_source() const { if (this->component_source_ == nullptr) return ""; +#ifdef USE_ESP8266 + // On ESP8266, component_source_ is stored in PROGMEM + // We need a static buffer to hold the string when read from flash + // Since this is only used for logging, a single shared buffer is fine + static char buffer[64]; // Component names are typically short + + // Copy from PROGMEM to buffer + strncpy_P(buffer, this->component_source_, sizeof(buffer) - 1); + buffer[sizeof(buffer) - 1] = '\0'; // Ensure null termination + return buffer; +#else return this->component_source_; +#endif } bool Component::should_warn_of_blocking(uint32_t blocking_time) { if (blocking_time > this->warn_if_blocking_over_) { diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index b61b215bdc..5164923b2d 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -9,7 +9,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, coroutine from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import add, get_variable +from esphome.cpp_generator import RawExpression, add, get_variable from esphome.cpp_types import App from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry @@ -76,7 +76,11 @@ async def register_component(var, config): "Error while finding name of component, please report this", exc_info=e ) if name is not None: - add(var.set_component_source(name)) + # On ESP8266, store component source strings in PROGMEM to save RAM + if CORE.is_esp8266: + add(var.set_component_source(RawExpression(f'PSTR("{name}")'))) + else: + add(var.set_component_source(name)) add(App.register_component(var)) return var From da9a7c41d157e2f7886262989be676d6af70e71c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 22:05:21 -0500 Subject: [PATCH 0842/1626] preen --- esphome/core/macros.h | 11 +++++++++++ esphome/cpp_helpers.py | 7 ++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/esphome/core/macros.h b/esphome/core/macros.h index 8b2383321b..0f0d556616 100644 --- a/esphome/core/macros.h +++ b/esphome/core/macros.h @@ -6,3 +6,14 @@ #ifdef USE_ARDUINO #include #endif + +// Portable PROGMEM string macro +// On ESP8266, PSTR() stores strings in flash memory +// On other platforms, it's a no-op +#ifndef ESPHOME_PSTR +#ifdef USE_ESP8266 +#define ESPHOME_PSTR(x) PSTR(x) +#else +#define ESPHOME_PSTR(x) (x) +#endif +#endif diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 5164923b2d..26a7084010 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -76,11 +76,8 @@ async def register_component(var, config): "Error while finding name of component, please report this", exc_info=e ) if name is not None: - # On ESP8266, store component source strings in PROGMEM to save RAM - if CORE.is_esp8266: - add(var.set_component_source(RawExpression(f'PSTR("{name}")'))) - else: - add(var.set_component_source(name)) + # Use ESPHOME_PSTR macro which stores strings in PROGMEM on ESP8266, no-op on other platforms + add(var.set_component_source(RawExpression(f'ESPHOME_PSTR("{name}")'))) add(App.register_component(var)) return var From aadbc41d6ae2c90ce6f4801eb5ca5e8badaa01ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 22:16:41 -0500 Subject: [PATCH 0843/1626] Revert "preen" This reverts commit da9a7c41d157e2f7886262989be676d6af70e71c. --- esphome/core/macros.h | 11 ----------- esphome/cpp_helpers.py | 7 +++++-- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/esphome/core/macros.h b/esphome/core/macros.h index 0f0d556616..8b2383321b 100644 --- a/esphome/core/macros.h +++ b/esphome/core/macros.h @@ -6,14 +6,3 @@ #ifdef USE_ARDUINO #include #endif - -// Portable PROGMEM string macro -// On ESP8266, PSTR() stores strings in flash memory -// On other platforms, it's a no-op -#ifndef ESPHOME_PSTR -#ifdef USE_ESP8266 -#define ESPHOME_PSTR(x) PSTR(x) -#else -#define ESPHOME_PSTR(x) (x) -#endif -#endif diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 26a7084010..5164923b2d 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -76,8 +76,11 @@ async def register_component(var, config): "Error while finding name of component, please report this", exc_info=e ) if name is not None: - # Use ESPHOME_PSTR macro which stores strings in PROGMEM on ESP8266, no-op on other platforms - add(var.set_component_source(RawExpression(f'ESPHOME_PSTR("{name}")'))) + # On ESP8266, store component source strings in PROGMEM to save RAM + if CORE.is_esp8266: + add(var.set_component_source(RawExpression(f'PSTR("{name}")'))) + else: + add(var.set_component_source(name)) add(App.register_component(var)) return var From c57631394c58ce847861e5d34aa98d9a8dfec799 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 22:16:59 -0500 Subject: [PATCH 0844/1626] Revert "[esp8266] Store component source strings in PROGMEM to save RAM" This reverts commit ea01cc598bc5cbd1f2d242783120a54f76a7106b. --- esphome/core/component.cpp | 15 --------------- esphome/cpp_helpers.py | 8 ++------ 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index ca8d31e922..40cda17ca3 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -12,9 +12,6 @@ #ifdef USE_RUNTIME_STATS #include "esphome/components/runtime_stats/runtime_stats.h" #endif -#ifdef USE_ESP8266 -#include -#endif namespace esphome { @@ -188,19 +185,7 @@ void Component::call() { const char *Component::get_component_source() const { if (this->component_source_ == nullptr) return ""; -#ifdef USE_ESP8266 - // On ESP8266, component_source_ is stored in PROGMEM - // We need a static buffer to hold the string when read from flash - // Since this is only used for logging, a single shared buffer is fine - static char buffer[64]; // Component names are typically short - - // Copy from PROGMEM to buffer - strncpy_P(buffer, this->component_source_, sizeof(buffer) - 1); - buffer[sizeof(buffer) - 1] = '\0'; // Ensure null termination - return buffer; -#else return this->component_source_; -#endif } bool Component::should_warn_of_blocking(uint32_t blocking_time) { if (blocking_time > this->warn_if_blocking_over_) { diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 5164923b2d..b61b215bdc 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -9,7 +9,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, coroutine from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import RawExpression, add, get_variable +from esphome.cpp_generator import add, get_variable from esphome.cpp_types import App from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry @@ -76,11 +76,7 @@ async def register_component(var, config): "Error while finding name of component, please report this", exc_info=e ) if name is not None: - # On ESP8266, store component source strings in PROGMEM to save RAM - if CORE.is_esp8266: - add(var.set_component_source(RawExpression(f'PSTR("{name}")'))) - else: - add(var.set_component_source(name)) + add(var.set_component_source(name)) add(App.register_component(var)) return var From 897bb4d13ffa6b4fa0e50947981c69dd999f0359 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 22:53:24 -0500 Subject: [PATCH 0845/1626] [esp8266] Store GPIO initialization arrays in PROGMEM to save RAM --- esphome/components/esp8266/core.cpp | 4 ++-- esphome/components/esp8266/core.h | 5 +++-- esphome/components/esp8266/gpio.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 2d3959b031..07659b34c8 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -58,8 +58,8 @@ extern "C" void resetPins() { // NOLINT #ifdef USE_ESP8266_EARLY_PIN_INIT for (int i = 0; i < 16; i++) { - uint8_t mode = ESPHOME_ESP8266_GPIO_INITIAL_MODE[i]; - uint8_t level = ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[i]; + uint8_t mode = pgm_read_byte(&ESPHOME_ESP8266_GPIO_INITIAL_MODE[i]); + uint8_t level = pgm_read_byte(&ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[i]); if (mode != 255) pinMode(i, mode); // NOLINT if (level != 255) diff --git a/esphome/components/esp8266/core.h b/esphome/components/esp8266/core.h index ac33305669..6daf0fd110 100644 --- a/esphome/components/esp8266/core.h +++ b/esphome/components/esp8266/core.h @@ -3,9 +3,10 @@ #ifdef USE_ESP8266 #include +#include -extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16]; -extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16]; +extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16] PROGMEM; +extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16] PROGMEM; namespace esphome { namespace esp8266 {} // namespace esp8266 diff --git a/esphome/components/esp8266/gpio.py b/esphome/components/esp8266/gpio.py index 2bc2291117..e7492fc505 100644 --- a/esphome/components/esp8266/gpio.py +++ b/esphome/components/esp8266/gpio.py @@ -199,11 +199,11 @@ async def add_pin_initial_states_array(): cg.add_global( cg.RawExpression( - f"const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16] = {{{initial_modes_s}}}" + f"const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16] PROGMEM = {{{initial_modes_s}}}" ) ) cg.add_global( cg.RawExpression( - f"const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16] = {{{initial_levels_s}}}" + f"const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16] PROGMEM = {{{initial_levels_s}}}" ) ) From 87f40cf24a866096396da2b967072b0e86a1ded3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 22:56:15 -0500 Subject: [PATCH 0846/1626] cleanup --- esphome/components/esp8266/core.cpp | 4 ++-- esphome/components/esp8266/core.h | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 07659b34c8..200ca567c2 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -58,8 +58,8 @@ extern "C" void resetPins() { // NOLINT #ifdef USE_ESP8266_EARLY_PIN_INIT for (int i = 0; i < 16; i++) { - uint8_t mode = pgm_read_byte(&ESPHOME_ESP8266_GPIO_INITIAL_MODE[i]); - uint8_t level = pgm_read_byte(&ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[i]); + uint8_t mode = progmem_read_byte(&ESPHOME_ESP8266_GPIO_INITIAL_MODE[i]); + uint8_t level = progmem_read_byte(&ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[i]); if (mode != 255) pinMode(i, mode); // NOLINT if (level != 255) diff --git a/esphome/components/esp8266/core.h b/esphome/components/esp8266/core.h index 6daf0fd110..8c9ffd40a4 100644 --- a/esphome/components/esp8266/core.h +++ b/esphome/components/esp8266/core.h @@ -3,7 +3,6 @@ #ifdef USE_ESP8266 #include -#include extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16] PROGMEM; extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16] PROGMEM; From ace79b1886cb661834055e303e8605a2c4109b75 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 22:57:15 -0500 Subject: [PATCH 0847/1626] fixes --- esphome/components/esp8266/core.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp8266/core.h b/esphome/components/esp8266/core.h index 8c9ffd40a4..8df9e26d55 100644 --- a/esphome/components/esp8266/core.h +++ b/esphome/components/esp8266/core.h @@ -3,6 +3,7 @@ #ifdef USE_ESP8266 #include +#include "esphome/core/hal.h" extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16] PROGMEM; extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16] PROGMEM; From 27594869e25ae3c04b90f3896c29a8e48d3d9afc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 22:58:14 -0500 Subject: [PATCH 0848/1626] fixes --- esphome/components/esp8266/core.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp8266/core.h b/esphome/components/esp8266/core.h index 8df9e26d55..d820683f23 100644 --- a/esphome/components/esp8266/core.h +++ b/esphome/components/esp8266/core.h @@ -5,8 +5,8 @@ #include #include "esphome/core/hal.h" -extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16] PROGMEM; -extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16] PROGMEM; +extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16]; +extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16]; namespace esphome { namespace esp8266 {} // namespace esp8266 From 0fa3d79c38aa685d48ba7c253a96f43ba03105ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 22:58:35 -0500 Subject: [PATCH 0849/1626] fixes --- esphome/components/esp8266/core.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp8266/core.h b/esphome/components/esp8266/core.h index d820683f23..ac33305669 100644 --- a/esphome/components/esp8266/core.h +++ b/esphome/components/esp8266/core.h @@ -3,7 +3,6 @@ #ifdef USE_ESP8266 #include -#include "esphome/core/hal.h" extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16]; extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16]; From 8fb6420b1c487a5dedeb1a5b2391866a8070e310 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 02:44:12 -0500 Subject: [PATCH 0850/1626] [esp8266] Store GPIO initialization arrays in PROGMEM to save RAM (#10560) --- esphome/components/esp8266/core.cpp | 4 ++-- esphome/components/esp8266/gpio.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 2d3959b031..200ca567c2 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -58,8 +58,8 @@ extern "C" void resetPins() { // NOLINT #ifdef USE_ESP8266_EARLY_PIN_INIT for (int i = 0; i < 16; i++) { - uint8_t mode = ESPHOME_ESP8266_GPIO_INITIAL_MODE[i]; - uint8_t level = ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[i]; + uint8_t mode = progmem_read_byte(&ESPHOME_ESP8266_GPIO_INITIAL_MODE[i]); + uint8_t level = progmem_read_byte(&ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[i]); if (mode != 255) pinMode(i, mode); // NOLINT if (level != 255) diff --git a/esphome/components/esp8266/gpio.py b/esphome/components/esp8266/gpio.py index 2bc2291117..e7492fc505 100644 --- a/esphome/components/esp8266/gpio.py +++ b/esphome/components/esp8266/gpio.py @@ -199,11 +199,11 @@ async def add_pin_initial_states_array(): cg.add_global( cg.RawExpression( - f"const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16] = {{{initial_modes_s}}}" + f"const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16] PROGMEM = {{{initial_modes_s}}}" ) ) cg.add_global( cg.RawExpression( - f"const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16] = {{{initial_levels_s}}}" + f"const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16] PROGMEM = {{{initial_levels_s}}}" ) ) From 101d553df999784b3e5507b2f76f4880bd18874d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 02:46:50 -0500 Subject: [PATCH 0851/1626] [esp8266] Reduce preference memory usage by 40% through field optimization (#10557) --- esphome/components/esp8266/preferences.cpp | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index bb7e436bea..a26e9cc498 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP8266 #include +#include extern "C" { #include "spi_flash.h" } @@ -119,16 +120,16 @@ static bool load_from_rtc(size_t offset, uint32_t *data, size_t len) { class ESP8266PreferenceBackend : public ESPPreferenceBackend { public: - size_t offset = 0; uint32_t type = 0; + uint16_t offset = 0; + uint8_t length_words = 0; // Max 255 words (1020 bytes of data) bool in_flash = false; - size_t length_words = 0; bool save(const uint8_t *data, size_t len) override { if (bytes_to_words(len) != length_words) { return false; } - size_t buffer_size = length_words + 1; + size_t buffer_size = static_cast(length_words) + 1; std::unique_ptr buffer(new uint32_t[buffer_size]()); // Note the () for zero-initialization memcpy(buffer.get(), data, len); buffer[length_words] = calculate_crc(buffer.get(), buffer.get() + length_words, type); @@ -142,7 +143,7 @@ class ESP8266PreferenceBackend : public ESPPreferenceBackend { if (bytes_to_words(len) != length_words) { return false; } - size_t buffer_size = length_words + 1; + size_t buffer_size = static_cast(length_words) + 1; std::unique_ptr buffer(new uint32_t[buffer_size]()); bool ret = in_flash ? load_from_flash(offset, buffer.get(), buffer_size) : load_from_rtc(offset, buffer.get(), buffer_size); @@ -176,15 +177,19 @@ class ESP8266Preferences : public ESPPreferences { ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) override { uint32_t length_words = bytes_to_words(length); + if (length_words > 255) { + ESP_LOGE(TAG, "Preference too large: %" PRIu32 " words > 255", length_words); + return {}; + } if (in_flash) { uint32_t start = current_flash_offset; uint32_t end = start + length_words + 1; if (end > ESP8266_FLASH_STORAGE_SIZE) return {}; auto *pref = new ESP8266PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->offset = start; + pref->offset = static_cast(start); pref->type = type; - pref->length_words = length_words; + pref->length_words = static_cast(length_words); pref->in_flash = true; current_flash_offset = end; return {pref}; @@ -210,9 +215,9 @@ class ESP8266Preferences : public ESPPreferences { uint32_t rtc_offset = in_normal ? start + 32 : start - 96; auto *pref = new ESP8266PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->offset = rtc_offset; + pref->offset = static_cast(rtc_offset); pref->type = type; - pref->length_words = length_words; + pref->length_words = static_cast(length_words); pref->in_flash = false; current_offset += length_words + 1; return pref; From 70da50b32f6a6e579a1af9085b68de0740e20c3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 08:32:49 -0500 Subject: [PATCH 0852/1626] [esp8266][api] Store error strings in PROGMEM to reduce RAM usage --- esphome/components/api/api_connection.cpp | 17 +-- esphome/components/api/api_connection.h | 2 +- esphome/components/api/api_frame_helper.cpp | 50 ++++----- esphome/components/api/api_frame_helper.h | 2 +- .../components/api/api_frame_helper_noise.cpp | 103 +++++++++++------- .../components/api/api_frame_helper_noise.h | 4 +- 6 files changed, 102 insertions(+), 76 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 4b3a3e2fc8..02b1d61368 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -112,7 +112,7 @@ void APIConnection::start() { APIError err = this->helper_->init(); if (err != APIError::OK) { on_fatal_error(); - this->log_warning_("Helper init failed", err); + this->log_warning_(LOG_STR("Helper init failed"), err); return; } this->client_info_.peername = helper_->getpeername(); @@ -159,7 +159,7 @@ void APIConnection::loop() { break; } else if (err != APIError::OK) { on_fatal_error(); - this->log_warning_("Reading failed", err); + this->log_warning_(LOG_STR("Reading failed"), err); return; } else { this->last_traffic_ = now; @@ -1565,7 +1565,7 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { return false; if (err != APIError::OK) { on_fatal_error(); - this->log_warning_("Packet write failed", err); + this->log_warning_(LOG_STR("Packet write failed"), err); return false; } // Do not set last_traffic_ on send @@ -1752,7 +1752,7 @@ void APIConnection::process_batch_() { std::span(packet_info, packet_count)); if (err != APIError::OK && err != APIError::WOULD_BLOCK) { on_fatal_error(); - this->log_warning_("Batch write failed", err); + this->log_warning_(LOG_STR("Batch write failed"), err); } #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1830,11 +1830,14 @@ void APIConnection::process_state_subscriptions_() { } #endif // USE_API_HOMEASSISTANT_STATES -void APIConnection::log_warning_(const char *message, APIError err) { - ESP_LOGW(TAG, "%s: %s %s errno=%d", this->get_client_combined_info().c_str(), message, api_error_to_str(err), errno); +void APIConnection::log_warning_(const LogString *message, APIError err) { + ESP_LOGW(TAG, "%s: %s %s errno=%d", this->get_client_combined_info().c_str(), LOG_STR_ARG(message), + LOG_STR_ARG(api_error_to_logstr(err)), errno); } -void APIConnection::log_socket_operation_failed_(APIError err) { this->log_warning_("Socket operation failed", err); } +void APIConnection::log_socket_operation_failed_(APIError err) { + this->log_warning_(LOG_STR("Socket operation failed"), err); +} } // namespace esphome::api #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 72254d1536..7ee82e0c68 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -732,7 +732,7 @@ class APIConnection final : public APIServerConnection { } // Helper function to log API errors with errno - void log_warning_(const char *message, APIError err); + void log_warning_(const LogString *message, APIError err); // Specific helper for duplicated error message void log_socket_operation_failed_(APIError err); }; diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index dee3af2ac3..a284e09c4a 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -23,59 +23,59 @@ static const char *const TAG = "api.frame_helper"; #define LOG_PACKET_SENDING(data, len) ((void) 0) #endif -const char *api_error_to_str(APIError err) { +const LogString *api_error_to_logstr(APIError err) { // not using switch to ensure compiler doesn't try to build a big table out of it if (err == APIError::OK) { - return "OK"; + return LOG_STR("OK"); } else if (err == APIError::WOULD_BLOCK) { - return "WOULD_BLOCK"; + return LOG_STR("WOULD_BLOCK"); } else if (err == APIError::BAD_INDICATOR) { - return "BAD_INDICATOR"; + return LOG_STR("BAD_INDICATOR"); } else if (err == APIError::BAD_DATA_PACKET) { - return "BAD_DATA_PACKET"; + return LOG_STR("BAD_DATA_PACKET"); } else if (err == APIError::TCP_NODELAY_FAILED) { - return "TCP_NODELAY_FAILED"; + return LOG_STR("TCP_NODELAY_FAILED"); } else if (err == APIError::TCP_NONBLOCKING_FAILED) { - return "TCP_NONBLOCKING_FAILED"; + return LOG_STR("TCP_NONBLOCKING_FAILED"); } else if (err == APIError::CLOSE_FAILED) { - return "CLOSE_FAILED"; + return LOG_STR("CLOSE_FAILED"); } else if (err == APIError::SHUTDOWN_FAILED) { - return "SHUTDOWN_FAILED"; + return LOG_STR("SHUTDOWN_FAILED"); } else if (err == APIError::BAD_STATE) { - return "BAD_STATE"; + return LOG_STR("BAD_STATE"); } else if (err == APIError::BAD_ARG) { - return "BAD_ARG"; + return LOG_STR("BAD_ARG"); } else if (err == APIError::SOCKET_READ_FAILED) { - return "SOCKET_READ_FAILED"; + return LOG_STR("SOCKET_READ_FAILED"); } else if (err == APIError::SOCKET_WRITE_FAILED) { - return "SOCKET_WRITE_FAILED"; + return LOG_STR("SOCKET_WRITE_FAILED"); } else if (err == APIError::OUT_OF_MEMORY) { - return "OUT_OF_MEMORY"; + return LOG_STR("OUT_OF_MEMORY"); } else if (err == APIError::CONNECTION_CLOSED) { - return "CONNECTION_CLOSED"; + return LOG_STR("CONNECTION_CLOSED"); } #ifdef USE_API_NOISE else if (err == APIError::BAD_HANDSHAKE_PACKET_LEN) { - return "BAD_HANDSHAKE_PACKET_LEN"; + return LOG_STR("BAD_HANDSHAKE_PACKET_LEN"); } else if (err == APIError::HANDSHAKESTATE_READ_FAILED) { - return "HANDSHAKESTATE_READ_FAILED"; + return LOG_STR("HANDSHAKESTATE_READ_FAILED"); } else if (err == APIError::HANDSHAKESTATE_WRITE_FAILED) { - return "HANDSHAKESTATE_WRITE_FAILED"; + return LOG_STR("HANDSHAKESTATE_WRITE_FAILED"); } else if (err == APIError::HANDSHAKESTATE_BAD_STATE) { - return "HANDSHAKESTATE_BAD_STATE"; + return LOG_STR("HANDSHAKESTATE_BAD_STATE"); } else if (err == APIError::CIPHERSTATE_DECRYPT_FAILED) { - return "CIPHERSTATE_DECRYPT_FAILED"; + return LOG_STR("CIPHERSTATE_DECRYPT_FAILED"); } else if (err == APIError::CIPHERSTATE_ENCRYPT_FAILED) { - return "CIPHERSTATE_ENCRYPT_FAILED"; + return LOG_STR("CIPHERSTATE_ENCRYPT_FAILED"); } else if (err == APIError::HANDSHAKESTATE_SETUP_FAILED) { - return "HANDSHAKESTATE_SETUP_FAILED"; + return LOG_STR("HANDSHAKESTATE_SETUP_FAILED"); } else if (err == APIError::HANDSHAKESTATE_SPLIT_FAILED) { - return "HANDSHAKESTATE_SPLIT_FAILED"; + return LOG_STR("HANDSHAKESTATE_SPLIT_FAILED"); } else if (err == APIError::BAD_HANDSHAKE_ERROR_BYTE) { - return "BAD_HANDSHAKE_ERROR_BYTE"; + return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE"); } #endif - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } // Default implementation for loop - handles sending buffered data diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 43e9d95fbe..c11d701ffe 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -66,7 +66,7 @@ enum class APIError : uint16_t { #endif }; -const char *api_error_to_str(APIError err); +const LogString *api_error_to_logstr(APIError err); class APIFrameHelper { public: diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 35d1715931..394a8baa35 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -27,42 +27,42 @@ static constexpr size_t PROLOGUE_INIT_LEN = 12; // strlen("NoiseAPIInit") #endif /// Convert a noise error code to a readable error -std::string noise_err_to_str(int err) { +const LogString *noise_err_to_logstr(int err) { if (err == NOISE_ERROR_NO_MEMORY) - return "NO_MEMORY"; + return LOG_STR("NO_MEMORY"); if (err == NOISE_ERROR_UNKNOWN_ID) - return "UNKNOWN_ID"; + return LOG_STR("UNKNOWN_ID"); if (err == NOISE_ERROR_UNKNOWN_NAME) - return "UNKNOWN_NAME"; + return LOG_STR("UNKNOWN_NAME"); if (err == NOISE_ERROR_MAC_FAILURE) - return "MAC_FAILURE"; + return LOG_STR("MAC_FAILURE"); if (err == NOISE_ERROR_NOT_APPLICABLE) - return "NOT_APPLICABLE"; + return LOG_STR("NOT_APPLICABLE"); if (err == NOISE_ERROR_SYSTEM) - return "SYSTEM"; + return LOG_STR("SYSTEM"); if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED) - return "REMOTE_KEY_REQUIRED"; + return LOG_STR("REMOTE_KEY_REQUIRED"); if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED) - return "LOCAL_KEY_REQUIRED"; + return LOG_STR("LOCAL_KEY_REQUIRED"); if (err == NOISE_ERROR_PSK_REQUIRED) - return "PSK_REQUIRED"; + return LOG_STR("PSK_REQUIRED"); if (err == NOISE_ERROR_INVALID_LENGTH) - return "INVALID_LENGTH"; + return LOG_STR("INVALID_LENGTH"); if (err == NOISE_ERROR_INVALID_PARAM) - return "INVALID_PARAM"; + return LOG_STR("INVALID_PARAM"); if (err == NOISE_ERROR_INVALID_STATE) - return "INVALID_STATE"; + return LOG_STR("INVALID_STATE"); if (err == NOISE_ERROR_INVALID_NONCE) - return "INVALID_NONCE"; + return LOG_STR("INVALID_NONCE"); if (err == NOISE_ERROR_INVALID_PRIVATE_KEY) - return "INVALID_PRIVATE_KEY"; + return LOG_STR("INVALID_PRIVATE_KEY"); if (err == NOISE_ERROR_INVALID_PUBLIC_KEY) - return "INVALID_PUBLIC_KEY"; + return LOG_STR("INVALID_PUBLIC_KEY"); if (err == NOISE_ERROR_INVALID_FORMAT) - return "INVALID_FORMAT"; + return LOG_STR("INVALID_FORMAT"); if (err == NOISE_ERROR_INVALID_SIGNATURE) - return "INVALID_SIGNATURE"; - return to_string(err); + return LOG_STR("INVALID_SIGNATURE"); + return LOG_STR("UNKNOWN"); } /// Initialize the frame helper, returns OK if successful. @@ -83,18 +83,18 @@ APIError APINoiseFrameHelper::init() { // Helper for handling handshake frame errors APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) { if (aerr == APIError::BAD_INDICATOR) { - send_explicit_handshake_reject_("Bad indicator byte"); + send_explicit_handshake_reject_(LOG_STR("Bad indicator byte")); } else if (aerr == APIError::BAD_HANDSHAKE_PACKET_LEN) { - send_explicit_handshake_reject_("Bad handshake packet len"); + send_explicit_handshake_reject_(LOG_STR("Bad handshake packet len")); } return aerr; } // Helper for handling noise library errors -APIError APINoiseFrameHelper::handle_noise_error_(int err, const char *func_name, APIError api_err) { +APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func_name, APIError api_err) { if (err != 0) { state_ = State::FAILED; - HELPER_LOG("%s failed: %s", func_name, noise_err_to_str(err).c_str()); + HELPER_LOG("%s failed: %s", LOG_STR_ARG(func_name), LOG_STR_ARG(noise_err_to_logstr(err))); return api_err; } return APIError::OK; @@ -279,11 +279,11 @@ APIError APINoiseFrameHelper::state_action_() { } if (frame.empty()) { - send_explicit_handshake_reject_("Empty handshake message"); + send_explicit_handshake_reject_(LOG_STR("Empty handshake message")); return APIError::BAD_HANDSHAKE_ERROR_BYTE; } else if (frame[0] != 0x00) { HELPER_LOG("Bad handshake error byte: %u", frame[0]); - send_explicit_handshake_reject_("Bad handshake error byte"); + send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte")); return APIError::BAD_HANDSHAKE_ERROR_BYTE; } @@ -293,8 +293,10 @@ APIError APINoiseFrameHelper::state_action_() { err = noise_handshakestate_read_message(handshake_, &mbuf, nullptr); if (err != 0) { // Special handling for MAC failure - send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? "Handshake MAC failure" : "Handshake error"); - return handle_noise_error_(err, "noise_handshakestate_read_message", APIError::HANDSHAKESTATE_READ_FAILED); + send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure") + : LOG_STR("Handshake error")); + return handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"), + APIError::HANDSHAKESTATE_READ_FAILED); } aerr = check_handshake_finished_(); @@ -307,8 +309,8 @@ APIError APINoiseFrameHelper::state_action_() { noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1); err = noise_handshakestate_write_message(handshake_, &mbuf, nullptr); - APIError aerr_write = - handle_noise_error_(err, "noise_handshakestate_write_message", APIError::HANDSHAKESTATE_WRITE_FAILED); + APIError aerr_write = handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"), + APIError::HANDSHAKESTATE_WRITE_FAILED); if (aerr_write != APIError::OK) return aerr_write; buffer[0] = 0x00; // success @@ -331,15 +333,31 @@ APIError APINoiseFrameHelper::state_action_() { } return APIError::OK; } -void APINoiseFrameHelper::send_explicit_handshake_reject_(const std::string &reason) { +void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) { +#ifdef USE_STORE_LOG_STR_IN_FLASH + // On ESP8266 with flash strings, we need to use PROGMEM-aware functions + size_t reason_len = strlen_P(reinterpret_cast(reason)); std::vector data; - data.resize(reason.length() + 1); + data.resize(reason_len + 1); + data[0] = 0x01; // failure + + // Copy error message from PROGMEM + if (reason_len > 0) { + memcpy_P(data.data() + 1, reinterpret_cast(reason), reason_len); + } +#else + // Normal memory access + const char *reason_str = LOG_STR_ARG(reason); + size_t reason_len = strlen(reason_str); + std::vector data; + data.resize(reason_len + 1); data[0] = 0x01; // failure // Copy error message in bulk - if (!reason.empty()) { - std::memcpy(data.data() + 1, reason.c_str(), reason.length()); + if (reason_len > 0) { + std::memcpy(data.data() + 1, reason_str, reason_len); } +#endif // temporarily remove failed state auto orig_state = state_; @@ -368,7 +386,8 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { noise_buffer_init(mbuf); noise_buffer_set_inout(mbuf, frame.data(), frame.size(), frame.size()); err = noise_cipherstate_decrypt(recv_cipher_, &mbuf); - APIError decrypt_err = handle_noise_error_(err, "noise_cipherstate_decrypt", APIError::CIPHERSTATE_DECRYPT_FAILED); + APIError decrypt_err = + handle_noise_error_(err, LOG_STR("noise_cipherstate_decrypt"), APIError::CIPHERSTATE_DECRYPT_FAILED); if (decrypt_err != APIError::OK) return decrypt_err; @@ -450,7 +469,8 @@ APIError APINoiseFrameHelper::write_protobuf_packets(ProtoWriteBuffer buffer, st 4 + packet.payload_size + frame_footer_size_); int err = noise_cipherstate_encrypt(send_cipher_, &mbuf); - APIError aerr = handle_noise_error_(err, "noise_cipherstate_encrypt", APIError::CIPHERSTATE_ENCRYPT_FAILED); + APIError aerr = + handle_noise_error_(err, LOG_STR("noise_cipherstate_encrypt"), APIError::CIPHERSTATE_ENCRYPT_FAILED); if (aerr != APIError::OK) return aerr; @@ -504,25 +524,27 @@ APIError APINoiseFrameHelper::init_handshake_() { nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0; err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER); - APIError aerr = handle_noise_error_(err, "noise_handshakestate_new_by_id", APIError::HANDSHAKESTATE_SETUP_FAILED); + APIError aerr = + handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) return aerr; const auto &psk = ctx_->get_psk(); err = noise_handshakestate_set_pre_shared_key(handshake_, psk.data(), psk.size()); - aerr = handle_noise_error_(err, "noise_handshakestate_set_pre_shared_key", APIError::HANDSHAKESTATE_SETUP_FAILED); + aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_pre_shared_key"), + APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) return aerr; err = noise_handshakestate_set_prologue(handshake_, prologue_.data(), prologue_.size()); - aerr = handle_noise_error_(err, "noise_handshakestate_set_prologue", APIError::HANDSHAKESTATE_SETUP_FAILED); + aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_prologue"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) return aerr; // set_prologue copies it into handshakestate, so we can get rid of it now prologue_ = {}; err = noise_handshakestate_start(handshake_); - aerr = handle_noise_error_(err, "noise_handshakestate_start", APIError::HANDSHAKESTATE_SETUP_FAILED); + aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) return aerr; return APIError::OK; @@ -540,7 +562,8 @@ APIError APINoiseFrameHelper::check_handshake_finished_() { return APIError::HANDSHAKESTATE_BAD_STATE; } int err = noise_handshakestate_split(handshake_, &send_cipher_, &recv_cipher_); - APIError aerr = handle_noise_error_(err, "noise_handshakestate_split", APIError::HANDSHAKESTATE_SPLIT_FAILED); + APIError aerr = + handle_noise_error_(err, LOG_STR("noise_handshakestate_split"), APIError::HANDSHAKESTATE_SPLIT_FAILED); if (aerr != APIError::OK) return aerr; diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 49bc6f8854..71a217c4ca 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -32,9 +32,9 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError write_frame_(const uint8_t *data, uint16_t len); APIError init_handshake_(); APIError check_handshake_finished_(); - void send_explicit_handshake_reject_(const std::string &reason); + void send_explicit_handshake_reject_(const LogString *reason); APIError handle_handshake_frame_error_(APIError aerr); - APIError handle_noise_error_(int err, const char *func_name, APIError api_err); + APIError handle_noise_error_(int err, const LogString *func_name, APIError api_err); // Pointers first (4 bytes each) NoiseHandshakeState *handshake_{nullptr}; From 313556bb49c6d24a5d584b4c080584c288c4cd46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 08:52:06 -0500 Subject: [PATCH 0853/1626] [esp8266][logger] Store LOG_LEVELS strings in PROGMEM to reduce RAM usage --- esphome/components/logger/logger.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 195e04948d..9a9393bd7c 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -246,14 +246,16 @@ void Logger::add_on_log_callback(std::functionlog_callback_.add(std::move(callback)); } float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; } -static const char *const LOG_LEVELS[] = {"NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"}; +static const LogString *const LOG_LEVELS[] = {LOG_STR("NONE"), LOG_STR("ERROR"), LOG_STR("WARN"), + LOG_STR("INFO"), LOG_STR("CONFIG"), LOG_STR("DEBUG"), + LOG_STR("VERBOSE"), LOG_STR("VERY_VERBOSE")}; void Logger::dump_config() { ESP_LOGCONFIG(TAG, "Logger:\n" " Max Level: %s\n" " Initial Level: %s", - LOG_LEVELS[ESPHOME_LOG_LEVEL], LOG_LEVELS[this->current_level_]); + LOG_STR_ARG(LOG_LEVELS[ESPHOME_LOG_LEVEL]), LOG_STR_ARG(LOG_LEVELS[this->current_level_])); #ifndef USE_HOST ESP_LOGCONFIG(TAG, " Log Baud Rate: %" PRIu32 "\n" @@ -267,14 +269,14 @@ void Logger::dump_config() { #endif for (auto &it : this->log_levels_) { - ESP_LOGCONFIG(TAG, " Level for '%s': %s", it.first.c_str(), LOG_LEVELS[it.second]); + ESP_LOGCONFIG(TAG, " Level for '%s': %s", it.first.c_str(), LOG_STR_ARG(LOG_LEVELS[it.second])); } } void Logger::set_log_level(uint8_t level) { if (level > ESPHOME_LOG_LEVEL) { level = ESPHOME_LOG_LEVEL; - ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s", LOG_LEVELS[ESPHOME_LOG_LEVEL]); + ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s", LOG_STR_ARG(LOG_LEVELS[ESPHOME_LOG_LEVEL])); } this->current_level_ = level; this->level_callback_.call(level); From 81783ef49d560a9f177f6a8acd3ee26da4445476 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 08:57:43 -0500 Subject: [PATCH 0854/1626] cleanup --- esphome/components/logger/logger.cpp | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 9a9393bd7c..322d153767 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -246,9 +246,28 @@ void Logger::add_on_log_callback(std::functionlog_callback_.add(std::move(callback)); } float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; } -static const LogString *const LOG_LEVELS[] = {LOG_STR("NONE"), LOG_STR("ERROR"), LOG_STR("WARN"), - LOG_STR("INFO"), LOG_STR("CONFIG"), LOG_STR("DEBUG"), - LOG_STR("VERBOSE"), LOG_STR("VERY_VERBOSE")}; + +#ifdef USE_STORE_LOG_STR_IN_FLASH +// ESP8266: PSTR() cannot be used in array initializers, so we need to declare +// each string separately as a global constant first +static const char LOG_LEVEL_NONE[] PROGMEM = "NONE"; +static const char LOG_LEVEL_ERROR[] PROGMEM = "ERROR"; +static const char LOG_LEVEL_WARN[] PROGMEM = "WARN"; +static const char LOG_LEVEL_INFO[] PROGMEM = "INFO"; +static const char LOG_LEVEL_CONFIG[] PROGMEM = "CONFIG"; +static const char LOG_LEVEL_DEBUG[] PROGMEM = "DEBUG"; +static const char LOG_LEVEL_VERBOSE[] PROGMEM = "VERBOSE"; +static const char LOG_LEVEL_VERY_VERBOSE[] PROGMEM = "VERY_VERBOSE"; + +static const LogString *const LOG_LEVELS[] = { + reinterpret_cast(LOG_LEVEL_NONE), reinterpret_cast(LOG_LEVEL_ERROR), + reinterpret_cast(LOG_LEVEL_WARN), reinterpret_cast(LOG_LEVEL_INFO), + reinterpret_cast(LOG_LEVEL_CONFIG), reinterpret_cast(LOG_LEVEL_DEBUG), + reinterpret_cast(LOG_LEVEL_VERBOSE), reinterpret_cast(LOG_LEVEL_VERY_VERBOSE), +}; +#else +static const char *const LOG_LEVELS[] = {"NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"}; +#endif void Logger::dump_config() { ESP_LOGCONFIG(TAG, From b4154831a683413fef662f9997087baf0e11e5ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 08:58:03 -0500 Subject: [PATCH 0855/1626] cleanup --- esphome/components/logger/logger.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 322d153767..1af2791644 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -266,7 +266,8 @@ static const LogString *const LOG_LEVELS[] = { reinterpret_cast(LOG_LEVEL_VERBOSE), reinterpret_cast(LOG_LEVEL_VERY_VERBOSE), }; #else -static const char *const LOG_LEVELS[] = {"NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"}; +static const LogString *const LOG_LEVELS[] = {"NONE", "ERROR", "WARN", "INFO", + "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"}; #endif void Logger::dump_config() { From 2e7ebc625828ca5fa054e93b2c7195a14820919f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 09:01:59 -0500 Subject: [PATCH 0856/1626] [esphome] Store OTA component log strings in flash on ESP8266 --- .../components/esphome/ota/ota_esphome.cpp | 40 ++++++++++--------- esphome/components/esphome/ota/ota_esphome.h | 7 ++-- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index fc10e5366e..6654ef8748 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -30,19 +30,19 @@ void ESPHomeOTAComponent::setup() { this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0); // monitored for incoming connections if (this->server_ == nullptr) { - this->log_socket_error_("creation"); + this->log_socket_error_(LOG_STR("creation")); this->mark_failed(); return; } int enable = 1; int err = this->server_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { - this->log_socket_error_("reuseaddr"); + this->log_socket_error_(LOG_STR("reuseaddr")); // we can still continue } err = this->server_->setblocking(false); if (err != 0) { - this->log_socket_error_("non-blocking"); + this->log_socket_error_(LOG_STR("non-blocking")); this->mark_failed(); return; } @@ -51,21 +51,21 @@ void ESPHomeOTAComponent::setup() { socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); if (sl == 0) { - this->log_socket_error_("set sockaddr"); + this->log_socket_error_(LOG_STR("set sockaddr")); this->mark_failed(); return; } err = this->server_->bind((struct sockaddr *) &server, sizeof(server)); if (err != 0) { - this->log_socket_error_("bind"); + this->log_socket_error_(LOG_STR("bind")); this->mark_failed(); return; } err = this->server_->listen(4); if (err != 0) { - this->log_socket_error_("listen"); + this->log_socket_error_(LOG_STR("listen")); this->mark_failed(); return; } @@ -114,17 +114,17 @@ void ESPHomeOTAComponent::handle_handshake_() { return; int err = this->client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); if (err != 0) { - this->log_socket_error_("nodelay"); + this->log_socket_error_(LOG_STR("nodelay")); this->cleanup_connection_(); return; } err = this->client_->setblocking(false); if (err != 0) { - this->log_socket_error_("non-blocking"); + this->log_socket_error_(LOG_STR("non-blocking")); this->cleanup_connection_(); return; } - this->log_start_("handshake"); + this->log_start_(LOG_STR("handshake")); this->client_connect_time_ = App.get_loop_component_start_time(); this->magic_buf_pos_ = 0; // Reset magic buffer position } @@ -150,7 +150,7 @@ void ESPHomeOTAComponent::handle_handshake_() { if (read <= 0) { // Error or connection closed if (read == -1) { - this->log_socket_error_("reading magic bytes"); + this->log_socket_error_(LOG_STR("reading magic bytes")); } else { ESP_LOGW(TAG, "Remote closed during handshake"); } @@ -209,7 +209,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read features - 1 byte if (!this->readall_(buf, 1)) { - this->log_read_error_("features"); + this->log_read_error_(LOG_STR("features")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_features = buf[0]; // NOLINT @@ -288,7 +288,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { - this->log_read_error_("size"); + this->log_read_error_(LOG_STR("size")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_size = 0; @@ -302,7 +302,7 @@ void ESPHomeOTAComponent::handle_data_() { // starting the update, set the warning status and notify // listeners. This ensures that port scanners do not // accidentally trigger the update process. - this->log_start_("update"); + this->log_start_(LOG_STR("update")); this->status_set_warning(); #ifdef USE_OTA_STATE_CALLBACK this->state_callback_.call(ota::OTA_STARTED, 0.0f, 0); @@ -320,7 +320,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read binary MD5, 32 bytes if (!this->readall_(buf, 32)) { - this->log_read_error_("MD5 checksum"); + this->log_read_error_(LOG_STR("MD5 checksum")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } sbuf[32] = '\0'; @@ -393,7 +393,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read ACK if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { - this->log_read_error_("ack"); + this->log_read_error_(LOG_STR("ack")); // do not go to error, this is not fatal } @@ -477,12 +477,14 @@ float ESPHomeOTAComponent::get_setup_priority() const { return setup_priority::A uint16_t ESPHomeOTAComponent::get_port() const { return this->port_; } void ESPHomeOTAComponent::set_port(uint16_t port) { this->port_ = port; } -void ESPHomeOTAComponent::log_socket_error_(const char *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", msg, errno); } +void ESPHomeOTAComponent::log_socket_error_(const LogString *msg) { + ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); +} -void ESPHomeOTAComponent::log_read_error_(const char *what) { ESP_LOGW(TAG, "Read %s failed", what); } +void ESPHomeOTAComponent::log_read_error_(const LogString *what) { ESP_LOGW(TAG, "Read %s failed", LOG_STR_ARG(what)); } -void ESPHomeOTAComponent::log_start_(const char *phase) { - ESP_LOGD(TAG, "Starting %s from %s", phase, this->client_->getpeername().c_str()); +void ESPHomeOTAComponent::log_start_(const LogString *phase) { + ESP_LOGD(TAG, "Starting %s from %s", LOG_STR_ARG(phase), this->client_->getpeername().c_str()); } void ESPHomeOTAComponent::cleanup_connection_() { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c1919c71e9..0a4393c824 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -4,6 +4,7 @@ #ifdef USE_OTA #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include "esphome/core/log.h" #include "esphome/components/ota/ota_backend.h" #include "esphome/components/socket/socket.h" @@ -31,9 +32,9 @@ class ESPHomeOTAComponent : public ota::OTAComponent { void handle_data_(); bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); - void log_socket_error_(const char *msg); - void log_read_error_(const char *what); - void log_start_(const char *phase); + void log_socket_error_(const LogString *msg); + void log_read_error_(const LogString *what); + void log_start_(const LogString *phase); void cleanup_connection_(); void yield_and_feed_watchdog_(); From e6ab45a78ddee2f0f3d58fd1d22f05227cd44dc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 09:06:11 -0500 Subject: [PATCH 0857/1626] esp32 fix --- esphome/components/logger/logger.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 1af2791644..322d153767 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -266,8 +266,7 @@ static const LogString *const LOG_LEVELS[] = { reinterpret_cast(LOG_LEVEL_VERBOSE), reinterpret_cast(LOG_LEVEL_VERY_VERBOSE), }; #else -static const LogString *const LOG_LEVELS[] = {"NONE", "ERROR", "WARN", "INFO", - "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"}; +static const char *const LOG_LEVELS[] = {"NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"}; #endif void Logger::dump_config() { From faca78aeb9a5696e1af4fe683f7eff8116af4a3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 10:33:05 -0500 Subject: [PATCH 0858/1626] [pca9554] Reduce I2C bus usage with lazy input caching --- esphome/components/pca9554/pca9554.cpp | 21 +++++++-------------- esphome/components/pca9554/pca9554.h | 4 ++-- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index 1166cc1a09..f22a13fc64 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -37,10 +37,9 @@ void PCA9554Component::setup() { } void PCA9554Component::loop() { - // The read_inputs_() method will cache the input values from the chip. - this->read_inputs_(); - // Clear all the previously read flags. - this->was_previously_read_ = 0x00; + // Invalidate the cache at the start of each loop. + // The actual read will happen on demand in digital_read() + this->cache_valid_ = false; } void PCA9554Component::dump_config() { @@ -55,16 +54,10 @@ void PCA9554Component::dump_config() { } bool PCA9554Component::digital_read(uint8_t pin) { - // Note: We want to try and avoid doing any I2C bus read transactions here - // to conserve I2C bus bandwidth. So what we do is check to see if we - // have seen a read during the time esphome is running this loop. If we have, - // we do an I2C bus transaction to get the latest value. If we haven't - // we return a cached value which was read at the time loop() was called. - if (this->was_previously_read_ & (1 << pin)) - this->read_inputs_(); // Force a read of a new value - // Indicate we saw a read request for this pin in case a - // read happens later in the same loop. - this->was_previously_read_ |= (1 << pin); + // Read the inputs once per loop on demand and cache the result + if (!this->cache_valid_ && this->read_inputs_()) { + this->cache_valid_ = true; + } return this->input_mask_ & (1 << pin); } diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index efeec4d306..3fc4737a4f 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -45,8 +45,8 @@ class PCA9554Component : public Component, public i2c::I2CDevice { uint16_t output_mask_{0x00}; /// The state of the actual input pin states - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; - /// Flags to check if read previously during this loop - uint16_t was_previously_read_ = {0x00}; + /// Cache validity flag - true if we've read inputs this loop cycle + bool cache_valid_{false}; /// Storage for last I2C error seen esphome::i2c::ErrorCode last_error_; }; From cd4a6bbe37e038e03ddf6262d3ea674747aa5ab4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 10:52:47 -0500 Subject: [PATCH 0859/1626] add myself since I use this in production now and will be swithing more devices to use it --- esphome/components/pca9554/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 05713cccda..8572486823 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -11,7 +11,7 @@ from esphome.const import ( CONF_OUTPUT, ) -CODEOWNERS = ["@hwstar", "@clydebarrow"] +CODEOWNERS = ["@hwstar", "@clydebarrow", "@bdraco"] DEPENDENCIES = ["i2c"] MULTI_CONF = True CONF_PIN_COUNT = "pin_count" From d2a9e0ef7ad83d6a52689a2321e7d022d1af789a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 10:55:27 -0500 Subject: [PATCH 0860/1626] build --- CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 116f35f3b6..c34774fce6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -342,7 +342,7 @@ esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core esphome/components/packet_transport/* @clydebarrow esphome/components/pca6416a/* @Mat931 -esphome/components/pca9554/* @clydebarrow @hwstar +esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman esphome/components/pi4ioe5v6408/* @jesserockz From 1d91bf5759a615273348ed722aa3983488bce1c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 11:02:17 -0500 Subject: [PATCH 0861/1626] [pcf8574] Add lazy input caching to reduce I2C bus usage --- esphome/components/pcf8574/pcf8574.cpp | 10 +++++++++- esphome/components/pcf8574/pcf8574.h | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 848fbed484..5da6f92aae 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -16,6 +16,11 @@ void PCF8574Component::setup() { this->write_gpio_(); this->read_gpio_(); } +void PCF8574Component::loop() { + // Invalidate the cache at the start of each loop. + // The actual read will happen on demand in digital_read() + this->cache_valid_ = false; +} void PCF8574Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF8574:"); LOG_I2C_DEVICE(this) @@ -25,7 +30,10 @@ void PCF8574Component::dump_config() { } } bool PCF8574Component::digital_read(uint8_t pin) { - this->read_gpio_(); + // Read the inputs once per loop on demand and cache the result + if (!this->cache_valid_ && this->read_gpio_()) { + this->cache_valid_ = true; + } return this->input_mask_ & (1 << pin); } void PCF8574Component::digital_write(uint8_t pin, bool value) { diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 6edc67fc96..fdeee1800d 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -15,6 +15,8 @@ class PCF8574Component : public Component, public i2c::I2CDevice { /// Check i2c availability and setup masks void setup() override; + /// Invalidate cache at start of each loop + void loop() override; /// Helper function to read the value of a pin. bool digital_read(uint8_t pin); /// Helper function to write the value of a pin. @@ -37,6 +39,8 @@ class PCF8574Component : public Component, public i2c::I2CDevice { uint16_t output_mask_{0x00}; /// The state read in read_gpio_ - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; + /// Cache validity flag - true if we've read inputs this loop cycle + bool cache_valid_{false}; bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574 }; From e0617e01e018526a77e9c90742ca02046e56852e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:10:53 -0500 Subject: [PATCH 0862/1626] Bump pypa/gh-action-pypi-publish from 1.12.4 to 1.13.0 in /.github/workflows (#10572) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9af67aa310..f9b0cfb6a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: pip3 install build python3 -m build - name: Publish - uses: pypa/gh-action-pypi-publish@v1.12.4 + uses: pypa/gh-action-pypi-publish@v1.13.0 with: skip-existing: true From 784d5472949b2de34702ca63c82930dc429047c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 11:19:30 -0500 Subject: [PATCH 0863/1626] correctness --- esphome/components/pcf8574/pcf8574.cpp | 3 +++ esphome/components/pcf8574/pcf8574.h | 1 + 2 files changed, 4 insertions(+) diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 5da6f92aae..a75f4f2256 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -99,6 +99,9 @@ bool PCF8574Component::write_gpio_() { } float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } +// Run our loop() method early to invalidate cache before any other components access the pins +float PCF8574Component::get_loop_priority() const { return 9.0f; } // Just after WIFI + void PCF8574GPIOPin::setup() { pin_mode(flags_); } void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index fdeee1800d..41cb411f28 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -25,6 +25,7 @@ class PCF8574Component : public Component, public i2c::I2CDevice { void pin_mode(uint8_t pin, gpio::Flags flags); float get_setup_priority() const override; + float get_loop_priority() const override; void dump_config() override; From c1c522dc08bba81520bbf01621665b32f2fe21f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 11:21:17 -0500 Subject: [PATCH 0864/1626] fix stale comment --- esphome/components/pca9554/pca9554.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index f22a13fc64..74be09a748 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -120,8 +120,7 @@ bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) { float PCA9554Component::get_setup_priority() const { return setup_priority::IO; } -// Run our loop() method very early in the loop, so that we cache read values before -// before other components call our digital_read() method. +// Run our loop() method early to invalidate cache before any other components access the pins float PCA9554Component::get_loop_priority() const { return 9.0f; } // Just after WIFI void PCA9554GPIOPin::setup() { pin_mode(flags_); } From 48858198818e0712b56069f6ca073e04bc88ad50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 12:21:48 -0500 Subject: [PATCH 0865/1626] use helper --- esphome/components/pcf8574/__init__.py | 1 + esphome/components/pcf8574/pcf8574.cpp | 22 +++++++++++----------- esphome/components/pcf8574/pcf8574.h | 16 ++++++++++------ 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index ff7c314bcd..f387d0a610 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_OUTPUT, ) +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] MULTI_CONF = True diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index a75f4f2256..67e84d6629 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -17,9 +17,8 @@ void PCF8574Component::setup() { this->read_gpio_(); } void PCF8574Component::loop() { - // Invalidate the cache at the start of each loop. - // The actual read will happen on demand in digital_read() - this->cache_valid_ = false; + // Invalidate the cache at the start of each loop + this->reset_pin_cache_(); } void PCF8574Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF8574:"); @@ -29,20 +28,21 @@ void PCF8574Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } } -bool PCF8574Component::digital_read(uint8_t pin) { - // Read the inputs once per loop on demand and cache the result - if (!this->cache_valid_ && this->read_gpio_()) { - this->cache_valid_ = true; - } - return this->input_mask_ & (1 << pin); +bool PCF8574Component::digital_read(uint8_t pin) { return this->get_pin_value_(pin); } + +bool PCF8574Component::digital_read_hw(uint8_t pin) { + return this->read_gpio_() ? (this->input_mask_ & (1 << pin)) : false; } -void PCF8574Component::digital_write(uint8_t pin, bool value) { + +bool PCF8574Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } +void PCF8574Component::digital_write(uint8_t pin, bool value) { this->set_pin_value_(pin, value); } + +void PCF8574Component::digital_write_hw(uint8_t pin, bool value) { if (value) { this->output_mask_ |= (1 << pin); } else { this->output_mask_ &= ~(1 << pin); } - this->write_gpio_(); } void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) { diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 41cb411f28..cb848060aa 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -3,11 +3,16 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/gpio_expander/cached_gpio.h" namespace esphome { namespace pcf8574 { -class PCF8574Component : public Component, public i2c::I2CDevice { +// PCF8574(8 pins)/PCF8575(16 pins) always read/write all pins in a single I2C transaction +// so we use uint16_t as bank type to ensure all pins are in one bank and cached together +class PCF8574Component : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCF8574Component() = default; @@ -17,8 +22,6 @@ class PCF8574Component : public Component, public i2c::I2CDevice { void setup() override; /// Invalidate cache at start of each loop void loop() override; - /// Helper function to read the value of a pin. - bool digital_read(uint8_t pin); /// Helper function to write the value of a pin. void digital_write(uint8_t pin, bool value); /// Helper function to set the pin mode of a pin. @@ -30,8 +33,11 @@ class PCF8574Component : public Component, public i2c::I2CDevice { void dump_config() override; protected: - bool read_gpio_(); + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + bool read_gpio_(); bool write_gpio_(); /// Mask for the pin mode - 1 means output, 0 means input @@ -40,8 +46,6 @@ class PCF8574Component : public Component, public i2c::I2CDevice { uint16_t output_mask_{0x00}; /// The state read in read_gpio_ - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; - /// Cache validity flag - true if we've read inputs this loop cycle - bool cache_valid_{false}; bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574 }; From e866ae0f50be06cc815531be677dfe683b370299 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 12:28:23 -0500 Subject: [PATCH 0866/1626] handle 16 pins --- esphome/components/gpio_expander/cached_gpio.h | 12 ++++++------ esphome/components/pcf8574/pcf8574.cpp | 11 +++++++++-- esphome/components/pcf8574/pcf8574.h | 2 ++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index d7230eb0b3..9ab3f2f445 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -16,12 +16,12 @@ namespace esphome::gpio_expander { /// T - Type which represents internal register. Could be uint8_t or uint16_t. Adjust to /// match size of your internal GPIO bank register. /// N - Number of pins -template class CachedGpioExpander { +template class CachedGpioExpander { public: /// @brief Read the state of the given pin. This will invalidate the cache for the given pin number. /// @param pin Pin number to read /// @return Pin state - bool digital_read(T pin) { + bool digital_read(uint8_t pin) { const uint8_t bank = pin / BANK_SIZE; const T pin_mask = (1 << (pin % BANK_SIZE)); // Check if specific pin cache is valid @@ -38,15 +38,15 @@ template class CachedGpioExpander { return this->digital_read_cache(pin); } - void digital_write(T pin, bool value) { this->digital_write_hw(pin, value); } + void digital_write(uint8_t pin, bool value) { this->digital_write_hw(pin, value); } protected: /// @brief Call component low level function to read GPIO state from device - virtual bool digital_read_hw(T pin) = 0; + virtual bool digital_read_hw(uint8_t pin) = 0; /// @brief Call component read function from internal cache. - virtual bool digital_read_cache(T pin) = 0; + virtual bool digital_read_cache(uint8_t pin) = 0; /// @brief Call component low level function to write GPIO state to device - virtual void digital_write_hw(T pin, bool value) = 0; + virtual void digital_write_hw(uint8_t pin, bool value) = 0; /// @brief Invalidate cache. This function should be called in component loop(). void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); } diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 67e84d6629..20dd0f97b1 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -28,14 +28,21 @@ void PCF8574Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } } -bool PCF8574Component::digital_read(uint8_t pin) { return this->get_pin_value_(pin); } +bool PCF8574Component::digital_read(uint8_t pin) { + // Call the base class method + return this->CachedGpioExpander::digital_read(pin); +} bool PCF8574Component::digital_read_hw(uint8_t pin) { return this->read_gpio_() ? (this->input_mask_ & (1 << pin)) : false; } bool PCF8574Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } -void PCF8574Component::digital_write(uint8_t pin, bool value) { this->set_pin_value_(pin, value); } + +void PCF8574Component::digital_write(uint8_t pin, bool value) { + // Call the base class method + this->CachedGpioExpander::digital_write(pin, value); +} void PCF8574Component::digital_write_hw(uint8_t pin, bool value) { if (value) { diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index cb848060aa..524766d18c 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -22,6 +22,8 @@ class PCF8574Component : public Component, void setup() override; /// Invalidate cache at start of each loop void loop() override; + /// Helper function to read the value of a pin. + bool digital_read(uint8_t pin); /// Helper function to write the value of a pin. void digital_write(uint8_t pin, bool value); /// Helper function to set the pin mode of a pin. From ee090c7c383072efaf67fd9e166c80f75ee6c4d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 12:32:52 -0500 Subject: [PATCH 0867/1626] cleanup --- esphome/components/pcf8574/pcf8574.cpp | 10 ---------- esphome/components/pcf8574/pcf8574.h | 4 ---- 2 files changed, 14 deletions(-) diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 20dd0f97b1..c937cd51db 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -28,22 +28,12 @@ void PCF8574Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } } -bool PCF8574Component::digital_read(uint8_t pin) { - // Call the base class method - return this->CachedGpioExpander::digital_read(pin); -} - bool PCF8574Component::digital_read_hw(uint8_t pin) { return this->read_gpio_() ? (this->input_mask_ & (1 << pin)) : false; } bool PCF8574Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } -void PCF8574Component::digital_write(uint8_t pin, bool value) { - // Call the base class method - this->CachedGpioExpander::digital_write(pin, value); -} - void PCF8574Component::digital_write_hw(uint8_t pin, bool value) { if (value) { this->output_mask_ |= (1 << pin); diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 524766d18c..fd1ea8af63 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -22,10 +22,6 @@ class PCF8574Component : public Component, void setup() override; /// Invalidate cache at start of each loop void loop() override; - /// Helper function to read the value of a pin. - bool digital_read(uint8_t pin); - /// Helper function to write the value of a pin. - void digital_write(uint8_t pin, bool value); /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); From 5b0d1fb30e00288510f842bea338d01cc60939f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 12:57:44 -0500 Subject: [PATCH 0868/1626] cleanup --- .../components/gpio_expander/cached_gpio.h | 16 +++++-- esphome/components/pcf8574/pcf8574.cpp | 3 +- .../gpio_expander_test_component.cpp | 2 + .../__init__.py | 24 +++++++++++ .../gpio_expander_test_component_uint16.cpp | 43 +++++++++++++++++++ .../gpio_expander_test_component_uint16.h | 23 ++++++++++ .../fixtures/gpio_expander_cache.yaml | 6 ++- tests/integration/test_gpio_expander_cache.py | 43 ++++++++++++++----- 8 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index 9ab3f2f445..d17c51f3e0 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -41,11 +41,21 @@ template class CachedGpioExpander { void digital_write(uint8_t pin, bool value) { this->digital_write_hw(pin, value); } protected: - /// @brief Call component low level function to read GPIO state from device + /// @brief Read GPIO bank from hardware into internal state + /// @param pin Pin number (used to determine which bank to read) + /// @return true if read succeeded, false on communication error + /// @note This does NOT return the pin state. It returns whether the read operation succeeded. + /// The actual pin state should be returned by digital_read_cache(). virtual bool digital_read_hw(uint8_t pin) = 0; - /// @brief Call component read function from internal cache. + + /// @brief Get cached pin value from internal state + /// @param pin Pin number to read + /// @return Pin state (true = HIGH, false = LOW) virtual bool digital_read_cache(uint8_t pin) = 0; - /// @brief Call component low level function to write GPIO state to device + + /// @brief Write GPIO state to hardware + /// @param pin Pin number to write + /// @param value Pin state to write (true = HIGH, false = LOW) virtual void digital_write_hw(uint8_t pin, bool value) = 0; /// @brief Invalidate cache. This function should be called in component loop(). diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index c937cd51db..72d8865d7f 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -29,7 +29,8 @@ void PCF8574Component::dump_config() { } } bool PCF8574Component::digital_read_hw(uint8_t pin) { - return this->read_gpio_() ? (this->input_mask_ & (1 << pin)) : false; + // Read all pins from hardware into input_mask_ + return this->read_gpio_(); // Return true if I2C read succeeded, false on error } bool PCF8574Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp index 7e88950592..6e128687c4 100644 --- a/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp @@ -27,11 +27,13 @@ void GPIOExpanderTestComponent::setup() { bool GPIOExpanderTestComponent::digital_read_hw(uint8_t pin) { ESP_LOGD(TAG, "digital_read_hw pin=%d", pin); + // Return true to indicate successful read operation return true; } bool GPIOExpanderTestComponent::digital_read_cache(uint8_t pin) { ESP_LOGD(TAG, "digital_read_cache pin=%d", pin); + // Return the pin state (always HIGH for testing) return true; } diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py new file mode 100644 index 0000000000..76f20b942c --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +AUTO_LOAD = ["gpio_expander"] + +gpio_expander_test_component_uint16_ns = cg.esphome_ns.namespace( + "gpio_expander_test_component_uint16" +) + +GPIOExpanderTestUint16Component = gpio_expander_test_component_uint16_ns.class_( + "GPIOExpanderTestUint16Component", cg.Component +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(GPIOExpanderTestUint16Component), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp new file mode 100644 index 0000000000..09537c81bb --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp @@ -0,0 +1,43 @@ +#include "gpio_expander_test_component_uint16.h" +#include "esphome/core/log.h" + +namespace esphome::gpio_expander_test_component_uint16 { + +static const char *const TAG = "gpio_expander_test_uint16"; + +void GPIOExpanderTestUint16Component::setup() { + ESP_LOGD(TAG, "Testing uint16_t bank (single 16-pin bank)"); + + // Test reading all 16 pins - first should trigger hw read, rest use cache + for (uint8_t pin = 0; pin < 16; pin++) { + this->digital_read(pin); + } + + // Reset cache and test specific reads + ESP_LOGD(TAG, "Resetting cache for uint16_t test"); + this->reset_pin_cache_(); + + // First read triggers hw for entire bank + this->digital_read(5); + // These should all use cache since they're in the same bank + this->digital_read(10); + this->digital_read(15); + this->digital_read(0); + + ESP_LOGD(TAG, "DONE_UINT16"); +} + +bool GPIOExpanderTestUint16Component::digital_read_hw(uint8_t pin) { + ESP_LOGD(TAG, "uint16_digital_read_hw pin=%d", pin); + // In a real component, this would read from I2C/SPI into internal state + // For testing, we just return true to indicate successful read + return true; // Return true to indicate successful read +} + +bool GPIOExpanderTestUint16Component::digital_read_cache(uint8_t pin) { + ESP_LOGD(TAG, "uint16_digital_read_cache pin=%d", pin); + // Return the actual pin state from our test pattern + return (this->test_state_ >> pin) & 1; +} + +} // namespace esphome::gpio_expander_test_component_uint16 diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h new file mode 100644 index 0000000000..be102f9b57 --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h @@ -0,0 +1,23 @@ +#pragma once + +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/core/component.h" + +namespace esphome::gpio_expander_test_component_uint16 { + +// Test component using uint16_t bank type (single 16-pin bank) +class GPIOExpanderTestUint16Component : public Component, + public esphome::gpio_expander::CachedGpioExpander { + public: + void setup() override; + + protected: + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override{}; + + private: + uint16_t test_state_{0xAAAA}; // Test pattern: alternating bits +}; + +} // namespace esphome::gpio_expander_test_component_uint16 diff --git a/tests/integration/fixtures/gpio_expander_cache.yaml b/tests/integration/fixtures/gpio_expander_cache.yaml index 7d7ca1a876..8b5375af4c 100644 --- a/tests/integration/fixtures/gpio_expander_cache.yaml +++ b/tests/integration/fixtures/gpio_expander_cache.yaml @@ -12,6 +12,10 @@ external_components: - source: type: local path: EXTERNAL_COMPONENT_PATH - components: [gpio_expander_test_component] + components: [gpio_expander_test_component, gpio_expander_test_component_uint16] +# Test with uint8_t (multiple banks) gpio_expander_test_component: + +# Test with uint16_t (single bank) +gpio_expander_test_component_uint16: diff --git a/tests/integration/test_gpio_expander_cache.py b/tests/integration/test_gpio_expander_cache.py index 9353bb1dd6..e5f0f2818f 100644 --- a/tests/integration/test_gpio_expander_cache.py +++ b/tests/integration/test_gpio_expander_cache.py @@ -30,9 +30,15 @@ async def test_gpio_expander_cache( logs_done = asyncio.Event() - # Patterns to match in logs - digital_read_hw_pattern = re.compile(r"digital_read_hw pin=(\d+)") - digital_read_cache_pattern = re.compile(r"digital_read_cache pin=(\d+)") + # Patterns to match in logs - match any variation of digital_read + read_hw_pattern = re.compile(r"(?:uint16_)?digital_read_hw pin=(\d+)") + read_cache_pattern = re.compile(r"(?:uint16_)?digital_read_cache pin=(\d+)") + + # Keep specific patterns for building the expected order + digital_read_hw_pattern = re.compile(r"^digital_read_hw pin=(\d+)") + digital_read_cache_pattern = re.compile(r"^digital_read_cache pin=(\d+)") + uint16_read_hw_pattern = re.compile(r"^uint16_digital_read_hw pin=(\d+)") + uint16_read_cache_pattern = re.compile(r"^uint16_digital_read_cache pin=(\d+)") # ensure logs are in the expected order log_order = [ @@ -59,6 +65,17 @@ async def test_gpio_expander_cache( (digital_read_cache_pattern, 14), (digital_read_hw_pattern, 14), (digital_read_cache_pattern, 14), + # uint16_t component tests (single bank of 16 pins) + (uint16_read_hw_pattern, 0), # First pin triggers hw read + [ + (uint16_read_cache_pattern, i) for i in range(0, 16) + ], # All 16 pins return via cache + # After cache reset + (uint16_read_hw_pattern, 5), # First read after reset triggers hw + (uint16_read_cache_pattern, 5), + (uint16_read_cache_pattern, 10), # These use cache (same bank) + (uint16_read_cache_pattern, 15), + (uint16_read_cache_pattern, 0), ] # Flatten the log order for easier processing log_order: list[tuple[re.Pattern, int]] = [ @@ -77,17 +94,22 @@ async def test_gpio_expander_cache( clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - if "digital_read" in clean_line: + # Extract just the log message part (after the log level) + msg = clean_line.split(": ", 1)[-1] if ": " in clean_line else clean_line + + # Check if this line contains a read operation we're tracking + if read_hw_pattern.search(msg) or read_cache_pattern.search(msg): if index >= len(log_order): - print(f"Received unexpected log line: {clean_line}") + print(f"Received unexpected log line: {msg}") logs_done.set() return pattern, expected_pin = log_order[index] - match = pattern.search(clean_line) + match = pattern.search(msg) if not match: - print(f"Log line did not match next expected pattern: {clean_line}") + print(f"Log line did not match next expected pattern: {msg}") + print(f"Expected pattern: {pattern.pattern}") logs_done.set() return @@ -99,9 +121,10 @@ async def test_gpio_expander_cache( index += 1 - elif "DONE" in clean_line: - # Check if we reached the end of the expected log entries - logs_done.set() + elif "DONE_UINT16" in clean_line: + # uint16 component is done, check if we've seen all expected logs + if index == len(log_order): + logs_done.set() # Run with log monitoring async with ( From cf9c8e3786e1b742ed8b285c6833666a6b593a45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 13:03:33 -0500 Subject: [PATCH 0869/1626] update pca as well --- esphome/components/pca9554/__init__.py | 1 + esphome/components/pca9554/pca9554.cpp | 18 ++++++++++-------- esphome/components/pca9554/pca9554.h | 19 ++++++++++--------- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 8572486823..626b08a378 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ) CODEOWNERS = ["@hwstar", "@clydebarrow", "@bdraco"] +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] MULTI_CONF = True CONF_PIN_COUNT = "pin_count" diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index 74be09a748..b0a12b6c45 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -38,8 +38,8 @@ void PCA9554Component::setup() { void PCA9554Component::loop() { // Invalidate the cache at the start of each loop. - // The actual read will happen on demand in digital_read() - this->cache_valid_ = false; + // The actual read will happen on demand when digital_read() is called + this->reset_pin_cache_(); } void PCA9554Component::dump_config() { @@ -53,15 +53,17 @@ void PCA9554Component::dump_config() { } } -bool PCA9554Component::digital_read(uint8_t pin) { - // Read the inputs once per loop on demand and cache the result - if (!this->cache_valid_ && this->read_inputs_()) { - this->cache_valid_ = true; - } +bool PCA9554Component::digital_read_hw(uint16_t pin) { + // Read all pins from hardware into input_mask_ + return this->read_inputs_(); // Return true if I2C read succeeded, false on error +} + +bool PCA9554Component::digital_read_cache(uint16_t pin) { + // Return the cached pin state from input_mask_ return this->input_mask_ & (1 << pin); } -void PCA9554Component::digital_write(uint8_t pin, bool value) { +void PCA9554Component::digital_write_hw(uint16_t pin, bool value) { if (value) { this->output_mask_ |= (1 << pin); } else { diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 3fc4737a4f..dd5154f0a1 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -3,22 +3,21 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/gpio_expander/cached_gpio.h" namespace esphome { namespace pca9554 { -class PCA9554Component : public Component, public i2c::I2CDevice { +class PCA9554Component : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCA9554Component() = default; /// Check i2c availability and setup masks void setup() override; - /// Poll for input changes periodically + /// Invalidate cache at start of each loop void loop() override; - /// Helper function to read the value of a pin. - bool digital_read(uint8_t pin); - /// Helper function to write the value of a pin. - void digital_write(uint8_t pin, bool value); /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); @@ -32,9 +31,13 @@ class PCA9554Component : public Component, public i2c::I2CDevice { protected: bool read_inputs_(); - bool write_register_(uint8_t reg, uint16_t value); + // Virtual methods from CachedGpioExpander + bool digital_read_hw(uint16_t pin) override; + bool digital_read_cache(uint16_t pin) override; + void digital_write_hw(uint16_t pin, bool value) override; + /// number of bits the expander has size_t pin_count_{8}; /// width of registers @@ -45,8 +48,6 @@ class PCA9554Component : public Component, public i2c::I2CDevice { uint16_t output_mask_{0x00}; /// The state of the actual input pin states - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; - /// Cache validity flag - true if we've read inputs this loop cycle - bool cache_valid_{false}; /// Storage for last I2C error seen esphome::i2c::ErrorCode last_error_; }; From 4065bdaea61cde1ace3f5be932cf73d07d34cdc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 13:08:57 -0500 Subject: [PATCH 0870/1626] merge --- .../components/gpio_expander/cached_gpio.h | 28 ++++++++---- .../gpio_expander_test_component.cpp | 2 + .../__init__.py | 24 +++++++++++ .../gpio_expander_test_component_uint16.cpp | 43 +++++++++++++++++++ .../gpio_expander_test_component_uint16.h | 23 ++++++++++ .../fixtures/gpio_expander_cache.yaml | 6 ++- tests/integration/test_gpio_expander_cache.py | 43 ++++++++++++++----- 7 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index d7230eb0b3..d17c51f3e0 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -16,12 +16,12 @@ namespace esphome::gpio_expander { /// T - Type which represents internal register. Could be uint8_t or uint16_t. Adjust to /// match size of your internal GPIO bank register. /// N - Number of pins -template class CachedGpioExpander { +template class CachedGpioExpander { public: /// @brief Read the state of the given pin. This will invalidate the cache for the given pin number. /// @param pin Pin number to read /// @return Pin state - bool digital_read(T pin) { + bool digital_read(uint8_t pin) { const uint8_t bank = pin / BANK_SIZE; const T pin_mask = (1 << (pin % BANK_SIZE)); // Check if specific pin cache is valid @@ -38,15 +38,25 @@ template class CachedGpioExpander { return this->digital_read_cache(pin); } - void digital_write(T pin, bool value) { this->digital_write_hw(pin, value); } + void digital_write(uint8_t pin, bool value) { this->digital_write_hw(pin, value); } protected: - /// @brief Call component low level function to read GPIO state from device - virtual bool digital_read_hw(T pin) = 0; - /// @brief Call component read function from internal cache. - virtual bool digital_read_cache(T pin) = 0; - /// @brief Call component low level function to write GPIO state to device - virtual void digital_write_hw(T pin, bool value) = 0; + /// @brief Read GPIO bank from hardware into internal state + /// @param pin Pin number (used to determine which bank to read) + /// @return true if read succeeded, false on communication error + /// @note This does NOT return the pin state. It returns whether the read operation succeeded. + /// The actual pin state should be returned by digital_read_cache(). + virtual bool digital_read_hw(uint8_t pin) = 0; + + /// @brief Get cached pin value from internal state + /// @param pin Pin number to read + /// @return Pin state (true = HIGH, false = LOW) + virtual bool digital_read_cache(uint8_t pin) = 0; + + /// @brief Write GPIO state to hardware + /// @param pin Pin number to write + /// @param value Pin state to write (true = HIGH, false = LOW) + virtual void digital_write_hw(uint8_t pin, bool value) = 0; /// @brief Invalidate cache. This function should be called in component loop(). void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); } diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp index 7e88950592..6e128687c4 100644 --- a/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp @@ -27,11 +27,13 @@ void GPIOExpanderTestComponent::setup() { bool GPIOExpanderTestComponent::digital_read_hw(uint8_t pin) { ESP_LOGD(TAG, "digital_read_hw pin=%d", pin); + // Return true to indicate successful read operation return true; } bool GPIOExpanderTestComponent::digital_read_cache(uint8_t pin) { ESP_LOGD(TAG, "digital_read_cache pin=%d", pin); + // Return the pin state (always HIGH for testing) return true; } diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py new file mode 100644 index 0000000000..76f20b942c --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +AUTO_LOAD = ["gpio_expander"] + +gpio_expander_test_component_uint16_ns = cg.esphome_ns.namespace( + "gpio_expander_test_component_uint16" +) + +GPIOExpanderTestUint16Component = gpio_expander_test_component_uint16_ns.class_( + "GPIOExpanderTestUint16Component", cg.Component +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(GPIOExpanderTestUint16Component), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp new file mode 100644 index 0000000000..09537c81bb --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp @@ -0,0 +1,43 @@ +#include "gpio_expander_test_component_uint16.h" +#include "esphome/core/log.h" + +namespace esphome::gpio_expander_test_component_uint16 { + +static const char *const TAG = "gpio_expander_test_uint16"; + +void GPIOExpanderTestUint16Component::setup() { + ESP_LOGD(TAG, "Testing uint16_t bank (single 16-pin bank)"); + + // Test reading all 16 pins - first should trigger hw read, rest use cache + for (uint8_t pin = 0; pin < 16; pin++) { + this->digital_read(pin); + } + + // Reset cache and test specific reads + ESP_LOGD(TAG, "Resetting cache for uint16_t test"); + this->reset_pin_cache_(); + + // First read triggers hw for entire bank + this->digital_read(5); + // These should all use cache since they're in the same bank + this->digital_read(10); + this->digital_read(15); + this->digital_read(0); + + ESP_LOGD(TAG, "DONE_UINT16"); +} + +bool GPIOExpanderTestUint16Component::digital_read_hw(uint8_t pin) { + ESP_LOGD(TAG, "uint16_digital_read_hw pin=%d", pin); + // In a real component, this would read from I2C/SPI into internal state + // For testing, we just return true to indicate successful read + return true; // Return true to indicate successful read +} + +bool GPIOExpanderTestUint16Component::digital_read_cache(uint8_t pin) { + ESP_LOGD(TAG, "uint16_digital_read_cache pin=%d", pin); + // Return the actual pin state from our test pattern + return (this->test_state_ >> pin) & 1; +} + +} // namespace esphome::gpio_expander_test_component_uint16 diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h new file mode 100644 index 0000000000..be102f9b57 --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h @@ -0,0 +1,23 @@ +#pragma once + +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/core/component.h" + +namespace esphome::gpio_expander_test_component_uint16 { + +// Test component using uint16_t bank type (single 16-pin bank) +class GPIOExpanderTestUint16Component : public Component, + public esphome::gpio_expander::CachedGpioExpander { + public: + void setup() override; + + protected: + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override{}; + + private: + uint16_t test_state_{0xAAAA}; // Test pattern: alternating bits +}; + +} // namespace esphome::gpio_expander_test_component_uint16 diff --git a/tests/integration/fixtures/gpio_expander_cache.yaml b/tests/integration/fixtures/gpio_expander_cache.yaml index 7d7ca1a876..8b5375af4c 100644 --- a/tests/integration/fixtures/gpio_expander_cache.yaml +++ b/tests/integration/fixtures/gpio_expander_cache.yaml @@ -12,6 +12,10 @@ external_components: - source: type: local path: EXTERNAL_COMPONENT_PATH - components: [gpio_expander_test_component] + components: [gpio_expander_test_component, gpio_expander_test_component_uint16] +# Test with uint8_t (multiple banks) gpio_expander_test_component: + +# Test with uint16_t (single bank) +gpio_expander_test_component_uint16: diff --git a/tests/integration/test_gpio_expander_cache.py b/tests/integration/test_gpio_expander_cache.py index 9353bb1dd6..e5f0f2818f 100644 --- a/tests/integration/test_gpio_expander_cache.py +++ b/tests/integration/test_gpio_expander_cache.py @@ -30,9 +30,15 @@ async def test_gpio_expander_cache( logs_done = asyncio.Event() - # Patterns to match in logs - digital_read_hw_pattern = re.compile(r"digital_read_hw pin=(\d+)") - digital_read_cache_pattern = re.compile(r"digital_read_cache pin=(\d+)") + # Patterns to match in logs - match any variation of digital_read + read_hw_pattern = re.compile(r"(?:uint16_)?digital_read_hw pin=(\d+)") + read_cache_pattern = re.compile(r"(?:uint16_)?digital_read_cache pin=(\d+)") + + # Keep specific patterns for building the expected order + digital_read_hw_pattern = re.compile(r"^digital_read_hw pin=(\d+)") + digital_read_cache_pattern = re.compile(r"^digital_read_cache pin=(\d+)") + uint16_read_hw_pattern = re.compile(r"^uint16_digital_read_hw pin=(\d+)") + uint16_read_cache_pattern = re.compile(r"^uint16_digital_read_cache pin=(\d+)") # ensure logs are in the expected order log_order = [ @@ -59,6 +65,17 @@ async def test_gpio_expander_cache( (digital_read_cache_pattern, 14), (digital_read_hw_pattern, 14), (digital_read_cache_pattern, 14), + # uint16_t component tests (single bank of 16 pins) + (uint16_read_hw_pattern, 0), # First pin triggers hw read + [ + (uint16_read_cache_pattern, i) for i in range(0, 16) + ], # All 16 pins return via cache + # After cache reset + (uint16_read_hw_pattern, 5), # First read after reset triggers hw + (uint16_read_cache_pattern, 5), + (uint16_read_cache_pattern, 10), # These use cache (same bank) + (uint16_read_cache_pattern, 15), + (uint16_read_cache_pattern, 0), ] # Flatten the log order for easier processing log_order: list[tuple[re.Pattern, int]] = [ @@ -77,17 +94,22 @@ async def test_gpio_expander_cache( clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - if "digital_read" in clean_line: + # Extract just the log message part (after the log level) + msg = clean_line.split(": ", 1)[-1] if ": " in clean_line else clean_line + + # Check if this line contains a read operation we're tracking + if read_hw_pattern.search(msg) or read_cache_pattern.search(msg): if index >= len(log_order): - print(f"Received unexpected log line: {clean_line}") + print(f"Received unexpected log line: {msg}") logs_done.set() return pattern, expected_pin = log_order[index] - match = pattern.search(clean_line) + match = pattern.search(msg) if not match: - print(f"Log line did not match next expected pattern: {clean_line}") + print(f"Log line did not match next expected pattern: {msg}") + print(f"Expected pattern: {pattern.pattern}") logs_done.set() return @@ -99,9 +121,10 @@ async def test_gpio_expander_cache( index += 1 - elif "DONE" in clean_line: - # Check if we reached the end of the expected log entries - logs_done.set() + elif "DONE_UINT16" in clean_line: + # uint16 component is done, check if we've seen all expected logs + if index == len(log_order): + logs_done.set() # Run with log monitoring async with ( From d29586ba5aada24327b2a792c72a22e647f1a937 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 13:10:21 -0500 Subject: [PATCH 0871/1626] fix --- esphome/components/pca9554/pca9554.cpp | 6 +++--- esphome/components/pca9554/pca9554.h | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index b0a12b6c45..e8d49f66e2 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -53,17 +53,17 @@ void PCA9554Component::dump_config() { } } -bool PCA9554Component::digital_read_hw(uint16_t pin) { +bool PCA9554Component::digital_read_hw(uint8_t pin) { // Read all pins from hardware into input_mask_ return this->read_inputs_(); // Return true if I2C read succeeded, false on error } -bool PCA9554Component::digital_read_cache(uint16_t pin) { +bool PCA9554Component::digital_read_cache(uint8_t pin) { // Return the cached pin state from input_mask_ return this->input_mask_ & (1 << pin); } -void PCA9554Component::digital_write_hw(uint16_t pin, bool value) { +void PCA9554Component::digital_write_hw(uint8_t pin, bool value) { if (value) { this->output_mask_ |= (1 << pin); } else { diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index dd5154f0a1..7b356b4068 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -34,9 +34,9 @@ class PCA9554Component : public Component, bool write_register_(uint8_t reg, uint16_t value); // Virtual methods from CachedGpioExpander - bool digital_read_hw(uint16_t pin) override; - bool digital_read_cache(uint16_t pin) override; - void digital_write_hw(uint16_t pin, bool value) override; + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; /// number of bits the expander has size_t pin_count_{8}; From ef50033766b61ea26cf89170d9c13993816c2301 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 13:16:48 -0500 Subject: [PATCH 0872/1626] [gpio_expander] Fix CachedGpioExpander template to support >8 pins per bank --- .../components/gpio_expander/cached_gpio.h | 28 ++++++++---- .../gpio_expander_test_component.cpp | 2 + .../__init__.py | 24 +++++++++++ .../gpio_expander_test_component_uint16.cpp | 43 +++++++++++++++++++ .../gpio_expander_test_component_uint16.h | 23 ++++++++++ .../fixtures/gpio_expander_cache.yaml | 6 ++- tests/integration/test_gpio_expander_cache.py | 43 ++++++++++++++----- 7 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index d7230eb0b3..d17c51f3e0 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -16,12 +16,12 @@ namespace esphome::gpio_expander { /// T - Type which represents internal register. Could be uint8_t or uint16_t. Adjust to /// match size of your internal GPIO bank register. /// N - Number of pins -template class CachedGpioExpander { +template class CachedGpioExpander { public: /// @brief Read the state of the given pin. This will invalidate the cache for the given pin number. /// @param pin Pin number to read /// @return Pin state - bool digital_read(T pin) { + bool digital_read(uint8_t pin) { const uint8_t bank = pin / BANK_SIZE; const T pin_mask = (1 << (pin % BANK_SIZE)); // Check if specific pin cache is valid @@ -38,15 +38,25 @@ template class CachedGpioExpander { return this->digital_read_cache(pin); } - void digital_write(T pin, bool value) { this->digital_write_hw(pin, value); } + void digital_write(uint8_t pin, bool value) { this->digital_write_hw(pin, value); } protected: - /// @brief Call component low level function to read GPIO state from device - virtual bool digital_read_hw(T pin) = 0; - /// @brief Call component read function from internal cache. - virtual bool digital_read_cache(T pin) = 0; - /// @brief Call component low level function to write GPIO state to device - virtual void digital_write_hw(T pin, bool value) = 0; + /// @brief Read GPIO bank from hardware into internal state + /// @param pin Pin number (used to determine which bank to read) + /// @return true if read succeeded, false on communication error + /// @note This does NOT return the pin state. It returns whether the read operation succeeded. + /// The actual pin state should be returned by digital_read_cache(). + virtual bool digital_read_hw(uint8_t pin) = 0; + + /// @brief Get cached pin value from internal state + /// @param pin Pin number to read + /// @return Pin state (true = HIGH, false = LOW) + virtual bool digital_read_cache(uint8_t pin) = 0; + + /// @brief Write GPIO state to hardware + /// @param pin Pin number to write + /// @param value Pin state to write (true = HIGH, false = LOW) + virtual void digital_write_hw(uint8_t pin, bool value) = 0; /// @brief Invalidate cache. This function should be called in component loop(). void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); } diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp index 7e88950592..6e128687c4 100644 --- a/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp @@ -27,11 +27,13 @@ void GPIOExpanderTestComponent::setup() { bool GPIOExpanderTestComponent::digital_read_hw(uint8_t pin) { ESP_LOGD(TAG, "digital_read_hw pin=%d", pin); + // Return true to indicate successful read operation return true; } bool GPIOExpanderTestComponent::digital_read_cache(uint8_t pin) { ESP_LOGD(TAG, "digital_read_cache pin=%d", pin); + // Return the pin state (always HIGH for testing) return true; } diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py new file mode 100644 index 0000000000..76f20b942c --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +AUTO_LOAD = ["gpio_expander"] + +gpio_expander_test_component_uint16_ns = cg.esphome_ns.namespace( + "gpio_expander_test_component_uint16" +) + +GPIOExpanderTestUint16Component = gpio_expander_test_component_uint16_ns.class_( + "GPIOExpanderTestUint16Component", cg.Component +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(GPIOExpanderTestUint16Component), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp new file mode 100644 index 0000000000..09537c81bb --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp @@ -0,0 +1,43 @@ +#include "gpio_expander_test_component_uint16.h" +#include "esphome/core/log.h" + +namespace esphome::gpio_expander_test_component_uint16 { + +static const char *const TAG = "gpio_expander_test_uint16"; + +void GPIOExpanderTestUint16Component::setup() { + ESP_LOGD(TAG, "Testing uint16_t bank (single 16-pin bank)"); + + // Test reading all 16 pins - first should trigger hw read, rest use cache + for (uint8_t pin = 0; pin < 16; pin++) { + this->digital_read(pin); + } + + // Reset cache and test specific reads + ESP_LOGD(TAG, "Resetting cache for uint16_t test"); + this->reset_pin_cache_(); + + // First read triggers hw for entire bank + this->digital_read(5); + // These should all use cache since they're in the same bank + this->digital_read(10); + this->digital_read(15); + this->digital_read(0); + + ESP_LOGD(TAG, "DONE_UINT16"); +} + +bool GPIOExpanderTestUint16Component::digital_read_hw(uint8_t pin) { + ESP_LOGD(TAG, "uint16_digital_read_hw pin=%d", pin); + // In a real component, this would read from I2C/SPI into internal state + // For testing, we just return true to indicate successful read + return true; // Return true to indicate successful read +} + +bool GPIOExpanderTestUint16Component::digital_read_cache(uint8_t pin) { + ESP_LOGD(TAG, "uint16_digital_read_cache pin=%d", pin); + // Return the actual pin state from our test pattern + return (this->test_state_ >> pin) & 1; +} + +} // namespace esphome::gpio_expander_test_component_uint16 diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h new file mode 100644 index 0000000000..be102f9b57 --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h @@ -0,0 +1,23 @@ +#pragma once + +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/core/component.h" + +namespace esphome::gpio_expander_test_component_uint16 { + +// Test component using uint16_t bank type (single 16-pin bank) +class GPIOExpanderTestUint16Component : public Component, + public esphome::gpio_expander::CachedGpioExpander { + public: + void setup() override; + + protected: + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override{}; + + private: + uint16_t test_state_{0xAAAA}; // Test pattern: alternating bits +}; + +} // namespace esphome::gpio_expander_test_component_uint16 diff --git a/tests/integration/fixtures/gpio_expander_cache.yaml b/tests/integration/fixtures/gpio_expander_cache.yaml index 7d7ca1a876..8b5375af4c 100644 --- a/tests/integration/fixtures/gpio_expander_cache.yaml +++ b/tests/integration/fixtures/gpio_expander_cache.yaml @@ -12,6 +12,10 @@ external_components: - source: type: local path: EXTERNAL_COMPONENT_PATH - components: [gpio_expander_test_component] + components: [gpio_expander_test_component, gpio_expander_test_component_uint16] +# Test with uint8_t (multiple banks) gpio_expander_test_component: + +# Test with uint16_t (single bank) +gpio_expander_test_component_uint16: diff --git a/tests/integration/test_gpio_expander_cache.py b/tests/integration/test_gpio_expander_cache.py index 9353bb1dd6..e5f0f2818f 100644 --- a/tests/integration/test_gpio_expander_cache.py +++ b/tests/integration/test_gpio_expander_cache.py @@ -30,9 +30,15 @@ async def test_gpio_expander_cache( logs_done = asyncio.Event() - # Patterns to match in logs - digital_read_hw_pattern = re.compile(r"digital_read_hw pin=(\d+)") - digital_read_cache_pattern = re.compile(r"digital_read_cache pin=(\d+)") + # Patterns to match in logs - match any variation of digital_read + read_hw_pattern = re.compile(r"(?:uint16_)?digital_read_hw pin=(\d+)") + read_cache_pattern = re.compile(r"(?:uint16_)?digital_read_cache pin=(\d+)") + + # Keep specific patterns for building the expected order + digital_read_hw_pattern = re.compile(r"^digital_read_hw pin=(\d+)") + digital_read_cache_pattern = re.compile(r"^digital_read_cache pin=(\d+)") + uint16_read_hw_pattern = re.compile(r"^uint16_digital_read_hw pin=(\d+)") + uint16_read_cache_pattern = re.compile(r"^uint16_digital_read_cache pin=(\d+)") # ensure logs are in the expected order log_order = [ @@ -59,6 +65,17 @@ async def test_gpio_expander_cache( (digital_read_cache_pattern, 14), (digital_read_hw_pattern, 14), (digital_read_cache_pattern, 14), + # uint16_t component tests (single bank of 16 pins) + (uint16_read_hw_pattern, 0), # First pin triggers hw read + [ + (uint16_read_cache_pattern, i) for i in range(0, 16) + ], # All 16 pins return via cache + # After cache reset + (uint16_read_hw_pattern, 5), # First read after reset triggers hw + (uint16_read_cache_pattern, 5), + (uint16_read_cache_pattern, 10), # These use cache (same bank) + (uint16_read_cache_pattern, 15), + (uint16_read_cache_pattern, 0), ] # Flatten the log order for easier processing log_order: list[tuple[re.Pattern, int]] = [ @@ -77,17 +94,22 @@ async def test_gpio_expander_cache( clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - if "digital_read" in clean_line: + # Extract just the log message part (after the log level) + msg = clean_line.split(": ", 1)[-1] if ": " in clean_line else clean_line + + # Check if this line contains a read operation we're tracking + if read_hw_pattern.search(msg) or read_cache_pattern.search(msg): if index >= len(log_order): - print(f"Received unexpected log line: {clean_line}") + print(f"Received unexpected log line: {msg}") logs_done.set() return pattern, expected_pin = log_order[index] - match = pattern.search(clean_line) + match = pattern.search(msg) if not match: - print(f"Log line did not match next expected pattern: {clean_line}") + print(f"Log line did not match next expected pattern: {msg}") + print(f"Expected pattern: {pattern.pattern}") logs_done.set() return @@ -99,9 +121,10 @@ async def test_gpio_expander_cache( index += 1 - elif "DONE" in clean_line: - # Check if we reached the end of the expected log entries - logs_done.set() + elif "DONE_UINT16" in clean_line: + # uint16 component is done, check if we've seen all expected logs + if index == len(log_order): + logs_done.set() # Run with log monitoring async with ( From e843f1759b1eae2a793dfeaa74f158b18b98553b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 13:49:10 -0500 Subject: [PATCH 0873/1626] update docs --- esphome/components/gpio_expander/cached_gpio.h | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index d17c51f3e0..d88b59bb9b 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -11,11 +11,16 @@ namespace esphome::gpio_expander { /// @brief A class to cache the read state of a GPIO expander. /// This class caches reads between GPIO Pins which are on the same bank. /// This means that for reading whole Port (ex. 8 pins) component needs only one -/// I2C/SPI read per main loop call. It assumes, that one bit in byte identifies one GPIO pin +/// I2C/SPI read per main loop call. It assumes that one bit in byte identifies one GPIO pin. +/// /// Template parameters: -/// T - Type which represents internal register. Could be uint8_t or uint16_t. Adjust to -/// match size of your internal GPIO bank register. -/// N - Number of pins +/// T - Type which represents internal bank register. Could be uint8_t or uint16_t. +/// Choose based on how your I/O expander reads pins: +/// * uint8_t: For chips that read banks separately (8 pins at a time) +/// Examples: MCP23017 (2x8-bit banks), TCA9555 (2x8-bit banks) +/// * uint16_t: For chips that read all pins at once (up to 16 pins) +/// Examples: PCF8574/8575 (8/16 pins), PCA9554/9555 (8/16 pins) +/// N - Total number of pins (as uint8_t) template class CachedGpioExpander { public: /// @brief Read the state of the given pin. This will invalidate the cache for the given pin number. From 053415a22e666a28e582c23fccebf89a033cfe88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 14:02:31 -0500 Subject: [PATCH 0874/1626] [mcp23016] Migrate to CachedGpioExpander to reduce I2C bus usage --- esphome/components/mcp23016/__init__.py | 1 + esphome/components/mcp23016/mcp23016.cpp | 27 ++++++++++++++++++------ esphome/components/mcp23016/mcp23016.h | 14 ++++++++---- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 3333e46c97..5a1f011617 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_OUTPUT, ) +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] MULTI_CONF = True diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index 9d8d6e4dae..56aa36b78b 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -22,14 +22,29 @@ void MCP23016::setup() { this->write_reg_(MCP23016_IODIR0, 0xFF); this->write_reg_(MCP23016_IODIR1, 0xFF); } -bool MCP23016::digital_read(uint8_t pin) { - uint8_t bit = pin % 8; + +void MCP23016::loop() { + // Invalidate cache at the start of each loop + this->reset_pin_cache_(); +} +bool MCP23016::digital_read_hw(uint8_t pin) { uint8_t reg_addr = pin < 8 ? MCP23016_GP0 : MCP23016_GP1; uint8_t value = 0; - this->read_reg_(reg_addr, &value); - return value & (1 << bit); + if (!this->read_reg_(reg_addr, &value)) { + return false; + } + + // Update the appropriate part of input_mask_ + if (pin < 8) { + this->input_mask_ = (this->input_mask_ & 0xFF00) | value; + } else { + this->input_mask_ = (this->input_mask_ & 0x00FF) | (uint16_t(value) << 8); + } + return true; } -void MCP23016::digital_write(uint8_t pin, bool value) { + +bool MCP23016::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } +void MCP23016::digital_write_hw(uint8_t pin, bool value) { uint8_t reg_addr = pin < 8 ? MCP23016_OLAT0 : MCP23016_OLAT1; this->update_reg_(pin, value, reg_addr); } @@ -41,7 +56,7 @@ void MCP23016::pin_mode(uint8_t pin, gpio::Flags flags) { this->update_reg_(pin, false, iodir); } } -float MCP23016::get_setup_priority() const { return setup_priority::HARDWARE; } +float MCP23016::get_setup_priority() const { return setup_priority::IO; } bool MCP23016::read_reg_(uint8_t reg, uint8_t *value) { if (this->is_failed()) return false; diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index e4ed47a3b2..781c207de0 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/gpio_expander/cached_gpio.h" namespace esphome { namespace mcp23016 { @@ -24,19 +25,22 @@ enum MCP23016GPIORegisters { MCP23016_IOCON1 = 0x0B, }; -class MCP23016 : public Component, public i2c::I2CDevice { +class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { public: MCP23016() = default; void setup() override; - - bool digital_read(uint8_t pin); - void digital_write(uint8_t pin, bool value); + void loop() override; void pin_mode(uint8_t pin, gpio::Flags flags); float get_setup_priority() const override; protected: + // Virtual methods from CachedGpioExpander + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + // read a given register bool read_reg_(uint8_t reg, uint8_t *value); // write a value to a given register @@ -46,6 +50,8 @@ class MCP23016 : public Component, public i2c::I2CDevice { uint8_t olat_0_{0x00}; uint8_t olat_1_{0x00}; + // Cache for input values (16-bit combined for both banks) + uint16_t input_mask_{0x00}; }; class MCP23016GPIOPin : public GPIOPin { From dc45a613f35f247df81a96dc57ee1be7439c48cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:14:18 -0500 Subject: [PATCH 0875/1626] Bump pytest from 8.4.1 to 8.4.2 (#10579) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 22f58fd3d7..e0009f2427 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==8.4.1 +pytest==8.4.2 pytest-cov==6.2.1 pytest-mock==3.14.1 pytest-asyncio==1.1.0 From 25489b6009c74f4daf101f2e0af5fe0e93507192 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:14:28 -0500 Subject: [PATCH 0876/1626] Bump codecov/codecov-action from 5.5.0 to 5.5.1 (#10585) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f429f7b40..ea5ca47a19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5.5.0 + uses: codecov/codecov-action@v5.5.1 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache From edf7094662923d5fe550ce1c2cbf13c12d4ac09b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:14:50 -0500 Subject: [PATCH 0877/1626] Bump esphome-dashboard from 20250828.0 to 20250904.0 (#10580) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 32fdfabcda..b86c7765c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyserial==3.5 platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 -esphome-dashboard==20250828.0 +esphome-dashboard==20250904.0 aioesphomeapi==39.0.1 zeroconf==0.147.0 puremagic==1.30 From cbac9caa522a81db481db34376e9d5dc5d19afe8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:15:43 -0500 Subject: [PATCH 0878/1626] Bump actions/setup-python from 5.6.0 to 6.0.0 (#10584) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-clang-tidy-hash.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/sync-device-classes.yml | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index c7cc720323..144cc11647 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -23,7 +23,7 @@ jobs: - name: Checkout uses: actions/checkout@v5.0.0 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.11" diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index c7da7f6672..582be17fbb 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@v5.0.0 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.11" diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 61ecf8183b..915a4dfb7e 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -45,7 +45,7 @@ jobs: steps: - uses: actions/checkout@v5.0.0 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.11" - name: Set up Docker Buildx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea5ca47a19..07fd91b1c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment @@ -217,7 +217,7 @@ jobs: uses: actions/checkout@v5.0.0 - name: Set up Python 3.13 id: python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.13" - name: Restore Python virtual environment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9b0cfb6a0..41db736caa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: steps: - uses: actions/checkout@v5.0.0 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.x" - name: Build @@ -94,7 +94,7 @@ jobs: steps: - uses: actions/checkout@v5.0.0 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: "3.11" diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index cc03ed3e3f..b129e8f4bf 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -22,7 +22,7 @@ jobs: path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: 3.13 From c471bdb44617a0b47153186eb1b98e5deaf6b1f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:15:57 -0500 Subject: [PATCH 0879/1626] Bump actions/setup-python from 5.6.0 to 6.0.0 in /.github/actions/restore-python (#10586) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 9fb80e6a9d..5d290894a7 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,7 +17,7 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.0.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment From ba2433197e232ef6e28e825b3ab450cf4da7efa1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:16:17 -0500 Subject: [PATCH 0880/1626] Bump actions/github-script from 7.0.1 to 8.0.0 (#10583) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 4 ++-- .github/workflows/ci-clang-tidy-hash.yml | 4 ++-- .github/workflows/codeowner-review-request.yml | 2 +- .github/workflows/external-component-bot.yml | 2 +- .github/workflows/issue-codeowner-notify.yml | 2 +- .github/workflows/release.yml | 4 ++-- .github/workflows/status-check-labels.yml | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index c42b5330d2..66369c706f 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -32,7 +32,7 @@ jobs: private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - name: Auto Label PR - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 144cc11647..ec214d1a77 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -47,7 +47,7 @@ jobs: fi - if: failure() name: Review PR - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | await github.rest.pulls.createReview({ @@ -70,7 +70,7 @@ jobs: esphome/components/api/api_pb2_service.* - if: success() name: Dismiss review - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | let reviews = await github.rest.pulls.listReviews({ diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 582be17fbb..2f47386abf 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -41,7 +41,7 @@ jobs: - if: failure() name: Request changes - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | await github.rest.pulls.createReview({ @@ -54,7 +54,7 @@ jobs: - if: success() name: Dismiss review - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | let reviews = await github.rest.pulls.listReviews({ diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index ab3377365d..475e05b970 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Request reviews from component codeowners - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | const owner = context.repo.owner; diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 29103e8eee..736c986f7e 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Add external component comment - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/issue-codeowner-notify.yml b/.github/workflows/issue-codeowner-notify.yml index 3639d346f5..ab9b96b45a 100644 --- a/.github/workflows/issue-codeowner-notify.yml +++ b/.github/workflows/issue-codeowner-notify.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify codeowners for component issues - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | const owner = context.repo.owner; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41db736caa..efc8424cd6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -220,7 +220,7 @@ jobs: - deploy-manifest steps: - name: Trigger Workflow - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }} script: | @@ -246,7 +246,7 @@ jobs: environment: ${{ needs.init.outputs.deploy_env }} steps: - name: Trigger Workflow - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: github-token: ${{ secrets.DEPLOY_ESPHOME_SCHEMA_REPO_TOKEN }} script: | diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index 157f60f3a1..675be49c27 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -16,7 +16,7 @@ jobs: - merge-after-release steps: - name: Check for ${{ matrix.label }} label - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | const { data: labels } = await github.rest.issues.listLabelsOnIssue({ From e55bce83e3a8b77a068c7b9f381dbe9f0a3e0431 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:20:11 -0500 Subject: [PATCH 0881/1626] Bump actions/stale from 9.1.0 to 10.0.0 (#10582) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b79939fc8e..88e07d3f58 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9.1.0 + - uses: actions/stale@v10.0.0 with: days-before-pr-stale: 90 days-before-pr-close: 7 @@ -37,7 +37,7 @@ jobs: close-issues: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9.1.0 + - uses: actions/stale@v10.0.0 with: days-before-pr-stale: -1 days-before-pr-close: -1 From 977f07c338d5dd9d1146e06493f426fff140dd13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 14:22:33 -0500 Subject: [PATCH 0882/1626] [pca6416a] Migrate to reduce I2C bus usage --- esphome/components/pca6416a/__init__.py | 1 + esphome/components/pca6416a/pca6416a.cpp | 25 +++++++++++++++++++----- esphome/components/pca6416a/pca6416a.h | 17 +++++++++++----- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index da6c4623c9..e540edb91f 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -14,6 +14,7 @@ from esphome.const import ( CODEOWNERS = ["@Mat931"] DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["gpio_expander"] MULTI_CONF = True pca6416a_ns = cg.esphome_ns.namespace("pca6416a") diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index 730c494e34..c0056e780b 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -51,6 +51,11 @@ void PCA6416AComponent::setup() { this->status_has_error()); } +void PCA6416AComponent::loop() { + // Invalidate cache at the start of each loop + this->reset_pin_cache_(); +} + void PCA6416AComponent::dump_config() { if (this->has_pullup_) { ESP_LOGCONFIG(TAG, "PCAL6416A:"); @@ -63,15 +68,25 @@ void PCA6416AComponent::dump_config() { } } -bool PCA6416AComponent::digital_read(uint8_t pin) { - uint8_t bit = pin % 8; +bool PCA6416AComponent::digital_read_hw(uint8_t pin) { uint8_t reg_addr = pin < 8 ? PCA6416A_INPUT0 : PCA6416A_INPUT1; uint8_t value = 0; - this->read_register_(reg_addr, &value); - return value & (1 << bit); + if (!this->read_register_(reg_addr, &value)) { + return false; + } + + // Update the appropriate part of input_mask_ + if (pin < 8) { + this->input_mask_ = (this->input_mask_ & 0xFF00) | value; + } else { + this->input_mask_ = (this->input_mask_ & 0x00FF) | (uint16_t(value) << 8); + } + return true; } -void PCA6416AComponent::digital_write(uint8_t pin, bool value) { +bool PCA6416AComponent::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } + +void PCA6416AComponent::digital_write_hw(uint8_t pin, bool value) { uint8_t reg_addr = pin < 8 ? PCA6416A_OUTPUT0 : PCA6416A_OUTPUT1; this->update_register_(pin, value, reg_addr); } diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 1e8015c40a..10a4a64e9b 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -3,20 +3,20 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/gpio_expander/cached_gpio.h" namespace esphome { namespace pca6416a { -class PCA6416AComponent : public Component, public i2c::I2CDevice { +class PCA6416AComponent : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCA6416AComponent() = default; /// Check i2c availability and setup masks void setup() override; - /// Helper function to read the value of a pin. - bool digital_read(uint8_t pin); - /// Helper function to write the value of a pin. - void digital_write(uint8_t pin, bool value); + void loop() override; /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); @@ -25,6 +25,11 @@ class PCA6416AComponent : public Component, public i2c::I2CDevice { void dump_config() override; protected: + // Virtual methods from CachedGpioExpander + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + bool read_register_(uint8_t reg, uint8_t *value); bool write_register_(uint8_t reg, uint8_t value); void update_register_(uint8_t pin, bool pin_value, uint8_t reg_addr); @@ -32,6 +37,8 @@ class PCA6416AComponent : public Component, public i2c::I2CDevice { /// The mask to write as output state - 1 means HIGH, 0 means LOW uint8_t output_0_{0x00}; uint8_t output_1_{0x00}; + /// Cache for input values (16-bit combined for both banks) + uint16_t input_mask_{0x00}; /// Storage for last I2C error seen esphome::i2c::ErrorCode last_error_; /// Only the PCAL6416A has pull-up resistors From 4c2f356b357647368ee1da5c21f20c504e5c9682 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 19:24:23 +0000 Subject: [PATCH 0883/1626] Bump ruff from 0.12.11 to 0.12.12 (#10578) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e05733ec96..2b161cf05c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.11 + rev: v0.12.12 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index e0009f2427..ba4ebeef2e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.8 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.12.11 # also change in .pre-commit-config.yaml when updating +ruff==0.12.12 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From d9ded6b87ef65bb96916801a7ac99b171943ee3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 14:35:34 -0500 Subject: [PATCH 0884/1626] [sx1509] Migrate to CachedGpioExpander to reduce I2C bus usage --- esphome/components/sx1509/__init__.py | 2 +- esphome/components/sx1509/sx1509.cpp | 20 ++++++++++++++------ esphome/components/sx1509/sx1509.h | 15 +++++++++++---- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index 67dc924903..b61b92fd1e 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -25,7 +25,7 @@ CONF_SCAN_TIME = "scan_time" CONF_DEBOUNCE_TIME = "debounce_time" CONF_SX1509_ID = "sx1509_id" -AUTO_LOAD = ["key_provider"] +AUTO_LOAD = ["key_provider", "gpio_expander"] DEPENDENCIES = ["i2c"] MULTI_CONF = True diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 2bf6701dd2..ea3d5a6adb 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -39,6 +39,9 @@ void SX1509Component::dump_config() { } void SX1509Component::loop() { + // Reset cache at the start of each loop + this->reset_pin_cache_(); + if (this->has_keypad_) { if (millis() - this->last_loop_timestamp_ < min_loop_period_) return; @@ -73,18 +76,23 @@ void SX1509Component::loop() { } } -bool SX1509Component::digital_read(uint8_t pin) { +bool SX1509Component::digital_read_hw(uint8_t pin) { if (this->ddr_mask_ & (1 << pin)) { - uint16_t temp_reg_data; - if (!this->read_byte_16(REG_DATA_B, &temp_reg_data)) + if (!this->read_byte_16(REG_DATA_B, &this->input_mask_)) return false; - if (temp_reg_data & (1 << pin)) - return true; + return true; } return false; } -void SX1509Component::digital_write(uint8_t pin, bool bit_value) { +bool SX1509Component::digital_read_cache(uint8_t pin) { + if (this->ddr_mask_ & (1 << pin)) { + return this->input_mask_ & (1 << pin); + } + return false; +} + +void SX1509Component::digital_write_hw(uint8_t pin, bool bit_value) { if ((~this->ddr_mask_) & (1 << pin)) { // If the pin is an output, write high/low uint16_t temp_reg_data = 0; diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index c0e86aa8a1..2afd0d0e4e 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -2,6 +2,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/key_provider/key_provider.h" +#include "esphome/components/gpio_expander/cached_gpio.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "sx1509_gpio_pin.h" @@ -30,7 +31,10 @@ class SX1509Processor { class SX1509KeyTrigger : public Trigger {}; -class SX1509Component : public Component, public i2c::I2CDevice, public key_provider::KeyProvider { +class SX1509Component : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander, + public key_provider::KeyProvider { public: SX1509Component() = default; @@ -39,11 +43,9 @@ class SX1509Component : public Component, public i2c::I2CDevice, public key_prov float get_setup_priority() const override { return setup_priority::HARDWARE; } void loop() override; - bool digital_read(uint8_t pin); uint16_t read_key_data(); void set_pin_value(uint8_t pin, uint8_t i_on) { this->write_byte(REG_I_ON[pin], i_on); }; void pin_mode(uint8_t pin, gpio::Flags flags); - void digital_write(uint8_t pin, bool bit_value); uint32_t get_clock() { return this->clk_x_; }; void set_rows_cols(uint8_t rows, uint8_t cols) { this->rows_ = rows; @@ -61,10 +63,15 @@ class SX1509Component : public Component, public i2c::I2CDevice, public key_prov void setup_led_driver(uint8_t pin); protected: + // Virtual methods from CachedGpioExpander + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + uint32_t clk_x_ = 2000000; uint8_t frequency_ = 0; uint16_t ddr_mask_ = 0x00; - uint16_t input_mask_ = 0x00; + uint16_t input_mask_ = 0x00; // Cache for input values (16-bit for all pins) uint16_t port_mask_ = 0x00; uint16_t output_state_ = 0x00; bool has_keypad_ = false; From 06833d6f8b06917f5ddd46b87ae002ddcf7ee2f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 14:36:27 -0500 Subject: [PATCH 0885/1626] [sx1509] Migrate to CachedGpioExpander to reduce I2C bus usage --- esphome/components/sx1509/sx1509.cpp | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index ea3d5a6adb..a7f2e446e3 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -77,15 +77,12 @@ void SX1509Component::loop() { } bool SX1509Component::digital_read_hw(uint8_t pin) { - if (this->ddr_mask_ & (1 << pin)) { - if (!this->read_byte_16(REG_DATA_B, &this->input_mask_)) - return false; - return true; - } - return false; + // Always read all pins when any input pin is accessed + return this->read_byte_16(REG_DATA_B, &this->input_mask_); } bool SX1509Component::digital_read_cache(uint8_t pin) { + // Return cached value for input pins, false for output pins if (this->ddr_mask_ & (1 << pin)) { return this->input_mask_ & (1 << pin); } From 245c36e628b179188b87de649d0d3ee06e632741 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 14:45:34 -0500 Subject: [PATCH 0886/1626] fix --- esphome/components/sx1509/sx1509.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index a7f2e446e3..746ec9cda3 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -84,7 +84,7 @@ bool SX1509Component::digital_read_hw(uint8_t pin) { bool SX1509Component::digital_read_cache(uint8_t pin) { // Return cached value for input pins, false for output pins if (this->ddr_mask_ & (1 << pin)) { - return this->input_mask_ & (1 << pin); + return (this->input_mask_ & (1 << pin)) != 0; } return false; } From e327ae8c95a220f6843a056972d98b8c050ae39d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 23:36:11 +0000 Subject: [PATCH 0887/1626] Bump pytest-mock from 3.14.1 to 3.15.0 (#10593) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index ba4ebeef2e..eba14fc0b1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ pre-commit # Unit tests pytest==8.4.2 pytest-cov==6.2.1 -pytest-mock==3.14.1 +pytest-mock==3.15.0 pytest-asyncio==1.1.0 pytest-xdist==3.8.0 asyncmock==0.4.2 From 365a427b5776ebbe240e5e7786fd9ab242301f76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 23:37:03 +0000 Subject: [PATCH 0888/1626] Bump aioesphomeapi from 39.0.1 to 40.0.0 (#10594) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b86c7765c7..60ce3e67e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==39.0.1 +aioesphomeapi==40.0.0 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From ccbe629f8d62a70fc589f1127d81b319319df75c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 19:58:44 -0500 Subject: [PATCH 0889/1626] Fix DNS resolution inconsistency between logs and OTA operations --- esphome/__main__.py | 51 ++++++++-------- esphome/espota2.py | 10 +++- esphome/helpers.py | 141 ++++++++++++++++++++------------------------ esphome/resolver.py | 61 +++++++++++++++++++ 4 files changed, 155 insertions(+), 108 deletions(-) create mode 100644 esphome/resolver.py diff --git a/esphome/__main__.py b/esphome/__main__.py index aab3035a5e..e3182ea55f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -396,25 +396,27 @@ def check_permissions(port: str): ) -def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | str: +def upload_program( + config: ConfigType, args: ArgsProtocol, devices: list[str] +) -> int | str: try: module = importlib.import_module("esphome.components." + CORE.target_platform) - if getattr(module, "upload_program")(config, args, host): + if getattr(module, "upload_program")(config, args, devices[0]): return 0 except AttributeError: pass - if get_port_type(host) == "SERIAL": - check_permissions(host) + if get_port_type(devices[0]) == "SERIAL": + check_permissions(devices[0]) if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266): file = getattr(args, "file", None) - return upload_using_esptool(config, host, file, args.upload_speed) + return upload_using_esptool(config, devices[0], file, args.upload_speed) if CORE.target_platform in (PLATFORM_RP2040): - return upload_using_platformio(config, host) + return upload_using_platformio(config, devices[0]) if CORE.is_libretiny: - return upload_using_platformio(config, host) + return upload_using_platformio(config, devices[0]) return 1 # Unknown target platform @@ -433,28 +435,27 @@ def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | s remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD, "") + binary = args.file if getattr(args, "file", None) is not None else CORE.firmware_bin # Check if we should use MQTT for address resolution # This happens when no device was specified, or the current host is "MQTT"/"OTA" - devices: list[str] = args.device or [] if ( CONF_MQTT in config # pylint: disable=too-many-boolean-expressions - and (not devices or host in ("MQTT", "OTA")) + and (not devices or devices[0] in ("MQTT", "OTA")) and ( ((config[CONF_MDNS][CONF_DISABLED]) and not is_ip_address(CORE.address)) - or get_port_type(host) == "MQTT" + or get_port_type(devices[0]) == "MQTT" ) ): from esphome import mqtt - host = mqtt.get_esphome_device_ip( - config, args.username, args.password, args.client_id - ) + devices = [ + mqtt.get_esphome_device_ip( + config, args.username, args.password, args.client_id + ) + ] - if getattr(args, "file", None) is not None: - return espota2.run_ota(host, remote_port, password, args.file) - - return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) + return espota2.run_ota(devices, remote_port, password, binary) def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: @@ -551,17 +552,11 @@ def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: purpose="uploading", ) - # Try each device until one succeeds - exit_code = 1 - for device in devices: - _LOGGER.info("Uploading to %s", device) - exit_code = upload_program(config, args, device) - if exit_code == 0: - _LOGGER.info("Successfully uploaded program.") - return 0 - if len(devices) > 1: - _LOGGER.warning("Failed to upload to %s", device) - + exit_code = upload_program(config, args, devices) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + else: + _LOGGER.warning("Failed to upload to %s", devices) return exit_code diff --git a/esphome/espota2.py b/esphome/espota2.py index 279bafee8e..d83f25a303 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -308,8 +308,12 @@ def perform_ota( time.sleep(1) -def run_ota_impl_(remote_host, remote_port, password, filename): +def run_ota_impl_( + remote_host: str | list[str], remote_port: int, password: str, filename: str +) -> int: + # Handle both single host and list of hosts try: + # Resolve all hosts at once for parallel DNS resolution res = resolve_ip_address(remote_host, remote_port) except EsphomeError as err: _LOGGER.error( @@ -350,7 +354,9 @@ def run_ota_impl_(remote_host, remote_port, password, filename): return 1 -def run_ota(remote_host, remote_port, password, filename): +def run_ota( + remote_host: str | list[str], remote_port: int, password: str, filename: str +) -> int: try: return run_ota_impl_(remote_host, remote_port, password, filename) except OTAError as err: diff --git a/esphome/helpers.py b/esphome/helpers.py index 377a4e1717..b00c97ff73 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import codecs from contextlib import suppress import ipaddress @@ -11,6 +13,18 @@ from urllib.parse import urlparse from esphome.const import __version__ as ESPHOME_VERSION +# Type aliases for socket address information +AddrInfo = tuple[ + int, # family (AF_INET, AF_INET6, etc.) + int, # type (SOCK_STREAM, SOCK_DGRAM, etc.) + int, # proto (IPPROTO_TCP, etc.) + str, # canonname + tuple[str, int] | tuple[str, int, int, int], # sockaddr (IPv4 or IPv6) +] +IPv4SockAddr = tuple[str, int] # (host, port) +IPv6SockAddr = tuple[str, int, int, int] # (host, port, flowinfo, scope_id) +SockAddr = IPv4SockAddr | IPv6SockAddr + _LOGGER = logging.getLogger(__name__) IS_MACOS = platform.system() == "Darwin" @@ -147,32 +161,7 @@ def is_ip_address(host): return False -def _resolve_with_zeroconf(host): - from esphome.core import EsphomeError - from esphome.zeroconf import EsphomeZeroconf - - try: - zc = EsphomeZeroconf() - except Exception as err: - raise EsphomeError( - "Cannot start mDNS sockets, is this a docker container without " - "host network mode?" - ) from err - try: - info = zc.resolve_host(f"{host}.") - except Exception as err: - raise EsphomeError(f"Error resolving mDNS hostname: {err}") from err - finally: - zc.close() - if info is None: - raise EsphomeError( - "Error resolving address with mDNS: Did not respond. " - "Maybe the device is offline." - ) - return info - - -def addr_preference_(res): +def addr_preference_(res: AddrInfo) -> int: # Trivial alternative to RFC6724 sorting. Put sane IPv6 first, then # Legacy IP, then IPv6 link-local addresses without an actual link. sa = res[4] @@ -184,66 +173,70 @@ def addr_preference_(res): return 1 -def resolve_ip_address(host, port): +def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: import socket - from esphome.core import EsphomeError - # There are five cases here. The host argument could be one of: # • a *list* of IP addresses discovered by MQTT, # • a single IP address specified by the user, # • a .local hostname to be resolved by mDNS, # • a normal hostname to be resolved in DNS, or # • A URL from which we should extract the hostname. - # - # In each of the first three cases, we end up with IP addresses in - # string form which need to be converted to a 5-tuple to be used - # for the socket connection attempt. The easiest way to construct - # those is to pass the IP address string to getaddrinfo(). Which, - # coincidentally, is how we do hostname lookups in the other cases - # too. So first build a list which contains either IP addresses or - # a single hostname, then call getaddrinfo() on each element of - # that list. - errs = [] + hosts: list[str] if isinstance(host, list): - addr_list = host - elif is_ip_address(host): - addr_list = [host] + hosts = host else: - url = urlparse(host) - if url.scheme != "": - host = url.hostname + if not is_ip_address(host): + url = urlparse(host) + if url.scheme != "": + host = url.hostname + hosts = [host] - addr_list = [] - if host.endswith(".local"): + res: list[AddrInfo] = [] + if all(is_ip_address(h) for h in hosts): + # Fast path: all are IP addresses, use socket.getaddrinfo with AI_NUMERICHOST + for addr in hosts: try: - _LOGGER.info("Resolving IP address of %s in mDNS", host) - addr_list = _resolve_with_zeroconf(host) - except EsphomeError as err: - errs.append(str(err)) + res += socket.getaddrinfo( + addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + except OSError: + _LOGGER.debug("Failed to parse IP address '%s'", addr) + # Sort by preference + res.sort(key=addr_preference_) + return res - # If not mDNS, or if mDNS failed, use normal DNS - if not addr_list: - addr_list = [host] + from esphome.resolver import AsyncResolver - # Now we have a list containing either IP addresses or a hostname - res = [] - for addr in addr_list: - if not is_ip_address(addr): - _LOGGER.info("Resolving IP address of %s", host) - try: - r = socket.getaddrinfo(addr, port, proto=socket.IPPROTO_TCP) - except OSError as err: - errs.append(str(err)) - raise EsphomeError( - f"Error resolving IP address: {', '.join(errs)}" - ) from err + resolver = AsyncResolver() + addr_infos = resolver.run(hosts, port) + # Convert aioesphomeapi AddrInfo to our format + for addr_info in addr_infos: + sockaddr = addr_info.sockaddr + if addr_info.family == socket.AF_INET6: + # IPv6 + sockaddr_tuple = ( + sockaddr.address, + sockaddr.port, + sockaddr.flowinfo, + sockaddr.scope_id, + ) + else: + # IPv4 + sockaddr_tuple = (sockaddr.address, sockaddr.port) - res = res + r + res.append( + ( + addr_info.family, + addr_info.type, + addr_info.proto, + "", # canonname + sockaddr_tuple, + ) + ) - # Zeroconf tends to give us link-local IPv6 addresses without specifying - # the link. Put those last in the list to be attempted. + # Sort by preference res.sort(key=addr_preference_) return res @@ -262,15 +255,7 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]: # First "resolve" all the IP addresses to getaddrinfo() tuples of the form # (family, type, proto, canonname, sockaddr) - res: list[ - tuple[ - int, - int, - int, - str | None, - tuple[str, int] | tuple[str, int, int, int], - ] - ] = [] + res: list[AddrInfo] = [] for addr in address_list: # This should always work as these are supposed to be IP addresses try: diff --git a/esphome/resolver.py b/esphome/resolver.py new file mode 100644 index 0000000000..a245737962 --- /dev/null +++ b/esphome/resolver.py @@ -0,0 +1,61 @@ +"""DNS resolver for ESPHome using aioesphomeapi.""" + +from __future__ import annotations + +import asyncio +import threading + +from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError +import aioesphomeapi.host_resolver as hr + +from esphome.core import EsphomeError + +RESOLVE_TIMEOUT = 10.0 # seconds + + +class AsyncResolver: + """Resolver using aioesphomeapi that runs in a thread for faster results. + + This resolver uses aioesphomeapi's async_resolve_host to handle DNS resolution, + including proper .local domain fallback. Running in a thread allows us to get + the result immediately without waiting for asyncio.run() to complete its + cleanup cycle, which can take significant time. + """ + + def __init__(self) -> None: + """Initialize the resolver.""" + self.result: list[hr.AddrInfo] | None = None + self.exception: Exception | None = None + self.event = threading.Event() + + async def _resolve(self, hosts: list[str], port: int) -> None: + """Resolve hostnames to IP addresses.""" + try: + self.result = await hr.async_resolve_host( + hosts, port, timeout=RESOLVE_TIMEOUT + ) + except Exception as e: + self.exception = e + finally: + self.event.set() + + def run(self, hosts: list[str], port: int) -> list[hr.AddrInfo]: + """Run the DNS resolution in a separate thread.""" + thread = threading.Thread( + target=lambda: asyncio.run(self._resolve(hosts, port)), daemon=True + ) + thread.start() + + if not self.event.wait( + timeout=RESOLVE_TIMEOUT + 1.0 + ): # Give it 1 second more than the resolver timeout + raise EsphomeError("Timeout resolving IP address") + + if exc := self.exception: + if isinstance(exc, ResolveAPIError): + raise EsphomeError(f"Error resolving IP address: {exc}") from exc + if isinstance(exc, ResolveTimeoutAPIError): + raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc + raise exc + + return self.result From d7aec744b78b2cd7228cc36698ed177d3fca2739 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 20:00:31 -0500 Subject: [PATCH 0890/1626] preen --- esphome/__main__.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index e3182ea55f..70d5cacd72 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -399,24 +399,25 @@ def check_permissions(port: str): def upload_program( config: ConfigType, args: ArgsProtocol, devices: list[str] ) -> int | str: + host = devices[0] try: module = importlib.import_module("esphome.components." + CORE.target_platform) - if getattr(module, "upload_program")(config, args, devices[0]): + if getattr(module, "upload_program")(config, args, host): return 0 except AttributeError: pass - if get_port_type(devices[0]) == "SERIAL": - check_permissions(devices[0]) + if get_port_type(host) == "SERIAL": + check_permissions(host) if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266): file = getattr(args, "file", None) - return upload_using_esptool(config, devices[0], file, args.upload_speed) + return upload_using_esptool(config, host, file, args.upload_speed) if CORE.target_platform in (PLATFORM_RP2040): - return upload_using_platformio(config, devices[0]) + return upload_using_platformio(config, host) if CORE.is_libretiny: - return upload_using_platformio(config, devices[0]) + return upload_using_platformio(config, host) return 1 # Unknown target platform @@ -441,10 +442,10 @@ def upload_program( # This happens when no device was specified, or the current host is "MQTT"/"OTA" if ( CONF_MQTT in config # pylint: disable=too-many-boolean-expressions - and (not devices or devices[0] in ("MQTT", "OTA")) + and (not devices or host in ("MQTT", "OTA")) and ( ((config[CONF_MDNS][CONF_DISABLED]) and not is_ip_address(CORE.address)) - or get_port_type(devices[0]) == "MQTT" + or get_port_type(host) == "MQTT" ) ): from esphome import mqtt From a282920d7c9fd79b287de7eee3662dd7c2841629 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 20:06:55 -0500 Subject: [PATCH 0891/1626] fix, cover --- esphome/resolver.py | 4 +- tests/unit_tests/test_helpers.py | 255 ++++++++++++++++++++++++++++++ tests/unit_tests/test_resolver.py | 157 ++++++++++++++++++ 3 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/test_resolver.py diff --git a/esphome/resolver.py b/esphome/resolver.py index a245737962..f70ecec357 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -52,10 +52,10 @@ class AsyncResolver: raise EsphomeError("Timeout resolving IP address") if exc := self.exception: - if isinstance(exc, ResolveAPIError): - raise EsphomeError(f"Error resolving IP address: {exc}") from exc if isinstance(exc, ResolveTimeoutAPIError): raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc + if isinstance(exc, ResolveAPIError): + raise EsphomeError(f"Error resolving IP address: {exc}") from exc raise exc return self.result diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index b353d1aa99..706acdd359 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1,8 +1,13 @@ +import socket +from unittest.mock import patch + +from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr from hypothesis import given from hypothesis.strategies import ip_addresses import pytest from esphome import helpers +from esphome.core import EsphomeError @pytest.mark.parametrize( @@ -277,3 +282,253 @@ def test_sort_ip_addresses(text: list[str], expected: list[str]) -> None: actual = helpers.sort_ip_addresses(text) assert actual == expected + + +# DNS resolution tests +def test_is_ip_address_ipv4(): + """Test is_ip_address with IPv4 addresses.""" + assert helpers.is_ip_address("192.168.1.1") is True + assert helpers.is_ip_address("127.0.0.1") is True + assert helpers.is_ip_address("255.255.255.255") is True + assert helpers.is_ip_address("0.0.0.0") is True + + +def test_is_ip_address_ipv6(): + """Test is_ip_address with IPv6 addresses.""" + assert helpers.is_ip_address("::1") is True + assert helpers.is_ip_address("2001:db8::1") is True + assert helpers.is_ip_address("fe80::1") is True + assert helpers.is_ip_address("::") is True + + +def test_is_ip_address_invalid(): + """Test is_ip_address with non-IP strings.""" + assert helpers.is_ip_address("hostname") is False + assert helpers.is_ip_address("hostname.local") is False + assert helpers.is_ip_address("256.256.256.256") is False + assert helpers.is_ip_address("192.168.1") is False + assert helpers.is_ip_address("") is False + + +def test_resolve_ip_address_single_ipv4(): + """Test resolving a single IPv4 address (fast path).""" + result = helpers.resolve_ip_address("192.168.1.100", 6053) + + assert len(result) == 1 + assert result[0][0] == socket.AF_INET # family + assert result[0][1] == socket.SOCK_STREAM # type + assert result[0][2] == socket.IPPROTO_TCP # proto + assert result[0][3] == "" # canonname + assert result[0][4] == ("192.168.1.100", 6053) # sockaddr + + +def test_resolve_ip_address_single_ipv6(): + """Test resolving a single IPv6 address (fast path).""" + result = helpers.resolve_ip_address("::1", 6053) + + assert len(result) == 1 + assert result[0][0] == socket.AF_INET6 # family + assert result[0][1] == socket.SOCK_STREAM # type + assert result[0][2] == socket.IPPROTO_TCP # proto + assert result[0][3] == "" # canonname + # IPv6 sockaddr has 4 elements + assert len(result[0][4]) == 4 + assert result[0][4][0] == "::1" # address + assert result[0][4][1] == 6053 # port + + +def test_resolve_ip_address_list_of_ips(): + """Test resolving a list of IP addresses (fast path).""" + ips = ["192.168.1.100", "10.0.0.1", "::1"] + result = helpers.resolve_ip_address(ips, 6053) + + # Should return results sorted by preference (IPv6 first, then IPv4) + assert len(result) >= 2 # At least IPv4 addresses should work + + # Check that results are properly formatted + for addr_info in result: + assert addr_info[0] in (socket.AF_INET, socket.AF_INET6) + assert addr_info[1] == socket.SOCK_STREAM + assert addr_info[2] == socket.IPPROTO_TCP + assert addr_info[3] == "" + + +def test_resolve_ip_address_hostname(): + """Test resolving a hostname (async resolver path).""" + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.run.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address("test.local", 6053) + + assert len(result) == 1 + assert result[0][0] == socket.AF_INET + assert result[0][4] == ("192.168.1.100", 6053) + mock_resolver.run.assert_called_once_with(["test.local"], 6053) + + +def test_resolve_ip_address_mixed_list(): + """Test resolving a mix of IPs and hostnames.""" + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.200", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.run.return_value = [mock_addr_info] + + # Mix of IP and hostname - should use async resolver + result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053) + + assert len(result) == 1 + assert result[0][4][0] == "192.168.1.200" + mock_resolver.run.assert_called_once_with(["192.168.1.100", "test.local"], 6053) + + +def test_resolve_ip_address_url(): + """Test extracting hostname from URL.""" + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.run.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address("http://test.local", 6053) + + assert len(result) == 1 + mock_resolver.run.assert_called_once_with(["test.local"], 6053) + + +def test_resolve_ip_address_ipv6_conversion(): + """Test proper IPv6 address info conversion.""" + mock_addr_info = AddrInfo( + family=socket.AF_INET6, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv6Sockaddr(address="2001:db8::1", port=6053, flowinfo=1, scope_id=2), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.run.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address("test.local", 6053) + + assert len(result) == 1 + assert result[0][0] == socket.AF_INET6 + assert result[0][4] == ("2001:db8::1", 6053, 1, 2) + + +def test_resolve_ip_address_error_handling(): + """Test error handling from AsyncResolver.""" + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.run.side_effect = EsphomeError("Resolution failed") + + with pytest.raises(EsphomeError, match="Resolution failed"): + helpers.resolve_ip_address("test.local", 6053) + + +def test_addr_preference_ipv4(): + """Test address preference for IPv4.""" + addr_info = ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("192.168.1.1", 6053), + ) + assert helpers.addr_preference_(addr_info) == 2 + + +def test_addr_preference_ipv6(): + """Test address preference for regular IPv6.""" + addr_info = ( + socket.AF_INET6, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("2001:db8::1", 6053, 0, 0), + ) + assert helpers.addr_preference_(addr_info) == 1 + + +def test_addr_preference_ipv6_link_local_no_scope(): + """Test address preference for link-local IPv6 without scope.""" + addr_info = ( + socket.AF_INET6, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("fe80::1", 6053, 0, 0), # link-local with scope_id=0 + ) + assert helpers.addr_preference_(addr_info) == 3 + + +def test_addr_preference_ipv6_link_local_with_scope(): + """Test address preference for link-local IPv6 with scope.""" + addr_info = ( + socket.AF_INET6, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("fe80::1", 6053, 0, 2), # link-local with scope_id=2 + ) + assert helpers.addr_preference_(addr_info) == 1 # Has scope, so it's usable + + +def test_resolve_ip_address_sorting(): + """Test that results are sorted by preference.""" + # Create multiple address infos with different preferences + mock_addr_infos = [ + AddrInfo( + family=socket.AF_INET6, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv6Sockaddr( + address="fe80::1", port=6053, flowinfo=0, scope_id=0 + ), # Preference 3 (link-local no scope) + ), + AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr( + address="192.168.1.100", port=6053 + ), # Preference 2 (IPv4) + ), + AddrInfo( + family=socket.AF_INET6, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv6Sockaddr( + address="2001:db8::1", port=6053, flowinfo=0, scope_id=0 + ), # Preference 1 (IPv6) + ), + ] + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.run.return_value = mock_addr_infos + + result = helpers.resolve_ip_address("test.local", 6053) + + # Should be sorted: IPv6 first, then IPv4, then link-local without scope + assert result[0][4][0] == "2001:db8::1" # IPv6 (preference 1) + assert result[1][4][0] == "192.168.1.100" # IPv4 (preference 2) + assert result[2][4][0] == "fe80::1" # Link-local no scope (preference 3) diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py new file mode 100644 index 0000000000..d49a367085 --- /dev/null +++ b/tests/unit_tests/test_resolver.py @@ -0,0 +1,157 @@ +"""Tests for the DNS resolver module.""" + +from __future__ import annotations + +import asyncio +import socket +from unittest.mock import patch + +from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError +from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr +import pytest + +from esphome.core import EsphomeError +from esphome.resolver import RESOLVE_TIMEOUT, AsyncResolver + + +@pytest.fixture +def mock_addr_info_ipv4(): + """Create a mock IPv4 AddrInfo.""" + return AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + +@pytest.fixture +def mock_addr_info_ipv6(): + """Create a mock IPv6 AddrInfo.""" + return AddrInfo( + family=socket.AF_INET6, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv6Sockaddr(address="2001:db8::1", port=6053, flowinfo=0, scope_id=0), + ) + + +def test_async_resolver_successful_resolution(mock_addr_info_ipv4): + """Test successful DNS resolution.""" + with patch( + "esphome.resolver.hr.async_resolve_host", + return_value=[mock_addr_info_ipv4], + ) as mock_resolve: + resolver = AsyncResolver() + result = resolver.run(["test.local"], 6053) + + assert result == [mock_addr_info_ipv4] + mock_resolve.assert_called_once_with( + ["test.local"], 6053, timeout=RESOLVE_TIMEOUT + ) + + +def test_async_resolver_multiple_hosts(mock_addr_info_ipv4, mock_addr_info_ipv6): + """Test resolving multiple hosts.""" + mock_results = [mock_addr_info_ipv4, mock_addr_info_ipv6] + + with patch( + "esphome.resolver.hr.async_resolve_host", + return_value=mock_results, + ) as mock_resolve: + resolver = AsyncResolver() + result = resolver.run(["test1.local", "test2.local"], 6053) + + assert result == mock_results + mock_resolve.assert_called_once_with( + ["test1.local", "test2.local"], 6053, timeout=RESOLVE_TIMEOUT + ) + + +def test_async_resolver_resolve_api_error(): + """Test handling of ResolveAPIError.""" + error_msg = "Failed to resolve" + with patch( + "esphome.resolver.hr.async_resolve_host", + side_effect=ResolveAPIError(error_msg), + ): + resolver = AsyncResolver() + with pytest.raises( + EsphomeError, match=f"Error resolving IP address: {error_msg}" + ): + resolver.run(["test.local"], 6053) + + +def test_async_resolver_timeout_error(): + """Test handling of ResolveTimeoutAPIError.""" + error_msg = "Resolution timed out" + with patch( + "esphome.resolver.hr.async_resolve_host", + side_effect=ResolveTimeoutAPIError(error_msg), + ): + resolver = AsyncResolver() + with pytest.raises( + EsphomeError, match=f"Timeout resolving IP address: {error_msg}" + ): + resolver.run(["test.local"], 6053) + + +def test_async_resolver_generic_exception(): + """Test handling of generic exceptions.""" + error = RuntimeError("Unexpected error") + with patch( + "esphome.resolver.hr.async_resolve_host", + side_effect=error, + ): + resolver = AsyncResolver() + with pytest.raises(RuntimeError, match="Unexpected error"): + resolver.run(["test.local"], 6053) + + +def test_async_resolver_thread_timeout(): + """Test timeout when thread doesn't complete in time.""" + + async def slow_resolve(hosts, port, timeout): + await asyncio.sleep(100) # Sleep longer than timeout + return [] + + with patch("esphome.resolver.hr.async_resolve_host", slow_resolve): + resolver = AsyncResolver() + # Override event.wait to simulate timeout + with ( + patch.object(resolver.event, "wait", return_value=False), + pytest.raises(EsphomeError, match="Timeout resolving IP address"), + ): + resolver.run(["test.local"], 6053) + + +def test_async_resolver_ip_addresses(mock_addr_info_ipv4): + """Test resolving IP addresses.""" + with patch( + "esphome.resolver.hr.async_resolve_host", + return_value=[mock_addr_info_ipv4], + ) as mock_resolve: + resolver = AsyncResolver() + result = resolver.run(["192.168.1.100"], 6053) + + assert result == [mock_addr_info_ipv4] + mock_resolve.assert_called_once_with( + ["192.168.1.100"], 6053, timeout=RESOLVE_TIMEOUT + ) + + +def test_async_resolver_mixed_addresses(mock_addr_info_ipv4, mock_addr_info_ipv6): + """Test resolving mix of hostnames and IP addresses.""" + mock_results = [mock_addr_info_ipv4, mock_addr_info_ipv6] + + with patch( + "esphome.resolver.hr.async_resolve_host", + return_value=mock_results, + ) as mock_resolve: + resolver = AsyncResolver() + result = resolver.run(["test.local", "192.168.1.100", "::1"], 6053) + + assert result == mock_results + mock_resolve.assert_called_once_with( + ["test.local", "192.168.1.100", "::1"], 6053, timeout=RESOLVE_TIMEOUT + ) From b8ed7ec145d72657ed5805a08932af82d289f243 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 01:08:15 +0000 Subject: [PATCH 0892/1626] Bump aioesphomeapi from 40.0.0 to 40.0.1 (#10596) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 60ce3e67e3..bac5622708 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==40.0.0 +aioesphomeapi==40.0.1 zeroconf==0.147.0 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 2d37518c00be97128def9e7881d6b9bf08b42e39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 20:16:30 -0500 Subject: [PATCH 0893/1626] fix, cover --- esphome/resolver.py | 2 +- tests/unit_tests/test_helpers.py | 62 ++++++++++++++++++++----------- tests/unit_tests/test_resolver.py | 24 +++++++----- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/esphome/resolver.py b/esphome/resolver.py index f70ecec357..dff0ca32d7 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -34,7 +34,7 @@ class AsyncResolver: self.result = await hr.async_resolve_host( hosts, port, timeout=RESOLVE_TIMEOUT ) - except Exception as e: + except (ResolveAPIError, ResolveTimeoutAPIError, OSError) as e: self.exception = e finally: self.event.set() diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 706acdd359..867e19a604 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -285,7 +285,7 @@ def test_sort_ip_addresses(text: list[str], expected: list[str]) -> None: # DNS resolution tests -def test_is_ip_address_ipv4(): +def test_is_ip_address_ipv4() -> None: """Test is_ip_address with IPv4 addresses.""" assert helpers.is_ip_address("192.168.1.1") is True assert helpers.is_ip_address("127.0.0.1") is True @@ -293,7 +293,7 @@ def test_is_ip_address_ipv4(): assert helpers.is_ip_address("0.0.0.0") is True -def test_is_ip_address_ipv6(): +def test_is_ip_address_ipv6() -> None: """Test is_ip_address with IPv6 addresses.""" assert helpers.is_ip_address("::1") is True assert helpers.is_ip_address("2001:db8::1") is True @@ -301,7 +301,7 @@ def test_is_ip_address_ipv6(): assert helpers.is_ip_address("::") is True -def test_is_ip_address_invalid(): +def test_is_ip_address_invalid() -> None: """Test is_ip_address with non-IP strings.""" assert helpers.is_ip_address("hostname") is False assert helpers.is_ip_address("hostname.local") is False @@ -310,26 +310,38 @@ def test_is_ip_address_invalid(): assert helpers.is_ip_address("") is False -def test_resolve_ip_address_single_ipv4(): +def test_resolve_ip_address_single_ipv4() -> None: """Test resolving a single IPv4 address (fast path).""" result = helpers.resolve_ip_address("192.168.1.100", 6053) assert len(result) == 1 assert result[0][0] == socket.AF_INET # family - assert result[0][1] == socket.SOCK_STREAM # type - assert result[0][2] == socket.IPPROTO_TCP # proto + assert result[0][1] in ( + 0, + socket.SOCK_STREAM, + ) # type (0 on Windows with AI_NUMERICHOST) + assert result[0][2] in ( + 0, + socket.IPPROTO_TCP, + ) # proto (0 on Windows with AI_NUMERICHOST) assert result[0][3] == "" # canonname assert result[0][4] == ("192.168.1.100", 6053) # sockaddr -def test_resolve_ip_address_single_ipv6(): +def test_resolve_ip_address_single_ipv6() -> None: """Test resolving a single IPv6 address (fast path).""" result = helpers.resolve_ip_address("::1", 6053) assert len(result) == 1 assert result[0][0] == socket.AF_INET6 # family - assert result[0][1] == socket.SOCK_STREAM # type - assert result[0][2] == socket.IPPROTO_TCP # proto + assert result[0][1] in ( + 0, + socket.SOCK_STREAM, + ) # type (0 on Windows with AI_NUMERICHOST) + assert result[0][2] in ( + 0, + socket.IPPROTO_TCP, + ) # proto (0 on Windows with AI_NUMERICHOST) assert result[0][3] == "" # canonname # IPv6 sockaddr has 4 elements assert len(result[0][4]) == 4 @@ -337,7 +349,7 @@ def test_resolve_ip_address_single_ipv6(): assert result[0][4][1] == 6053 # port -def test_resolve_ip_address_list_of_ips(): +def test_resolve_ip_address_list_of_ips() -> None: """Test resolving a list of IP addresses (fast path).""" ips = ["192.168.1.100", "10.0.0.1", "::1"] result = helpers.resolve_ip_address(ips, 6053) @@ -348,12 +360,18 @@ def test_resolve_ip_address_list_of_ips(): # Check that results are properly formatted for addr_info in result: assert addr_info[0] in (socket.AF_INET, socket.AF_INET6) - assert addr_info[1] == socket.SOCK_STREAM - assert addr_info[2] == socket.IPPROTO_TCP + assert addr_info[1] in ( + 0, + socket.SOCK_STREAM, + ) # 0 on Windows with AI_NUMERICHOST + assert addr_info[2] in ( + 0, + socket.IPPROTO_TCP, + ) # 0 on Windows with AI_NUMERICHOST assert addr_info[3] == "" -def test_resolve_ip_address_hostname(): +def test_resolve_ip_address_hostname() -> None: """Test resolving a hostname (async resolver path).""" mock_addr_info = AddrInfo( family=socket.AF_INET, @@ -374,7 +392,7 @@ def test_resolve_ip_address_hostname(): mock_resolver.run.assert_called_once_with(["test.local"], 6053) -def test_resolve_ip_address_mixed_list(): +def test_resolve_ip_address_mixed_list() -> None: """Test resolving a mix of IPs and hostnames.""" mock_addr_info = AddrInfo( family=socket.AF_INET, @@ -395,7 +413,7 @@ def test_resolve_ip_address_mixed_list(): mock_resolver.run.assert_called_once_with(["192.168.1.100", "test.local"], 6053) -def test_resolve_ip_address_url(): +def test_resolve_ip_address_url() -> None: """Test extracting hostname from URL.""" mock_addr_info = AddrInfo( family=socket.AF_INET, @@ -414,7 +432,7 @@ def test_resolve_ip_address_url(): mock_resolver.run.assert_called_once_with(["test.local"], 6053) -def test_resolve_ip_address_ipv6_conversion(): +def test_resolve_ip_address_ipv6_conversion() -> None: """Test proper IPv6 address info conversion.""" mock_addr_info = AddrInfo( family=socket.AF_INET6, @@ -434,7 +452,7 @@ def test_resolve_ip_address_ipv6_conversion(): assert result[0][4] == ("2001:db8::1", 6053, 1, 2) -def test_resolve_ip_address_error_handling(): +def test_resolve_ip_address_error_handling() -> None: """Test error handling from AsyncResolver.""" with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value @@ -444,7 +462,7 @@ def test_resolve_ip_address_error_handling(): helpers.resolve_ip_address("test.local", 6053) -def test_addr_preference_ipv4(): +def test_addr_preference_ipv4() -> None: """Test address preference for IPv4.""" addr_info = ( socket.AF_INET, @@ -456,7 +474,7 @@ def test_addr_preference_ipv4(): assert helpers.addr_preference_(addr_info) == 2 -def test_addr_preference_ipv6(): +def test_addr_preference_ipv6() -> None: """Test address preference for regular IPv6.""" addr_info = ( socket.AF_INET6, @@ -468,7 +486,7 @@ def test_addr_preference_ipv6(): assert helpers.addr_preference_(addr_info) == 1 -def test_addr_preference_ipv6_link_local_no_scope(): +def test_addr_preference_ipv6_link_local_no_scope() -> None: """Test address preference for link-local IPv6 without scope.""" addr_info = ( socket.AF_INET6, @@ -480,7 +498,7 @@ def test_addr_preference_ipv6_link_local_no_scope(): assert helpers.addr_preference_(addr_info) == 3 -def test_addr_preference_ipv6_link_local_with_scope(): +def test_addr_preference_ipv6_link_local_with_scope() -> None: """Test address preference for link-local IPv6 with scope.""" addr_info = ( socket.AF_INET6, @@ -492,7 +510,7 @@ def test_addr_preference_ipv6_link_local_with_scope(): assert helpers.addr_preference_(addr_info) == 1 # Has scope, so it's usable -def test_resolve_ip_address_sorting(): +def test_resolve_ip_address_sorting() -> None: """Test that results are sorted by preference.""" # Create multiple address infos with different preferences mock_addr_infos = [ diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index d49a367085..2044443ddd 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -15,7 +15,7 @@ from esphome.resolver import RESOLVE_TIMEOUT, AsyncResolver @pytest.fixture -def mock_addr_info_ipv4(): +def mock_addr_info_ipv4() -> AddrInfo: """Create a mock IPv4 AddrInfo.""" return AddrInfo( family=socket.AF_INET, @@ -26,7 +26,7 @@ def mock_addr_info_ipv4(): @pytest.fixture -def mock_addr_info_ipv6(): +def mock_addr_info_ipv6() -> AddrInfo: """Create a mock IPv6 AddrInfo.""" return AddrInfo( family=socket.AF_INET6, @@ -36,7 +36,7 @@ def mock_addr_info_ipv6(): ) -def test_async_resolver_successful_resolution(mock_addr_info_ipv4): +def test_async_resolver_successful_resolution(mock_addr_info_ipv4: AddrInfo) -> None: """Test successful DNS resolution.""" with patch( "esphome.resolver.hr.async_resolve_host", @@ -51,7 +51,9 @@ def test_async_resolver_successful_resolution(mock_addr_info_ipv4): ) -def test_async_resolver_multiple_hosts(mock_addr_info_ipv4, mock_addr_info_ipv6): +def test_async_resolver_multiple_hosts( + mock_addr_info_ipv4: AddrInfo, mock_addr_info_ipv6: AddrInfo +) -> None: """Test resolving multiple hosts.""" mock_results = [mock_addr_info_ipv4, mock_addr_info_ipv6] @@ -68,7 +70,7 @@ def test_async_resolver_multiple_hosts(mock_addr_info_ipv4, mock_addr_info_ipv6) ) -def test_async_resolver_resolve_api_error(): +def test_async_resolver_resolve_api_error() -> None: """Test handling of ResolveAPIError.""" error_msg = "Failed to resolve" with patch( @@ -82,7 +84,7 @@ def test_async_resolver_resolve_api_error(): resolver.run(["test.local"], 6053) -def test_async_resolver_timeout_error(): +def test_async_resolver_timeout_error() -> None: """Test handling of ResolveTimeoutAPIError.""" error_msg = "Resolution timed out" with patch( @@ -96,7 +98,7 @@ def test_async_resolver_timeout_error(): resolver.run(["test.local"], 6053) -def test_async_resolver_generic_exception(): +def test_async_resolver_generic_exception() -> None: """Test handling of generic exceptions.""" error = RuntimeError("Unexpected error") with patch( @@ -108,7 +110,7 @@ def test_async_resolver_generic_exception(): resolver.run(["test.local"], 6053) -def test_async_resolver_thread_timeout(): +def test_async_resolver_thread_timeout() -> None: """Test timeout when thread doesn't complete in time.""" async def slow_resolve(hosts, port, timeout): @@ -125,7 +127,7 @@ def test_async_resolver_thread_timeout(): resolver.run(["test.local"], 6053) -def test_async_resolver_ip_addresses(mock_addr_info_ipv4): +def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: """Test resolving IP addresses.""" with patch( "esphome.resolver.hr.async_resolve_host", @@ -140,7 +142,9 @@ def test_async_resolver_ip_addresses(mock_addr_info_ipv4): ) -def test_async_resolver_mixed_addresses(mock_addr_info_ipv4, mock_addr_info_ipv6): +def test_async_resolver_mixed_addresses( + mock_addr_info_ipv4: AddrInfo, mock_addr_info_ipv6: AddrInfo +) -> None: """Test resolving mix of hostnames and IP addresses.""" mock_results = [mock_addr_info_ipv4, mock_addr_info_ipv6] From 3fc928f5d1c1b9769619a1b1d733f7737caf17c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 20:17:08 -0500 Subject: [PATCH 0894/1626] fix, cover --- esphome/resolver.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/resolver.py b/esphome/resolver.py index dff0ca32d7..24972a456f 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -34,7 +34,9 @@ class AsyncResolver: self.result = await hr.async_resolve_host( hosts, port, timeout=RESOLVE_TIMEOUT ) - except (ResolveAPIError, ResolveTimeoutAPIError, OSError) as e: + except Exception as e: # pylint: disable=broad-except + # We need to catch all exceptions to ensure the event is set + # Otherwise the thread could hang forever self.exception = e finally: self.event.set() From f18303fe2b45b69d8fe51846b822ed9551cddc18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 20:56:05 -0500 Subject: [PATCH 0895/1626] fix test --- tests/unit_tests/test_helpers.py | 41 +++++++++++++++++++++++++++++++ tests/unit_tests/test_resolver.py | 24 +++++++++++++++--- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 867e19a604..9a052ad9c2 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1,3 +1,4 @@ +import logging import socket from unittest.mock import patch @@ -371,6 +372,46 @@ def test_resolve_ip_address_list_of_ips() -> None: assert addr_info[3] == "" +def test_resolve_ip_address_with_getaddrinfo_failure(caplog) -> None: + """Test that getaddrinfo OSError is handled gracefully in fast path.""" + with ( + caplog.at_level(logging.DEBUG), + patch("socket.getaddrinfo") as mock_getaddrinfo, + ): + # First IP succeeds + mock_getaddrinfo.side_effect = [ + [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("192.168.1.100", 6053), + ) + ], + OSError("Failed to resolve"), # Second IP fails + ] + + # Should continue despite one failure + result = helpers.resolve_ip_address(["192.168.1.100", "192.168.1.101"], 6053) + + # Should have result from first IP only + assert len(result) == 1 + assert result[0][4][0] == "192.168.1.100" + + # Verify both IPs were attempted + assert mock_getaddrinfo.call_count == 2 + mock_getaddrinfo.assert_any_call( + "192.168.1.100", 6053, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + mock_getaddrinfo.assert_any_call( + "192.168.1.101", 6053, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + + # Verify the debug log was called for the failed IP + assert "Failed to parse IP address '192.168.1.101'" in caplog.text + + def test_resolve_ip_address_hostname() -> None: """Test resolving a hostname (async resolver path).""" mock_addr_info = AddrInfo( diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index 2044443ddd..0ec3ef71c5 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -3,7 +3,10 @@ from __future__ import annotations import asyncio +import re import socket +import threading +import time from unittest.mock import patch from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError @@ -79,7 +82,7 @@ def test_async_resolver_resolve_api_error() -> None: ): resolver = AsyncResolver() with pytest.raises( - EsphomeError, match=f"Error resolving IP address: {error_msg}" + EsphomeError, match=re.escape(f"Error resolving IP address: {error_msg}") ): resolver.run(["test.local"], 6053) @@ -87,13 +90,17 @@ def test_async_resolver_resolve_api_error() -> None: def test_async_resolver_timeout_error() -> None: """Test handling of ResolveTimeoutAPIError.""" error_msg = "Resolution timed out" + with patch( "esphome.resolver.hr.async_resolve_host", side_effect=ResolveTimeoutAPIError(error_msg), ): resolver = AsyncResolver() + # Match either "Timeout" or "Error" since ResolveTimeoutAPIError is a subclass of ResolveAPIError + # and depending on import order/test execution context, it might be caught as either with pytest.raises( - EsphomeError, match=f"Timeout resolving IP address: {error_msg}" + EsphomeError, + match=f"(Timeout|Error) resolving IP address: {re.escape(error_msg)}", ): resolver.run(["test.local"], 6053) @@ -112,9 +119,12 @@ def test_async_resolver_generic_exception() -> None: def test_async_resolver_thread_timeout() -> None: """Test timeout when thread doesn't complete in time.""" + # Use an event to control when the async function completes + test_event = threading.Event() async def slow_resolve(hosts, port, timeout): - await asyncio.sleep(100) # Sleep longer than timeout + # Wait for the test to signal completion + await asyncio.get_event_loop().run_in_executor(None, test_event.wait, 0.5) return [] with patch("esphome.resolver.hr.async_resolve_host", slow_resolve): @@ -122,10 +132,16 @@ def test_async_resolver_thread_timeout() -> None: # Override event.wait to simulate timeout with ( patch.object(resolver.event, "wait", return_value=False), - pytest.raises(EsphomeError, match="Timeout resolving IP address"), + pytest.raises( + EsphomeError, match=re.escape("Timeout resolving IP address") + ), ): resolver.run(["test.local"], 6053) + # Signal the async function to complete and give it time to clean up + test_event.set() + time.sleep(0.1) + def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: """Test resolving IP addresses.""" From f836b71e1c911f7d60c7dc8c60e9043feb9f286d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 21:11:22 -0500 Subject: [PATCH 0896/1626] Update test_resolver.py --- tests/unit_tests/test_resolver.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index 0ec3ef71c5..8a00c51635 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -140,7 +140,6 @@ def test_async_resolver_thread_timeout() -> None: # Signal the async function to complete and give it time to clean up test_event.set() - time.sleep(0.1) def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: From 6ab0581c9396a1e83730e13131ff2eea762c6dc4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 02:12:35 +0000 Subject: [PATCH 0897/1626] [pre-commit.ci lite] apply automatic fixes --- tests/unit_tests/test_resolver.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index 8a00c51635..0dbe89b206 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -6,7 +6,6 @@ import asyncio import re import socket import threading -import time from unittest.mock import patch from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError From 830b9a881a389e8a4c6b8bcb70e68eae8c5d42ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 21:39:50 -0500 Subject: [PATCH 0898/1626] redesign --- esphome/helpers.py | 4 +-- esphome/resolver.py | 24 ++++++++------ tests/unit_tests/test_helpers.py | 21 ++++++------ tests/unit_tests/test_resolver.py | 54 ++++++++++++++----------------- 4 files changed, 52 insertions(+), 51 deletions(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index b00c97ff73..6beaa24a96 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -209,8 +209,8 @@ def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: from esphome.resolver import AsyncResolver - resolver = AsyncResolver() - addr_infos = resolver.run(hosts, port) + resolver = AsyncResolver(hosts, port) + addr_infos = resolver.resolve() # Convert aioesphomeapi AddrInfo to our format for addr_info in addr_infos: sockaddr = addr_info.sockaddr diff --git a/esphome/resolver.py b/esphome/resolver.py index 24972a456f..99482aa20e 100644 --- a/esphome/resolver.py +++ b/esphome/resolver.py @@ -13,7 +13,7 @@ from esphome.core import EsphomeError RESOLVE_TIMEOUT = 10.0 # seconds -class AsyncResolver: +class AsyncResolver(threading.Thread): """Resolver using aioesphomeapi that runs in a thread for faster results. This resolver uses aioesphomeapi's async_resolve_host to handle DNS resolution, @@ -22,17 +22,20 @@ class AsyncResolver: cleanup cycle, which can take significant time. """ - def __init__(self) -> None: + def __init__(self, hosts: list[str], port: int) -> None: """Initialize the resolver.""" + super().__init__(daemon=True) + self.hosts = hosts + self.port = port self.result: list[hr.AddrInfo] | None = None self.exception: Exception | None = None self.event = threading.Event() - async def _resolve(self, hosts: list[str], port: int) -> None: + async def _resolve(self) -> None: """Resolve hostnames to IP addresses.""" try: self.result = await hr.async_resolve_host( - hosts, port, timeout=RESOLVE_TIMEOUT + self.hosts, self.port, timeout=RESOLVE_TIMEOUT ) except Exception as e: # pylint: disable=broad-except # We need to catch all exceptions to ensure the event is set @@ -41,12 +44,13 @@ class AsyncResolver: finally: self.event.set() - def run(self, hosts: list[str], port: int) -> list[hr.AddrInfo]: - """Run the DNS resolution in a separate thread.""" - thread = threading.Thread( - target=lambda: asyncio.run(self._resolve(hosts, port)), daemon=True - ) - thread.start() + def run(self) -> None: + """Run the DNS resolution.""" + asyncio.run(self._resolve()) + + def resolve(self) -> list[hr.AddrInfo]: + """Start the thread and wait for the result.""" + self.start() if not self.event.wait( timeout=RESOLVE_TIMEOUT + 1.0 diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 9a052ad9c2..9f51206ff9 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -423,14 +423,15 @@ def test_resolve_ip_address_hostname() -> None: with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value - mock_resolver.run.return_value = [mock_addr_info] + mock_resolver.resolve.return_value = [mock_addr_info] result = helpers.resolve_ip_address("test.local", 6053) assert len(result) == 1 assert result[0][0] == socket.AF_INET assert result[0][4] == ("192.168.1.100", 6053) - mock_resolver.run.assert_called_once_with(["test.local"], 6053) + MockResolver.assert_called_once_with(["test.local"], 6053) + mock_resolver.resolve.assert_called_once() def test_resolve_ip_address_mixed_list() -> None: @@ -444,14 +445,15 @@ def test_resolve_ip_address_mixed_list() -> None: with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value - mock_resolver.run.return_value = [mock_addr_info] + mock_resolver.resolve.return_value = [mock_addr_info] # Mix of IP and hostname - should use async resolver result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053) assert len(result) == 1 assert result[0][4][0] == "192.168.1.200" - mock_resolver.run.assert_called_once_with(["192.168.1.100", "test.local"], 6053) + MockResolver.assert_called_once_with(["192.168.1.100", "test.local"], 6053) + mock_resolver.resolve.assert_called_once() def test_resolve_ip_address_url() -> None: @@ -465,12 +467,13 @@ def test_resolve_ip_address_url() -> None: with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value - mock_resolver.run.return_value = [mock_addr_info] + mock_resolver.resolve.return_value = [mock_addr_info] result = helpers.resolve_ip_address("http://test.local", 6053) assert len(result) == 1 - mock_resolver.run.assert_called_once_with(["test.local"], 6053) + MockResolver.assert_called_once_with(["test.local"], 6053) + mock_resolver.resolve.assert_called_once() def test_resolve_ip_address_ipv6_conversion() -> None: @@ -484,7 +487,7 @@ def test_resolve_ip_address_ipv6_conversion() -> None: with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value - mock_resolver.run.return_value = [mock_addr_info] + mock_resolver.resolve.return_value = [mock_addr_info] result = helpers.resolve_ip_address("test.local", 6053) @@ -497,7 +500,7 @@ def test_resolve_ip_address_error_handling() -> None: """Test error handling from AsyncResolver.""" with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value - mock_resolver.run.side_effect = EsphomeError("Resolution failed") + mock_resolver.resolve.side_effect = EsphomeError("Resolution failed") with pytest.raises(EsphomeError, match="Resolution failed"): helpers.resolve_ip_address("test.local", 6053) @@ -583,7 +586,7 @@ def test_resolve_ip_address_sorting() -> None: with patch("esphome.resolver.AsyncResolver") as MockResolver: mock_resolver = MockResolver.return_value - mock_resolver.run.return_value = mock_addr_infos + mock_resolver.resolve.return_value = mock_addr_infos result = helpers.resolve_ip_address("test.local", 6053) diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index 0dbe89b206..b4cca05d9f 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -2,10 +2,8 @@ from __future__ import annotations -import asyncio import re import socket -import threading from unittest.mock import patch from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError @@ -44,8 +42,8 @@ def test_async_resolver_successful_resolution(mock_addr_info_ipv4: AddrInfo) -> "esphome.resolver.hr.async_resolve_host", return_value=[mock_addr_info_ipv4], ) as mock_resolve: - resolver = AsyncResolver() - result = resolver.run(["test.local"], 6053) + resolver = AsyncResolver(["test.local"], 6053) + result = resolver.resolve() assert result == [mock_addr_info_ipv4] mock_resolve.assert_called_once_with( @@ -63,8 +61,8 @@ def test_async_resolver_multiple_hosts( "esphome.resolver.hr.async_resolve_host", return_value=mock_results, ) as mock_resolve: - resolver = AsyncResolver() - result = resolver.run(["test1.local", "test2.local"], 6053) + resolver = AsyncResolver(["test1.local", "test2.local"], 6053) + result = resolver.resolve() assert result == mock_results mock_resolve.assert_called_once_with( @@ -79,11 +77,11 @@ def test_async_resolver_resolve_api_error() -> None: "esphome.resolver.hr.async_resolve_host", side_effect=ResolveAPIError(error_msg), ): - resolver = AsyncResolver() + resolver = AsyncResolver(["test.local"], 6053) with pytest.raises( EsphomeError, match=re.escape(f"Error resolving IP address: {error_msg}") ): - resolver.run(["test.local"], 6053) + resolver.resolve() def test_async_resolver_timeout_error() -> None: @@ -94,14 +92,14 @@ def test_async_resolver_timeout_error() -> None: "esphome.resolver.hr.async_resolve_host", side_effect=ResolveTimeoutAPIError(error_msg), ): - resolver = AsyncResolver() + resolver = AsyncResolver(["test.local"], 6053) # Match either "Timeout" or "Error" since ResolveTimeoutAPIError is a subclass of ResolveAPIError # and depending on import order/test execution context, it might be caught as either with pytest.raises( EsphomeError, match=f"(Timeout|Error) resolving IP address: {re.escape(error_msg)}", ): - resolver.run(["test.local"], 6053) + resolver.resolve() def test_async_resolver_generic_exception() -> None: @@ -111,34 +109,30 @@ def test_async_resolver_generic_exception() -> None: "esphome.resolver.hr.async_resolve_host", side_effect=error, ): - resolver = AsyncResolver() + resolver = AsyncResolver(["test.local"], 6053) with pytest.raises(RuntimeError, match="Unexpected error"): - resolver.run(["test.local"], 6053) + resolver.resolve() def test_async_resolver_thread_timeout() -> None: """Test timeout when thread doesn't complete in time.""" - # Use an event to control when the async function completes - test_event = threading.Event() - - async def slow_resolve(hosts, port, timeout): - # Wait for the test to signal completion - await asyncio.get_event_loop().run_in_executor(None, test_event.wait, 0.5) - return [] - - with patch("esphome.resolver.hr.async_resolve_host", slow_resolve): - resolver = AsyncResolver() - # Override event.wait to simulate timeout + # Mock the start method to prevent actual thread execution + with ( + patch.object(AsyncResolver, "start"), + patch("esphome.resolver.hr.async_resolve_host"), + ): + resolver = AsyncResolver(["test.local"], 6053) + # Override event.wait to simulate timeout (return False = timeout occurred) with ( patch.object(resolver.event, "wait", return_value=False), pytest.raises( EsphomeError, match=re.escape("Timeout resolving IP address") ), ): - resolver.run(["test.local"], 6053) + resolver.resolve() - # Signal the async function to complete and give it time to clean up - test_event.set() + # Verify thread start was called + resolver.start.assert_called_once() def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: @@ -147,8 +141,8 @@ def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: "esphome.resolver.hr.async_resolve_host", return_value=[mock_addr_info_ipv4], ) as mock_resolve: - resolver = AsyncResolver() - result = resolver.run(["192.168.1.100"], 6053) + resolver = AsyncResolver(["192.168.1.100"], 6053) + result = resolver.resolve() assert result == [mock_addr_info_ipv4] mock_resolve.assert_called_once_with( @@ -166,8 +160,8 @@ def test_async_resolver_mixed_addresses( "esphome.resolver.hr.async_resolve_host", return_value=mock_results, ) as mock_resolve: - resolver = AsyncResolver() - result = resolver.run(["test.local", "192.168.1.100", "::1"], 6053) + resolver = AsyncResolver(["test.local", "192.168.1.100", "::1"], 6053) + result = resolver.resolve() assert result == mock_results mock_resolve.assert_called_once_with( From e2b6efd8dec8fd4be9391857a77ae4cc04817099 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 22:28:42 -0500 Subject: [PATCH 0899/1626] [api] Store Noise protocol prologue in flash on ESP8266 --- esphome/components/api/api_frame_helper_noise.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 35d1715931..37aba7ec13 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -10,10 +10,18 @@ #include #include +#ifdef USE_ESP8266 +#include +#endif + namespace esphome::api { static const char *const TAG = "api.noise"; +#ifdef USE_ESP8266 +static const char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit"; +#else static const char *const PROLOGUE_INIT = "NoiseAPIInit"; +#endif static constexpr size_t PROLOGUE_INIT_LEN = 12; // strlen("NoiseAPIInit") #define HELPER_LOG(msg, ...) ESP_LOGVV(TAG, "%s: " msg, this->client_info_->get_combined_info().c_str(), ##__VA_ARGS__) @@ -75,7 +83,11 @@ APIError APINoiseFrameHelper::init() { // init prologue size_t old_size = prologue_.size(); prologue_.resize(old_size + PROLOGUE_INIT_LEN); +#ifdef USE_ESP8266 + memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); +#else std::memcpy(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); +#endif state_ = State::CLIENT_HELLO; return APIError::OK; From 639b924be35de965424ee3341b73116d587de13c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 22:57:44 -0500 Subject: [PATCH 0900/1626] [mdns] Move constant strings to flash on ESP8266 --- esphome/components/mdns/mdns_component.cpp | 88 ++++++++++++++++------ 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 316a10596f..90dcb7958a 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -5,6 +5,24 @@ #include "esphome/core/version.h" #include "mdns_component.h" +#ifdef USE_ESP8266 +#include +// Macro to define strings in PROGMEM on ESP8266, regular memory on other platforms +#define MDNS_STATIC_CONST_CHAR(name, value) static const char name[] PROGMEM = value +// Helper to get string from PROGMEM - returns a temporary std::string +static std::string mdns_string_p(const char *src) { + char buf[64]; + strncpy_P(buf, src, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + return std::string(buf); +} +#define MDNS_STR(name) mdns_string_p(name) +#else +// On non-ESP8266 platforms, use regular const char* +#define MDNS_STATIC_CONST_CHAR(name, value) static constexpr const char *name = value +#define MDNS_STR(name) name +#endif + #ifdef USE_API #include "esphome/components/api/api_server.h" #endif @@ -21,6 +39,32 @@ static const char *const TAG = "mdns"; #define USE_WEBSERVER_PORT 80 // NOLINT #endif +// Define all constant strings using the macro +MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib"); +MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); +MDNS_STATIC_CONST_CHAR(SERVICE_PROMETHEUS, "_prometheus-http"); +MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http"); + +MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name"); +MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); +MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac"); +MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform"); +MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board"); +MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network"); +MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION, "api_encryption"); +MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION_SUPPORTED, "api_encryption_supported"); +MDNS_STATIC_CONST_CHAR(TXT_PROJECT_NAME, "project_name"); +MDNS_STATIC_CONST_CHAR(TXT_PROJECT_VERSION, "project_version"); +MDNS_STATIC_CONST_CHAR(TXT_PACKAGE_IMPORT_URL, "package_import_url"); + +MDNS_STATIC_CONST_CHAR(PLATFORM_ESP8266, "ESP8266"); +MDNS_STATIC_CONST_CHAR(PLATFORM_ESP32, "ESP32"); +MDNS_STATIC_CONST_CHAR(PLATFORM_RP2040, "RP2040"); + +MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); +MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet"); +MDNS_STATIC_CONST_CHAR(NETWORK_THREAD, "thread"); + void MDNSComponent::compile_records_() { this->hostname_ = App.get_name(); @@ -50,8 +94,8 @@ void MDNSComponent::compile_records_() { if (api::global_api_server != nullptr) { this->services_.emplace_back(); auto &service = this->services_.back(); - service.service_type = "_esphomelib"; - service.proto = "_tcp"; + service.service_type = MDNS_STR(SERVICE_ESPHOMELIB); + service.proto = MDNS_STR(SERVICE_TCP); service.port = api::global_api_server->get_port(); const std::string &friendly_name = App.get_friendly_name(); @@ -82,47 +126,47 @@ void MDNSComponent::compile_records_() { txt_records.reserve(txt_count); if (!friendly_name_empty) { - txt_records.emplace_back(MDNSTXTRecord{"friendly_name", friendly_name}); + txt_records.push_back({MDNS_STR(TXT_FRIENDLY_NAME), friendly_name}); } - txt_records.emplace_back(MDNSTXTRecord{"version", ESPHOME_VERSION}); - txt_records.emplace_back(MDNSTXTRecord{"mac", get_mac_address()}); + txt_records.push_back({MDNS_STR(TXT_VERSION), ESPHOME_VERSION}); + txt_records.push_back({MDNS_STR(TXT_MAC), get_mac_address()}); #ifdef USE_ESP8266 - txt_records.emplace_back(MDNSTXTRecord{"platform", "ESP8266"}); + txt_records.push_back({MDNS_STR(TXT_PLATFORM), MDNS_STR(PLATFORM_ESP8266)}); #elif defined(USE_ESP32) - txt_records.emplace_back(MDNSTXTRecord{"platform", "ESP32"}); + txt_records.push_back({MDNS_STR(TXT_PLATFORM), MDNS_STR(PLATFORM_ESP32)}); #elif defined(USE_RP2040) - txt_records.emplace_back(MDNSTXTRecord{"platform", "RP2040"}); + txt_records.push_back({MDNS_STR(TXT_PLATFORM), MDNS_STR(PLATFORM_RP2040)}); #elif defined(USE_LIBRETINY) txt_records.emplace_back(MDNSTXTRecord{"platform", lt_cpu_get_model_name()}); #endif - txt_records.emplace_back(MDNSTXTRecord{"board", ESPHOME_BOARD}); + txt_records.push_back({MDNS_STR(TXT_BOARD), ESPHOME_BOARD}); #if defined(USE_WIFI) - txt_records.emplace_back(MDNSTXTRecord{"network", "wifi"}); + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)}); #elif defined(USE_ETHERNET) - txt_records.emplace_back(MDNSTXTRecord{"network", "ethernet"}); + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_ETHERNET)}); #elif defined(USE_OPENTHREAD) - txt_records.emplace_back(MDNSTXTRecord{"network", "thread"}); + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_THREAD)}); #endif #ifdef USE_API_NOISE - static constexpr const char *NOISE_ENCRYPTION = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"; + MDNS_STATIC_CONST_CHAR(NOISE_ENCRYPTION, "Noise_NNpsk0_25519_ChaChaPoly_SHA256"); if (api::global_api_server->get_noise_ctx()->has_psk()) { - txt_records.emplace_back(MDNSTXTRecord{"api_encryption", NOISE_ENCRYPTION}); + txt_records.push_back({MDNS_STR(TXT_API_ENCRYPTION), MDNS_STR(NOISE_ENCRYPTION)}); } else { - txt_records.emplace_back(MDNSTXTRecord{"api_encryption_supported", NOISE_ENCRYPTION}); + txt_records.push_back({MDNS_STR(TXT_API_ENCRYPTION_SUPPORTED), MDNS_STR(NOISE_ENCRYPTION)}); } #endif #ifdef ESPHOME_PROJECT_NAME - txt_records.emplace_back(MDNSTXTRecord{"project_name", ESPHOME_PROJECT_NAME}); - txt_records.emplace_back(MDNSTXTRecord{"project_version", ESPHOME_PROJECT_VERSION}); + txt_records.push_back({MDNS_STR(TXT_PROJECT_NAME), ESPHOME_PROJECT_NAME}); + txt_records.push_back({MDNS_STR(TXT_PROJECT_VERSION), ESPHOME_PROJECT_VERSION}); #endif // ESPHOME_PROJECT_NAME #ifdef USE_DASHBOARD_IMPORT - txt_records.emplace_back(MDNSTXTRecord{"package_import_url", dashboard_import::get_package_import_url()}); + txt_records.push_back({MDNS_STR(TXT_PACKAGE_IMPORT_URL), dashboard_import::get_package_import_url()}); #endif } #endif // USE_API @@ -130,16 +174,16 @@ void MDNSComponent::compile_records_() { #ifdef USE_PROMETHEUS this->services_.emplace_back(); auto &prom_service = this->services_.back(); - prom_service.service_type = "_prometheus-http"; - prom_service.proto = "_tcp"; + prom_service.service_type = MDNS_STR(SERVICE_PROMETHEUS); + prom_service.proto = MDNS_STR(SERVICE_TCP); prom_service.port = USE_WEBSERVER_PORT; #endif #ifdef USE_WEBSERVER this->services_.emplace_back(); auto &web_service = this->services_.back(); - web_service.service_type = "_http"; - web_service.proto = "_tcp"; + web_service.service_type = MDNS_STR(SERVICE_HTTP); + web_service.proto = MDNS_STR(SERVICE_TCP); web_service.port = USE_WEBSERVER_PORT; #endif From b4b795dcaf0cb1ef3ba440bcc3d6cda223627876 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 23:26:46 -0500 Subject: [PATCH 0901/1626] [i2c] Optimize memory usage with stack allocation for small buffers (#10565) Co-authored-by: Keith Burzinski --- esphome/components/i2c/i2c.cpp | 22 ++++++++++------- esphome/components/i2c/i2c_bus.h | 42 +++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 48e1cf8aca..31c21f398c 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -39,18 +39,22 @@ ErrorCode I2CDevice::read_register16(uint16_t a_register, uint8_t *data, size_t } ErrorCode I2CDevice::write_register(uint8_t a_register, const uint8_t *data, size_t len) const { - std::vector v{}; - v.push_back(a_register); - v.insert(v.end(), data, data + len); - return bus_->write_readv(this->address_, v.data(), v.size(), nullptr, 0); + SmallBufferWithHeapFallback<17> buffer_alloc; // Most I2C writes are <= 16 bytes + uint8_t *buffer = buffer_alloc.get(len + 1); + + buffer[0] = a_register; + std::copy(data, data + len, buffer + 1); + return this->bus_->write_readv(this->address_, buffer, len + 1, nullptr, 0); } ErrorCode I2CDevice::write_register16(uint16_t a_register, const uint8_t *data, size_t len) const { - std::vector v(len + 2); - v[0] = a_register >> 8; - v[1] = a_register; - std::copy(data, data + len, v.begin() + 2); - return bus_->write_readv(this->address_, v.data(), v.size(), nullptr, 0); + SmallBufferWithHeapFallback<18> buffer_alloc; // Most I2C writes are <= 16 bytes + 2 for register + uint8_t *buffer = buffer_alloc.get(len + 2); + + buffer[0] = a_register >> 8; + buffer[1] = a_register; + std::copy(data, data + len, buffer + 2); + return this->bus_->write_readv(this->address_, buffer, len + 2, nullptr, 0); } bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { diff --git a/esphome/components/i2c/i2c_bus.h b/esphome/components/i2c/i2c_bus.h index df4df628e8..1acbe506a3 100644 --- a/esphome/components/i2c/i2c_bus.h +++ b/esphome/components/i2c/i2c_bus.h @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -10,6 +11,22 @@ namespace esphome { namespace i2c { +/// @brief Helper class for efficient buffer allocation - uses stack for small sizes, heap for large +template class SmallBufferWithHeapFallback { + public: + uint8_t *get(size_t size) { + if (size <= STACK_SIZE) { + return this->stack_buffer_; + } + this->heap_buffer_ = std::unique_ptr(new uint8_t[size]); + return this->heap_buffer_.get(); + } + + private: + uint8_t stack_buffer_[STACK_SIZE]; + std::unique_ptr heap_buffer_; +}; + /// @brief Error codes returned by I2CBus and I2CDevice methods enum ErrorCode { NO_ERROR = 0, ///< No error found during execution of method @@ -74,14 +91,17 @@ class I2CBus { for (size_t i = 0; i != count; i++) { total_len += read_buffers[i].len; } - std::vector buffer(total_len); - auto err = this->write_readv(address, nullptr, 0, buffer.data(), total_len); + + SmallBufferWithHeapFallback<128> buffer_alloc; // Most I2C reads are small + uint8_t *buffer = buffer_alloc.get(total_len); + + auto err = this->write_readv(address, nullptr, 0, buffer, total_len); if (err != ERROR_OK) return err; size_t pos = 0; for (size_t i = 0; i != count; i++) { if (read_buffers[i].len != 0) { - std::memcpy(read_buffers[i].data, buffer.data() + pos, read_buffers[i].len); + std::memcpy(read_buffers[i].data, buffer + pos, read_buffers[i].len); pos += read_buffers[i].len; } } @@ -91,11 +111,21 @@ class I2CBus { ESPDEPRECATED("This method is deprecated and will be removed in ESPHome 2026.3.0. Use write_readv() instead.", "2025.9.0") ErrorCode writev(uint8_t address, const WriteBuffer *write_buffers, size_t count, bool stop = true) { - std::vector buffer{}; + size_t total_len = 0; for (size_t i = 0; i != count; i++) { - buffer.insert(buffer.end(), write_buffers[i].data, write_buffers[i].data + write_buffers[i].len); + total_len += write_buffers[i].len; } - return this->write_readv(address, buffer.data(), buffer.size(), nullptr, 0); + + SmallBufferWithHeapFallback<128> buffer_alloc; // Most I2C writes are small + uint8_t *buffer = buffer_alloc.get(total_len); + + size_t pos = 0; + for (size_t i = 0; i != count; i++) { + std::memcpy(buffer + pos, write_buffers[i].data, write_buffers[i].len); + pos += write_buffers[i].len; + } + + return this->write_readv(address, buffer, total_len, nullptr, 0); } protected: From 089430abc9e29d97ef7ced1630faa0ab12bd055e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Sep 2025 23:31:44 -0500 Subject: [PATCH 0902/1626] [captive_portal] ESP8266: Move strings to PROGMEM (saves 192 bytes RAM) --- .../captive_portal/captive_portal.cpp | 47 ++++++++++++++----- .../captive_portal/captive_portal.h | 6 +-- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 25179fdacc..148563d0ef 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -11,17 +11,37 @@ namespace captive_portal { static const char *const TAG = "captive_portal"; void CaptivePortal::handle_config(AsyncWebServerRequest *request) { - AsyncResponseStream *stream = request->beginResponseStream("application/json"); - stream->addHeader("cache-control", "public, max-age=0, must-revalidate"); +#ifdef USE_ESP8266 + AsyncResponseStream *stream = request->beginResponseStream(F("application/json")); + stream->addHeader(F("cache-control"), F("public, max-age=0, must-revalidate")); + stream->print(F("{\"mac\":\"")); + stream->print(get_mac_address_pretty().c_str()); + stream->print(F("\",\"name\":\"")); + stream->print(App.get_name().c_str()); + stream->print(F("\",\"aps\":[")); +#else + AsyncResponseStream *stream = request->beginResponseStream(F("application/json")); + stream->addHeader(F("cache-control"), F("public, max-age=0, must-revalidate")); stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", get_mac_address_pretty().c_str(), App.get_name().c_str()); +#endif for (auto &scan : wifi::global_wifi_component->get_scan_result()) { if (scan.get_is_hidden()) continue; - // Assumes no " in ssid, possible unicode isses? + // Assumes no " in ssid, possible unicode isses? +#ifdef USE_ESP8266 + stream->print(F(",{\"ssid\":\"")); + stream->print(scan.get_ssid().c_str()); + stream->print(F("\",\"rssi\":")); + stream->print(scan.get_rssi()); + stream->print(F(",\"lock\":")); + stream->print(scan.get_with_auth()); + stream->print(F("}")); +#else stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(), scan.get_with_auth()); +#endif } stream->print(F("]}")); request->send(stream); @@ -34,7 +54,7 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); wifi::global_wifi_component->save_wifi_sta(ssid, psk); wifi::global_wifi_component->start_scanning(); - request->redirect("/?save"); + request->redirect(F("/?save")); } void CaptivePortal::setup() { @@ -53,18 +73,23 @@ void CaptivePortal::start() { this->dns_server_ = make_unique(); this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); - this->dns_server_->start(53, "*", ip); + this->dns_server_->start(53, F("*"), ip); // Re-enable loop() when DNS server is started this->enable_loop(); #endif this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *req) { if (!this->active_ || req->host().c_str() == wifi::global_wifi_component->wifi_soft_ap_ip().str()) { - req->send(404, "text/html", "File not found"); + req->send(404, F("text/html"), F("File not found")); return; } +#ifdef USE_ESP8266 + String url = F("http://"); + url += wifi::global_wifi_component->wifi_soft_ap_ip().str().c_str(); +#else auto url = "http://" + wifi::global_wifi_component->wifi_soft_ap_ip().str(); +#endif req->redirect(url.c_str()); }); @@ -73,19 +98,19 @@ void CaptivePortal::start() { } void CaptivePortal::handleRequest(AsyncWebServerRequest *req) { - if (req->url() == "/") { + if (req->url() == F("/")) { #ifndef USE_ESP8266 - auto *response = req->beginResponse(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); + auto *response = req->beginResponse(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); #else auto *response = req->beginResponse_P(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); #endif - response->addHeader("Content-Encoding", "gzip"); + response->addHeader(F("Content-Encoding"), F("gzip")); req->send(response); return; - } else if (req->url() == "/config.json") { + } else if (req->url() == F("/config.json")) { this->handle_config(req); return; - } else if (req->url() == "/wifisave") { + } else if (req->url() == F("/wifisave")) { this->handle_wifisave(req); return; } diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index c78fff824a..382afe92f0 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -45,11 +45,11 @@ class CaptivePortal : public AsyncWebHandler, public Component { return false; if (request->method() == HTTP_GET) { - if (request->url() == "/") + if (request->url() == F("/")) return true; - if (request->url() == "/config.json") + if (request->url() == F("/config.json")) return true; - if (request->url() == "/wifisave") + if (request->url() == F("/wifisave")) return true; } From 86c2af48825d825801098982efa32b67f8a21cf6 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 5 Sep 2025 01:37:57 -0500 Subject: [PATCH 0903/1626] [sen5x] Fix initialization (#10603) --- esphome/components/sen5x/sen5x.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 0f27ec1b10..f3222221a2 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -38,6 +38,7 @@ void SEN5XComponent::setup() { this->mark_failed(); return; } + delay(20); // per datasheet uint16_t raw_read_status; if (!this->read_data(raw_read_status)) { From 6ff31bdbbf2650b0216a1270f60659b7e3fb403f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 08:27:39 -0500 Subject: [PATCH 0904/1626] fix refactoring error --- esphome/components/captive_portal/captive_portal.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 148563d0ef..5b30841c3f 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -18,7 +18,7 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { stream->print(get_mac_address_pretty().c_str()); stream->print(F("\",\"name\":\"")); stream->print(App.get_name().c_str()); - stream->print(F("\",\"aps\":[")); + stream->print(F("\",\"aps\":[{}")); #else AsyncResponseStream *stream = request->beginResponseStream(F("application/json")); stream->addHeader(F("cache-control"), F("public, max-age=0, must-revalidate")); From a8352ef2cb4360233a9ca8948e0781e0b56c92ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 08:29:32 -0500 Subject: [PATCH 0905/1626] preen --- esphome/components/captive_portal/captive_portal.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 5b30841c3f..5d10dffc64 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -11,17 +11,15 @@ namespace captive_portal { static const char *const TAG = "captive_portal"; void CaptivePortal::handle_config(AsyncWebServerRequest *request) { -#ifdef USE_ESP8266 AsyncResponseStream *stream = request->beginResponseStream(F("application/json")); stream->addHeader(F("cache-control"), F("public, max-age=0, must-revalidate")); +#ifdef USE_ESP8266 stream->print(F("{\"mac\":\"")); stream->print(get_mac_address_pretty().c_str()); stream->print(F("\",\"name\":\"")); stream->print(App.get_name().c_str()); stream->print(F("\",\"aps\":[{}")); #else - AsyncResponseStream *stream = request->beginResponseStream(F("application/json")); - stream->addHeader(F("cache-control"), F("public, max-age=0, must-revalidate")); stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", get_mac_address_pretty().c_str(), App.get_name().c_str()); #endif From 3f622169b91ea6d021293ce2c0755cf32f117e55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 08:42:46 -0500 Subject: [PATCH 0906/1626] missed one --- esphome/components/captive_portal/captive_portal.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 5d10dffc64..7eb0ffa99e 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -100,7 +100,7 @@ void CaptivePortal::handleRequest(AsyncWebServerRequest *req) { #ifndef USE_ESP8266 auto *response = req->beginResponse(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); #else - auto *response = req->beginResponse_P(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); + auto *response = req->beginResponse_P(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); #endif response->addHeader(F("Content-Encoding"), F("gzip")); req->send(response); From f67c5fbab2ec1fd927916bc2410e12113a4240f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 09:13:15 -0500 Subject: [PATCH 0907/1626] [web_server] ESP8266: Move strings to PROGMEM (saves 128 bytes RAM) --- esphome/components/web_server/web_server.cpp | 120 +++++++++++++------ 1 file changed, 83 insertions(+), 37 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 290992b096..8046bce7c5 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -39,12 +39,58 @@ namespace web_server { static const char *const TAG = "web_server"; +#ifdef USE_ESP8266 +// Common strings used multiple times - deduplicated in PROGMEM on ESP8266 +static const char CONTENT_TYPE_JSON[] PROGMEM = "application/json"; +static const char CONTENT_TYPE_HTML[] PROGMEM = "text/html"; +static const char CONTENT_TYPE_CSS[] PROGMEM = "text/css"; +static const char CONTENT_TYPE_JS[] PROGMEM = "text/javascript"; +static const char CONTENT_TYPE_PLAIN[] PROGMEM = "text/plain"; +static const char HEADER_CONTENT_ENCODING[] PROGMEM = "Content-Encoding"; +static const char ENCODING_GZIP[] PROGMEM = "gzip"; +static const char EVENT_STATE[] PROGMEM = "state"; + +// Helper macros to get __FlashStringHelper* from PROGMEM strings +#define CONTENT_JSON_F FPSTR(CONTENT_TYPE_JSON) +#define CONTENT_HTML_F FPSTR(CONTENT_TYPE_HTML) +#define CONTENT_CSS_F FPSTR(CONTENT_TYPE_CSS) +#define CONTENT_JS_F FPSTR(CONTENT_TYPE_JS) +#define CONTENT_PLAIN_F FPSTR(CONTENT_TYPE_PLAIN) +#define HEADER_ENCODING_F FPSTR(HEADER_CONTENT_ENCODING) +#define GZIP_F FPSTR(ENCODING_GZIP) +#define STATE_F FPSTR(EVENT_STATE) +#else +// For all other platforms, define the same names as regular string literals +#define CONTENT_JSON_F "application/json" +#define CONTENT_HTML_F "text/html" +#define CONTENT_CSS_F "text/css" +#define CONTENT_JS_F "text/javascript" +#define CONTENT_PLAIN_F "text/plain" +#define HEADER_ENCODING_F "Content-Encoding" +#define GZIP_F "gzip" +#define STATE_F "state" +#endif + #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS +#ifdef USE_ESP8266 +// Store single copy in PROGMEM on ESP8266 +static const char HEADER_PNA_NAME_P[] PROGMEM = "Private-Network-Access-Name"; +static const char HEADER_PNA_ID_P[] PROGMEM = "Private-Network-Access-ID"; +static const char HEADER_CORS_REQ_PNA_P[] PROGMEM = "Access-Control-Request-Private-Network"; +static const char HEADER_CORS_ALLOW_PNA_P[] PROGMEM = "Access-Control-Allow-Private-Network"; +// Use FPSTR() to get __FlashStringHelper* from PROGMEM data +#define HEADER_PNA_NAME FPSTR(HEADER_PNA_NAME_P) +#define HEADER_PNA_ID FPSTR(HEADER_PNA_ID_P) +#define HEADER_CORS_REQ_PNA FPSTR(HEADER_CORS_REQ_PNA_P) +#define HEADER_CORS_ALLOW_PNA FPSTR(HEADER_CORS_ALLOW_PNA_P) +#else +// For all other platforms, use regular strings static const char *const HEADER_PNA_NAME = "Private-Network-Access-Name"; static const char *const HEADER_PNA_ID = "Private-Network-Access-ID"; static const char *const HEADER_CORS_REQ_PNA = "Access-Control-Request-Private-Network"; static const char *const HEADER_CORS_ALLOW_PNA = "Access-Control-Allow-Private-Network"; #endif +#endif // Parse URL and return match info static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) { @@ -122,7 +168,7 @@ void DeferredUpdateEventSource::process_deferred_queue_() { while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); std::string message = de.message_generator_(web_server_, de.source_); - if (this->send(message.c_str(), "state") != DISCARDED) { + if (this->send(message.c_str(), STATE_F) != DISCARDED) { // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); this->consecutive_send_failures_ = 0; // Reset failure count on successful send @@ -171,7 +217,7 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char * deq_push_back_with_dedup_(source, message_generator); } else { std::string message = message_generator(web_server_, source); - if (this->send(message.c_str(), "state") == DISCARDED) { + if (this->send(message.c_str(), STATE_F) == DISCARDED) { deq_push_back_with_dedup_(source, message_generator); } else { this->consecutive_send_failures_ = 0; // Reset failure count on successful send @@ -206,7 +252,7 @@ void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, const } void DeferredUpdateEventSourceList::add_new_client(WebServer *ws, AsyncWebServerRequest *request) { - DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, "/events"); + DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, F("/events")); this->push_back(es); es->onConnect([this, ws, es](AsyncEventSourceClient *client) { @@ -316,21 +362,21 @@ float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f #ifdef USE_WEBSERVER_LOCAL void WebServer::handle_index_request(AsyncWebServerRequest *request) { #ifndef USE_ESP8266 - AsyncWebServerResponse *response = request->beginResponse(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); + AsyncWebServerResponse *response = request->beginResponse(200, CONTENT_HTML_F, INDEX_GZ, sizeof(INDEX_GZ)); #else - AsyncWebServerResponse *response = request->beginResponse_P(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); + AsyncWebServerResponse *response = request->beginResponse_P(200, CONTENT_HTML_F, INDEX_GZ, sizeof(INDEX_GZ)); #endif - response->addHeader("Content-Encoding", "gzip"); + response->addHeader(HEADER_ENCODING_F, GZIP_F); request->send(response); } #elif USE_WEBSERVER_VERSION >= 2 void WebServer::handle_index_request(AsyncWebServerRequest *request) { #ifndef USE_ESP8266 AsyncWebServerResponse *response = - request->beginResponse(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); + request->beginResponse(200, CONTENT_HTML_F, ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); #else AsyncWebServerResponse *response = - request->beginResponse_P(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); + request->beginResponse_P(200, CONTENT_HTML_F, ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); #endif // No gzip header here because the HTML file is so small request->send(response); @@ -339,8 +385,8 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { - AsyncWebServerResponse *response = request->beginResponse(200, ""); - response->addHeader(HEADER_CORS_ALLOW_PNA, "true"); + AsyncWebServerResponse *response = request->beginResponse(200, F("")); + response->addHeader(HEADER_CORS_ALLOW_PNA, F("true")); response->addHeader(HEADER_PNA_NAME, App.get_name().c_str()); std::string mac = get_mac_address_pretty(); response->addHeader(HEADER_PNA_ID, mac.c_str()); @@ -352,12 +398,12 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { void WebServer::handle_css_request(AsyncWebServerRequest *request) { #ifndef USE_ESP8266 AsyncWebServerResponse *response = - request->beginResponse(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); + request->beginResponse(200, CONTENT_CSS_F, ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); #else AsyncWebServerResponse *response = - request->beginResponse_P(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); + request->beginResponse_P(200, CONTENT_CSS_F, ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); #endif - response->addHeader("Content-Encoding", "gzip"); + response->addHeader(HEADER_ENCODING_F, GZIP_F); request->send(response); } #endif @@ -366,12 +412,12 @@ void WebServer::handle_css_request(AsyncWebServerRequest *request) { void WebServer::handle_js_request(AsyncWebServerRequest *request) { #ifndef USE_ESP8266 AsyncWebServerResponse *response = - request->beginResponse(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); + request->beginResponse(200, CONTENT_JS_F, ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); #else AsyncWebServerResponse *response = - request->beginResponse_P(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); + request->beginResponse_P(200, CONTENT_JS_F, ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); #endif - response->addHeader("Content-Encoding", "gzip"); + response->addHeader(HEADER_ENCODING_F, GZIP_F); request->send(response); } #endif @@ -422,7 +468,7 @@ void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->sensor_json(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } } @@ -467,7 +513,7 @@ void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->text_sensor_json(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } } @@ -506,7 +552,7 @@ void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->switch_json(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } @@ -571,7 +617,7 @@ void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->button_json(obj, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); } else if (match.method_equals("press")) { this->defer([obj]() { obj->press(); }); request->send(200); @@ -612,7 +658,7 @@ void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, con if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->binary_sensor_json(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } } @@ -651,7 +697,7 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->fan_json(obj, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); } else if (match.method_equals("toggle")) { this->defer([obj]() { obj->toggle().perform(); }); request->send(200); @@ -725,7 +771,7 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->light_json(obj, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); } else if (match.method_equals("toggle")) { this->defer([obj]() { obj->toggle().perform(); }); request->send(200); @@ -798,7 +844,7 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->cover_json(obj, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } @@ -869,7 +915,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->number_json(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } if (!match.method_equals("set")) { @@ -935,7 +981,7 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->date_json(obj, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } if (!match.method_equals("set")) { @@ -991,7 +1037,7 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->time_json(obj, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } if (!match.method_equals("set")) { @@ -1046,7 +1092,7 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->datetime_json(obj, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } if (!match.method_equals("set")) { @@ -1103,7 +1149,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->text_json(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } if (!match.method_equals("set")) { @@ -1161,7 +1207,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->select_json(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } @@ -1216,7 +1262,7 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->climate_json(obj, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } @@ -1354,7 +1400,7 @@ void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->lock_json(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } @@ -1425,7 +1471,7 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->valve_json(obj, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } @@ -1492,7 +1538,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->alarm_control_panel_json(obj, obj->get_state(), detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } @@ -1557,7 +1603,7 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->event_json(obj, "", detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } } @@ -1621,7 +1667,7 @@ void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->update_json(obj, detail); - request->send(200, "application/json", data.c_str()); + request->send(200, CONTENT_JSON_F, data.c_str()); return; } @@ -1907,7 +1953,7 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { // No matching handler found - send 404 ESP_LOGV(TAG, "Request for unknown URL: %s", url.c_str()); - request->send(404, "text/plain", "Not Found"); + request->send(404, CONTENT_PLAIN_F, F("Not Found")); } bool WebServer::isRequestHandlerTrivial() const { return false; } From fd67da9fb0389e8a2a2fa4684fffcfaa973566c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 09:22:06 -0500 Subject: [PATCH 0908/1626] [gpio] ESP8266: Store log strings in flash memory --- .../gpio/binary_sensor/gpio_binary_sensor.cpp | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index 4b8369cd59..45544c185b 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -6,6 +6,23 @@ namespace gpio { static const char *const TAG = "gpio.binary_sensor"; +static const LogString *interrupt_type_to_string(gpio::InterruptType type) { + switch (type) { + case gpio::INTERRUPT_RISING_EDGE: + return LOG_STR("RISING_EDGE"); + case gpio::INTERRUPT_FALLING_EDGE: + return LOG_STR("FALLING_EDGE"); + case gpio::INTERRUPT_ANY_EDGE: + return LOG_STR("ANY_EDGE"); + default: + return LOG_STR("UNKNOWN"); + } +} + +static const LogString *gpio_mode_to_string(bool use_interrupt) { + return use_interrupt ? LOG_STR("interrupt") : LOG_STR("polling"); +} + void IRAM_ATTR GPIOBinarySensorStore::gpio_intr(GPIOBinarySensorStore *arg) { bool new_state = arg->isr_pin_.digital_read(); if (new_state != arg->last_state_) { @@ -51,25 +68,9 @@ void GPIOBinarySensor::setup() { void GPIOBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "GPIO Binary Sensor", this); LOG_PIN(" Pin: ", this->pin_); - const char *mode = this->use_interrupt_ ? "interrupt" : "polling"; - ESP_LOGCONFIG(TAG, " Mode: %s", mode); + ESP_LOGCONFIG(TAG, " Mode: %s", LOG_STR_ARG(gpio_mode_to_string(this->use_interrupt_))); if (this->use_interrupt_) { - const char *interrupt_type; - switch (this->interrupt_type_) { - case gpio::INTERRUPT_RISING_EDGE: - interrupt_type = "RISING_EDGE"; - break; - case gpio::INTERRUPT_FALLING_EDGE: - interrupt_type = "FALLING_EDGE"; - break; - case gpio::INTERRUPT_ANY_EDGE: - interrupt_type = "ANY_EDGE"; - break; - default: - interrupt_type = "UNKNOWN"; - break; - } - ESP_LOGCONFIG(TAG, " Interrupt Type: %s", interrupt_type); + ESP_LOGCONFIG(TAG, " Interrupt Type: %s", LOG_STR_ARG(interrupt_type_to_string(this->interrupt_type_))); } } From 4969b8ab80f39d1a4e10bddce5786e5e659f94c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 09:42:53 -0500 Subject: [PATCH 0909/1626] [light] ESP8266: Store log strings in flash memory --- esphome/components/light/light_call.cpp | 42 +++++++++++++------------ 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 60945531cf..cbe9ed0454 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -11,19 +11,21 @@ static const char *const TAG = "light"; // Helper functions to reduce code size for logging #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN -static void log_validation_warning(const char *name, const char *param_name, float val, float min, float max) { - ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, param_name, val, min, max); +static void log_validation_warning(const char *name, const LogString *param_name, float val, float min, float max) { + ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), val, min, max); } -static void log_feature_not_supported(const char *name, const char *feature) { - ESP_LOGW(TAG, "'%s': %s not supported", name, feature); +static void log_feature_not_supported(const char *name, const LogString *feature) { + ESP_LOGW(TAG, "'%s': %s not supported", name, LOG_STR_ARG(feature)); } -static void log_color_mode_not_supported(const char *name, const char *feature) { - ESP_LOGW(TAG, "'%s': color mode does not support setting %s", name, feature); +static void log_color_mode_not_supported(const char *name, const LogString *feature) { + ESP_LOGW(TAG, "'%s': color mode does not support setting %s", name, LOG_STR_ARG(feature)); } -static void log_invalid_parameter(const char *name, const char *message) { ESP_LOGW(TAG, "'%s': %s", name, message); } +static void log_invalid_parameter(const char *name, const LogString *message) { + ESP_LOGW(TAG, "'%s': %s", name, LOG_STR_ARG(message)); +} #else #define log_validation_warning(name, param_name, val, min, max) #define log_feature_not_supported(name, feature) @@ -201,19 +203,19 @@ LightColorValues LightCall::validate_() { // Brightness exists check if (this->has_brightness() && this->brightness_ > 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) { - log_feature_not_supported(name, "brightness"); + log_feature_not_supported(name, LOG_STR("brightness")); this->set_flag_(FLAG_HAS_BRIGHTNESS, false); } // Transition length possible check if (this->has_transition_() && this->transition_length_ != 0 && !(color_mode & ColorCapability::BRIGHTNESS)) { - log_feature_not_supported(name, "transitions"); + log_feature_not_supported(name, LOG_STR("transitions")); this->set_flag_(FLAG_HAS_TRANSITION, false); } // Color brightness exists check if (this->has_color_brightness() && this->color_brightness_ > 0.0f && !(color_mode & ColorCapability::RGB)) { - log_color_mode_not_supported(name, "RGB brightness"); + log_color_mode_not_supported(name, LOG_STR("RGB brightness")); this->set_flag_(FLAG_HAS_COLOR_BRIGHTNESS, false); } @@ -221,7 +223,7 @@ LightColorValues LightCall::validate_() { if ((this->has_red() && this->red_ > 0.0f) || (this->has_green() && this->green_ > 0.0f) || (this->has_blue() && this->blue_ > 0.0f)) { if (!(color_mode & ColorCapability::RGB)) { - log_color_mode_not_supported(name, "RGB color"); + log_color_mode_not_supported(name, LOG_STR("RGB color")); this->set_flag_(FLAG_HAS_RED, false); this->set_flag_(FLAG_HAS_GREEN, false); this->set_flag_(FLAG_HAS_BLUE, false); @@ -231,21 +233,21 @@ LightColorValues LightCall::validate_() { // White value exists check if (this->has_white() && this->white_ > 0.0f && !(color_mode & ColorCapability::WHITE || color_mode & ColorCapability::COLD_WARM_WHITE)) { - log_color_mode_not_supported(name, "white value"); + log_color_mode_not_supported(name, LOG_STR("white value")); this->set_flag_(FLAG_HAS_WHITE, false); } // Color temperature exists check if (this->has_color_temperature() && !(color_mode & ColorCapability::COLOR_TEMPERATURE || color_mode & ColorCapability::COLD_WARM_WHITE)) { - log_color_mode_not_supported(name, "color temperature"); + log_color_mode_not_supported(name, LOG_STR("color temperature")); this->set_flag_(FLAG_HAS_COLOR_TEMPERATURE, false); } // Cold/warm white value exists check if ((this->has_cold_white() && this->cold_white_ > 0.0f) || (this->has_warm_white() && this->warm_white_ > 0.0f)) { if (!(color_mode & ColorCapability::COLD_WARM_WHITE)) { - log_color_mode_not_supported(name, "cold/warm white value"); + log_color_mode_not_supported(name, LOG_STR("cold/warm white value")); this->set_flag_(FLAG_HAS_COLD_WHITE, false); this->set_flag_(FLAG_HAS_WARM_WHITE, false); } @@ -255,7 +257,7 @@ LightColorValues LightCall::validate_() { if (this->has_##name_()) { \ auto val = this->name_##_; \ if (val < (min) || val > (max)) { \ - log_validation_warning(name, LOG_STR_LITERAL(upper_name), val, (min), (max)); \ + log_validation_warning(name, LOG_STR(upper_name), val, (min), (max)); \ this->name_##_ = clamp(val, (min), (max)); \ } \ } @@ -319,7 +321,7 @@ LightColorValues LightCall::validate_() { // Flash length check if (this->has_flash_() && this->flash_length_ == 0) { - log_invalid_parameter(name, "flash length must be greater than zero"); + log_invalid_parameter(name, LOG_STR("flash length must be greater than zero")); this->set_flag_(FLAG_HAS_FLASH, false); } @@ -338,13 +340,13 @@ LightColorValues LightCall::validate_() { } if (this->has_effect_() && (this->has_transition_() || this->has_flash_())) { - log_invalid_parameter(name, "effect cannot be used with transition/flash"); + log_invalid_parameter(name, LOG_STR("effect cannot be used with transition/flash")); this->set_flag_(FLAG_HAS_TRANSITION, false); this->set_flag_(FLAG_HAS_FLASH, false); } if (this->has_flash_() && this->has_transition_()) { - log_invalid_parameter(name, "flash cannot be used with transition"); + log_invalid_parameter(name, LOG_STR("flash cannot be used with transition")); this->set_flag_(FLAG_HAS_TRANSITION, false); } @@ -361,7 +363,7 @@ LightColorValues LightCall::validate_() { } if (this->has_transition_() && !supports_transition) { - log_feature_not_supported(name, "transitions"); + log_feature_not_supported(name, LOG_STR("transitions")); this->set_flag_(FLAG_HAS_TRANSITION, false); } @@ -371,7 +373,7 @@ LightColorValues LightCall::validate_() { bool target_state = this->has_state() ? this->state_ : v.is_on(); if (!this->has_flash_() && !target_state) { if (this->has_effect_()) { - log_invalid_parameter(name, "cannot start effect when turning off"); + log_invalid_parameter(name, LOG_STR("cannot start effect when turning off")); this->set_flag_(FLAG_HAS_EFFECT, false); } else if (this->parent_->active_effect_index_ != 0 && explicit_turn_off_request) { // Auto turn off effect From d2d0f06be3695669e87f53010fb0e5566e19f888 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 10:11:11 -0500 Subject: [PATCH 0910/1626] [script] ESP8266: Store log format strings in PROGMEM (saves 240 bytes RAM) --- esphome/components/script/script.cpp | 6 ++++++ esphome/components/script/script.h | 24 +++++++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/esphome/components/script/script.cpp b/esphome/components/script/script.cpp index 331f7dcd65..81f652d26a 100644 --- a/esphome/components/script/script.cpp +++ b/esphome/components/script/script.cpp @@ -6,9 +6,15 @@ namespace script { static const char *const TAG = "script"; +#ifdef USE_STORE_LOG_STR_IN_FLASH +void ScriptLogger::esp_log_(int level, int line, const __FlashStringHelper *format, const char *param) { + esp_log_printf_(level, TAG, line, format, param); +} +#else void ScriptLogger::esp_log_(int level, int line, const char *format, const char *param) { esp_log_printf_(level, TAG, line, format, param); } +#endif } // namespace script } // namespace esphome diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 60175ec933..b16bb53acc 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -10,6 +10,15 @@ namespace script { class ScriptLogger { protected: +#ifdef USE_STORE_LOG_STR_IN_FLASH + void esp_logw_(int line, const __FlashStringHelper *format, const char *param) { + esp_log_(ESPHOME_LOG_LEVEL_WARN, line, format, param); + } + void esp_logd_(int line, const __FlashStringHelper *format, const char *param) { + esp_log_(ESPHOME_LOG_LEVEL_DEBUG, line, format, param); + } + void esp_log_(int level, int line, const __FlashStringHelper *format, const char *param); +#else void esp_logw_(int line, const char *format, const char *param) { esp_log_(ESPHOME_LOG_LEVEL_WARN, line, format, param); } @@ -17,6 +26,7 @@ class ScriptLogger { esp_log_(ESPHOME_LOG_LEVEL_DEBUG, line, format, param); } void esp_log_(int level, int line, const char *format, const char *param); +#endif }; /// The abstract base class for all script types. @@ -57,7 +67,8 @@ template class SingleScript : public Script { public: void execute(Ts... x) override { if (this->is_action_running()) { - this->esp_logw_(__LINE__, "Script '%s' is already running! (mode: single)", this->name_.c_str()); + this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' is already running! (mode: single)"), + this->name_.c_str()); return; } @@ -74,7 +85,7 @@ template class RestartScript : public Script { public: void execute(Ts... x) override { if (this->is_action_running()) { - this->esp_logd_(__LINE__, "Script '%s' restarting (mode: restart)", this->name_.c_str()); + this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), this->name_.c_str()); this->stop_action(); } @@ -93,11 +104,13 @@ template class QueueingScript : public Script, public Com // num_runs_ is the number of *queued* instances, so total number of instances is // num_runs_ + 1 if (this->max_runs_ != 0 && this->num_runs_ + 1 >= this->max_runs_) { - this->esp_logw_(__LINE__, "Script '%s' maximum number of queued runs exceeded!", this->name_.c_str()); + this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of queued runs exceeded!"), + this->name_.c_str()); return; } - this->esp_logd_(__LINE__, "Script '%s' queueing new instance (mode: queued)", this->name_.c_str()); + this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' queueing new instance (mode: queued)"), + this->name_.c_str()); this->num_runs_++; this->var_queue_.push(std::make_tuple(x...)); return; @@ -143,7 +156,8 @@ template class ParallelScript : public Script { public: void execute(Ts... x) override { if (this->max_runs_ != 0 && this->automation_parent_->num_running() >= this->max_runs_) { - this->esp_logw_(__LINE__, "Script '%s' maximum number of parallel runs exceeded!", this->name_.c_str()); + this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of parallel runs exceeded!"), + this->name_.c_str()); return; } this->trigger(x...); From 7903e43a664b2ed470b515cf9db16159ed27e108 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 10:18:07 -0500 Subject: [PATCH 0911/1626] [logger] ESP8266: Store UART selection strings in PROGMEM (saves 36 bytes RAM) --- esphome/components/logger/logger_esp8266.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index fb5f6cee5d..f74c24e761 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -35,9 +35,12 @@ void Logger::pre_setup() { void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } -const char *const UART_SELECTIONS[] = {"UART0", "UART1", "UART0_SWAP"}; +static const char UART0_STR[] PROGMEM = "UART0"; +static const char UART1_STR[] PROGMEM = "UART1"; +static const char UART0_SWAP_STR[] PROGMEM = "UART0_SWAP"; +static const char *const UART_SELECTIONS[] PROGMEM = {UART0_STR, UART1_STR, UART0_SWAP_STR}; -const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } +const char *Logger::get_uart_selection_() { return (const char *) pgm_read_ptr(&UART_SELECTIONS[this->uart_]); } } // namespace esphome::logger #endif From 9e56bc17106561db0e3da20652fb47c207cd9aff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 10:19:03 -0500 Subject: [PATCH 0912/1626] Update esphome/components/logger/logger_esp8266.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/logger/logger_esp8266.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index f74c24e761..ae1ff82540 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -40,7 +40,7 @@ static const char UART1_STR[] PROGMEM = "UART1"; static const char UART0_SWAP_STR[] PROGMEM = "UART0_SWAP"; static const char *const UART_SELECTIONS[] PROGMEM = {UART0_STR, UART1_STR, UART0_SWAP_STR}; -const char *Logger::get_uart_selection_() { return (const char *) pgm_read_ptr(&UART_SELECTIONS[this->uart_]); } +const char *Logger::get_uart_selection_() { return pgm_read_ptr(&UART_SELECTIONS[this->uart_]); } } // namespace esphome::logger #endif From d323d49185e39ee4d3dc5a746311432935c2f14a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 10:20:13 -0500 Subject: [PATCH 0913/1626] Revert "Update esphome/components/logger/logger_esp8266.cpp" This reverts commit 9e56bc17106561db0e3da20652fb47c207cd9aff. --- esphome/components/logger/logger_esp8266.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index ae1ff82540..f74c24e761 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -40,7 +40,7 @@ static const char UART1_STR[] PROGMEM = "UART1"; static const char UART0_SWAP_STR[] PROGMEM = "UART0_SWAP"; static const char *const UART_SELECTIONS[] PROGMEM = {UART0_STR, UART1_STR, UART0_SWAP_STR}; -const char *Logger::get_uart_selection_() { return pgm_read_ptr(&UART_SELECTIONS[this->uart_]); } +const char *Logger::get_uart_selection_() { return (const char *) pgm_read_ptr(&UART_SELECTIONS[this->uart_]); } } // namespace esphome::logger #endif From 70358c27d39e3a1acd015d37450292b6725036f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 10:25:34 -0500 Subject: [PATCH 0914/1626] [web_server] ESP8266: Store OTA response strings in PROGMEM (saves 52 bytes RAM) --- esphome/components/web_server/ota/ota_web_server.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 7211f707e9..8d46860361 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -198,9 +198,20 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { AsyncWebServerResponse *response; // Use the ota_success_ flag to determine the actual result +#ifdef USE_ESP8266 + static const char UPDATE_SUCCESS[] PROGMEM = "Update Successful!"; + static const char UPDATE_FAILED[] PROGMEM = "Update Failed!"; + static const char TEXT_PLAIN[] PROGMEM = "text/plain"; + static const char CONNECTION_STR[] PROGMEM = "Connection"; + static const char CLOSE_STR[] PROGMEM = "close"; + const char *msg = this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED; + response = request->beginResponse(200, TEXT_PLAIN, msg); + response->addHeader(CONNECTION_STR, CLOSE_STR); +#else const char *msg = this->ota_success_ ? "Update Successful!" : "Update Failed!"; response = request->beginResponse(200, "text/plain", msg); response->addHeader("Connection", "close"); +#endif request->send(response); } From 6e24048a9032db9a6bca36463f8efe6f011397ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 10:30:45 -0500 Subject: [PATCH 0915/1626] preen --- esphome/components/web_server/web_server.cpp | 113 ++++++++----------- 1 file changed, 50 insertions(+), 63 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 8046bce7c5..e2211bd402 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -49,40 +49,27 @@ static const char CONTENT_TYPE_PLAIN[] PROGMEM = "text/plain"; static const char HEADER_CONTENT_ENCODING[] PROGMEM = "Content-Encoding"; static const char ENCODING_GZIP[] PROGMEM = "gzip"; static const char EVENT_STATE[] PROGMEM = "state"; - -// Helper macros to get __FlashStringHelper* from PROGMEM strings -#define CONTENT_JSON_F FPSTR(CONTENT_TYPE_JSON) -#define CONTENT_HTML_F FPSTR(CONTENT_TYPE_HTML) -#define CONTENT_CSS_F FPSTR(CONTENT_TYPE_CSS) -#define CONTENT_JS_F FPSTR(CONTENT_TYPE_JS) -#define CONTENT_PLAIN_F FPSTR(CONTENT_TYPE_PLAIN) -#define HEADER_ENCODING_F FPSTR(HEADER_CONTENT_ENCODING) -#define GZIP_F FPSTR(ENCODING_GZIP) -#define STATE_F FPSTR(EVENT_STATE) +static const char MSG_NOT_FOUND[] PROGMEM = "Not Found"; #else -// For all other platforms, define the same names as regular string literals -#define CONTENT_JSON_F "application/json" -#define CONTENT_HTML_F "text/html" -#define CONTENT_CSS_F "text/css" -#define CONTENT_JS_F "text/javascript" -#define CONTENT_PLAIN_F "text/plain" -#define HEADER_ENCODING_F "Content-Encoding" -#define GZIP_F "gzip" -#define STATE_F "state" +// For all other platforms, regular string constants +static const char *const CONTENT_TYPE_JSON = "application/json"; +static const char *const CONTENT_TYPE_HTML = "text/html"; +static const char *const CONTENT_TYPE_CSS = "text/css"; +static const char *const CONTENT_TYPE_JS = "text/javascript"; +static const char *const CONTENT_TYPE_PLAIN = "text/plain"; +static const char *const HEADER_CONTENT_ENCODING = "Content-Encoding"; +static const char *const ENCODING_GZIP = "gzip"; +static const char *const EVENT_STATE = "state"; +static const char *const MSG_NOT_FOUND = "Not Found"; #endif #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS #ifdef USE_ESP8266 // Store single copy in PROGMEM on ESP8266 -static const char HEADER_PNA_NAME_P[] PROGMEM = "Private-Network-Access-Name"; -static const char HEADER_PNA_ID_P[] PROGMEM = "Private-Network-Access-ID"; -static const char HEADER_CORS_REQ_PNA_P[] PROGMEM = "Access-Control-Request-Private-Network"; -static const char HEADER_CORS_ALLOW_PNA_P[] PROGMEM = "Access-Control-Allow-Private-Network"; -// Use FPSTR() to get __FlashStringHelper* from PROGMEM data -#define HEADER_PNA_NAME FPSTR(HEADER_PNA_NAME_P) -#define HEADER_PNA_ID FPSTR(HEADER_PNA_ID_P) -#define HEADER_CORS_REQ_PNA FPSTR(HEADER_CORS_REQ_PNA_P) -#define HEADER_CORS_ALLOW_PNA FPSTR(HEADER_CORS_ALLOW_PNA_P) +static const char HEADER_PNA_NAME[] PROGMEM = "Private-Network-Access-Name"; +static const char HEADER_PNA_ID[] PROGMEM = "Private-Network-Access-ID"; +static const char HEADER_CORS_REQ_PNA[] PROGMEM = "Access-Control-Request-Private-Network"; +static const char HEADER_CORS_ALLOW_PNA[] PROGMEM = "Access-Control-Allow-Private-Network"; #else // For all other platforms, use regular strings static const char *const HEADER_PNA_NAME = "Private-Network-Access-Name"; @@ -168,7 +155,7 @@ void DeferredUpdateEventSource::process_deferred_queue_() { while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); std::string message = de.message_generator_(web_server_, de.source_); - if (this->send(message.c_str(), STATE_F) != DISCARDED) { + if (this->send(message.c_str(), EVENT_STATE) != DISCARDED) { // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); this->consecutive_send_failures_ = 0; // Reset failure count on successful send @@ -217,7 +204,7 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char * deq_push_back_with_dedup_(source, message_generator); } else { std::string message = message_generator(web_server_, source); - if (this->send(message.c_str(), STATE_F) == DISCARDED) { + if (this->send(message.c_str(), EVENT_STATE) == DISCARDED) { deq_push_back_with_dedup_(source, message_generator); } else { this->consecutive_send_failures_ = 0; // Reset failure count on successful send @@ -362,21 +349,21 @@ float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f #ifdef USE_WEBSERVER_LOCAL void WebServer::handle_index_request(AsyncWebServerRequest *request) { #ifndef USE_ESP8266 - AsyncWebServerResponse *response = request->beginResponse(200, CONTENT_HTML_F, INDEX_GZ, sizeof(INDEX_GZ)); + AsyncWebServerResponse *response = request->beginResponse(200, CONTENT_TYPE_HTML, INDEX_GZ, sizeof(INDEX_GZ)); #else - AsyncWebServerResponse *response = request->beginResponse_P(200, CONTENT_HTML_F, INDEX_GZ, sizeof(INDEX_GZ)); + AsyncWebServerResponse *response = request->beginResponse_P(200, CONTENT_TYPE_HTML, INDEX_GZ, sizeof(INDEX_GZ)); #endif - response->addHeader(HEADER_ENCODING_F, GZIP_F); + response->addHeader(HEADER_CONTENT_ENCODING, ENCODING_GZIP); request->send(response); } #elif USE_WEBSERVER_VERSION >= 2 void WebServer::handle_index_request(AsyncWebServerRequest *request) { #ifndef USE_ESP8266 AsyncWebServerResponse *response = - request->beginResponse(200, CONTENT_HTML_F, ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); + request->beginResponse(200, CONTENT_TYPE_HTML, ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); #else AsyncWebServerResponse *response = - request->beginResponse_P(200, CONTENT_HTML_F, ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); + request->beginResponse_P(200, CONTENT_TYPE_HTML, ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); #endif // No gzip header here because the HTML file is so small request->send(response); @@ -398,12 +385,12 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { void WebServer::handle_css_request(AsyncWebServerRequest *request) { #ifndef USE_ESP8266 AsyncWebServerResponse *response = - request->beginResponse(200, CONTENT_CSS_F, ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); + request->beginResponse(200, CONTENT_TYPE_CSS, ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); #else - AsyncWebServerResponse *response = - request->beginResponse_P(200, CONTENT_CSS_F, ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); + AsyncWebServerResponse *response = request->beginResponse_P(200, CONTENT_TYPE_CSS, ESPHOME_WEBSERVER_CSS_INCLUDE, + ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); #endif - response->addHeader(HEADER_ENCODING_F, GZIP_F); + response->addHeader(HEADER_CONTENT_ENCODING, ENCODING_GZIP); request->send(response); } #endif @@ -412,12 +399,12 @@ void WebServer::handle_css_request(AsyncWebServerRequest *request) { void WebServer::handle_js_request(AsyncWebServerRequest *request) { #ifndef USE_ESP8266 AsyncWebServerResponse *response = - request->beginResponse(200, CONTENT_JS_F, ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); + request->beginResponse(200, CONTENT_TYPE_JS, ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); #else AsyncWebServerResponse *response = - request->beginResponse_P(200, CONTENT_JS_F, ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); + request->beginResponse_P(200, CONTENT_TYPE_JS, ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); #endif - response->addHeader(HEADER_ENCODING_F, GZIP_F); + response->addHeader(HEADER_CONTENT_ENCODING, ENCODING_GZIP); request->send(response); } #endif @@ -468,7 +455,7 @@ void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->sensor_json(obj, obj->state, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } } @@ -513,7 +500,7 @@ void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->text_sensor_json(obj, obj->state, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } } @@ -552,7 +539,7 @@ void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->switch_json(obj, obj->state, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } @@ -617,7 +604,7 @@ void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->button_json(obj, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); } else if (match.method_equals("press")) { this->defer([obj]() { obj->press(); }); request->send(200); @@ -658,7 +645,7 @@ void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, con if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->binary_sensor_json(obj, obj->state, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } } @@ -697,7 +684,7 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->fan_json(obj, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); } else if (match.method_equals("toggle")) { this->defer([obj]() { obj->toggle().perform(); }); request->send(200); @@ -771,7 +758,7 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->light_json(obj, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); } else if (match.method_equals("toggle")) { this->defer([obj]() { obj->toggle().perform(); }); request->send(200); @@ -844,7 +831,7 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->cover_json(obj, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } @@ -915,7 +902,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->number_json(obj, obj->state, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } if (!match.method_equals("set")) { @@ -981,7 +968,7 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->date_json(obj, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } if (!match.method_equals("set")) { @@ -1037,7 +1024,7 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->time_json(obj, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } if (!match.method_equals("set")) { @@ -1092,7 +1079,7 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->datetime_json(obj, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } if (!match.method_equals("set")) { @@ -1149,7 +1136,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->text_json(obj, obj->state, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } if (!match.method_equals("set")) { @@ -1207,7 +1194,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->select_json(obj, obj->state, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } @@ -1262,7 +1249,7 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->climate_json(obj, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } @@ -1400,7 +1387,7 @@ void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->lock_json(obj, obj->state, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } @@ -1471,7 +1458,7 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->valve_json(obj, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } @@ -1538,7 +1525,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->alarm_control_panel_json(obj, obj->get_state(), detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } @@ -1603,7 +1590,7 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->event_json(obj, "", detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } } @@ -1667,7 +1654,7 @@ void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->update_json(obj, detail); - request->send(200, CONTENT_JSON_F, data.c_str()); + request->send(200, CONTENT_TYPE_JSON, data.c_str()); return; } @@ -1953,7 +1940,7 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { // No matching handler found - send 404 ESP_LOGV(TAG, "Request for unknown URL: %s", url.c_str()); - request->send(404, CONTENT_PLAIN_F, F("Not Found")); + request->send(404, CONTENT_TYPE_PLAIN, MSG_NOT_FOUND); } bool WebServer::isRequestHandlerTrivial() const { return false; } From 4911c859d45d7007e0587c29ef68d6a6c528faac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 10:38:58 -0500 Subject: [PATCH 0916/1626] beginResponse_P --- esphome/components/web_server/ota/ota_web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 8d46860361..672a9868c5 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -205,7 +205,7 @@ void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { static const char CONNECTION_STR[] PROGMEM = "Connection"; static const char CLOSE_STR[] PROGMEM = "close"; const char *msg = this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED; - response = request->beginResponse(200, TEXT_PLAIN, msg); + response = request->beginResponse_P(200, TEXT_PLAIN, msg); response->addHeader(CONNECTION_STR, CLOSE_STR); #else const char *msg = this->ota_success_ ? "Update Successful!" : "Update Failed!"; From 6d70417caef3dd907a76a0d33d2e5b9ae068e94e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 10:42:39 -0500 Subject: [PATCH 0917/1626] silence false positive --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index e2211bd402..c22ba6f00a 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1940,7 +1940,7 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { // No matching handler found - send 404 ESP_LOGV(TAG, "Request for unknown URL: %s", url.c_str()); - request->send(404, CONTENT_TYPE_PLAIN, MSG_NOT_FOUND); + request->send(404, CONTENT_TYPE_PLAIN, MSG_NOT_FOUND); // NOLINT(readability-suspicious-call-argument) } bool WebServer::isRequestHandlerTrivial() const { return false; } From abe768a704079f698058ffb0927cda2e85457569 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 10:45:21 -0500 Subject: [PATCH 0918/1626] header --- esphome/components/logger/logger_esp8266.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index f74c24e761..7fe5996e79 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP8266 #include "logger.h" #include "esphome/core/log.h" +#include namespace esphome::logger { From ae3f4ad919bc7d9f8e8d5358ed5637d9af31b9c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 11:02:14 -0500 Subject: [PATCH 0919/1626] json keys --- esphome/components/web_server/web_server.cpp | 172 +++++++++---------- 1 file changed, 86 insertions(+), 86 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index c22ba6f00a..168a003932 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -262,8 +262,8 @@ void DeferredUpdateEventSourceList::on_client_connect_(WebServer *ws, DeferredUp #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { message = json::build_json([group](JsonObject root) { - root["name"] = group.second.name; - root["sorting_weight"] = group.second.weight; + root[F("name")] = group.second.name; + root[F("sorting_weight")] = group.second.weight; }); // up to 31 groups should be able to be queued initially without defer @@ -299,15 +299,15 @@ void WebServer::set_js_include(const char *js_include) { this->js_include_ = js_ std::string WebServer::get_config_json() { return json::build_json([this](JsonObject root) { - root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); - root["comment"] = App.get_comment(); + root[F("title")] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); + root[F("comment")] = App.get_comment(); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) - root["ota"] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal + root[F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else - root["ota"] = true; + root[F("ota")] = true; #endif - root["log"] = this->expose_log_; - root["lang"] = "en"; + root[F("log")] = this->expose_log_; + root[F("lang")] = "en"; }); } @@ -411,14 +411,14 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { // Helper functions to reduce code size by avoiding macro expansion static void set_json_id(JsonObject &root, EntityBase *obj, const std::string &id, JsonDetail start_config) { - root["id"] = id; + root[F("id")] = id; if (start_config == DETAIL_ALL) { - root["name"] = obj->get_name(); - root["icon"] = obj->get_icon(); - root["entity_category"] = obj->get_entity_category(); + root[F("name")] = obj->get_name(); + root[F("icon")] = obj->get_icon(); + root[F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); if (is_disabled) - root["is_disabled_by_default"] = is_disabled; + root[F("is_disabled_by_default")] = is_disabled; } } @@ -426,14 +426,14 @@ template static void set_json_value(JsonObject &root, EntityBase *obj, const std::string &id, const T &value, JsonDetail start_config) { set_json_id(root, obj, id, start_config); - root["value"] = value; + root[F("value")] = value; } template static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const std::string &id, const std::string &state, const T &value, JsonDetail start_config) { set_json_value(root, obj, id, value, start_config); - root["state"] = state; + root[F("state")] = state; } // Helper to get request detail parameter @@ -481,7 +481,7 @@ std::string WebServer::sensor_json(sensor::Sensor *obj, float value, JsonDetail if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); if (!obj->get_unit_of_measurement().empty()) - root["uom"] = obj->get_unit_of_measurement(); + root[F("uom")] = obj->get_unit_of_measurement(); } }); } @@ -589,7 +589,7 @@ std::string WebServer::switch_json(switch_::Switch *obj, bool value, JsonDetail return json::build_json([this, obj, value, start_config](JsonObject root) { set_json_icon_state_value(root, obj, "switch-" + obj->get_object_id(), value ? "ON" : "OFF", value, start_config); if (start_config == DETAIL_ALL) { - root["assumed_state"] = obj->assumed_state(); + root[F("assumed_state")] = obj->assumed_state(); this->add_sorting_info_(root, obj); } }); @@ -732,11 +732,11 @@ std::string WebServer::fan_json(fan::Fan *obj, JsonDetail start_config) { start_config); const auto traits = obj->get_traits(); if (traits.supports_speed()) { - root["speed_level"] = obj->speed; - root["speed_count"] = traits.supported_speed_count(); + root[F("speed_level")] = obj->speed; + root[F("speed_count")] = traits.supported_speed_count(); } if (obj->get_traits().supports_oscillation()) - root["oscillation"] = obj->oscillating; + root[F("oscillation")] = obj->oscillating; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -802,11 +802,11 @@ std::string WebServer::light_all_json_generator(WebServer *web_server, void *sou std::string WebServer::light_json(light::LightState *obj, JsonDetail start_config) { return json::build_json([this, obj, start_config](JsonObject root) { set_json_id(root, obj, "light-" + obj->get_object_id(), start_config); - root["state"] = obj->remote_values.is_on() ? "ON" : "OFF"; + root[F("state")] = obj->remote_values.is_on() ? "ON" : "OFF"; light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { - JsonArray opt = root["effects"].to(); + JsonArray opt = root[F("effects")].to(); opt.add("None"); for (auto const &option : obj->get_effects()) { opt.add(option->get_name()); @@ -875,12 +875,12 @@ std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) { return json::build_json([this, obj, start_config](JsonObject root) { set_json_icon_state_value(root, obj, "cover-" + obj->get_object_id(), obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - root["current_operation"] = cover::cover_operation_to_str(obj->current_operation); + root[F("current_operation")] = cover::cover_operation_to_str(obj->current_operation); if (obj->get_traits().get_supports_position()) - root["position"] = obj->position; + root[F("position")] = obj->position; if (obj->get_traits().get_supports_tilt()) - root["tilt"] = obj->tilt; + root[F("tilt")] = obj->tilt; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -930,26 +930,26 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail return json::build_json([this, obj, value, start_config](JsonObject root) { set_json_id(root, obj, "number-" + obj->get_object_id(), start_config); if (start_config == DETAIL_ALL) { - root["min_value"] = + root[F("min_value")] = value_accuracy_to_string(obj->traits.get_min_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root["max_value"] = + root[F("max_value")] = value_accuracy_to_string(obj->traits.get_max_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root["step"] = + root[F("step")] = value_accuracy_to_string(obj->traits.get_step(), step_to_accuracy_decimals(obj->traits.get_step())); - root["mode"] = (int) obj->traits.get_mode(); + root[F("mode")] = (int) obj->traits.get_mode(); if (!obj->traits.get_unit_of_measurement().empty()) - root["uom"] = obj->traits.get_unit_of_measurement(); + root[F("uom")] = obj->traits.get_unit_of_measurement(); this->add_sorting_info_(root, obj); } if (std::isnan(value)) { - root["value"] = "\"NaN\""; - root["state"] = "NA"; + root[F("value")] = "\"NaN\""; + root[F("state")] = "NA"; } else { - root["value"] = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); + root[F("value")] = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); std::string state = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); if (!obj->traits.get_unit_of_measurement().empty()) state += " " + obj->traits.get_unit_of_measurement(); - root["state"] = state; + root[F("state")] = state; } }); } @@ -1002,8 +1002,8 @@ std::string WebServer::date_json(datetime::DateEntity *obj, JsonDetail start_con return json::build_json([this, obj, start_config](JsonObject root) { set_json_id(root, obj, "date-" + obj->get_object_id(), start_config); std::string value = str_sprintf("%d-%02d-%02d", obj->year, obj->month, obj->day); - root["value"] = value; - root["state"] = value; + root[F("value")] = value; + root[F("state")] = value; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1057,8 +1057,8 @@ std::string WebServer::time_json(datetime::TimeEntity *obj, JsonDetail start_con return json::build_json([this, obj, start_config](JsonObject root) { set_json_id(root, obj, "time-" + obj->get_object_id(), start_config); std::string value = str_sprintf("%02d:%02d:%02d", obj->hour, obj->minute, obj->second); - root["value"] = value; - root["state"] = value; + root[F("value")] = value; + root[F("state")] = value; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1113,8 +1113,8 @@ std::string WebServer::datetime_json(datetime::DateTimeEntity *obj, JsonDetail s set_json_id(root, obj, "datetime-" + obj->get_object_id(), start_config); std::string value = str_sprintf("%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second); - root["value"] = value; - root["state"] = value; + root[F("value")] = value; + root[F("state")] = value; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1163,17 +1163,17 @@ std::string WebServer::text_all_json_generator(WebServer *web_server, void *sour std::string WebServer::text_json(text::Text *obj, const std::string &value, JsonDetail start_config) { return json::build_json([this, obj, value, start_config](JsonObject root) { set_json_id(root, obj, "text-" + obj->get_object_id(), start_config); - root["min_length"] = obj->traits.get_min_length(); - root["max_length"] = obj->traits.get_max_length(); - root["pattern"] = obj->traits.get_pattern(); + root[F("min_length")] = obj->traits.get_min_length(); + root[F("max_length")] = obj->traits.get_max_length(); + root[F("pattern")] = obj->traits.get_pattern(); if (obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD) { - root["state"] = "********"; + root[F("state")] = "********"; } else { - root["state"] = value; + root[F("state")] = value; } - root["value"] = value; + root[F("value")] = value; if (start_config == DETAIL_ALL) { - root["mode"] = (int) obj->traits.get_mode(); + root[F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); } }); @@ -1222,7 +1222,7 @@ std::string WebServer::select_json(select::Select *obj, const std::string &value return json::build_json([this, obj, value, start_config](JsonObject root) { set_json_icon_state_value(root, obj, "select-" + obj->get_object_id(), value, value, start_config); if (start_config == DETAIL_ALL) { - JsonArray opt = root["option"].to(); + JsonArray opt = root[F("option")].to(); for (auto &option : obj->traits.get_options()) { opt.add(option); } @@ -1292,32 +1292,32 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf char buf[16]; if (start_config == DETAIL_ALL) { - JsonArray opt = root["modes"].to(); + JsonArray opt = root[F("modes")].to(); for (climate::ClimateMode m : traits.get_supported_modes()) opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root["fan_modes"].to(); + JsonArray opt = root[F("fan_modes")].to(); for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); } if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root["custom_fan_modes"].to(); + JsonArray opt = root[F("custom_fan_modes")].to(); for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) opt.add(custom_fan_mode); } if (traits.get_supports_swing_modes()) { - JsonArray opt = root["swing_modes"].to(); + JsonArray opt = root[F("swing_modes")].to(); for (auto swing_mode : traits.get_supported_swing_modes()) opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); } if (traits.get_supports_presets() && obj->preset.has_value()) { - JsonArray opt = root["presets"].to(); + JsonArray opt = root[F("presets")].to(); for (climate::ClimatePreset m : traits.get_supported_presets()) opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); } if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { - JsonArray opt = root["custom_presets"].to(); + JsonArray opt = root[F("custom_presets")].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) opt.add(custom_preset); } @@ -1325,48 +1325,48 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf } bool has_state = false; - root["mode"] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); - root["max_temp"] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); - root["min_temp"] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); - root["step"] = traits.get_visual_target_temperature_step(); + root[F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); + root[F("max_temp")] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); + root[F("min_temp")] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); + root[F("step")] = traits.get_visual_target_temperature_step(); if (traits.get_supports_action()) { - root["action"] = PSTR_LOCAL(climate_action_to_string(obj->action)); - root["state"] = root["action"]; + root[F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action)); + root[F("state")] = root[F("action")]; has_state = true; } if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root["fan_mode"] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + root[F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->custom_fan_mode.has_value()) { - root["custom_fan_mode"] = obj->custom_fan_mode.value().c_str(); + root[F("custom_fan_mode")] = obj->custom_fan_mode.value().c_str(); } if (traits.get_supports_presets() && obj->preset.has_value()) { - root["preset"] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + root[F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { - root["custom_preset"] = obj->custom_preset.value().c_str(); + root[F("custom_preset")] = obj->custom_preset.value().c_str(); } if (traits.get_supports_swing_modes()) { - root["swing_mode"] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + root[F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); } if (traits.get_supports_current_temperature()) { if (!std::isnan(obj->current_temperature)) { - root["current_temperature"] = value_accuracy_to_string(obj->current_temperature, current_accuracy); + root[F("current_temperature")] = value_accuracy_to_string(obj->current_temperature, current_accuracy); } else { - root["current_temperature"] = "NA"; + root[F("current_temperature")] = "NA"; } } if (traits.get_supports_two_point_target_temperature()) { - root["target_temperature_low"] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); - root["target_temperature_high"] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy); + root[F("target_temperature_low")] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); + root[F("target_temperature_high")] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy); if (!has_state) { - root["state"] = value_accuracy_to_string((obj->target_temperature_high + obj->target_temperature_low) / 2.0f, - target_accuracy); + root[F("state")] = value_accuracy_to_string((obj->target_temperature_high + obj->target_temperature_low) / 2.0f, + target_accuracy); } } else { - root["target_temperature"] = value_accuracy_to_string(obj->target_temperature, target_accuracy); + root[F("target_temperature")] = value_accuracy_to_string(obj->target_temperature, target_accuracy); if (!has_state) - root["state"] = root["target_temperature"]; + root[F("state")] = root[F("target_temperature")]; } }); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) @@ -1500,10 +1500,10 @@ std::string WebServer::valve_json(valve::Valve *obj, JsonDetail start_config) { return json::build_json([this, obj, start_config](JsonObject root) { set_json_icon_state_value(root, obj, "valve-" + obj->get_object_id(), obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - root["current_operation"] = valve::valve_operation_to_str(obj->current_operation); + root[F("current_operation")] = valve::valve_operation_to_str(obj->current_operation); if (obj->get_traits().get_supports_position()) - root["position"] = obj->position; + root[F("position")] = obj->position; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1613,14 +1613,14 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty return json::build_json([this, obj, event_type, start_config](JsonObject root) { set_json_id(root, obj, "event-" + obj->get_object_id(), start_config); if (!event_type.empty()) { - root["event_type"] = event_type; + root[F("event_type")] = event_type; } if (start_config == DETAIL_ALL) { - JsonArray event_types = root["event_types"].to(); + JsonArray event_types = root[F("event_types")].to(); for (auto const &event_type : obj->get_event_types()) { event_types.add(event_type); } - root["device_class"] = obj->get_device_class(); + root[F("device_class")] = obj->get_device_class(); this->add_sorting_info_(root, obj); } }); @@ -1679,13 +1679,13 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return json::build_json([this, obj, start_config](JsonObject root) { set_json_id(root, obj, "update-" + obj->get_object_id(), start_config); - root["value"] = obj->update_info.latest_version; - root["state"] = update_state_to_string(obj->state); + root[F("value")] = obj->update_info.latest_version; + root[F("state")] = update_state_to_string(obj->state); if (start_config == DETAIL_ALL) { - root["current_version"] = obj->update_info.current_version; - root["title"] = obj->update_info.title; - root["summary"] = obj->update_info.summary; - root["release_url"] = obj->update_info.release_url; + root[F("current_version")] = obj->update_info.current_version; + root[F("title")] = obj->update_info.title; + root[F("summary")] = obj->update_info.summary; + root[F("release_url")] = obj->update_info.release_url; this->add_sorting_info_(root, obj); } }); @@ -1948,9 +1948,9 @@ bool WebServer::isRequestHandlerTrivial() const { return false; } void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { #ifdef USE_WEBSERVER_SORTING if (this->sorting_entitys_.find(entity) != this->sorting_entitys_.end()) { - root["sorting_weight"] = this->sorting_entitys_[entity].weight; + root[F("sorting_weight")] = this->sorting_entitys_[entity].weight; if (this->sorting_groups_.find(this->sorting_entitys_[entity].group_id) != this->sorting_groups_.end()) { - root["sorting_group"] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; + root[F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; } } #endif From ef0e93a9cb2e4b3d6b7fd767f51bfec9c787a398 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 11:05:10 -0500 Subject: [PATCH 0920/1626] more --- esphome/components/web_server/web_server.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 168a003932..6f26ff58bc 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -307,7 +307,7 @@ std::string WebServer::get_config_json() { root[F("ota")] = true; #endif root[F("log")] = this->expose_log_; - root[F("lang")] = "en"; + root[F("lang")] = F("en"); }); } @@ -802,12 +802,12 @@ std::string WebServer::light_all_json_generator(WebServer *web_server, void *sou std::string WebServer::light_json(light::LightState *obj, JsonDetail start_config) { return json::build_json([this, obj, start_config](JsonObject root) { set_json_id(root, obj, "light-" + obj->get_object_id(), start_config); - root[F("state")] = obj->remote_values.is_on() ? "ON" : "OFF"; + root[F("state")] = obj->remote_values.is_on() ? F("ON") : F("OFF"); light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { JsonArray opt = root[F("effects")].to(); - opt.add("None"); + opt.add(F("None")); for (auto const &option : obj->get_effects()) { opt.add(option->get_name()); } @@ -942,8 +942,8 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail this->add_sorting_info_(root, obj); } if (std::isnan(value)) { - root[F("value")] = "\"NaN\""; - root[F("state")] = "NA"; + root[F("value")] = F("\"NaN\""); + root[F("state")] = F("NA"); } else { root[F("value")] = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); std::string state = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); @@ -1167,7 +1167,7 @@ std::string WebServer::text_json(text::Text *obj, const std::string &value, Json root[F("max_length")] = obj->traits.get_max_length(); root[F("pattern")] = obj->traits.get_pattern(); if (obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD) { - root[F("state")] = "********"; + root[F("state")] = F("********"); } else { root[F("state")] = value; } @@ -1353,7 +1353,7 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf if (!std::isnan(obj->current_temperature)) { root[F("current_temperature")] = value_accuracy_to_string(obj->current_temperature, current_accuracy); } else { - root[F("current_temperature")] = "NA"; + root[F("current_temperature")] = F("NA"); } } if (traits.get_supports_two_point_target_temperature()) { From 4321fc86c2e2bca70b7c98cd0cb10bf101f6e47b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 11:12:15 -0500 Subject: [PATCH 0921/1626] Revert "more" This reverts commit ef0e93a9cb2e4b3d6b7fd767f51bfec9c787a398. --- esphome/components/web_server/web_server.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 6f26ff58bc..168a003932 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -307,7 +307,7 @@ std::string WebServer::get_config_json() { root[F("ota")] = true; #endif root[F("log")] = this->expose_log_; - root[F("lang")] = F("en"); + root[F("lang")] = "en"; }); } @@ -802,12 +802,12 @@ std::string WebServer::light_all_json_generator(WebServer *web_server, void *sou std::string WebServer::light_json(light::LightState *obj, JsonDetail start_config) { return json::build_json([this, obj, start_config](JsonObject root) { set_json_id(root, obj, "light-" + obj->get_object_id(), start_config); - root[F("state")] = obj->remote_values.is_on() ? F("ON") : F("OFF"); + root[F("state")] = obj->remote_values.is_on() ? "ON" : "OFF"; light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { JsonArray opt = root[F("effects")].to(); - opt.add(F("None")); + opt.add("None"); for (auto const &option : obj->get_effects()) { opt.add(option->get_name()); } @@ -942,8 +942,8 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail this->add_sorting_info_(root, obj); } if (std::isnan(value)) { - root[F("value")] = F("\"NaN\""); - root[F("state")] = F("NA"); + root[F("value")] = "\"NaN\""; + root[F("state")] = "NA"; } else { root[F("value")] = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); std::string state = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); @@ -1167,7 +1167,7 @@ std::string WebServer::text_json(text::Text *obj, const std::string &value, Json root[F("max_length")] = obj->traits.get_max_length(); root[F("pattern")] = obj->traits.get_pattern(); if (obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD) { - root[F("state")] = F("********"); + root[F("state")] = "********"; } else { root[F("state")] = value; } @@ -1353,7 +1353,7 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf if (!std::isnan(obj->current_temperature)) { root[F("current_temperature")] = value_accuracy_to_string(obj->current_temperature, current_accuracy); } else { - root[F("current_temperature")] = F("NA"); + root[F("current_temperature")] = "NA"; } } if (traits.get_supports_two_point_target_temperature()) { From a0b2d9c34c5d506977953f792a4196e1fd07a1d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 11:12:25 -0500 Subject: [PATCH 0922/1626] Revert "json keys" This reverts commit ae3f4ad919bc7d9f8e8d5358ed5637d9af31b9c5. --- esphome/components/web_server/web_server.cpp | 172 +++++++++---------- 1 file changed, 86 insertions(+), 86 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 168a003932..c22ba6f00a 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -262,8 +262,8 @@ void DeferredUpdateEventSourceList::on_client_connect_(WebServer *ws, DeferredUp #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { message = json::build_json([group](JsonObject root) { - root[F("name")] = group.second.name; - root[F("sorting_weight")] = group.second.weight; + root["name"] = group.second.name; + root["sorting_weight"] = group.second.weight; }); // up to 31 groups should be able to be queued initially without defer @@ -299,15 +299,15 @@ void WebServer::set_js_include(const char *js_include) { this->js_include_ = js_ std::string WebServer::get_config_json() { return json::build_json([this](JsonObject root) { - root[F("title")] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); - root[F("comment")] = App.get_comment(); + root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); + root["comment"] = App.get_comment(); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) - root[F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal + root["ota"] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else - root[F("ota")] = true; + root["ota"] = true; #endif - root[F("log")] = this->expose_log_; - root[F("lang")] = "en"; + root["log"] = this->expose_log_; + root["lang"] = "en"; }); } @@ -411,14 +411,14 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { // Helper functions to reduce code size by avoiding macro expansion static void set_json_id(JsonObject &root, EntityBase *obj, const std::string &id, JsonDetail start_config) { - root[F("id")] = id; + root["id"] = id; if (start_config == DETAIL_ALL) { - root[F("name")] = obj->get_name(); - root[F("icon")] = obj->get_icon(); - root[F("entity_category")] = obj->get_entity_category(); + root["name"] = obj->get_name(); + root["icon"] = obj->get_icon(); + root["entity_category"] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); if (is_disabled) - root[F("is_disabled_by_default")] = is_disabled; + root["is_disabled_by_default"] = is_disabled; } } @@ -426,14 +426,14 @@ template static void set_json_value(JsonObject &root, EntityBase *obj, const std::string &id, const T &value, JsonDetail start_config) { set_json_id(root, obj, id, start_config); - root[F("value")] = value; + root["value"] = value; } template static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const std::string &id, const std::string &state, const T &value, JsonDetail start_config) { set_json_value(root, obj, id, value, start_config); - root[F("state")] = state; + root["state"] = state; } // Helper to get request detail parameter @@ -481,7 +481,7 @@ std::string WebServer::sensor_json(sensor::Sensor *obj, float value, JsonDetail if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); if (!obj->get_unit_of_measurement().empty()) - root[F("uom")] = obj->get_unit_of_measurement(); + root["uom"] = obj->get_unit_of_measurement(); } }); } @@ -589,7 +589,7 @@ std::string WebServer::switch_json(switch_::Switch *obj, bool value, JsonDetail return json::build_json([this, obj, value, start_config](JsonObject root) { set_json_icon_state_value(root, obj, "switch-" + obj->get_object_id(), value ? "ON" : "OFF", value, start_config); if (start_config == DETAIL_ALL) { - root[F("assumed_state")] = obj->assumed_state(); + root["assumed_state"] = obj->assumed_state(); this->add_sorting_info_(root, obj); } }); @@ -732,11 +732,11 @@ std::string WebServer::fan_json(fan::Fan *obj, JsonDetail start_config) { start_config); const auto traits = obj->get_traits(); if (traits.supports_speed()) { - root[F("speed_level")] = obj->speed; - root[F("speed_count")] = traits.supported_speed_count(); + root["speed_level"] = obj->speed; + root["speed_count"] = traits.supported_speed_count(); } if (obj->get_traits().supports_oscillation()) - root[F("oscillation")] = obj->oscillating; + root["oscillation"] = obj->oscillating; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -802,11 +802,11 @@ std::string WebServer::light_all_json_generator(WebServer *web_server, void *sou std::string WebServer::light_json(light::LightState *obj, JsonDetail start_config) { return json::build_json([this, obj, start_config](JsonObject root) { set_json_id(root, obj, "light-" + obj->get_object_id(), start_config); - root[F("state")] = obj->remote_values.is_on() ? "ON" : "OFF"; + root["state"] = obj->remote_values.is_on() ? "ON" : "OFF"; light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { - JsonArray opt = root[F("effects")].to(); + JsonArray opt = root["effects"].to(); opt.add("None"); for (auto const &option : obj->get_effects()) { opt.add(option->get_name()); @@ -875,12 +875,12 @@ std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) { return json::build_json([this, obj, start_config](JsonObject root) { set_json_icon_state_value(root, obj, "cover-" + obj->get_object_id(), obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - root[F("current_operation")] = cover::cover_operation_to_str(obj->current_operation); + root["current_operation"] = cover::cover_operation_to_str(obj->current_operation); if (obj->get_traits().get_supports_position()) - root[F("position")] = obj->position; + root["position"] = obj->position; if (obj->get_traits().get_supports_tilt()) - root[F("tilt")] = obj->tilt; + root["tilt"] = obj->tilt; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -930,26 +930,26 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail return json::build_json([this, obj, value, start_config](JsonObject root) { set_json_id(root, obj, "number-" + obj->get_object_id(), start_config); if (start_config == DETAIL_ALL) { - root[F("min_value")] = + root["min_value"] = value_accuracy_to_string(obj->traits.get_min_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root[F("max_value")] = + root["max_value"] = value_accuracy_to_string(obj->traits.get_max_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root[F("step")] = + root["step"] = value_accuracy_to_string(obj->traits.get_step(), step_to_accuracy_decimals(obj->traits.get_step())); - root[F("mode")] = (int) obj->traits.get_mode(); + root["mode"] = (int) obj->traits.get_mode(); if (!obj->traits.get_unit_of_measurement().empty()) - root[F("uom")] = obj->traits.get_unit_of_measurement(); + root["uom"] = obj->traits.get_unit_of_measurement(); this->add_sorting_info_(root, obj); } if (std::isnan(value)) { - root[F("value")] = "\"NaN\""; - root[F("state")] = "NA"; + root["value"] = "\"NaN\""; + root["state"] = "NA"; } else { - root[F("value")] = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); + root["value"] = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); std::string state = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); if (!obj->traits.get_unit_of_measurement().empty()) state += " " + obj->traits.get_unit_of_measurement(); - root[F("state")] = state; + root["state"] = state; } }); } @@ -1002,8 +1002,8 @@ std::string WebServer::date_json(datetime::DateEntity *obj, JsonDetail start_con return json::build_json([this, obj, start_config](JsonObject root) { set_json_id(root, obj, "date-" + obj->get_object_id(), start_config); std::string value = str_sprintf("%d-%02d-%02d", obj->year, obj->month, obj->day); - root[F("value")] = value; - root[F("state")] = value; + root["value"] = value; + root["state"] = value; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1057,8 +1057,8 @@ std::string WebServer::time_json(datetime::TimeEntity *obj, JsonDetail start_con return json::build_json([this, obj, start_config](JsonObject root) { set_json_id(root, obj, "time-" + obj->get_object_id(), start_config); std::string value = str_sprintf("%02d:%02d:%02d", obj->hour, obj->minute, obj->second); - root[F("value")] = value; - root[F("state")] = value; + root["value"] = value; + root["state"] = value; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1113,8 +1113,8 @@ std::string WebServer::datetime_json(datetime::DateTimeEntity *obj, JsonDetail s set_json_id(root, obj, "datetime-" + obj->get_object_id(), start_config); std::string value = str_sprintf("%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second); - root[F("value")] = value; - root[F("state")] = value; + root["value"] = value; + root["state"] = value; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1163,17 +1163,17 @@ std::string WebServer::text_all_json_generator(WebServer *web_server, void *sour std::string WebServer::text_json(text::Text *obj, const std::string &value, JsonDetail start_config) { return json::build_json([this, obj, value, start_config](JsonObject root) { set_json_id(root, obj, "text-" + obj->get_object_id(), start_config); - root[F("min_length")] = obj->traits.get_min_length(); - root[F("max_length")] = obj->traits.get_max_length(); - root[F("pattern")] = obj->traits.get_pattern(); + root["min_length"] = obj->traits.get_min_length(); + root["max_length"] = obj->traits.get_max_length(); + root["pattern"] = obj->traits.get_pattern(); if (obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD) { - root[F("state")] = "********"; + root["state"] = "********"; } else { - root[F("state")] = value; + root["state"] = value; } - root[F("value")] = value; + root["value"] = value; if (start_config == DETAIL_ALL) { - root[F("mode")] = (int) obj->traits.get_mode(); + root["mode"] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); } }); @@ -1222,7 +1222,7 @@ std::string WebServer::select_json(select::Select *obj, const std::string &value return json::build_json([this, obj, value, start_config](JsonObject root) { set_json_icon_state_value(root, obj, "select-" + obj->get_object_id(), value, value, start_config); if (start_config == DETAIL_ALL) { - JsonArray opt = root[F("option")].to(); + JsonArray opt = root["option"].to(); for (auto &option : obj->traits.get_options()) { opt.add(option); } @@ -1292,32 +1292,32 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf char buf[16]; if (start_config == DETAIL_ALL) { - JsonArray opt = root[F("modes")].to(); + JsonArray opt = root["modes"].to(); for (climate::ClimateMode m : traits.get_supported_modes()) opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root[F("fan_modes")].to(); + JsonArray opt = root["fan_modes"].to(); for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); } if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root[F("custom_fan_modes")].to(); + JsonArray opt = root["custom_fan_modes"].to(); for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) opt.add(custom_fan_mode); } if (traits.get_supports_swing_modes()) { - JsonArray opt = root[F("swing_modes")].to(); + JsonArray opt = root["swing_modes"].to(); for (auto swing_mode : traits.get_supported_swing_modes()) opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); } if (traits.get_supports_presets() && obj->preset.has_value()) { - JsonArray opt = root[F("presets")].to(); + JsonArray opt = root["presets"].to(); for (climate::ClimatePreset m : traits.get_supported_presets()) opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); } if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { - JsonArray opt = root[F("custom_presets")].to(); + JsonArray opt = root["custom_presets"].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) opt.add(custom_preset); } @@ -1325,48 +1325,48 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf } bool has_state = false; - root[F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); - root[F("max_temp")] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); - root[F("min_temp")] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); - root[F("step")] = traits.get_visual_target_temperature_step(); + root["mode"] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); + root["max_temp"] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); + root["min_temp"] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); + root["step"] = traits.get_visual_target_temperature_step(); if (traits.get_supports_action()) { - root[F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action)); - root[F("state")] = root[F("action")]; + root["action"] = PSTR_LOCAL(climate_action_to_string(obj->action)); + root["state"] = root["action"]; has_state = true; } if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root[F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + root["fan_mode"] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->custom_fan_mode.has_value()) { - root[F("custom_fan_mode")] = obj->custom_fan_mode.value().c_str(); + root["custom_fan_mode"] = obj->custom_fan_mode.value().c_str(); } if (traits.get_supports_presets() && obj->preset.has_value()) { - root[F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + root["preset"] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { - root[F("custom_preset")] = obj->custom_preset.value().c_str(); + root["custom_preset"] = obj->custom_preset.value().c_str(); } if (traits.get_supports_swing_modes()) { - root[F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + root["swing_mode"] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); } if (traits.get_supports_current_temperature()) { if (!std::isnan(obj->current_temperature)) { - root[F("current_temperature")] = value_accuracy_to_string(obj->current_temperature, current_accuracy); + root["current_temperature"] = value_accuracy_to_string(obj->current_temperature, current_accuracy); } else { - root[F("current_temperature")] = "NA"; + root["current_temperature"] = "NA"; } } if (traits.get_supports_two_point_target_temperature()) { - root[F("target_temperature_low")] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); - root[F("target_temperature_high")] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy); + root["target_temperature_low"] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); + root["target_temperature_high"] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy); if (!has_state) { - root[F("state")] = value_accuracy_to_string((obj->target_temperature_high + obj->target_temperature_low) / 2.0f, - target_accuracy); + root["state"] = value_accuracy_to_string((obj->target_temperature_high + obj->target_temperature_low) / 2.0f, + target_accuracy); } } else { - root[F("target_temperature")] = value_accuracy_to_string(obj->target_temperature, target_accuracy); + root["target_temperature"] = value_accuracy_to_string(obj->target_temperature, target_accuracy); if (!has_state) - root[F("state")] = root[F("target_temperature")]; + root["state"] = root["target_temperature"]; } }); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) @@ -1500,10 +1500,10 @@ std::string WebServer::valve_json(valve::Valve *obj, JsonDetail start_config) { return json::build_json([this, obj, start_config](JsonObject root) { set_json_icon_state_value(root, obj, "valve-" + obj->get_object_id(), obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - root[F("current_operation")] = valve::valve_operation_to_str(obj->current_operation); + root["current_operation"] = valve::valve_operation_to_str(obj->current_operation); if (obj->get_traits().get_supports_position()) - root[F("position")] = obj->position; + root["position"] = obj->position; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1613,14 +1613,14 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty return json::build_json([this, obj, event_type, start_config](JsonObject root) { set_json_id(root, obj, "event-" + obj->get_object_id(), start_config); if (!event_type.empty()) { - root[F("event_type")] = event_type; + root["event_type"] = event_type; } if (start_config == DETAIL_ALL) { - JsonArray event_types = root[F("event_types")].to(); + JsonArray event_types = root["event_types"].to(); for (auto const &event_type : obj->get_event_types()) { event_types.add(event_type); } - root[F("device_class")] = obj->get_device_class(); + root["device_class"] = obj->get_device_class(); this->add_sorting_info_(root, obj); } }); @@ -1679,13 +1679,13 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return json::build_json([this, obj, start_config](JsonObject root) { set_json_id(root, obj, "update-" + obj->get_object_id(), start_config); - root[F("value")] = obj->update_info.latest_version; - root[F("state")] = update_state_to_string(obj->state); + root["value"] = obj->update_info.latest_version; + root["state"] = update_state_to_string(obj->state); if (start_config == DETAIL_ALL) { - root[F("current_version")] = obj->update_info.current_version; - root[F("title")] = obj->update_info.title; - root[F("summary")] = obj->update_info.summary; - root[F("release_url")] = obj->update_info.release_url; + root["current_version"] = obj->update_info.current_version; + root["title"] = obj->update_info.title; + root["summary"] = obj->update_info.summary; + root["release_url"] = obj->update_info.release_url; this->add_sorting_info_(root, obj); } }); @@ -1948,9 +1948,9 @@ bool WebServer::isRequestHandlerTrivial() const { return false; } void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { #ifdef USE_WEBSERVER_SORTING if (this->sorting_entitys_.find(entity) != this->sorting_entitys_.end()) { - root[F("sorting_weight")] = this->sorting_entitys_[entity].weight; + root["sorting_weight"] = this->sorting_entitys_[entity].weight; if (this->sorting_groups_.find(this->sorting_entitys_[entity].group_id) != this->sorting_groups_.end()) { - root[F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; + root["sorting_group"] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; } } #endif From 71ac279adc96b1d1907f585cf44d5f19748ffc54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 11:17:08 -0500 Subject: [PATCH 0923/1626] revert --- esphome/components/web_server/web_server.cpp | 109 +++++++------------ 1 file changed, 38 insertions(+), 71 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index c22ba6f00a..290992b096 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -39,45 +39,12 @@ namespace web_server { static const char *const TAG = "web_server"; -#ifdef USE_ESP8266 -// Common strings used multiple times - deduplicated in PROGMEM on ESP8266 -static const char CONTENT_TYPE_JSON[] PROGMEM = "application/json"; -static const char CONTENT_TYPE_HTML[] PROGMEM = "text/html"; -static const char CONTENT_TYPE_CSS[] PROGMEM = "text/css"; -static const char CONTENT_TYPE_JS[] PROGMEM = "text/javascript"; -static const char CONTENT_TYPE_PLAIN[] PROGMEM = "text/plain"; -static const char HEADER_CONTENT_ENCODING[] PROGMEM = "Content-Encoding"; -static const char ENCODING_GZIP[] PROGMEM = "gzip"; -static const char EVENT_STATE[] PROGMEM = "state"; -static const char MSG_NOT_FOUND[] PROGMEM = "Not Found"; -#else -// For all other platforms, regular string constants -static const char *const CONTENT_TYPE_JSON = "application/json"; -static const char *const CONTENT_TYPE_HTML = "text/html"; -static const char *const CONTENT_TYPE_CSS = "text/css"; -static const char *const CONTENT_TYPE_JS = "text/javascript"; -static const char *const CONTENT_TYPE_PLAIN = "text/plain"; -static const char *const HEADER_CONTENT_ENCODING = "Content-Encoding"; -static const char *const ENCODING_GZIP = "gzip"; -static const char *const EVENT_STATE = "state"; -static const char *const MSG_NOT_FOUND = "Not Found"; -#endif - #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS -#ifdef USE_ESP8266 -// Store single copy in PROGMEM on ESP8266 -static const char HEADER_PNA_NAME[] PROGMEM = "Private-Network-Access-Name"; -static const char HEADER_PNA_ID[] PROGMEM = "Private-Network-Access-ID"; -static const char HEADER_CORS_REQ_PNA[] PROGMEM = "Access-Control-Request-Private-Network"; -static const char HEADER_CORS_ALLOW_PNA[] PROGMEM = "Access-Control-Allow-Private-Network"; -#else -// For all other platforms, use regular strings static const char *const HEADER_PNA_NAME = "Private-Network-Access-Name"; static const char *const HEADER_PNA_ID = "Private-Network-Access-ID"; static const char *const HEADER_CORS_REQ_PNA = "Access-Control-Request-Private-Network"; static const char *const HEADER_CORS_ALLOW_PNA = "Access-Control-Allow-Private-Network"; #endif -#endif // Parse URL and return match info static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) { @@ -155,7 +122,7 @@ void DeferredUpdateEventSource::process_deferred_queue_() { while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); std::string message = de.message_generator_(web_server_, de.source_); - if (this->send(message.c_str(), EVENT_STATE) != DISCARDED) { + if (this->send(message.c_str(), "state") != DISCARDED) { // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); this->consecutive_send_failures_ = 0; // Reset failure count on successful send @@ -204,7 +171,7 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char * deq_push_back_with_dedup_(source, message_generator); } else { std::string message = message_generator(web_server_, source); - if (this->send(message.c_str(), EVENT_STATE) == DISCARDED) { + if (this->send(message.c_str(), "state") == DISCARDED) { deq_push_back_with_dedup_(source, message_generator); } else { this->consecutive_send_failures_ = 0; // Reset failure count on successful send @@ -239,7 +206,7 @@ void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, const } void DeferredUpdateEventSourceList::add_new_client(WebServer *ws, AsyncWebServerRequest *request) { - DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, F("/events")); + DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, "/events"); this->push_back(es); es->onConnect([this, ws, es](AsyncEventSourceClient *client) { @@ -349,21 +316,21 @@ float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f #ifdef USE_WEBSERVER_LOCAL void WebServer::handle_index_request(AsyncWebServerRequest *request) { #ifndef USE_ESP8266 - AsyncWebServerResponse *response = request->beginResponse(200, CONTENT_TYPE_HTML, INDEX_GZ, sizeof(INDEX_GZ)); + AsyncWebServerResponse *response = request->beginResponse(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); #else - AsyncWebServerResponse *response = request->beginResponse_P(200, CONTENT_TYPE_HTML, INDEX_GZ, sizeof(INDEX_GZ)); + AsyncWebServerResponse *response = request->beginResponse_P(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); #endif - response->addHeader(HEADER_CONTENT_ENCODING, ENCODING_GZIP); + response->addHeader("Content-Encoding", "gzip"); request->send(response); } #elif USE_WEBSERVER_VERSION >= 2 void WebServer::handle_index_request(AsyncWebServerRequest *request) { #ifndef USE_ESP8266 AsyncWebServerResponse *response = - request->beginResponse(200, CONTENT_TYPE_HTML, ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); + request->beginResponse(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); #else AsyncWebServerResponse *response = - request->beginResponse_P(200, CONTENT_TYPE_HTML, ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); + request->beginResponse_P(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); #endif // No gzip header here because the HTML file is so small request->send(response); @@ -372,8 +339,8 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { - AsyncWebServerResponse *response = request->beginResponse(200, F("")); - response->addHeader(HEADER_CORS_ALLOW_PNA, F("true")); + AsyncWebServerResponse *response = request->beginResponse(200, ""); + response->addHeader(HEADER_CORS_ALLOW_PNA, "true"); response->addHeader(HEADER_PNA_NAME, App.get_name().c_str()); std::string mac = get_mac_address_pretty(); response->addHeader(HEADER_PNA_ID, mac.c_str()); @@ -385,12 +352,12 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { void WebServer::handle_css_request(AsyncWebServerRequest *request) { #ifndef USE_ESP8266 AsyncWebServerResponse *response = - request->beginResponse(200, CONTENT_TYPE_CSS, ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); + request->beginResponse(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); #else - AsyncWebServerResponse *response = request->beginResponse_P(200, CONTENT_TYPE_CSS, ESPHOME_WEBSERVER_CSS_INCLUDE, - ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); + AsyncWebServerResponse *response = + request->beginResponse_P(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); #endif - response->addHeader(HEADER_CONTENT_ENCODING, ENCODING_GZIP); + response->addHeader("Content-Encoding", "gzip"); request->send(response); } #endif @@ -399,12 +366,12 @@ void WebServer::handle_css_request(AsyncWebServerRequest *request) { void WebServer::handle_js_request(AsyncWebServerRequest *request) { #ifndef USE_ESP8266 AsyncWebServerResponse *response = - request->beginResponse(200, CONTENT_TYPE_JS, ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); + request->beginResponse(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); #else AsyncWebServerResponse *response = - request->beginResponse_P(200, CONTENT_TYPE_JS, ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); + request->beginResponse_P(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); #endif - response->addHeader(HEADER_CONTENT_ENCODING, ENCODING_GZIP); + response->addHeader("Content-Encoding", "gzip"); request->send(response); } #endif @@ -455,7 +422,7 @@ void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->sensor_json(obj, obj->state, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } } @@ -500,7 +467,7 @@ void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->text_sensor_json(obj, obj->state, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } } @@ -539,7 +506,7 @@ void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->switch_json(obj, obj->state, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } @@ -604,7 +571,7 @@ void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->button_json(obj, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); } else if (match.method_equals("press")) { this->defer([obj]() { obj->press(); }); request->send(200); @@ -645,7 +612,7 @@ void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, con if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->binary_sensor_json(obj, obj->state, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } } @@ -684,7 +651,7 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->fan_json(obj, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); } else if (match.method_equals("toggle")) { this->defer([obj]() { obj->toggle().perform(); }); request->send(200); @@ -758,7 +725,7 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->light_json(obj, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); } else if (match.method_equals("toggle")) { this->defer([obj]() { obj->toggle().perform(); }); request->send(200); @@ -831,7 +798,7 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->cover_json(obj, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } @@ -902,7 +869,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->number_json(obj, obj->state, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } if (!match.method_equals("set")) { @@ -968,7 +935,7 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->date_json(obj, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } if (!match.method_equals("set")) { @@ -1024,7 +991,7 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->time_json(obj, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } if (!match.method_equals("set")) { @@ -1079,7 +1046,7 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->datetime_json(obj, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } if (!match.method_equals("set")) { @@ -1136,7 +1103,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->text_json(obj, obj->state, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } if (!match.method_equals("set")) { @@ -1194,7 +1161,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->select_json(obj, obj->state, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } @@ -1249,7 +1216,7 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->climate_json(obj, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } @@ -1387,7 +1354,7 @@ void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->lock_json(obj, obj->state, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } @@ -1458,7 +1425,7 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->valve_json(obj, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } @@ -1525,7 +1492,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->alarm_control_panel_json(obj, obj->get_state(), detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } @@ -1590,7 +1557,7 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->event_json(obj, "", detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } } @@ -1654,7 +1621,7 @@ void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && match.method_empty()) { auto detail = get_request_detail(request); std::string data = this->update_json(obj, detail); - request->send(200, CONTENT_TYPE_JSON, data.c_str()); + request->send(200, "application/json", data.c_str()); return; } @@ -1940,7 +1907,7 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { // No matching handler found - send 404 ESP_LOGV(TAG, "Request for unknown URL: %s", url.c_str()); - request->send(404, CONTENT_TYPE_PLAIN, MSG_NOT_FOUND); // NOLINT(readability-suspicious-call-argument) + request->send(404, "text/plain", "Not Found"); } bool WebServer::isRequestHandlerTrivial() const { return false; } From dd870b036272a7a6be7d9eebeeed55dccfa51f7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 11:19:38 -0500 Subject: [PATCH 0924/1626] fix header --- esphome/components/logger/logger_esp8266.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 7fe5996e79..33fbc4713f 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -1,7 +1,8 @@ #ifdef USE_ESP8266 #include "logger.h" #include "esphome/core/log.h" -#include + +#include namespace esphome::logger { From 4248cbc596027988d88637f0bcb1cb040781aeef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 11:32:52 -0500 Subject: [PATCH 0925/1626] [sensor] ESP8266: Use LogString for state_class_to_string() to save RAM --- esphome/components/mqtt/mqtt_sensor.cpp | 7 ++++++- esphome/components/sensor/sensor.cpp | 13 +++++++------ esphome/components/sensor/sensor.h | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index 2e1db1908f..032dd3b6c6 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -58,8 +58,13 @@ void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon if (this->sensor_->get_force_update()) root[MQTT_FORCE_UPDATE] = true; - if (this->sensor_->get_state_class() != STATE_CLASS_NONE) + if (this->sensor_->get_state_class() != STATE_CLASS_NONE) { +#ifdef USE_STORE_LOG_STR_IN_FLASH + root[MQTT_STATE_CLASS] = (const __FlashStringHelper *) state_class_to_string(this->sensor_->get_state_class()); +#else root[MQTT_STATE_CLASS] = state_class_to_string(this->sensor_->get_state_class()); +#endif + } config.command_topic = false; } diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index e2e8302d8b..4292b8c0bc 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -17,7 +17,8 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o "%s State Class: '%s'\n" "%s Unit of Measurement: '%s'\n" "%s Accuracy Decimals: %d", - prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()), prefix, + prefix, type, obj->get_name().c_str(), prefix, + LOG_STR_ARG(state_class_to_string(obj->get_state_class())), prefix, obj->get_unit_of_measurement_ref().c_str(), prefix, obj->get_accuracy_decimals()); if (!obj->get_device_class_ref().empty()) { @@ -33,17 +34,17 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o } } -const char *state_class_to_string(StateClass state_class) { +const LogString *state_class_to_string(StateClass state_class) { switch (state_class) { case STATE_CLASS_MEASUREMENT: - return "measurement"; + return LOG_STR("measurement"); case STATE_CLASS_TOTAL_INCREASING: - return "total_increasing"; + return LOG_STR("total_increasing"); case STATE_CLASS_TOTAL: - return "total"; + return LOG_STR("total"); case STATE_CLASS_NONE: default: - return ""; + return LOG_STR(""); } } diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index 507cb326b2..f3fa601a5e 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -33,7 +33,7 @@ enum StateClass : uint8_t { STATE_CLASS_TOTAL = 3, }; -const char *state_class_to_string(StateClass state_class); +const LogString *state_class_to_string(StateClass state_class); /** Base-class for all sensors. * From 406e6852d2b9e1bc7005a5f1a571f5b4863abffb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 13:01:58 -0500 Subject: [PATCH 0926/1626] preen --- esphome/components/mqtt/mqtt_sensor.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index 032dd3b6c6..6c77404767 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -59,10 +59,11 @@ void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_FORCE_UPDATE] = true; if (this->sensor_->get_state_class() != STATE_CLASS_NONE) { + auto state_class_s = state_class_to_string(this->sensor_->get_state_class()); #ifdef USE_STORE_LOG_STR_IN_FLASH - root[MQTT_STATE_CLASS] = (const __FlashStringHelper *) state_class_to_string(this->sensor_->get_state_class()); + root[MQTT_STATE_CLASS] = (const __FlashStringHelper *) state_class_s; #else - root[MQTT_STATE_CLASS] = state_class_to_string(this->sensor_->get_state_class()); + root[MQTT_STATE_CLASS] = LOG_STR_ARG(state_class_s); #endif } From 3f3b31a2b44a6ad1be25cbeeaa9e93fc837ef642 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 13:04:27 -0500 Subject: [PATCH 0927/1626] simplify --- esphome/components/mqtt/mqtt_sensor.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index 6c77404767..9e61f6ef3b 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -59,11 +59,10 @@ void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_FORCE_UPDATE] = true; if (this->sensor_->get_state_class() != STATE_CLASS_NONE) { - auto state_class_s = state_class_to_string(this->sensor_->get_state_class()); #ifdef USE_STORE_LOG_STR_IN_FLASH - root[MQTT_STATE_CLASS] = (const __FlashStringHelper *) state_class_s; + root[MQTT_STATE_CLASS] = (const __FlashStringHelper *) state_class_to_string(this->sensor_->get_state_class()); #else - root[MQTT_STATE_CLASS] = LOG_STR_ARG(state_class_s); + root[MQTT_STATE_CLASS] = LOG_STR_ARG(state_class_to_string(this->sensor_->get_state_class())); #endif } From 35ab40faf49e6f64271ece2e3622c16780934386 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 13:14:51 -0500 Subject: [PATCH 0928/1626] try to make tidy happy --- esphome/components/logger/logger_esp8266.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 33fbc4713f..bb9878d3d7 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include +#include namespace esphome::logger { From 47fac7c99a3c579670a22e75655e4c67119efca7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 13:21:54 -0500 Subject: [PATCH 0929/1626] standard way --- esphome/components/logger/logger.cpp | 2 +- esphome/components/logger/logger.h | 2 +- esphome/components/logger/logger_esp32.cpp | 24 ++++++++++++------- esphome/components/logger/logger_esp8266.cpp | 20 +++++++++------- .../components/logger/logger_libretiny.cpp | 16 ++++++++++--- esphome/components/logger/logger_rp2040.cpp | 14 ++++++++--- esphome/components/logger/logger_zephyr.cpp | 16 +++++++++---- 7 files changed, 65 insertions(+), 29 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 195e04948d..0ade9cedae 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -258,7 +258,7 @@ void Logger::dump_config() { ESP_LOGCONFIG(TAG, " Log Baud Rate: %" PRIu32 "\n" " Hardware UART: %s", - this->baud_rate_, get_uart_selection_()); + this->baud_rate_, LOG_STR_ARG(get_uart_selection_())); #endif #ifdef USE_ESPHOME_TASK_LOG_BUFFER if (this->log_buffer_) { diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index aa76a188c9..a4cf5e3004 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -226,7 +226,7 @@ class Logger : public Component { } #ifndef USE_HOST - const char *get_uart_selection_(); + const LogString *get_uart_selection_(); #endif // Group 4-byte aligned members first diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 44243d4aa8..6cb57c1540 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -190,20 +190,28 @@ void HOT Logger::write_msg_(const char *msg) { void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } #endif -const char *const UART_SELECTIONS[] = { - "UART0", "UART1", +const LogString *Logger::get_uart_selection_() { + switch (this->uart_) { + case UART_SELECTION_UART0: + return LOG_STR("UART0"); + case UART_SELECTION_UART1: + return LOG_STR("UART1"); #ifdef USE_ESP32_VARIANT_ESP32 - "UART2", + case UART_SELECTION_UART2: + return LOG_STR("UART2"); #endif #ifdef USE_LOGGER_USB_CDC - "USB_CDC", + case UART_SELECTION_USB_CDC: + return LOG_STR("USB_CDC"); #endif #ifdef USE_LOGGER_USB_SERIAL_JTAG - "USB_SERIAL_JTAG", + case UART_SELECTION_USB_SERIAL_JTAG: + return LOG_STR("USB_SERIAL_JTAG"); #endif -}; - -const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } + default: + return LOG_STR("UNKNOWN"); + } +} } // namespace esphome::logger #endif diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index bb9878d3d7..5063d88b92 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -2,9 +2,6 @@ #include "logger.h" #include "esphome/core/log.h" -#include -#include - namespace esphome::logger { static const char *const TAG = "logger"; @@ -38,12 +35,17 @@ void Logger::pre_setup() { void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } -static const char UART0_STR[] PROGMEM = "UART0"; -static const char UART1_STR[] PROGMEM = "UART1"; -static const char UART0_SWAP_STR[] PROGMEM = "UART0_SWAP"; -static const char *const UART_SELECTIONS[] PROGMEM = {UART0_STR, UART1_STR, UART0_SWAP_STR}; - -const char *Logger::get_uart_selection_() { return (const char *) pgm_read_ptr(&UART_SELECTIONS[this->uart_]); } +const LogString *Logger::get_uart_selection_() { + switch (this->uart_) { + case UART_SELECTION_UART0: + return LOG_STR("UART0"); + case UART_SELECTION_UART1: + return LOG_STR("UART1"); + case UART_SELECTION_UART0_SWAP: + default: + return LOG_STR("UART0_SWAP"); + } +} } // namespace esphome::logger #endif diff --git a/esphome/components/logger/logger_libretiny.cpp b/esphome/components/logger/logger_libretiny.cpp index 09d0622bc3..3edfa74480 100644 --- a/esphome/components/logger/logger_libretiny.cpp +++ b/esphome/components/logger/logger_libretiny.cpp @@ -51,9 +51,19 @@ void Logger::pre_setup() { void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } -const char *const UART_SELECTIONS[] = {"DEFAULT", "UART0", "UART1", "UART2"}; - -const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } +const LogString *Logger::get_uart_selection_() { + switch (this->uart_) { + case UART_SELECTION_DEFAULT: + return LOG_STR("DEFAULT"); + case UART_SELECTION_UART0: + return LOG_STR("UART0"); + case UART_SELECTION_UART1: + return LOG_STR("UART1"); + case UART_SELECTION_UART2: + default: + return LOG_STR("UART2"); + } +} } // namespace esphome::logger diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index f1cad9b283..f5951bfdef 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -29,9 +29,17 @@ void Logger::pre_setup() { void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } -const char *const UART_SELECTIONS[] = {"UART0", "UART1", "USB_CDC"}; - -const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } +const LogString *Logger::get_uart_selection_() { + switch (this->uart_) { + case UART_SELECTION_UART0: + return LOG_STR("UART0"); + case UART_SELECTION_UART1: + return LOG_STR("UART1"); + case UART_SELECTION_USB_CDC: + default: + return LOG_STR("USB_CDC"); + } +} } // namespace esphome::logger #endif // USE_RP2040 diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 58a09facd5..ffa27535c9 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -54,7 +54,7 @@ void Logger::pre_setup() { #endif } if (!device_is_ready(uart_dev)) { - ESP_LOGE(TAG, "%s is not ready.", get_uart_selection_()); + ESP_LOGE(TAG, "%s is not ready.", LOG_STR_ARG(get_uart_selection_())); } else { this->uart_dev_ = uart_dev; } @@ -77,9 +77,17 @@ void HOT Logger::write_msg_(const char *msg) { uart_poll_out(this->uart_dev_, '\n'); } -const char *const UART_SELECTIONS[] = {"UART0", "UART1", "USB_CDC"}; - -const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } +const LogString *Logger::get_uart_selection_() { + switch (this->uart_) { + case UART_SELECTION_UART0: + return LOG_STR("UART0"); + case UART_SELECTION_UART1: + return LOG_STR("UART1"); + case UART_SELECTION_USB_CDC: + default: + return LOG_STR("USB_CDC"); + } +} } // namespace esphome::logger From f2bde669337d8a923b58072b4c2af434ef374e0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 13:35:30 -0500 Subject: [PATCH 0930/1626] cleanup --- esphome/components/logger/logger_rp2040.cpp | 5 ++++- esphome/components/logger/logger_zephyr.cpp | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index f5951bfdef..63727c2cda 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -35,9 +35,12 @@ const LogString *Logger::get_uart_selection_() { return LOG_STR("UART0"); case UART_SELECTION_UART1: return LOG_STR("UART1"); +#ifdef USE_LOGGER_USB_CDC case UART_SELECTION_USB_CDC: - default: return LOG_STR("USB_CDC"); +#endif + default: + return LOG_STR("UNKNOWN"); } } diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index ffa27535c9..817ca168f8 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -83,9 +83,12 @@ const LogString *Logger::get_uart_selection_() { return LOG_STR("UART0"); case UART_SELECTION_UART1: return LOG_STR("UART1"); +#ifdef USE_LOGGER_USB_CDC case UART_SELECTION_USB_CDC: - default: return LOG_STR("USB_CDC"); +#endif + default: + return LOG_STR("UNKNOWN"); } } From bc671965569ca594088ff502538309e729629d5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 13:53:10 -0500 Subject: [PATCH 0931/1626] [esphome] ESP8266: Move OTA error strings to PROGMEM (saves 116 bytes RAM) --- .../components/esphome/ota/ota_esphome.cpp | 40 ++++++++++--------- esphome/components/esphome/ota/ota_esphome.h | 7 ++-- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index fc10e5366e..6654ef8748 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -30,19 +30,19 @@ void ESPHomeOTAComponent::setup() { this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0); // monitored for incoming connections if (this->server_ == nullptr) { - this->log_socket_error_("creation"); + this->log_socket_error_(LOG_STR("creation")); this->mark_failed(); return; } int enable = 1; int err = this->server_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { - this->log_socket_error_("reuseaddr"); + this->log_socket_error_(LOG_STR("reuseaddr")); // we can still continue } err = this->server_->setblocking(false); if (err != 0) { - this->log_socket_error_("non-blocking"); + this->log_socket_error_(LOG_STR("non-blocking")); this->mark_failed(); return; } @@ -51,21 +51,21 @@ void ESPHomeOTAComponent::setup() { socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); if (sl == 0) { - this->log_socket_error_("set sockaddr"); + this->log_socket_error_(LOG_STR("set sockaddr")); this->mark_failed(); return; } err = this->server_->bind((struct sockaddr *) &server, sizeof(server)); if (err != 0) { - this->log_socket_error_("bind"); + this->log_socket_error_(LOG_STR("bind")); this->mark_failed(); return; } err = this->server_->listen(4); if (err != 0) { - this->log_socket_error_("listen"); + this->log_socket_error_(LOG_STR("listen")); this->mark_failed(); return; } @@ -114,17 +114,17 @@ void ESPHomeOTAComponent::handle_handshake_() { return; int err = this->client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); if (err != 0) { - this->log_socket_error_("nodelay"); + this->log_socket_error_(LOG_STR("nodelay")); this->cleanup_connection_(); return; } err = this->client_->setblocking(false); if (err != 0) { - this->log_socket_error_("non-blocking"); + this->log_socket_error_(LOG_STR("non-blocking")); this->cleanup_connection_(); return; } - this->log_start_("handshake"); + this->log_start_(LOG_STR("handshake")); this->client_connect_time_ = App.get_loop_component_start_time(); this->magic_buf_pos_ = 0; // Reset magic buffer position } @@ -150,7 +150,7 @@ void ESPHomeOTAComponent::handle_handshake_() { if (read <= 0) { // Error or connection closed if (read == -1) { - this->log_socket_error_("reading magic bytes"); + this->log_socket_error_(LOG_STR("reading magic bytes")); } else { ESP_LOGW(TAG, "Remote closed during handshake"); } @@ -209,7 +209,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read features - 1 byte if (!this->readall_(buf, 1)) { - this->log_read_error_("features"); + this->log_read_error_(LOG_STR("features")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_features = buf[0]; // NOLINT @@ -288,7 +288,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { - this->log_read_error_("size"); + this->log_read_error_(LOG_STR("size")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_size = 0; @@ -302,7 +302,7 @@ void ESPHomeOTAComponent::handle_data_() { // starting the update, set the warning status and notify // listeners. This ensures that port scanners do not // accidentally trigger the update process. - this->log_start_("update"); + this->log_start_(LOG_STR("update")); this->status_set_warning(); #ifdef USE_OTA_STATE_CALLBACK this->state_callback_.call(ota::OTA_STARTED, 0.0f, 0); @@ -320,7 +320,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read binary MD5, 32 bytes if (!this->readall_(buf, 32)) { - this->log_read_error_("MD5 checksum"); + this->log_read_error_(LOG_STR("MD5 checksum")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } sbuf[32] = '\0'; @@ -393,7 +393,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read ACK if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { - this->log_read_error_("ack"); + this->log_read_error_(LOG_STR("ack")); // do not go to error, this is not fatal } @@ -477,12 +477,14 @@ float ESPHomeOTAComponent::get_setup_priority() const { return setup_priority::A uint16_t ESPHomeOTAComponent::get_port() const { return this->port_; } void ESPHomeOTAComponent::set_port(uint16_t port) { this->port_ = port; } -void ESPHomeOTAComponent::log_socket_error_(const char *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", msg, errno); } +void ESPHomeOTAComponent::log_socket_error_(const LogString *msg) { + ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); +} -void ESPHomeOTAComponent::log_read_error_(const char *what) { ESP_LOGW(TAG, "Read %s failed", what); } +void ESPHomeOTAComponent::log_read_error_(const LogString *what) { ESP_LOGW(TAG, "Read %s failed", LOG_STR_ARG(what)); } -void ESPHomeOTAComponent::log_start_(const char *phase) { - ESP_LOGD(TAG, "Starting %s from %s", phase, this->client_->getpeername().c_str()); +void ESPHomeOTAComponent::log_start_(const LogString *phase) { + ESP_LOGD(TAG, "Starting %s from %s", LOG_STR_ARG(phase), this->client_->getpeername().c_str()); } void ESPHomeOTAComponent::cleanup_connection_() { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c1919c71e9..3a5d9f4f7a 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -3,6 +3,7 @@ #include "esphome/core/defines.h" #ifdef USE_OTA #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/preferences.h" #include "esphome/components/ota/ota_backend.h" #include "esphome/components/socket/socket.h" @@ -31,9 +32,9 @@ class ESPHomeOTAComponent : public ota::OTAComponent { void handle_data_(); bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); - void log_socket_error_(const char *msg); - void log_read_error_(const char *what); - void log_start_(const char *phase); + void log_socket_error_(const LogString *msg); + void log_read_error_(const LogString *what); + void log_start_(const LogString *phase); void cleanup_connection_(); void yield_and_feed_watchdog_(); From 0069163d31fc24a336afd4b9a3454da9f3e0796a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 5 Sep 2025 14:11:14 -0500 Subject: [PATCH 0932/1626] [sps30] Tidy up, optimize (#10606) --- esphome/components/sps30/sps30.cpp | 41 +++++++++++++----------------- esphome/components/sps30/sps30.h | 6 ++--- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/esphome/components/sps30/sps30.cpp b/esphome/components/sps30/sps30.cpp index 272acc78f2..b99bf416d6 100644 --- a/esphome/components/sps30/sps30.cpp +++ b/esphome/components/sps30/sps30.cpp @@ -43,20 +43,20 @@ void SPS30Component::setup() { this->serial_number_[i * 2] = static_cast(raw_serial_number[i] >> 8); this->serial_number_[i * 2 + 1] = uint16_t(uint16_t(raw_serial_number[i] & 0xFF)); } - ESP_LOGD(TAG, " Serial Number: '%s'", this->serial_number_); + ESP_LOGV(TAG, " Serial number: %s", this->serial_number_); bool result; if (this->fan_interval_.has_value()) { // override default value - result = write_command(SPS30_CMD_SET_AUTOMATIC_CLEANING_INTERVAL_SECONDS, this->fan_interval_.value()); + result = this->write_command(SPS30_CMD_SET_AUTOMATIC_CLEANING_INTERVAL_SECONDS, this->fan_interval_.value()); } else { - result = write_command(SPS30_CMD_SET_AUTOMATIC_CLEANING_INTERVAL_SECONDS); + result = this->write_command(SPS30_CMD_SET_AUTOMATIC_CLEANING_INTERVAL_SECONDS); } if (result) { delay(20); uint16_t secs[2]; if (this->read_data(secs, 2)) { - fan_interval_ = secs[0] << 16 | secs[1]; + this->fan_interval_ = secs[0] << 16 | secs[1]; } } @@ -67,7 +67,7 @@ void SPS30Component::setup() { } void SPS30Component::dump_config() { - ESP_LOGCONFIG(TAG, "sps30:"); + ESP_LOGCONFIG(TAG, "SPS30:"); LOG_I2C_DEVICE(this); if (this->is_failed()) { switch (this->error_code_) { @@ -78,16 +78,16 @@ void SPS30Component::dump_config() { ESP_LOGW(TAG, "Measurement Initialization failed"); break; case SERIAL_NUMBER_REQUEST_FAILED: - ESP_LOGW(TAG, "Unable to request sensor serial number"); + ESP_LOGW(TAG, "Unable to request serial number"); break; case SERIAL_NUMBER_READ_FAILED: - ESP_LOGW(TAG, "Unable to read sensor serial number"); + ESP_LOGW(TAG, "Unable to read serial number"); break; case FIRMWARE_VERSION_REQUEST_FAILED: - ESP_LOGW(TAG, "Unable to request sensor firmware version"); + ESP_LOGW(TAG, "Unable to request firmware version"); break; case FIRMWARE_VERSION_READ_FAILED: - ESP_LOGW(TAG, "Unable to read sensor firmware version"); + ESP_LOGW(TAG, "Unable to read firmware version"); break; default: ESP_LOGW(TAG, "Unknown setup error"); @@ -96,9 +96,9 @@ void SPS30Component::dump_config() { } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, - " Serial Number: '%s'\n" + " Serial number: %s\n" " Firmware version v%0d.%0d", - this->serial_number_, (raw_firmware_version_ >> 8), uint16_t(raw_firmware_version_ & 0xFF)); + this->serial_number_, this->raw_firmware_version_ >> 8, this->raw_firmware_version_ & 0xFF); LOG_SENSOR(" ", "PM1.0 Weight Concentration", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM2.5 Weight Concentration", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM4 Weight Concentration", this->pm_4_0_sensor_); @@ -113,15 +113,15 @@ void SPS30Component::dump_config() { void SPS30Component::update() { /// Check if warning flag active (sensor reconnected?) if (this->status_has_warning()) { - ESP_LOGD(TAG, "Trying to reconnect"); + ESP_LOGD(TAG, "Reconnecting"); if (this->write_command(SPS30_CMD_SOFT_RESET)) { - ESP_LOGD(TAG, "Soft-reset successful. Waiting for reconnection in 500 ms"); + ESP_LOGD(TAG, "Soft-reset successful; waiting 500 ms"); this->set_timeout(500, [this]() { this->start_continuous_measurement_(); /// Sensor restarted and reading attempt made next cycle this->status_clear_warning(); this->skipped_data_read_cycles_ = 0; - ESP_LOGD(TAG, "Reconnect successful. Resuming continuous measurement"); + ESP_LOGD(TAG, "Reconnected; resuming continuous measurement"); }); } else { ESP_LOGD(TAG, "Soft-reset failed"); @@ -136,12 +136,12 @@ void SPS30Component::update() { uint16_t raw_read_status; if (!this->read_data(&raw_read_status, 1) || raw_read_status == 0x00) { - ESP_LOGD(TAG, "Not ready yet"); + ESP_LOGD(TAG, "Not ready"); this->skipped_data_read_cycles_++; /// The following logic is required to address the cases when a sensor is quickly replaced before it's marked /// as failed so that new sensor is eventually forced to be reinitialized for continuous measurement. if (this->skipped_data_read_cycles_ > MAX_SKIPPED_DATA_CYCLES_BEFORE_ERROR) { - ESP_LOGD(TAG, "Exceeded max allowed attempts; communication will be reinitialized"); + ESP_LOGD(TAG, "Exceeded max attempts; will reinitialize"); this->status_set_warning(); } return; @@ -211,11 +211,6 @@ void SPS30Component::update() { } bool SPS30Component::start_continuous_measurement_() { - uint8_t data[4]; - data[0] = SPS30_CMD_START_CONTINUOUS_MEASUREMENTS & 0xFF; - data[1] = 0x03; - data[2] = 0x00; - data[3] = sht_crc_(0x03, 0x00); if (!this->write_command(SPS30_CMD_START_CONTINUOUS_MEASUREMENTS, SPS30_CMD_START_CONTINUOUS_MEASUREMENTS_ARG)) { ESP_LOGE(TAG, "Error initiating measurements"); return false; @@ -224,9 +219,9 @@ bool SPS30Component::start_continuous_measurement_() { } bool SPS30Component::start_fan_cleaning() { - if (!write_command(SPS30_CMD_START_FAN_CLEANING)) { + if (!this->write_command(SPS30_CMD_START_FAN_CLEANING)) { this->status_set_warning(); - ESP_LOGE(TAG, "write error start fan (%d)", this->last_error_); + ESP_LOGE(TAG, "Start fan cleaning failed (%d)", this->last_error_); return false; } else { ESP_LOGD(TAG, "Fan auto clean started"); diff --git a/esphome/components/sps30/sps30.h b/esphome/components/sps30/sps30.h index 04189247e8..461a770ab6 100644 --- a/esphome/components/sps30/sps30.h +++ b/esphome/components/sps30/sps30.h @@ -30,12 +30,12 @@ class SPS30Component : public PollingComponent, public sensirion_common::Sensiri bool start_fan_cleaning(); protected: - char serial_number_[17] = {0}; /// Terminating NULL character uint16_t raw_firmware_version_; - bool start_continuous_measurement_(); + char serial_number_[17] = {0}; /// Terminating NULL character uint8_t skipped_data_read_cycles_ = 0; + bool start_continuous_measurement_(); - enum ErrorCode { + enum ErrorCode : uint8_t { COMMUNICATION_FAILED, FIRMWARE_VERSION_REQUEST_FAILED, FIRMWARE_VERSION_READ_FAILED, From 09b40b882e3755d3778e6252a41bffbdeabffeb0 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 5 Sep 2025 14:20:11 -0500 Subject: [PATCH 0933/1626] [sgp30] Tidy up, optimize (#10607) --- .../sensirion_common/i2c_sensirion.h | 2 + esphome/components/sgp30/sgp30.cpp | 69 +++++++++---------- esphome/components/sgp30/sgp30.h | 21 +++--- 3 files changed, 47 insertions(+), 45 deletions(-) diff --git a/esphome/components/sensirion_common/i2c_sensirion.h b/esphome/components/sensirion_common/i2c_sensirion.h index aba93d6cc3..e141591525 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.h +++ b/esphome/components/sensirion_common/i2c_sensirion.h @@ -15,6 +15,8 @@ namespace sensirion_common { * Format: * | 16 Bit Command Code | 16 bit Data word 1 | CRC of DW 1 | 16 bit Data word 1 | CRC of DW 2 | .. */ +static const uint8_t CRC_POLYNOMIAL = 0x31; // default for Sensirion + class SensirionI2CDevice : public i2c::I2CDevice { public: enum CommandLen : uint8_t { ADDR_8_BIT = 1, ADDR_16_BIT = 2 }; diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index 42baff6d23..0e3aeff812 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -1,9 +1,11 @@ #include "sgp30.h" -#include #include "esphome/core/application.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome { namespace sgp30 { @@ -39,9 +41,8 @@ void SGP30Component::setup() { this->mark_failed(); return; } - this->serial_number_ = (uint64_t(raw_serial_number[0]) << 24) | (uint64_t(raw_serial_number[1]) << 16) | - (uint64_t(raw_serial_number[2])); - ESP_LOGD(TAG, "Serial Number: %" PRIu64, this->serial_number_); + this->serial_number_ = encode_uint24(raw_serial_number[0], raw_serial_number[1], raw_serial_number[2]); + ESP_LOGD(TAG, "Serial number: %" PRIu64, this->serial_number_); // Featureset identification for future use uint16_t raw_featureset; @@ -61,11 +62,11 @@ void SGP30Component::setup() { this->mark_failed(); return; } - ESP_LOGD(TAG, "Product version: 0x%0X", uint16_t(this->featureset_ & 0x1FF)); + ESP_LOGV(TAG, "Product version: 0x%0X", uint16_t(this->featureset_ & 0x1FF)); // Sensor initialization if (!this->write_command(SGP30_CMD_IAQ_INIT)) { - ESP_LOGE(TAG, "Sensor sgp30_iaq_init failed."); + ESP_LOGE(TAG, "sgp30_iaq_init failed"); this->error_code_ = MEASUREMENT_INIT_FAILED; this->mark_failed(); return; @@ -123,7 +124,7 @@ void SGP30Component::read_iaq_baseline_() { uint16_t eco2baseline = (raw_data[0]); uint16_t tvocbaseline = (raw_data[1]); - ESP_LOGI(TAG, "Current eCO2 baseline: 0x%04X, TVOC baseline: 0x%04X", eco2baseline, tvocbaseline); + ESP_LOGI(TAG, "Baselines: eCO2: 0x%04X, TVOC: 0x%04X", eco2baseline, tvocbaseline); if (eco2baseline != this->eco2_baseline_ || tvocbaseline != this->tvoc_baseline_) { this->eco2_baseline_ = eco2baseline; this->tvoc_baseline_ = tvocbaseline; @@ -142,7 +143,7 @@ void SGP30Component::read_iaq_baseline_() { this->baselines_storage_.eco2 = this->eco2_baseline_; this->baselines_storage_.tvoc = this->tvoc_baseline_; if (this->pref_.save(&this->baselines_storage_)) { - ESP_LOGI(TAG, "Store eCO2 baseline: 0x%04X, TVOC baseline: 0x%04X", this->baselines_storage_.eco2, + ESP_LOGI(TAG, "Store baselines: eCO2: 0x%04X, TVOC: 0x%04X", this->baselines_storage_.eco2, this->baselines_storage_.tvoc); } else { ESP_LOGW(TAG, "Could not store eCO2 and TVOC baselines"); @@ -164,7 +165,7 @@ void SGP30Component::send_env_data_() { if (this->humidity_sensor_ != nullptr) humidity = this->humidity_sensor_->state; if (std::isnan(humidity) || humidity < 0.0f || humidity > 100.0f) { - ESP_LOGW(TAG, "Compensation not possible yet: bad humidity data."); + ESP_LOGW(TAG, "Compensation not possible yet: bad humidity data"); return; } else { ESP_LOGD(TAG, "External compensation data received: Humidity %0.2f%%", humidity); @@ -174,7 +175,7 @@ void SGP30Component::send_env_data_() { temperature = float(this->temperature_sensor_->state); } if (std::isnan(temperature) || temperature < -40.0f || temperature > 85.0f) { - ESP_LOGW(TAG, "Compensation not possible yet: bad temperature value data."); + ESP_LOGW(TAG, "Compensation not possible yet: bad temperature value"); return; } else { ESP_LOGD(TAG, "External compensation data received: Temperature %0.2f°C", temperature); @@ -192,18 +193,17 @@ void SGP30Component::send_env_data_() { ((humidity * 0.061121f * std::exp((18.678f - temperature / 234.5f) * (temperature / (257.14f + temperature)))) / (273.15f + temperature)); } - uint8_t humidity_full = uint8_t(std::floor(absolute_humidity)); - uint8_t humidity_dec = uint8_t(std::floor((absolute_humidity - std::floor(absolute_humidity)) * 256)); - ESP_LOGD(TAG, "Calculated Absolute humidity: %0.3f g/m³ (0x%04X)", absolute_humidity, - uint16_t(uint16_t(humidity_full) << 8 | uint16_t(humidity_dec))); - uint8_t crc = sht_crc_(humidity_full, humidity_dec); - uint8_t data[4]; - data[0] = SGP30_CMD_SET_ABSOLUTE_HUMIDITY & 0xFF; - data[1] = humidity_full; - data[2] = humidity_dec; - data[3] = crc; + uint8_t data[4] = { + SGP30_CMD_SET_ABSOLUTE_HUMIDITY & 0xFF, + uint8_t(std::floor(absolute_humidity)), // humidity_full + uint8_t(std::floor((absolute_humidity - std::floor(absolute_humidity)) * 256)), // humidity_dec + 0, + }; + data[3] = crc8(&data[1], 2, 0xFF, sensirion_common::CRC_POLYNOMIAL, true); + ESP_LOGD(TAG, "Calculated absolute humidity: %0.3f g/m³ (0x%04X)", absolute_humidity, + encode_uint16(data[1], data[2])); if (!this->write_bytes(SGP30_CMD_SET_ABSOLUTE_HUMIDITY >> 8, data, 4)) { - ESP_LOGE(TAG, "Error sending compensation data."); + ESP_LOGE(TAG, "Error sending compensation data"); } } @@ -212,15 +212,14 @@ void SGP30Component::write_iaq_baseline_(uint16_t eco2_baseline, uint16_t tvoc_b data[0] = SGP30_CMD_SET_IAQ_BASELINE & 0xFF; data[1] = tvoc_baseline >> 8; data[2] = tvoc_baseline & 0xFF; - data[3] = sht_crc_(data[1], data[2]); + data[3] = crc8(&data[1], 2, 0xFF, sensirion_common::CRC_POLYNOMIAL, true); data[4] = eco2_baseline >> 8; data[5] = eco2_baseline & 0xFF; - data[6] = sht_crc_(data[4], data[5]); + data[6] = crc8(&data[4], 2, 0xFF, sensirion_common::CRC_POLYNOMIAL, true); if (!this->write_bytes(SGP30_CMD_SET_IAQ_BASELINE >> 8, data, 7)) { - ESP_LOGE(TAG, "Error applying eCO2 baseline: 0x%04X, TVOC baseline: 0x%04X", eco2_baseline, tvoc_baseline); + ESP_LOGE(TAG, "Error applying baselines: eCO2: 0x%04X, TVOC: 0x%04X", eco2_baseline, tvoc_baseline); } else { - ESP_LOGI(TAG, "Initial baselines applied successfully! eCO2 baseline: 0x%04X, TVOC baseline: 0x%04X", eco2_baseline, - tvoc_baseline); + ESP_LOGI(TAG, "Initial baselines applied: eCO2: 0x%04X, TVOC: 0x%04X", eco2_baseline, tvoc_baseline); } } @@ -236,10 +235,10 @@ void SGP30Component::dump_config() { ESP_LOGW(TAG, "Measurement Initialization failed"); break; case INVALID_ID: - ESP_LOGW(TAG, "Sensor reported an invalid ID. Is this an SGP30?"); + ESP_LOGW(TAG, "Invalid ID"); break; case UNSUPPORTED_ID: - ESP_LOGW(TAG, "Sensor reported an unsupported ID (SGPC3)"); + ESP_LOGW(TAG, "Unsupported ID"); break; default: ESP_LOGW(TAG, "Unknown setup error"); @@ -249,12 +248,12 @@ void SGP30Component::dump_config() { ESP_LOGCONFIG(TAG, " Serial number: %" PRIu64, this->serial_number_); if (this->eco2_baseline_ != 0x0000 && this->tvoc_baseline_ != 0x0000) { ESP_LOGCONFIG(TAG, - " Baseline:\n" - " eCO2 Baseline: 0x%04X\n" - " TVOC Baseline: 0x%04X", + " Baselines:\n" + " eCO2: 0x%04X\n" + " TVOC: 0x%04X", this->eco2_baseline_, this->tvoc_baseline_); } else { - ESP_LOGCONFIG(TAG, " Baseline: No baseline configured"); + ESP_LOGCONFIG(TAG, " Baselines not configured"); } ESP_LOGCONFIG(TAG, " Warm up time: %" PRIu32 "s", this->required_warm_up_time_); } @@ -266,8 +265,8 @@ void SGP30Component::dump_config() { ESP_LOGCONFIG(TAG, "Store baseline: %s", YESNO(this->store_baseline_)); if (this->humidity_sensor_ != nullptr && this->temperature_sensor_ != nullptr) { ESP_LOGCONFIG(TAG, " Compensation:"); - LOG_SENSOR(" ", "Temperature Source:", this->temperature_sensor_); - LOG_SENSOR(" ", "Humidity Source:", this->humidity_sensor_); + LOG_SENSOR(" ", "Temperature source:", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity source:", this->humidity_sensor_); } else { ESP_LOGCONFIG(TAG, " Compensation: No source configured"); } @@ -289,7 +288,7 @@ void SGP30Component::update() { float eco2 = (raw_data[0]); float tvoc = (raw_data[1]); - ESP_LOGD(TAG, "Got eCO2=%.1fppm TVOC=%.1fppb", eco2, tvoc); + ESP_LOGV(TAG, "eCO2=%.1fppm TVOC=%.1fppb", eco2, tvoc); if (this->eco2_sensor_ != nullptr) this->eco2_sensor_->publish_state(eco2); if (this->tvoc_sensor_ != nullptr) diff --git a/esphome/components/sgp30/sgp30.h b/esphome/components/sgp30/sgp30.h index e6429a7bfa..4648a33e15 100644 --- a/esphome/components/sgp30/sgp30.h +++ b/esphome/components/sgp30/sgp30.h @@ -1,8 +1,8 @@ #pragma once -#include "esphome/core/component.h" -#include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" #include "esphome/core/preferences.h" #include @@ -38,14 +38,16 @@ class SGP30Component : public PollingComponent, public sensirion_common::Sensiri void read_iaq_baseline_(); bool is_sensor_baseline_reliable_(); void write_iaq_baseline_(uint16_t eco2_baseline, uint16_t tvoc_baseline); + uint64_t serial_number_; - uint16_t featureset_; uint32_t required_warm_up_time_; uint32_t seconds_since_last_store_; - SGP30Baselines baselines_storage_; - ESPPreferenceObject pref_; + uint16_t featureset_; + uint16_t eco2_baseline_{0x0000}; + uint16_t tvoc_baseline_{0x0000}; + bool store_baseline_; - enum ErrorCode { + enum ErrorCode : uint8_t { COMMUNICATION_FAILED, MEASUREMENT_INIT_FAILED, INVALID_ID, @@ -53,14 +55,13 @@ class SGP30Component : public PollingComponent, public sensirion_common::Sensiri UNKNOWN } error_code_{UNKNOWN}; + ESPPreferenceObject pref_; + SGP30Baselines baselines_storage_; + sensor::Sensor *eco2_sensor_{nullptr}; sensor::Sensor *tvoc_sensor_{nullptr}; sensor::Sensor *eco2_sensor_baseline_{nullptr}; sensor::Sensor *tvoc_sensor_baseline_{nullptr}; - uint16_t eco2_baseline_{0x0000}; - uint16_t tvoc_baseline_{0x0000}; - bool store_baseline_; - /// Input sensor for humidity and temperature compensation. sensor::Sensor *humidity_sensor_{nullptr}; sensor::Sensor *temperature_sensor_{nullptr}; From a1773e0a3cedc54941ed77df112b1ba1195c6a6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 14:40:32 -0500 Subject: [PATCH 0934/1626] fix warning --- esphome/components/mdns/mdns_component.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 90dcb7958a..5d9788198f 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -10,6 +10,8 @@ // Macro to define strings in PROGMEM on ESP8266, regular memory on other platforms #define MDNS_STATIC_CONST_CHAR(name, value) static const char name[] PROGMEM = value // Helper to get string from PROGMEM - returns a temporary std::string +// Only define this function if we have services that will use it +#if defined(USE_API) || defined(USE_PROMETHEUS) || defined(USE_WEBSERVER) || defined(USE_MDNS_EXTRA_SERVICES) static std::string mdns_string_p(const char *src) { char buf[64]; strncpy_P(buf, src, sizeof(buf) - 1); @@ -18,6 +20,10 @@ static std::string mdns_string_p(const char *src) { } #define MDNS_STR(name) mdns_string_p(name) #else +// If no services are configured, we still need the fallback service but it uses string literals +#define MDNS_STR(name) std::string(name) +#endif +#else // On non-ESP8266 platforms, use regular const char* #define MDNS_STATIC_CONST_CHAR(name, value) static constexpr const char *name = value #define MDNS_STR(name) name From c84928aba5a7924fc14adebb91d02144a07e4f02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 17:08:11 -0500 Subject: [PATCH 0935/1626] [core] Store component source strings in flash on ESP8266 (breaking change) --- esphome/components/debug/debug_esp32.cpp | 2 +- .../runtime_stats/runtime_stats.cpp | 19 ++++------ .../components/runtime_stats/runtime_stats.h | 14 +++----- esphome/core/application.cpp | 8 ++--- esphome/core/component.cpp | 35 ++++++++++--------- esphome/core/component.h | 11 +++--- esphome/core/scheduler.cpp | 8 ++--- esphome/core/scheduler.h | 2 +- esphome/cpp_generator.py | 13 +++++++ esphome/cpp_helpers.py | 4 +-- 10 files changed, 59 insertions(+), 57 deletions(-) diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 37990aeec5..b1dfe1bc9a 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -52,7 +52,7 @@ void DebugComponent::on_shutdown() { char buffer[REBOOT_MAX_LEN]{}; auto pref = global_preferences->make_preference(REBOOT_MAX_LEN, fnv1_hash(REBOOT_KEY + App.get_name())); if (component != nullptr) { - strncpy(buffer, component->get_component_source(), REBOOT_MAX_LEN - 1); + strncpy(buffer, LOG_STR_ARG(component->get_component_log_str()), REBOOT_MAX_LEN - 1); buffer[REBOOT_MAX_LEN - 1] = '\0'; } ESP_LOGD(TAG, "Storing reboot source: %s", buffer); diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 8f5d5daf01..2da517b1f4 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -17,16 +17,8 @@ void RuntimeStatsCollector::record_component_time(Component *component, uint32_t if (component == nullptr) return; - // Check if we have cached the name for this component - auto name_it = this->component_names_cache_.find(component); - if (name_it == this->component_names_cache_.end()) { - // First time seeing this component, cache its name - const char *source = component->get_component_source(); - this->component_names_cache_[component] = source; - this->component_stats_[source].record_time(duration_ms); - } else { - this->component_stats_[name_it->second].record_time(duration_ms); - } + // Record stats using component pointer as key + this->component_stats_[component].record_time(duration_ms); if (this->next_log_time_ == 0) { this->next_log_time_ = current_time + this->log_interval_; @@ -42,9 +34,10 @@ void RuntimeStatsCollector::log_stats_() { std::vector stats_to_display; for (const auto &it : this->component_stats_) { + Component *component = it.first; const ComponentRuntimeStats &stats = it.second; if (stats.get_period_count() > 0) { - ComponentStatPair pair = {it.first, &stats}; + ComponentStatPair pair = {component, &stats}; stats_to_display.push_back(pair); } } @@ -54,7 +47,7 @@ void RuntimeStatsCollector::log_stats_() { // Log top components by period runtime for (const auto &it : stats_to_display) { - const char *source = it.name; + const char *source = LOG_STR_ARG(it.component->get_component_log_str()); const ComponentRuntimeStats *stats = it.stats; ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", source, @@ -72,7 +65,7 @@ void RuntimeStatsCollector::log_stats_() { }); for (const auto &it : stats_to_display) { - const char *source = it.name; + const char *source = LOG_STR_ARG(it.component->get_component_log_str()); const ComponentRuntimeStats *stats = it.stats; ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", source, diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index e2f8bee563..56122364c2 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -79,7 +79,7 @@ class ComponentRuntimeStats { // For sorting components by run time struct ComponentStatPair { - const char *name; + Component *component; const ComponentRuntimeStats *stats; bool operator>(const ComponentStatPair &other) const { @@ -109,15 +109,9 @@ class RuntimeStatsCollector { } } - // Use const char* keys for efficiency - // Custom comparator for const char* keys in map - // Without this, std::map would compare pointer addresses instead of string contents, - // causing identical component names at different addresses to be treated as different keys - struct CStrCompare { - bool operator()(const char *a, const char *b) const { return std::strcmp(a, b) < 0; } - }; - std::map component_stats_; - std::map component_names_cache_; + // Map from component to its stats + // We use Component* as the key since each component is unique + std::map component_stats_; uint32_t log_interval_; uint32_t next_log_time_; }; diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index dc745a2a46..b78f6fb903 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -80,7 +80,7 @@ void Application::register_component_(Component *comp) { for (auto *c : this->components_) { if (comp == c) { - ESP_LOGW(TAG, "Component %s already registered! (%p)", c->get_component_source(), c); + ESP_LOGW(TAG, "Component %s already registered! (%p)", LOG_STR_ARG(c->get_component_log_str()), c); return; } } @@ -340,8 +340,8 @@ void Application::teardown_components(uint32_t timeout_ms) { // Note: At this point, connections are either disconnected or in a bad state, // so this warning will only appear via serial rather than being transmitted to clients for (size_t i = 0; i < pending_count; ++i) { - ESP_LOGW(TAG, "%s did not complete teardown within %" PRIu32 " ms", pending_components[i]->get_component_source(), - timeout_ms); + ESP_LOGW(TAG, "%s did not complete teardown within %" PRIu32 " ms", + LOG_STR_ARG(pending_components[i]->get_component_log_str()), timeout_ms); } } } @@ -473,7 +473,7 @@ void Application::enable_pending_loops_() { // Clear the pending flag and enable the loop component->pending_enable_loop_ = false; - ESP_LOGVV(TAG, "%s loop enabled from ISR", component->get_component_source()); + ESP_LOGVV(TAG, "%s loop enabled from ISR", LOG_STR_ARG(component->get_component_log_str())); component->component_state_ &= ~COMPONENT_STATE_MASK; component->component_state_ |= COMPONENT_STATE_LOOP; diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 40cda17ca3..3f6beeb28e 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -142,7 +142,7 @@ void Component::call_dump_config() { } } } - ESP_LOGE(TAG, " %s is marked FAILED: %s", this->get_component_source(), + ESP_LOGE(TAG, " %s is marked FAILED: %s", LOG_STR_ARG(this->get_component_log_str()), error_msg ? error_msg : UNSPECIFIED_MESSAGE); } } @@ -154,14 +154,14 @@ void Component::call() { case COMPONENT_STATE_CONSTRUCTION: { // State Construction: Call setup and set state to setup this->set_component_state_(COMPONENT_STATE_SETUP); - ESP_LOGV(TAG, "Setup %s", this->get_component_source()); + ESP_LOGV(TAG, "Setup %s", LOG_STR_ARG(this->get_component_log_str())); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t start_time = millis(); #endif this->call_setup(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t setup_time = millis() - start_time; - ESP_LOGCONFIG(TAG, "Setup %s took %ums", this->get_component_source(), (unsigned) setup_time); + ESP_LOGCONFIG(TAG, "Setup %s took %ums", LOG_STR_ARG(this->get_component_log_str()), (unsigned) setup_time); #endif break; } @@ -182,10 +182,8 @@ void Component::call() { break; } } -const char *Component::get_component_source() const { - if (this->component_source_ == nullptr) - return ""; - return this->component_source_; +const LogString *Component::get_component_log_str() const { + return this->component_source_ == nullptr ? LOG_STR("") : this->component_source_; } bool Component::should_warn_of_blocking(uint32_t blocking_time) { if (blocking_time > this->warn_if_blocking_over_) { @@ -201,7 +199,7 @@ bool Component::should_warn_of_blocking(uint32_t blocking_time) { return false; } void Component::mark_failed() { - ESP_LOGE(TAG, "%s was marked as failed", this->get_component_source()); + ESP_LOGE(TAG, "%s was marked as failed", LOG_STR_ARG(this->get_component_log_str())); this->set_component_state_(COMPONENT_STATE_FAILED); this->status_set_error(); // Also remove from loop since failed components shouldn't loop @@ -213,14 +211,14 @@ void Component::set_component_state_(uint8_t state) { } void Component::disable_loop() { if ((this->component_state_ & COMPONENT_STATE_MASK) != COMPONENT_STATE_LOOP_DONE) { - ESP_LOGVV(TAG, "%s loop disabled", this->get_component_source()); + ESP_LOGVV(TAG, "%s loop disabled", LOG_STR_ARG(this->get_component_log_str())); this->set_component_state_(COMPONENT_STATE_LOOP_DONE); App.disable_component_loop_(this); } } void Component::enable_loop() { if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { - ESP_LOGVV(TAG, "%s loop enabled", this->get_component_source()); + ESP_LOGVV(TAG, "%s loop enabled", LOG_STR_ARG(this->get_component_log_str())); this->set_component_state_(COMPONENT_STATE_LOOP); App.enable_component_loop_(this); } @@ -240,7 +238,7 @@ void IRAM_ATTR HOT Component::enable_loop_soon_any_context() { } void Component::reset_to_construction_state() { if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED) { - ESP_LOGI(TAG, "%s is being reset to construction state", this->get_component_source()); + ESP_LOGI(TAG, "%s is being reset to construction state", LOG_STR_ARG(this->get_component_log_str())); this->set_component_state_(COMPONENT_STATE_CONSTRUCTION); // Clear error status when resetting this->status_clear_error(); @@ -286,14 +284,16 @@ void Component::status_set_warning(const char *message) { return; this->component_state_ |= STATUS_LED_WARNING; App.app_state_ |= STATUS_LED_WARNING; - ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), message ? message : UNSPECIFIED_MESSAGE); + ESP_LOGW(TAG, "%s set Warning flag: %s", LOG_STR_ARG(this->get_component_log_str()), + message ? message : UNSPECIFIED_MESSAGE); } void Component::status_set_error(const char *message) { if ((this->component_state_ & STATUS_LED_ERROR) != 0) return; this->component_state_ |= STATUS_LED_ERROR; App.app_state_ |= STATUS_LED_ERROR; - ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), message ? message : UNSPECIFIED_MESSAGE); + ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), + message ? message : UNSPECIFIED_MESSAGE); if (message != nullptr) { // Lazy allocate the error messages vector if needed if (!component_error_messages) { @@ -314,13 +314,13 @@ void Component::status_clear_warning() { if ((this->component_state_ & STATUS_LED_WARNING) == 0) return; this->component_state_ &= ~STATUS_LED_WARNING; - ESP_LOGW(TAG, "%s cleared Warning flag", this->get_component_source()); + ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_clear_error() { if ((this->component_state_ & STATUS_LED_ERROR) == 0) return; this->component_state_ &= ~STATUS_LED_ERROR; - ESP_LOGE(TAG, "%s cleared Error flag", this->get_component_source()); + ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_momentary_warning(const std::string &name, uint32_t length) { this->status_set_warning(); @@ -419,8 +419,9 @@ uint32_t WarnIfComponentBlockingGuard::finish() { should_warn = blocking_time > WARN_IF_BLOCKING_OVER_MS; } if (should_warn) { - const char *src = component_ == nullptr ? "" : component_->get_component_source(); - ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms)", src, blocking_time); + ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms)", + component_ == nullptr ? LOG_STR_LITERAL("") : LOG_STR_ARG(component_->get_component_log_str()), + blocking_time); ESP_LOGW(TAG, "Components should block for at most 30 ms"); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 096c6f9c69..9a7e442c5c 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -5,6 +5,7 @@ #include #include +#include "esphome/core/log.h" #include "esphome/core/optional.h" namespace esphome { @@ -220,12 +221,12 @@ class Component { * * This is set by the ESPHome core, and should not be called manually. */ - void set_component_source(const char *source) { component_source_ = source; } - /** Get the integration where this component was declared as a string. + void set_component_source(const LogString *source) { component_source_ = source; } + /** Get the integration where this component was declared as a LogString for logging. * - * Returns "" if source not set + * Returns LOG_STR("") if source not set */ - const char *get_component_source() const; + const LogString *get_component_log_str() const; bool should_warn_of_blocking(uint32_t blocking_time); @@ -405,7 +406,7 @@ class Component { bool cancel_defer(const std::string &name); // NOLINT // Ordered for optimal packing on 32-bit systems - const char *component_source_{nullptr}; + const LogString *component_source_{nullptr}; uint16_t warn_if_blocking_over_{WARN_IF_BLOCKING_OVER_MS}; ///< Warn if blocked for this many ms (max 65.5s) /// State of this component - each bit has a purpose: /// Bits 0-2: Component state (0x00=CONSTRUCTION, 0x01=SETUP, 0x02=LOOP, 0x03=FAILED, 0x04=LOOP_DONE) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a907b89b02..1552ac8862 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -134,10 +134,10 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Debug logging const char *type_str = (type == SchedulerItem::TIMEOUT) ? "timeout" : "interval"; if (type == SchedulerItem::TIMEOUT) { - ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ")", type_str, item->get_source(), + ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ")", type_str, LOG_STR_ARG(item->get_source()), name_cstr ? name_cstr : "(null)", type_str, delay); } else { - ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ", offset=%" PRIu32 ")", type_str, item->get_source(), + ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ", offset=%" PRIu32 ")", type_str, LOG_STR_ARG(item->get_source()), name_cstr ? name_cstr : "(null)", type_str, delay, static_cast(item->next_execution_ - now)); } #endif /* ESPHOME_DEBUG_SCHEDULER */ @@ -353,7 +353,7 @@ void HOT Scheduler::call(uint32_t now) { const char *name = item->get_name(); ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64, - item->get_type_str(), item->get_source(), name ? name : "(null)", item->interval, + item->get_type_str(), LOG_STR_ARG(item->get_source()), name ? name : "(null)", item->interval, item->next_execution_ - now_64, item->next_execution_); old_items.push_back(std::move(item)); @@ -439,7 +439,7 @@ void HOT Scheduler::call(uint32_t now) { #ifdef ESPHOME_DEBUG_SCHEDULER const char *item_name = item->get_name(); ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")", - item->get_type_str(), item->get_source(), item_name ? item_name : "(null)", item->interval, + item->get_type_str(), LOG_STR_ARG(item->get_source()), item_name ? item_name : "(null)", item->interval, item->next_execution_, now_64); #endif /* ESPHOME_DEBUG_SCHEDULER */ diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index f469a60d5c..39141efcad 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -184,7 +184,7 @@ class Scheduler { static bool cmp(const std::unique_ptr &a, const std::unique_ptr &b); const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } - const char *get_source() const { return component ? component->get_component_source() : "unknown"; } + const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); } }; // Common implementation for both timeout and interval diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 34e4eec1ee..291592dd2b 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -253,6 +253,19 @@ class StringLiteral(Literal): return cpp_string_escape(self.string) +class LogStringLiteral(Literal): + """A string literal that uses LOG_STR() macro for flash storage on ESP8266.""" + + __slots__ = ("string",) + + def __init__(self, string: str) -> None: + super().__init__() + self.string = string + + def __str__(self) -> str: + return f"LOG_STR({cpp_string_escape(self.string)})" + + class IntLiteral(Literal): __slots__ = ("i",) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index b61b215bdc..2698b9b3d5 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -9,7 +9,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, coroutine from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import add, get_variable +from esphome.cpp_generator import LogStringLiteral, add, get_variable from esphome.cpp_types import App from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry @@ -76,7 +76,7 @@ async def register_component(var, config): "Error while finding name of component, please report this", exc_info=e ) if name is not None: - add(var.set_component_source(name)) + add(var.set_component_source(LogStringLiteral(name))) add(App.register_component(var)) return var From a49669ee58036bceb4a8e0b0b82a646777858e21 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 5 Sep 2025 17:17:20 -0500 Subject: [PATCH 0936/1626] [sensirion_common] Tidy up, optimize (#10604) --- .../sensirion_common/i2c_sensirion.cpp | 65 ++++--------- .../sensirion_common/i2c_sensirion.h | 92 ++++++++----------- 2 files changed, 55 insertions(+), 102 deletions(-) diff --git a/esphome/components/sensirion_common/i2c_sensirion.cpp b/esphome/components/sensirion_common/i2c_sensirion.cpp index f71b3c14cb..22c4b0e53c 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.cpp +++ b/esphome/components/sensirion_common/i2c_sensirion.cpp @@ -11,21 +11,22 @@ static const char *const TAG = "sensirion_i2c"; // To avoid memory allocations for small writes a stack buffer is used static const size_t BUFFER_STACK_SIZE = 16; -bool SensirionI2CDevice::read_data(uint16_t *data, uint8_t len) { +bool SensirionI2CDevice::read_data(uint16_t *data, const uint8_t len) { const uint8_t num_bytes = len * 3; - std::vector buf(num_bytes); + uint8_t buf[num_bytes]; - last_error_ = this->read(buf.data(), num_bytes); - if (last_error_ != i2c::ERROR_OK) { + this->last_error_ = this->read(buf, num_bytes); + if (this->last_error_ != i2c::ERROR_OK) { return false; } for (uint8_t i = 0; i < len; i++) { const uint8_t j = 3 * i; - uint8_t crc = sht_crc_(buf[j], buf[j + 1]); + // Use MSB first since Sensirion devices use CRC-8 with MSB first + uint8_t crc = crc8(&buf[j], 2, 0xFF, CRC_POLYNOMIAL, true); if (crc != buf[j + 2]) { - ESP_LOGE(TAG, "CRC8 Checksum invalid at pos %d! 0x%02X != 0x%02X", i, buf[j + 2], crc); - last_error_ = i2c::ERROR_CRC; + ESP_LOGE(TAG, "CRC invalid @ %d! 0x%02X != 0x%02X", i, buf[j + 2], crc); + this->last_error_ = i2c::ERROR_CRC; return false; } data[i] = encode_uint16(buf[j], buf[j + 1]); @@ -34,10 +35,10 @@ bool SensirionI2CDevice::read_data(uint16_t *data, uint8_t len) { } /*** * write command with parameters and insert crc - * use stack array for less than 4 parameters. Most sensirion i2c commands have less parameters + * use stack array for less than 4 parameters. Most Sensirion I2C commands have less parameters */ bool SensirionI2CDevice::write_command_(uint16_t command, CommandLen command_len, const uint16_t *data, - uint8_t data_len) { + const uint8_t data_len) { uint8_t temp_stack[BUFFER_STACK_SIZE]; std::unique_ptr temp_heap; uint8_t *temp; @@ -74,56 +75,26 @@ bool SensirionI2CDevice::write_command_(uint16_t command, CommandLen command_len temp[raw_idx++] = data[i] & 0xFF; temp[raw_idx++] = data[i] >> 8; #endif - temp[raw_idx++] = sht_crc_(data[i]); + // Use MSB first since Sensirion devices use CRC-8 with MSB first + temp[raw_idx++] = crc8(&temp[raw_idx - 2], 2, 0xFF, CRC_POLYNOMIAL, true); } - last_error_ = this->write(temp, raw_idx); - return last_error_ == i2c::ERROR_OK; + this->last_error_ = this->write(temp, raw_idx); + return this->last_error_ == i2c::ERROR_OK; } -bool SensirionI2CDevice::get_register_(uint16_t reg, CommandLen command_len, uint16_t *data, uint8_t len, - uint8_t delay_ms) { +bool SensirionI2CDevice::get_register_(uint16_t reg, CommandLen command_len, uint16_t *data, const uint8_t len, + const uint8_t delay_ms) { if (!this->write_command_(reg, command_len, nullptr, 0)) { - ESP_LOGE(TAG, "Failed to write i2c register=0x%X (%d) err=%d,", reg, command_len, this->last_error_); + ESP_LOGE(TAG, "Write failed: reg=0x%X (%d) err=%d,", reg, command_len, this->last_error_); return false; } delay(delay_ms); bool result = this->read_data(data, len); if (!result) { - ESP_LOGE(TAG, "Failed to read data from register=0x%X err=%d,", reg, this->last_error_); + ESP_LOGE(TAG, "Read failed: reg=0x%X err=%d,", reg, this->last_error_); } return result; } -// The 8-bit CRC checksum is transmitted after each data word -uint8_t SensirionI2CDevice::sht_crc_(uint16_t data) { - uint8_t bit; - uint8_t crc = 0xFF; -#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - crc ^= data >> 8; -#else - crc ^= data & 0xFF; -#endif - for (bit = 8; bit > 0; --bit) { - if (crc & 0x80) { - crc = (crc << 1) ^ crc_polynomial_; - } else { - crc = (crc << 1); - } - } -#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - crc ^= data & 0xFF; -#else - crc ^= data >> 8; -#endif - for (bit = 8; bit > 0; --bit) { - if (crc & 0x80) { - crc = (crc << 1) ^ crc_polynomial_; - } else { - crc = (crc << 1); - } - } - return crc; -} - } // namespace sensirion_common } // namespace esphome diff --git a/esphome/components/sensirion_common/i2c_sensirion.h b/esphome/components/sensirion_common/i2c_sensirion.h index e141591525..f3eb3761f6 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.h +++ b/esphome/components/sensirion_common/i2c_sensirion.h @@ -8,10 +8,10 @@ namespace esphome { namespace sensirion_common { /** - * Implementation of a i2c functions for Sensirion sensors - * Sensirion data requires crc checking. + * Implementation of I2C functions for Sensirion sensors + * Sensirion data requires CRC checking. * Each 16 bit word is/must be followed 8 bit CRC code - * (Applies to read and write - note the i2c command code doesn't need a CRC) + * (Applies to read and write - note the I2C command code doesn't need a CRC) * Format: * | 16 Bit Command Code | 16 bit Data word 1 | CRC of DW 1 | 16 bit Data word 1 | CRC of DW 2 | .. */ @@ -21,79 +21,79 @@ class SensirionI2CDevice : public i2c::I2CDevice { public: enum CommandLen : uint8_t { ADDR_8_BIT = 1, ADDR_16_BIT = 2 }; - /** Read data words from i2c device. - * handles crc check used by Sensirion sensors + /** Read data words from I2C device. + * handles CRC check used by Sensirion sensors * @param data pointer to raw result * @param len number of words to read * @return true if reading succeeded */ bool read_data(uint16_t *data, uint8_t len); - /** Read 1 data word from i2c device. + /** Read 1 data word from I2C device. * @param data reference to raw result * @return true if reading succeeded */ bool read_data(uint16_t &data) { return this->read_data(&data, 1); } - /** get data words from i2c register. - * handles crc check used by Sensirion sensors - * @param i2c register + /** get data words from I2C register. + * handles CRC check used by Sensirion sensors + * @param I2C register * @param data pointer to raw result * @param len number of words to read - * @param delay milliseconds to to wait between sending the i2c command and reading the result + * @param delay milliseconds to to wait between sending the I2C command and reading the result * @return true if reading succeeded */ bool get_register(uint16_t command, uint16_t *data, uint8_t len, uint8_t delay = 0) { return get_register_(command, ADDR_16_BIT, data, len, delay); } - /** Read 1 data word from 16 bit i2c register. - * @param i2c register + /** Read 1 data word from 16 bit I2C register. + * @param I2C register * @param data reference to raw result - * @param delay milliseconds to to wait between sending the i2c command and reading the result + * @param delay milliseconds to to wait between sending the I2C command and reading the result * @return true if reading succeeded */ bool get_register(uint16_t i2c_register, uint16_t &data, uint8_t delay = 0) { return this->get_register_(i2c_register, ADDR_16_BIT, &data, 1, delay); } - /** get data words from i2c register. - * handles crc check used by Sensirion sensors - * @param i2c register + /** get data words from I2C register. + * handles CRC check used by Sensirion sensors + * @param I2C register * @param data pointer to raw result * @param len number of words to read - * @param delay milliseconds to to wait between sending the i2c command and reading the result + * @param delay milliseconds to to wait between sending the I2C command and reading the result * @return true if reading succeeded */ bool get_8bit_register(uint8_t i2c_register, uint16_t *data, uint8_t len, uint8_t delay = 0) { return get_register_(i2c_register, ADDR_8_BIT, data, len, delay); } - /** Read 1 data word from 8 bit i2c register. - * @param i2c register + /** Read 1 data word from 8 bit I2C register. + * @param I2C register * @param data reference to raw result - * @param delay milliseconds to to wait between sending the i2c command and reading the result + * @param delay milliseconds to to wait between sending the I2C command and reading the result * @return true if reading succeeded */ bool get_8bit_register(uint8_t i2c_register, uint16_t &data, uint8_t delay = 0) { return this->get_register_(i2c_register, ADDR_8_BIT, &data, 1, delay); } - /** Write a command to the i2c device. - * @param command i2c command to send + /** Write a command to the I2C device. + * @param command I2C command to send * @return true if reading succeeded */ template bool write_command(T i2c_register) { return write_command(i2c_register, nullptr, 0); } - /** Write a command and one data word to the i2c device . - * @param command i2c command to send - * @param data argument for the i2c command + /** Write a command and one data word to the I2C device . + * @param command I2C command to send + * @param data argument for the I2C command * @return true if reading succeeded */ template bool write_command(T i2c_register, uint16_t data) { return write_command(i2c_register, &data, 1); } /** Write a command with arguments as words - * @param i2c_register i2c command to send - an be uint8_t or uint16_t - * @param data vector arguments for the i2c command + * @param i2c_register I2C command to send - an be uint8_t or uint16_t + * @param data vector arguments for the I2C command * @return true if reading succeeded */ template bool write_command(T i2c_register, const std::vector &data) { @@ -101,57 +101,39 @@ class SensirionI2CDevice : public i2c::I2CDevice { } /** Write a command with arguments as words - * @param i2c_register i2c command to send - an be uint8_t or uint16_t - * @param data arguments for the i2c command + * @param i2c_register I2C command to send - an be uint8_t or uint16_t + * @param data arguments for the I2C command * @param len number of arguments (words) * @return true if reading succeeded */ template bool write_command(T i2c_register, const uint16_t *data, uint8_t len) { // limit to 8 or 16 bit only - static_assert(sizeof(i2c_register) == 1 || sizeof(i2c_register) == 2, - "only 8 or 16 bit command types are supported."); + static_assert(sizeof(i2c_register) == 1 || sizeof(i2c_register) == 2, "Only 8 or 16 bit command types supported"); return write_command_(i2c_register, CommandLen(sizeof(T)), data, len); } protected: - uint8_t crc_polynomial_{0x31u}; // default for sensirion /** Write a command with arguments as words - * @param command i2c command to send can be uint8_t or uint16_t + * @param command I2C command to send can be uint8_t or uint16_t * @param command_len either 1 for short 8 bit command or 2 for 16 bit command codes - * @param data arguments for the i2c command + * @param data arguments for the I2C command * @param data_len number of arguments (words) * @return true if reading succeeded */ bool write_command_(uint16_t command, CommandLen command_len, const uint16_t *data, uint8_t data_len); - /** get data words from i2c register. - * handles crc check used by Sensirion sensors - * @param i2c register + /** get data words from I2C register. + * handles CRC check used by Sensirion sensors + * @param I2C register * @param command_len either 1 for short 8 bit command or 2 for 16 bit command codes * @param data pointer to raw result * @param len number of words to read - * @param delay milliseconds to to wait between sending the i2c command and reading the result + * @param delay milliseconds to to wait between sending the I2C command and reading the result * @return true if reading succeeded */ bool get_register_(uint16_t reg, CommandLen command_len, uint16_t *data, uint8_t len, uint8_t delay); - /** 8-bit CRC checksum that is transmitted after each data word for read and write operation - * @param command i2c command to send - * @param data data word for which the crc8 checksum is calculated - * @param len number of arguments (words) - * @return 8 Bit CRC - */ - uint8_t sht_crc_(uint16_t data); - - /** 8-bit CRC checksum that is transmitted after each data word for read and write operation - * @param command i2c command to send - * @param data1 high byte of data word - * @param data2 low byte of data word - * @return 8 Bit CRC - */ - uint8_t sht_crc_(uint8_t data1, uint8_t data2) { return sht_crc_(encode_uint16(data1, data2)); } - - /** last error code from i2c operation + /** last error code from I2C operation */ i2c::ErrorCode last_error_; }; From d90d7e77e9608418338df72faa21008afd4518b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 17:19:52 -0500 Subject: [PATCH 0937/1626] cleanup --- .../components/runtime_stats/runtime_stats.cpp | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 2da517b1f4..f95be5291f 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -47,12 +47,9 @@ void RuntimeStatsCollector::log_stats_() { // Log top components by period runtime for (const auto &it : stats_to_display) { - const char *source = LOG_STR_ARG(it.component->get_component_log_str()); - const ComponentRuntimeStats *stats = it.stats; - - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", source, - stats->get_period_count(), stats->get_period_avg_time_ms(), stats->get_period_max_time_ms(), - stats->get_period_time_ms()); + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", + LOG_STR_ARG(it.component->get_component_log_str()), it.stats->get_period_count(), + it.stats->get_period_avg_time_ms(), it.stats->get_period_max_time_ms(), it.stats->get_period_time_ms()); } // Log total stats since boot @@ -65,12 +62,9 @@ void RuntimeStatsCollector::log_stats_() { }); for (const auto &it : stats_to_display) { - const char *source = LOG_STR_ARG(it.component->get_component_log_str()); - const ComponentRuntimeStats *stats = it.stats; - - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", source, - stats->get_total_count(), stats->get_total_avg_time_ms(), stats->get_total_max_time_ms(), - stats->get_total_time_ms()); + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", + LOG_STR_ARG(it.component->get_component_log_str()), it.stats->get_total_count(), + it.stats->get_total_avg_time_ms(), it.stats->get_total_max_time_ms(), it.stats->get_total_time_ms()); } } From 1510db277c66c1e8999252d0468445d20ad8c00f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 17:44:23 -0500 Subject: [PATCH 0938/1626] [esphome] ESP8266: Move OTA error strings to PROGMEM (saves 116 bytes RAM) (#10620) --- .../components/esphome/ota/ota_esphome.cpp | 40 ++++++++++--------- esphome/components/esphome/ota/ota_esphome.h | 7 ++-- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index fc10e5366e..6654ef8748 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -30,19 +30,19 @@ void ESPHomeOTAComponent::setup() { this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0); // monitored for incoming connections if (this->server_ == nullptr) { - this->log_socket_error_("creation"); + this->log_socket_error_(LOG_STR("creation")); this->mark_failed(); return; } int enable = 1; int err = this->server_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { - this->log_socket_error_("reuseaddr"); + this->log_socket_error_(LOG_STR("reuseaddr")); // we can still continue } err = this->server_->setblocking(false); if (err != 0) { - this->log_socket_error_("non-blocking"); + this->log_socket_error_(LOG_STR("non-blocking")); this->mark_failed(); return; } @@ -51,21 +51,21 @@ void ESPHomeOTAComponent::setup() { socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); if (sl == 0) { - this->log_socket_error_("set sockaddr"); + this->log_socket_error_(LOG_STR("set sockaddr")); this->mark_failed(); return; } err = this->server_->bind((struct sockaddr *) &server, sizeof(server)); if (err != 0) { - this->log_socket_error_("bind"); + this->log_socket_error_(LOG_STR("bind")); this->mark_failed(); return; } err = this->server_->listen(4); if (err != 0) { - this->log_socket_error_("listen"); + this->log_socket_error_(LOG_STR("listen")); this->mark_failed(); return; } @@ -114,17 +114,17 @@ void ESPHomeOTAComponent::handle_handshake_() { return; int err = this->client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); if (err != 0) { - this->log_socket_error_("nodelay"); + this->log_socket_error_(LOG_STR("nodelay")); this->cleanup_connection_(); return; } err = this->client_->setblocking(false); if (err != 0) { - this->log_socket_error_("non-blocking"); + this->log_socket_error_(LOG_STR("non-blocking")); this->cleanup_connection_(); return; } - this->log_start_("handshake"); + this->log_start_(LOG_STR("handshake")); this->client_connect_time_ = App.get_loop_component_start_time(); this->magic_buf_pos_ = 0; // Reset magic buffer position } @@ -150,7 +150,7 @@ void ESPHomeOTAComponent::handle_handshake_() { if (read <= 0) { // Error or connection closed if (read == -1) { - this->log_socket_error_("reading magic bytes"); + this->log_socket_error_(LOG_STR("reading magic bytes")); } else { ESP_LOGW(TAG, "Remote closed during handshake"); } @@ -209,7 +209,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read features - 1 byte if (!this->readall_(buf, 1)) { - this->log_read_error_("features"); + this->log_read_error_(LOG_STR("features")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_features = buf[0]; // NOLINT @@ -288,7 +288,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read size, 4 bytes MSB first if (!this->readall_(buf, 4)) { - this->log_read_error_("size"); + this->log_read_error_(LOG_STR("size")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_size = 0; @@ -302,7 +302,7 @@ void ESPHomeOTAComponent::handle_data_() { // starting the update, set the warning status and notify // listeners. This ensures that port scanners do not // accidentally trigger the update process. - this->log_start_("update"); + this->log_start_(LOG_STR("update")); this->status_set_warning(); #ifdef USE_OTA_STATE_CALLBACK this->state_callback_.call(ota::OTA_STARTED, 0.0f, 0); @@ -320,7 +320,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read binary MD5, 32 bytes if (!this->readall_(buf, 32)) { - this->log_read_error_("MD5 checksum"); + this->log_read_error_(LOG_STR("MD5 checksum")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } sbuf[32] = '\0'; @@ -393,7 +393,7 @@ void ESPHomeOTAComponent::handle_data_() { // Read ACK if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { - this->log_read_error_("ack"); + this->log_read_error_(LOG_STR("ack")); // do not go to error, this is not fatal } @@ -477,12 +477,14 @@ float ESPHomeOTAComponent::get_setup_priority() const { return setup_priority::A uint16_t ESPHomeOTAComponent::get_port() const { return this->port_; } void ESPHomeOTAComponent::set_port(uint16_t port) { this->port_ = port; } -void ESPHomeOTAComponent::log_socket_error_(const char *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", msg, errno); } +void ESPHomeOTAComponent::log_socket_error_(const LogString *msg) { + ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); +} -void ESPHomeOTAComponent::log_read_error_(const char *what) { ESP_LOGW(TAG, "Read %s failed", what); } +void ESPHomeOTAComponent::log_read_error_(const LogString *what) { ESP_LOGW(TAG, "Read %s failed", LOG_STR_ARG(what)); } -void ESPHomeOTAComponent::log_start_(const char *phase) { - ESP_LOGD(TAG, "Starting %s from %s", phase, this->client_->getpeername().c_str()); +void ESPHomeOTAComponent::log_start_(const LogString *phase) { + ESP_LOGD(TAG, "Starting %s from %s", LOG_STR_ARG(phase), this->client_->getpeername().c_str()); } void ESPHomeOTAComponent::cleanup_connection_() { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c1919c71e9..3a5d9f4f7a 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -3,6 +3,7 @@ #include "esphome/core/defines.h" #ifdef USE_OTA #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/preferences.h" #include "esphome/components/ota/ota_backend.h" #include "esphome/components/socket/socket.h" @@ -31,9 +32,9 @@ class ESPHomeOTAComponent : public ota::OTAComponent { void handle_data_(); bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); - void log_socket_error_(const char *msg); - void log_read_error_(const char *what); - void log_start_(const char *phase); + void log_socket_error_(const LogString *msg); + void log_read_error_(const LogString *what); + void log_start_(const LogString *phase); void cleanup_connection_(); void yield_and_feed_watchdog_(); From 1340665ac7d0f44698c061dcb4b880bdd97f12cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 17:47:53 -0500 Subject: [PATCH 0939/1626] [logger] Use LogString for UART selection strings (saves 28 bytes RAM on ESP8266) (#10615) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/logger/logger.cpp | 2 +- esphome/components/logger/logger.h | 2 +- esphome/components/logger/logger_esp32.cpp | 24 ++++++++++++------- esphome/components/logger/logger_esp8266.cpp | 14 ++++++++--- .../components/logger/logger_libretiny.cpp | 16 ++++++++++--- esphome/components/logger/logger_rp2040.cpp | 17 ++++++++++--- esphome/components/logger/logger_zephyr.cpp | 19 +++++++++++---- 7 files changed, 71 insertions(+), 23 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 195e04948d..0ade9cedae 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -258,7 +258,7 @@ void Logger::dump_config() { ESP_LOGCONFIG(TAG, " Log Baud Rate: %" PRIu32 "\n" " Hardware UART: %s", - this->baud_rate_, get_uart_selection_()); + this->baud_rate_, LOG_STR_ARG(get_uart_selection_())); #endif #ifdef USE_ESPHOME_TASK_LOG_BUFFER if (this->log_buffer_) { diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index aa76a188c9..a4cf5e3004 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -226,7 +226,7 @@ class Logger : public Component { } #ifndef USE_HOST - const char *get_uart_selection_(); + const LogString *get_uart_selection_(); #endif // Group 4-byte aligned members first diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 44243d4aa8..6cb57c1540 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -190,20 +190,28 @@ void HOT Logger::write_msg_(const char *msg) { void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } #endif -const char *const UART_SELECTIONS[] = { - "UART0", "UART1", +const LogString *Logger::get_uart_selection_() { + switch (this->uart_) { + case UART_SELECTION_UART0: + return LOG_STR("UART0"); + case UART_SELECTION_UART1: + return LOG_STR("UART1"); #ifdef USE_ESP32_VARIANT_ESP32 - "UART2", + case UART_SELECTION_UART2: + return LOG_STR("UART2"); #endif #ifdef USE_LOGGER_USB_CDC - "USB_CDC", + case UART_SELECTION_USB_CDC: + return LOG_STR("USB_CDC"); #endif #ifdef USE_LOGGER_USB_SERIAL_JTAG - "USB_SERIAL_JTAG", + case UART_SELECTION_USB_SERIAL_JTAG: + return LOG_STR("USB_SERIAL_JTAG"); #endif -}; - -const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } + default: + return LOG_STR("UNKNOWN"); + } +} } // namespace esphome::logger #endif diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index fb5f6cee5d..5063d88b92 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -35,9 +35,17 @@ void Logger::pre_setup() { void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } -const char *const UART_SELECTIONS[] = {"UART0", "UART1", "UART0_SWAP"}; - -const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } +const LogString *Logger::get_uart_selection_() { + switch (this->uart_) { + case UART_SELECTION_UART0: + return LOG_STR("UART0"); + case UART_SELECTION_UART1: + return LOG_STR("UART1"); + case UART_SELECTION_UART0_SWAP: + default: + return LOG_STR("UART0_SWAP"); + } +} } // namespace esphome::logger #endif diff --git a/esphome/components/logger/logger_libretiny.cpp b/esphome/components/logger/logger_libretiny.cpp index 09d0622bc3..3edfa74480 100644 --- a/esphome/components/logger/logger_libretiny.cpp +++ b/esphome/components/logger/logger_libretiny.cpp @@ -51,9 +51,19 @@ void Logger::pre_setup() { void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } -const char *const UART_SELECTIONS[] = {"DEFAULT", "UART0", "UART1", "UART2"}; - -const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } +const LogString *Logger::get_uart_selection_() { + switch (this->uart_) { + case UART_SELECTION_DEFAULT: + return LOG_STR("DEFAULT"); + case UART_SELECTION_UART0: + return LOG_STR("UART0"); + case UART_SELECTION_UART1: + return LOG_STR("UART1"); + case UART_SELECTION_UART2: + default: + return LOG_STR("UART2"); + } +} } // namespace esphome::logger diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index f1cad9b283..63727c2cda 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -29,9 +29,20 @@ void Logger::pre_setup() { void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } -const char *const UART_SELECTIONS[] = {"UART0", "UART1", "USB_CDC"}; - -const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } +const LogString *Logger::get_uart_selection_() { + switch (this->uart_) { + case UART_SELECTION_UART0: + return LOG_STR("UART0"); + case UART_SELECTION_UART1: + return LOG_STR("UART1"); +#ifdef USE_LOGGER_USB_CDC + case UART_SELECTION_USB_CDC: + return LOG_STR("USB_CDC"); +#endif + default: + return LOG_STR("UNKNOWN"); + } +} } // namespace esphome::logger #endif // USE_RP2040 diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 58a09facd5..817ca168f8 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -54,7 +54,7 @@ void Logger::pre_setup() { #endif } if (!device_is_ready(uart_dev)) { - ESP_LOGE(TAG, "%s is not ready.", get_uart_selection_()); + ESP_LOGE(TAG, "%s is not ready.", LOG_STR_ARG(get_uart_selection_())); } else { this->uart_dev_ = uart_dev; } @@ -77,9 +77,20 @@ void HOT Logger::write_msg_(const char *msg) { uart_poll_out(this->uart_dev_, '\n'); } -const char *const UART_SELECTIONS[] = {"UART0", "UART1", "USB_CDC"}; - -const char *Logger::get_uart_selection_() { return UART_SELECTIONS[this->uart_]; } +const LogString *Logger::get_uart_selection_() { + switch (this->uart_) { + case UART_SELECTION_UART0: + return LOG_STR("UART0"); + case UART_SELECTION_UART1: + return LOG_STR("UART1"); +#ifdef USE_LOGGER_USB_CDC + case UART_SELECTION_USB_CDC: + return LOG_STR("USB_CDC"); +#endif + default: + return LOG_STR("UNKNOWN"); + } +} } // namespace esphome::logger From 5b283d6d3882e45c3636cfbbc3051179d32ed1bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 17:51:35 -0500 Subject: [PATCH 0940/1626] [sensor] ESP8266: Use LogString for state_class_to_string() to save RAM (#10617) --- esphome/components/mqtt/mqtt_sensor.cpp | 9 +++++++-- esphome/components/sensor/sensor.cpp | 13 +++++++------ esphome/components/sensor/sensor.h | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index 2e1db1908f..9e61f6ef3b 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -58,8 +58,13 @@ void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon if (this->sensor_->get_force_update()) root[MQTT_FORCE_UPDATE] = true; - if (this->sensor_->get_state_class() != STATE_CLASS_NONE) - root[MQTT_STATE_CLASS] = state_class_to_string(this->sensor_->get_state_class()); + if (this->sensor_->get_state_class() != STATE_CLASS_NONE) { +#ifdef USE_STORE_LOG_STR_IN_FLASH + root[MQTT_STATE_CLASS] = (const __FlashStringHelper *) state_class_to_string(this->sensor_->get_state_class()); +#else + root[MQTT_STATE_CLASS] = LOG_STR_ARG(state_class_to_string(this->sensor_->get_state_class())); +#endif + } config.command_topic = false; } diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index e2e8302d8b..4292b8c0bc 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -17,7 +17,8 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o "%s State Class: '%s'\n" "%s Unit of Measurement: '%s'\n" "%s Accuracy Decimals: %d", - prefix, type, obj->get_name().c_str(), prefix, state_class_to_string(obj->get_state_class()), prefix, + prefix, type, obj->get_name().c_str(), prefix, + LOG_STR_ARG(state_class_to_string(obj->get_state_class())), prefix, obj->get_unit_of_measurement_ref().c_str(), prefix, obj->get_accuracy_decimals()); if (!obj->get_device_class_ref().empty()) { @@ -33,17 +34,17 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o } } -const char *state_class_to_string(StateClass state_class) { +const LogString *state_class_to_string(StateClass state_class) { switch (state_class) { case STATE_CLASS_MEASUREMENT: - return "measurement"; + return LOG_STR("measurement"); case STATE_CLASS_TOTAL_INCREASING: - return "total_increasing"; + return LOG_STR("total_increasing"); case STATE_CLASS_TOTAL: - return "total"; + return LOG_STR("total"); case STATE_CLASS_NONE: default: - return ""; + return LOG_STR(""); } } diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index 507cb326b2..f3fa601a5e 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -33,7 +33,7 @@ enum StateClass : uint8_t { STATE_CLASS_TOTAL = 3, }; -const char *state_class_to_string(StateClass state_class); +const LogString *state_class_to_string(StateClass state_class); /** Base-class for all sensors. * From f1806046a94664b50d8106f4368ab9593b51fd36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 17:53:23 -0500 Subject: [PATCH 0941/1626] [web_server] ESP8266: Store OTA response strings in PROGMEM (saves 52 bytes RAM) (#10616) --- esphome/components/web_server/ota/ota_web_server.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 7211f707e9..672a9868c5 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -198,9 +198,20 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { AsyncWebServerResponse *response; // Use the ota_success_ flag to determine the actual result +#ifdef USE_ESP8266 + static const char UPDATE_SUCCESS[] PROGMEM = "Update Successful!"; + static const char UPDATE_FAILED[] PROGMEM = "Update Failed!"; + static const char TEXT_PLAIN[] PROGMEM = "text/plain"; + static const char CONNECTION_STR[] PROGMEM = "Connection"; + static const char CLOSE_STR[] PROGMEM = "close"; + const char *msg = this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED; + response = request->beginResponse_P(200, TEXT_PLAIN, msg); + response->addHeader(CONNECTION_STR, CLOSE_STR); +#else const char *msg = this->ota_success_ ? "Update Successful!" : "Update Failed!"; response = request->beginResponse(200, "text/plain", msg); response->addHeader("Connection", "close"); +#endif request->send(response); } From 91b2f75d041cef453ba568a07a05b27c5d2ca27f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 17:56:00 -0500 Subject: [PATCH 0942/1626] [script] ESP8266: Store log format strings in PROGMEM (saves 240 bytes RAM) (#10614) --- esphome/components/script/script.cpp | 6 ++++++ esphome/components/script/script.h | 24 +++++++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/esphome/components/script/script.cpp b/esphome/components/script/script.cpp index 331f7dcd65..81f652d26a 100644 --- a/esphome/components/script/script.cpp +++ b/esphome/components/script/script.cpp @@ -6,9 +6,15 @@ namespace script { static const char *const TAG = "script"; +#ifdef USE_STORE_LOG_STR_IN_FLASH +void ScriptLogger::esp_log_(int level, int line, const __FlashStringHelper *format, const char *param) { + esp_log_printf_(level, TAG, line, format, param); +} +#else void ScriptLogger::esp_log_(int level, int line, const char *format, const char *param) { esp_log_printf_(level, TAG, line, format, param); } +#endif } // namespace script } // namespace esphome diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 60175ec933..b16bb53acc 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -10,6 +10,15 @@ namespace script { class ScriptLogger { protected: +#ifdef USE_STORE_LOG_STR_IN_FLASH + void esp_logw_(int line, const __FlashStringHelper *format, const char *param) { + esp_log_(ESPHOME_LOG_LEVEL_WARN, line, format, param); + } + void esp_logd_(int line, const __FlashStringHelper *format, const char *param) { + esp_log_(ESPHOME_LOG_LEVEL_DEBUG, line, format, param); + } + void esp_log_(int level, int line, const __FlashStringHelper *format, const char *param); +#else void esp_logw_(int line, const char *format, const char *param) { esp_log_(ESPHOME_LOG_LEVEL_WARN, line, format, param); } @@ -17,6 +26,7 @@ class ScriptLogger { esp_log_(ESPHOME_LOG_LEVEL_DEBUG, line, format, param); } void esp_log_(int level, int line, const char *format, const char *param); +#endif }; /// The abstract base class for all script types. @@ -57,7 +67,8 @@ template class SingleScript : public Script { public: void execute(Ts... x) override { if (this->is_action_running()) { - this->esp_logw_(__LINE__, "Script '%s' is already running! (mode: single)", this->name_.c_str()); + this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' is already running! (mode: single)"), + this->name_.c_str()); return; } @@ -74,7 +85,7 @@ template class RestartScript : public Script { public: void execute(Ts... x) override { if (this->is_action_running()) { - this->esp_logd_(__LINE__, "Script '%s' restarting (mode: restart)", this->name_.c_str()); + this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), this->name_.c_str()); this->stop_action(); } @@ -93,11 +104,13 @@ template class QueueingScript : public Script, public Com // num_runs_ is the number of *queued* instances, so total number of instances is // num_runs_ + 1 if (this->max_runs_ != 0 && this->num_runs_ + 1 >= this->max_runs_) { - this->esp_logw_(__LINE__, "Script '%s' maximum number of queued runs exceeded!", this->name_.c_str()); + this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of queued runs exceeded!"), + this->name_.c_str()); return; } - this->esp_logd_(__LINE__, "Script '%s' queueing new instance (mode: queued)", this->name_.c_str()); + this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' queueing new instance (mode: queued)"), + this->name_.c_str()); this->num_runs_++; this->var_queue_.push(std::make_tuple(x...)); return; @@ -143,7 +156,8 @@ template class ParallelScript : public Script { public: void execute(Ts... x) override { if (this->max_runs_ != 0 && this->automation_parent_->num_running() >= this->max_runs_) { - this->esp_logw_(__LINE__, "Script '%s' maximum number of parallel runs exceeded!", this->name_.c_str()); + this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of parallel runs exceeded!"), + this->name_.c_str()); return; } this->trigger(x...); From 98e8a0c2016c74c913c11fd986658cf1e4ce81d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 17:57:24 -0500 Subject: [PATCH 0943/1626] [gpio] ESP8266: Store log strings in flash memory (#10610) --- .../gpio/binary_sensor/gpio_binary_sensor.cpp | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index 4b8369cd59..45544c185b 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -6,6 +6,23 @@ namespace gpio { static const char *const TAG = "gpio.binary_sensor"; +static const LogString *interrupt_type_to_string(gpio::InterruptType type) { + switch (type) { + case gpio::INTERRUPT_RISING_EDGE: + return LOG_STR("RISING_EDGE"); + case gpio::INTERRUPT_FALLING_EDGE: + return LOG_STR("FALLING_EDGE"); + case gpio::INTERRUPT_ANY_EDGE: + return LOG_STR("ANY_EDGE"); + default: + return LOG_STR("UNKNOWN"); + } +} + +static const LogString *gpio_mode_to_string(bool use_interrupt) { + return use_interrupt ? LOG_STR("interrupt") : LOG_STR("polling"); +} + void IRAM_ATTR GPIOBinarySensorStore::gpio_intr(GPIOBinarySensorStore *arg) { bool new_state = arg->isr_pin_.digital_read(); if (new_state != arg->last_state_) { @@ -51,25 +68,9 @@ void GPIOBinarySensor::setup() { void GPIOBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "GPIO Binary Sensor", this); LOG_PIN(" Pin: ", this->pin_); - const char *mode = this->use_interrupt_ ? "interrupt" : "polling"; - ESP_LOGCONFIG(TAG, " Mode: %s", mode); + ESP_LOGCONFIG(TAG, " Mode: %s", LOG_STR_ARG(gpio_mode_to_string(this->use_interrupt_))); if (this->use_interrupt_) { - const char *interrupt_type; - switch (this->interrupt_type_) { - case gpio::INTERRUPT_RISING_EDGE: - interrupt_type = "RISING_EDGE"; - break; - case gpio::INTERRUPT_FALLING_EDGE: - interrupt_type = "FALLING_EDGE"; - break; - case gpio::INTERRUPT_ANY_EDGE: - interrupt_type = "ANY_EDGE"; - break; - default: - interrupt_type = "UNKNOWN"; - break; - } - ESP_LOGCONFIG(TAG, " Interrupt Type: %s", interrupt_type); + ESP_LOGCONFIG(TAG, " Interrupt Type: %s", LOG_STR_ARG(interrupt_type_to_string(this->interrupt_type_))); } } From b74463c3e6e87a82c815227abc780bb9453479e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 17:59:24 -0500 Subject: [PATCH 0944/1626] [light] ESP8266: Store log strings in flash memory (#10611) --- esphome/components/light/light_call.cpp | 42 +++++++++++++------------ 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 60945531cf..cbe9ed0454 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -11,19 +11,21 @@ static const char *const TAG = "light"; // Helper functions to reduce code size for logging #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN -static void log_validation_warning(const char *name, const char *param_name, float val, float min, float max) { - ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, param_name, val, min, max); +static void log_validation_warning(const char *name, const LogString *param_name, float val, float min, float max) { + ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), val, min, max); } -static void log_feature_not_supported(const char *name, const char *feature) { - ESP_LOGW(TAG, "'%s': %s not supported", name, feature); +static void log_feature_not_supported(const char *name, const LogString *feature) { + ESP_LOGW(TAG, "'%s': %s not supported", name, LOG_STR_ARG(feature)); } -static void log_color_mode_not_supported(const char *name, const char *feature) { - ESP_LOGW(TAG, "'%s': color mode does not support setting %s", name, feature); +static void log_color_mode_not_supported(const char *name, const LogString *feature) { + ESP_LOGW(TAG, "'%s': color mode does not support setting %s", name, LOG_STR_ARG(feature)); } -static void log_invalid_parameter(const char *name, const char *message) { ESP_LOGW(TAG, "'%s': %s", name, message); } +static void log_invalid_parameter(const char *name, const LogString *message) { + ESP_LOGW(TAG, "'%s': %s", name, LOG_STR_ARG(message)); +} #else #define log_validation_warning(name, param_name, val, min, max) #define log_feature_not_supported(name, feature) @@ -201,19 +203,19 @@ LightColorValues LightCall::validate_() { // Brightness exists check if (this->has_brightness() && this->brightness_ > 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) { - log_feature_not_supported(name, "brightness"); + log_feature_not_supported(name, LOG_STR("brightness")); this->set_flag_(FLAG_HAS_BRIGHTNESS, false); } // Transition length possible check if (this->has_transition_() && this->transition_length_ != 0 && !(color_mode & ColorCapability::BRIGHTNESS)) { - log_feature_not_supported(name, "transitions"); + log_feature_not_supported(name, LOG_STR("transitions")); this->set_flag_(FLAG_HAS_TRANSITION, false); } // Color brightness exists check if (this->has_color_brightness() && this->color_brightness_ > 0.0f && !(color_mode & ColorCapability::RGB)) { - log_color_mode_not_supported(name, "RGB brightness"); + log_color_mode_not_supported(name, LOG_STR("RGB brightness")); this->set_flag_(FLAG_HAS_COLOR_BRIGHTNESS, false); } @@ -221,7 +223,7 @@ LightColorValues LightCall::validate_() { if ((this->has_red() && this->red_ > 0.0f) || (this->has_green() && this->green_ > 0.0f) || (this->has_blue() && this->blue_ > 0.0f)) { if (!(color_mode & ColorCapability::RGB)) { - log_color_mode_not_supported(name, "RGB color"); + log_color_mode_not_supported(name, LOG_STR("RGB color")); this->set_flag_(FLAG_HAS_RED, false); this->set_flag_(FLAG_HAS_GREEN, false); this->set_flag_(FLAG_HAS_BLUE, false); @@ -231,21 +233,21 @@ LightColorValues LightCall::validate_() { // White value exists check if (this->has_white() && this->white_ > 0.0f && !(color_mode & ColorCapability::WHITE || color_mode & ColorCapability::COLD_WARM_WHITE)) { - log_color_mode_not_supported(name, "white value"); + log_color_mode_not_supported(name, LOG_STR("white value")); this->set_flag_(FLAG_HAS_WHITE, false); } // Color temperature exists check if (this->has_color_temperature() && !(color_mode & ColorCapability::COLOR_TEMPERATURE || color_mode & ColorCapability::COLD_WARM_WHITE)) { - log_color_mode_not_supported(name, "color temperature"); + log_color_mode_not_supported(name, LOG_STR("color temperature")); this->set_flag_(FLAG_HAS_COLOR_TEMPERATURE, false); } // Cold/warm white value exists check if ((this->has_cold_white() && this->cold_white_ > 0.0f) || (this->has_warm_white() && this->warm_white_ > 0.0f)) { if (!(color_mode & ColorCapability::COLD_WARM_WHITE)) { - log_color_mode_not_supported(name, "cold/warm white value"); + log_color_mode_not_supported(name, LOG_STR("cold/warm white value")); this->set_flag_(FLAG_HAS_COLD_WHITE, false); this->set_flag_(FLAG_HAS_WARM_WHITE, false); } @@ -255,7 +257,7 @@ LightColorValues LightCall::validate_() { if (this->has_##name_()) { \ auto val = this->name_##_; \ if (val < (min) || val > (max)) { \ - log_validation_warning(name, LOG_STR_LITERAL(upper_name), val, (min), (max)); \ + log_validation_warning(name, LOG_STR(upper_name), val, (min), (max)); \ this->name_##_ = clamp(val, (min), (max)); \ } \ } @@ -319,7 +321,7 @@ LightColorValues LightCall::validate_() { // Flash length check if (this->has_flash_() && this->flash_length_ == 0) { - log_invalid_parameter(name, "flash length must be greater than zero"); + log_invalid_parameter(name, LOG_STR("flash length must be greater than zero")); this->set_flag_(FLAG_HAS_FLASH, false); } @@ -338,13 +340,13 @@ LightColorValues LightCall::validate_() { } if (this->has_effect_() && (this->has_transition_() || this->has_flash_())) { - log_invalid_parameter(name, "effect cannot be used with transition/flash"); + log_invalid_parameter(name, LOG_STR("effect cannot be used with transition/flash")); this->set_flag_(FLAG_HAS_TRANSITION, false); this->set_flag_(FLAG_HAS_FLASH, false); } if (this->has_flash_() && this->has_transition_()) { - log_invalid_parameter(name, "flash cannot be used with transition"); + log_invalid_parameter(name, LOG_STR("flash cannot be used with transition")); this->set_flag_(FLAG_HAS_TRANSITION, false); } @@ -361,7 +363,7 @@ LightColorValues LightCall::validate_() { } if (this->has_transition_() && !supports_transition) { - log_feature_not_supported(name, "transitions"); + log_feature_not_supported(name, LOG_STR("transitions")); this->set_flag_(FLAG_HAS_TRANSITION, false); } @@ -371,7 +373,7 @@ LightColorValues LightCall::validate_() { bool target_state = this->has_state() ? this->state_ : v.is_on(); if (!this->has_flash_() && !target_state) { if (this->has_effect_()) { - log_invalid_parameter(name, "cannot start effect when turning off"); + log_invalid_parameter(name, LOG_STR("cannot start effect when turning off")); this->set_flag_(FLAG_HAS_EFFECT, false); } else if (this->parent_->active_effect_index_ != 0 && explicit_turn_off_request) { // Auto turn off effect From 694c590eb6dc419c848c19cdd04f09b3442b51d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 18:02:12 -0500 Subject: [PATCH 0945/1626] [captive_portal] ESP8266: Move strings to PROGMEM (saves 192 bytes RAM) (#10600) --- .../captive_portal/captive_portal.cpp | 47 ++++++++++++++----- .../captive_portal/captive_portal.h | 6 +-- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 25179fdacc..7eb0ffa99e 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -11,17 +11,35 @@ namespace captive_portal { static const char *const TAG = "captive_portal"; void CaptivePortal::handle_config(AsyncWebServerRequest *request) { - AsyncResponseStream *stream = request->beginResponseStream("application/json"); - stream->addHeader("cache-control", "public, max-age=0, must-revalidate"); + AsyncResponseStream *stream = request->beginResponseStream(F("application/json")); + stream->addHeader(F("cache-control"), F("public, max-age=0, must-revalidate")); +#ifdef USE_ESP8266 + stream->print(F("{\"mac\":\"")); + stream->print(get_mac_address_pretty().c_str()); + stream->print(F("\",\"name\":\"")); + stream->print(App.get_name().c_str()); + stream->print(F("\",\"aps\":[{}")); +#else stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", get_mac_address_pretty().c_str(), App.get_name().c_str()); +#endif for (auto &scan : wifi::global_wifi_component->get_scan_result()) { if (scan.get_is_hidden()) continue; - // Assumes no " in ssid, possible unicode isses? + // Assumes no " in ssid, possible unicode isses? +#ifdef USE_ESP8266 + stream->print(F(",{\"ssid\":\"")); + stream->print(scan.get_ssid().c_str()); + stream->print(F("\",\"rssi\":")); + stream->print(scan.get_rssi()); + stream->print(F(",\"lock\":")); + stream->print(scan.get_with_auth()); + stream->print(F("}")); +#else stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(), scan.get_with_auth()); +#endif } stream->print(F("]}")); request->send(stream); @@ -34,7 +52,7 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); wifi::global_wifi_component->save_wifi_sta(ssid, psk); wifi::global_wifi_component->start_scanning(); - request->redirect("/?save"); + request->redirect(F("/?save")); } void CaptivePortal::setup() { @@ -53,18 +71,23 @@ void CaptivePortal::start() { this->dns_server_ = make_unique(); this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); - this->dns_server_->start(53, "*", ip); + this->dns_server_->start(53, F("*"), ip); // Re-enable loop() when DNS server is started this->enable_loop(); #endif this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *req) { if (!this->active_ || req->host().c_str() == wifi::global_wifi_component->wifi_soft_ap_ip().str()) { - req->send(404, "text/html", "File not found"); + req->send(404, F("text/html"), F("File not found")); return; } +#ifdef USE_ESP8266 + String url = F("http://"); + url += wifi::global_wifi_component->wifi_soft_ap_ip().str().c_str(); +#else auto url = "http://" + wifi::global_wifi_component->wifi_soft_ap_ip().str(); +#endif req->redirect(url.c_str()); }); @@ -73,19 +96,19 @@ void CaptivePortal::start() { } void CaptivePortal::handleRequest(AsyncWebServerRequest *req) { - if (req->url() == "/") { + if (req->url() == F("/")) { #ifndef USE_ESP8266 - auto *response = req->beginResponse(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); + auto *response = req->beginResponse(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); #else - auto *response = req->beginResponse_P(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); + auto *response = req->beginResponse_P(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); #endif - response->addHeader("Content-Encoding", "gzip"); + response->addHeader(F("Content-Encoding"), F("gzip")); req->send(response); return; - } else if (req->url() == "/config.json") { + } else if (req->url() == F("/config.json")) { this->handle_config(req); return; - } else if (req->url() == "/wifisave") { + } else if (req->url() == F("/wifisave")) { this->handle_wifisave(req); return; } diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index c78fff824a..382afe92f0 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -45,11 +45,11 @@ class CaptivePortal : public AsyncWebHandler, public Component { return false; if (request->method() == HTTP_GET) { - if (request->url() == "/") + if (request->url() == F("/")) return true; - if (request->url() == "/config.json") + if (request->url() == F("/config.json")) return true; - if (request->url() == "/wifisave") + if (request->url() == F("/wifisave")) return true; } From 487ba4dad09c75d651cba7f967404807a8fbd12f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 18:08:25 -0500 Subject: [PATCH 0946/1626] [mdns] Move constant strings to flash on ESP8266 (#10599) --- esphome/components/mdns/mdns_component.cpp | 94 +++++++++++++++++----- 1 file changed, 72 insertions(+), 22 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 316a10596f..5d9788198f 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -5,6 +5,30 @@ #include "esphome/core/version.h" #include "mdns_component.h" +#ifdef USE_ESP8266 +#include +// Macro to define strings in PROGMEM on ESP8266, regular memory on other platforms +#define MDNS_STATIC_CONST_CHAR(name, value) static const char name[] PROGMEM = value +// Helper to get string from PROGMEM - returns a temporary std::string +// Only define this function if we have services that will use it +#if defined(USE_API) || defined(USE_PROMETHEUS) || defined(USE_WEBSERVER) || defined(USE_MDNS_EXTRA_SERVICES) +static std::string mdns_string_p(const char *src) { + char buf[64]; + strncpy_P(buf, src, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + return std::string(buf); +} +#define MDNS_STR(name) mdns_string_p(name) +#else +// If no services are configured, we still need the fallback service but it uses string literals +#define MDNS_STR(name) std::string(name) +#endif +#else +// On non-ESP8266 platforms, use regular const char* +#define MDNS_STATIC_CONST_CHAR(name, value) static constexpr const char *name = value +#define MDNS_STR(name) name +#endif + #ifdef USE_API #include "esphome/components/api/api_server.h" #endif @@ -21,6 +45,32 @@ static const char *const TAG = "mdns"; #define USE_WEBSERVER_PORT 80 // NOLINT #endif +// Define all constant strings using the macro +MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib"); +MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); +MDNS_STATIC_CONST_CHAR(SERVICE_PROMETHEUS, "_prometheus-http"); +MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http"); + +MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name"); +MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); +MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac"); +MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform"); +MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board"); +MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network"); +MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION, "api_encryption"); +MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION_SUPPORTED, "api_encryption_supported"); +MDNS_STATIC_CONST_CHAR(TXT_PROJECT_NAME, "project_name"); +MDNS_STATIC_CONST_CHAR(TXT_PROJECT_VERSION, "project_version"); +MDNS_STATIC_CONST_CHAR(TXT_PACKAGE_IMPORT_URL, "package_import_url"); + +MDNS_STATIC_CONST_CHAR(PLATFORM_ESP8266, "ESP8266"); +MDNS_STATIC_CONST_CHAR(PLATFORM_ESP32, "ESP32"); +MDNS_STATIC_CONST_CHAR(PLATFORM_RP2040, "RP2040"); + +MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); +MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet"); +MDNS_STATIC_CONST_CHAR(NETWORK_THREAD, "thread"); + void MDNSComponent::compile_records_() { this->hostname_ = App.get_name(); @@ -50,8 +100,8 @@ void MDNSComponent::compile_records_() { if (api::global_api_server != nullptr) { this->services_.emplace_back(); auto &service = this->services_.back(); - service.service_type = "_esphomelib"; - service.proto = "_tcp"; + service.service_type = MDNS_STR(SERVICE_ESPHOMELIB); + service.proto = MDNS_STR(SERVICE_TCP); service.port = api::global_api_server->get_port(); const std::string &friendly_name = App.get_friendly_name(); @@ -82,47 +132,47 @@ void MDNSComponent::compile_records_() { txt_records.reserve(txt_count); if (!friendly_name_empty) { - txt_records.emplace_back(MDNSTXTRecord{"friendly_name", friendly_name}); + txt_records.push_back({MDNS_STR(TXT_FRIENDLY_NAME), friendly_name}); } - txt_records.emplace_back(MDNSTXTRecord{"version", ESPHOME_VERSION}); - txt_records.emplace_back(MDNSTXTRecord{"mac", get_mac_address()}); + txt_records.push_back({MDNS_STR(TXT_VERSION), ESPHOME_VERSION}); + txt_records.push_back({MDNS_STR(TXT_MAC), get_mac_address()}); #ifdef USE_ESP8266 - txt_records.emplace_back(MDNSTXTRecord{"platform", "ESP8266"}); + txt_records.push_back({MDNS_STR(TXT_PLATFORM), MDNS_STR(PLATFORM_ESP8266)}); #elif defined(USE_ESP32) - txt_records.emplace_back(MDNSTXTRecord{"platform", "ESP32"}); + txt_records.push_back({MDNS_STR(TXT_PLATFORM), MDNS_STR(PLATFORM_ESP32)}); #elif defined(USE_RP2040) - txt_records.emplace_back(MDNSTXTRecord{"platform", "RP2040"}); + txt_records.push_back({MDNS_STR(TXT_PLATFORM), MDNS_STR(PLATFORM_RP2040)}); #elif defined(USE_LIBRETINY) txt_records.emplace_back(MDNSTXTRecord{"platform", lt_cpu_get_model_name()}); #endif - txt_records.emplace_back(MDNSTXTRecord{"board", ESPHOME_BOARD}); + txt_records.push_back({MDNS_STR(TXT_BOARD), ESPHOME_BOARD}); #if defined(USE_WIFI) - txt_records.emplace_back(MDNSTXTRecord{"network", "wifi"}); + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)}); #elif defined(USE_ETHERNET) - txt_records.emplace_back(MDNSTXTRecord{"network", "ethernet"}); + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_ETHERNET)}); #elif defined(USE_OPENTHREAD) - txt_records.emplace_back(MDNSTXTRecord{"network", "thread"}); + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_THREAD)}); #endif #ifdef USE_API_NOISE - static constexpr const char *NOISE_ENCRYPTION = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"; + MDNS_STATIC_CONST_CHAR(NOISE_ENCRYPTION, "Noise_NNpsk0_25519_ChaChaPoly_SHA256"); if (api::global_api_server->get_noise_ctx()->has_psk()) { - txt_records.emplace_back(MDNSTXTRecord{"api_encryption", NOISE_ENCRYPTION}); + txt_records.push_back({MDNS_STR(TXT_API_ENCRYPTION), MDNS_STR(NOISE_ENCRYPTION)}); } else { - txt_records.emplace_back(MDNSTXTRecord{"api_encryption_supported", NOISE_ENCRYPTION}); + txt_records.push_back({MDNS_STR(TXT_API_ENCRYPTION_SUPPORTED), MDNS_STR(NOISE_ENCRYPTION)}); } #endif #ifdef ESPHOME_PROJECT_NAME - txt_records.emplace_back(MDNSTXTRecord{"project_name", ESPHOME_PROJECT_NAME}); - txt_records.emplace_back(MDNSTXTRecord{"project_version", ESPHOME_PROJECT_VERSION}); + txt_records.push_back({MDNS_STR(TXT_PROJECT_NAME), ESPHOME_PROJECT_NAME}); + txt_records.push_back({MDNS_STR(TXT_PROJECT_VERSION), ESPHOME_PROJECT_VERSION}); #endif // ESPHOME_PROJECT_NAME #ifdef USE_DASHBOARD_IMPORT - txt_records.emplace_back(MDNSTXTRecord{"package_import_url", dashboard_import::get_package_import_url()}); + txt_records.push_back({MDNS_STR(TXT_PACKAGE_IMPORT_URL), dashboard_import::get_package_import_url()}); #endif } #endif // USE_API @@ -130,16 +180,16 @@ void MDNSComponent::compile_records_() { #ifdef USE_PROMETHEUS this->services_.emplace_back(); auto &prom_service = this->services_.back(); - prom_service.service_type = "_prometheus-http"; - prom_service.proto = "_tcp"; + prom_service.service_type = MDNS_STR(SERVICE_PROMETHEUS); + prom_service.proto = MDNS_STR(SERVICE_TCP); prom_service.port = USE_WEBSERVER_PORT; #endif #ifdef USE_WEBSERVER this->services_.emplace_back(); auto &web_service = this->services_.back(); - web_service.service_type = "_http"; - web_service.proto = "_tcp"; + web_service.service_type = MDNS_STR(SERVICE_HTTP); + web_service.proto = MDNS_STR(SERVICE_TCP); web_service.port = USE_WEBSERVER_PORT; #endif From 1359142106ca14334b49186f28aad0f01988d9c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 18:10:18 -0500 Subject: [PATCH 0947/1626] [api] Store Noise protocol prologue in flash on ESP8266 (#10598) --- esphome/components/api/api_frame_helper_noise.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 35d1715931..37aba7ec13 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -10,10 +10,18 @@ #include #include +#ifdef USE_ESP8266 +#include +#endif + namespace esphome::api { static const char *const TAG = "api.noise"; +#ifdef USE_ESP8266 +static const char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit"; +#else static const char *const PROLOGUE_INIT = "NoiseAPIInit"; +#endif static constexpr size_t PROLOGUE_INIT_LEN = 12; // strlen("NoiseAPIInit") #define HELPER_LOG(msg, ...) ESP_LOGVV(TAG, "%s: " msg, this->client_info_->get_combined_info().c_str(), ##__VA_ARGS__) @@ -75,7 +83,11 @@ APIError APINoiseFrameHelper::init() { // init prologue size_t old_size = prologue_.size(); prologue_.resize(old_size + PROLOGUE_INIT_LEN); +#ifdef USE_ESP8266 + memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); +#else std::memcpy(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); +#endif state_ = State::CLIENT_HELLO; return APIError::OK; From 3fd469cfe8a12927aa65497dab3bb195edf4e337 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 18:16:43 -0500 Subject: [PATCH 0948/1626] [esp8266][api] Store error strings in PROGMEM to reduce RAM usage (#10568) --- esphome/components/api/api_connection.cpp | 17 +-- esphome/components/api/api_connection.h | 2 +- esphome/components/api/api_frame_helper.cpp | 50 ++++----- esphome/components/api/api_frame_helper.h | 2 +- .../components/api/api_frame_helper_noise.cpp | 103 +++++++++++------- .../components/api/api_frame_helper_noise.h | 4 +- 6 files changed, 102 insertions(+), 76 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 4b3a3e2fc8..02b1d61368 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -112,7 +112,7 @@ void APIConnection::start() { APIError err = this->helper_->init(); if (err != APIError::OK) { on_fatal_error(); - this->log_warning_("Helper init failed", err); + this->log_warning_(LOG_STR("Helper init failed"), err); return; } this->client_info_.peername = helper_->getpeername(); @@ -159,7 +159,7 @@ void APIConnection::loop() { break; } else if (err != APIError::OK) { on_fatal_error(); - this->log_warning_("Reading failed", err); + this->log_warning_(LOG_STR("Reading failed"), err); return; } else { this->last_traffic_ = now; @@ -1565,7 +1565,7 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { return false; if (err != APIError::OK) { on_fatal_error(); - this->log_warning_("Packet write failed", err); + this->log_warning_(LOG_STR("Packet write failed"), err); return false; } // Do not set last_traffic_ on send @@ -1752,7 +1752,7 @@ void APIConnection::process_batch_() { std::span(packet_info, packet_count)); if (err != APIError::OK && err != APIError::WOULD_BLOCK) { on_fatal_error(); - this->log_warning_("Batch write failed", err); + this->log_warning_(LOG_STR("Batch write failed"), err); } #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1830,11 +1830,14 @@ void APIConnection::process_state_subscriptions_() { } #endif // USE_API_HOMEASSISTANT_STATES -void APIConnection::log_warning_(const char *message, APIError err) { - ESP_LOGW(TAG, "%s: %s %s errno=%d", this->get_client_combined_info().c_str(), message, api_error_to_str(err), errno); +void APIConnection::log_warning_(const LogString *message, APIError err) { + ESP_LOGW(TAG, "%s: %s %s errno=%d", this->get_client_combined_info().c_str(), LOG_STR_ARG(message), + LOG_STR_ARG(api_error_to_logstr(err)), errno); } -void APIConnection::log_socket_operation_failed_(APIError err) { this->log_warning_("Socket operation failed", err); } +void APIConnection::log_socket_operation_failed_(APIError err) { + this->log_warning_(LOG_STR("Socket operation failed"), err); +} } // namespace esphome::api #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 72254d1536..7ee82e0c68 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -732,7 +732,7 @@ class APIConnection final : public APIServerConnection { } // Helper function to log API errors with errno - void log_warning_(const char *message, APIError err); + void log_warning_(const LogString *message, APIError err); // Specific helper for duplicated error message void log_socket_operation_failed_(APIError err); }; diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index dee3af2ac3..a284e09c4a 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -23,59 +23,59 @@ static const char *const TAG = "api.frame_helper"; #define LOG_PACKET_SENDING(data, len) ((void) 0) #endif -const char *api_error_to_str(APIError err) { +const LogString *api_error_to_logstr(APIError err) { // not using switch to ensure compiler doesn't try to build a big table out of it if (err == APIError::OK) { - return "OK"; + return LOG_STR("OK"); } else if (err == APIError::WOULD_BLOCK) { - return "WOULD_BLOCK"; + return LOG_STR("WOULD_BLOCK"); } else if (err == APIError::BAD_INDICATOR) { - return "BAD_INDICATOR"; + return LOG_STR("BAD_INDICATOR"); } else if (err == APIError::BAD_DATA_PACKET) { - return "BAD_DATA_PACKET"; + return LOG_STR("BAD_DATA_PACKET"); } else if (err == APIError::TCP_NODELAY_FAILED) { - return "TCP_NODELAY_FAILED"; + return LOG_STR("TCP_NODELAY_FAILED"); } else if (err == APIError::TCP_NONBLOCKING_FAILED) { - return "TCP_NONBLOCKING_FAILED"; + return LOG_STR("TCP_NONBLOCKING_FAILED"); } else if (err == APIError::CLOSE_FAILED) { - return "CLOSE_FAILED"; + return LOG_STR("CLOSE_FAILED"); } else if (err == APIError::SHUTDOWN_FAILED) { - return "SHUTDOWN_FAILED"; + return LOG_STR("SHUTDOWN_FAILED"); } else if (err == APIError::BAD_STATE) { - return "BAD_STATE"; + return LOG_STR("BAD_STATE"); } else if (err == APIError::BAD_ARG) { - return "BAD_ARG"; + return LOG_STR("BAD_ARG"); } else if (err == APIError::SOCKET_READ_FAILED) { - return "SOCKET_READ_FAILED"; + return LOG_STR("SOCKET_READ_FAILED"); } else if (err == APIError::SOCKET_WRITE_FAILED) { - return "SOCKET_WRITE_FAILED"; + return LOG_STR("SOCKET_WRITE_FAILED"); } else if (err == APIError::OUT_OF_MEMORY) { - return "OUT_OF_MEMORY"; + return LOG_STR("OUT_OF_MEMORY"); } else if (err == APIError::CONNECTION_CLOSED) { - return "CONNECTION_CLOSED"; + return LOG_STR("CONNECTION_CLOSED"); } #ifdef USE_API_NOISE else if (err == APIError::BAD_HANDSHAKE_PACKET_LEN) { - return "BAD_HANDSHAKE_PACKET_LEN"; + return LOG_STR("BAD_HANDSHAKE_PACKET_LEN"); } else if (err == APIError::HANDSHAKESTATE_READ_FAILED) { - return "HANDSHAKESTATE_READ_FAILED"; + return LOG_STR("HANDSHAKESTATE_READ_FAILED"); } else if (err == APIError::HANDSHAKESTATE_WRITE_FAILED) { - return "HANDSHAKESTATE_WRITE_FAILED"; + return LOG_STR("HANDSHAKESTATE_WRITE_FAILED"); } else if (err == APIError::HANDSHAKESTATE_BAD_STATE) { - return "HANDSHAKESTATE_BAD_STATE"; + return LOG_STR("HANDSHAKESTATE_BAD_STATE"); } else if (err == APIError::CIPHERSTATE_DECRYPT_FAILED) { - return "CIPHERSTATE_DECRYPT_FAILED"; + return LOG_STR("CIPHERSTATE_DECRYPT_FAILED"); } else if (err == APIError::CIPHERSTATE_ENCRYPT_FAILED) { - return "CIPHERSTATE_ENCRYPT_FAILED"; + return LOG_STR("CIPHERSTATE_ENCRYPT_FAILED"); } else if (err == APIError::HANDSHAKESTATE_SETUP_FAILED) { - return "HANDSHAKESTATE_SETUP_FAILED"; + return LOG_STR("HANDSHAKESTATE_SETUP_FAILED"); } else if (err == APIError::HANDSHAKESTATE_SPLIT_FAILED) { - return "HANDSHAKESTATE_SPLIT_FAILED"; + return LOG_STR("HANDSHAKESTATE_SPLIT_FAILED"); } else if (err == APIError::BAD_HANDSHAKE_ERROR_BYTE) { - return "BAD_HANDSHAKE_ERROR_BYTE"; + return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE"); } #endif - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } // Default implementation for loop - handles sending buffered data diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 43e9d95fbe..c11d701ffe 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -66,7 +66,7 @@ enum class APIError : uint16_t { #endif }; -const char *api_error_to_str(APIError err); +const LogString *api_error_to_logstr(APIError err); class APIFrameHelper { public: diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 37aba7ec13..0e49f93db5 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -35,42 +35,42 @@ static constexpr size_t PROLOGUE_INIT_LEN = 12; // strlen("NoiseAPIInit") #endif /// Convert a noise error code to a readable error -std::string noise_err_to_str(int err) { +const LogString *noise_err_to_logstr(int err) { if (err == NOISE_ERROR_NO_MEMORY) - return "NO_MEMORY"; + return LOG_STR("NO_MEMORY"); if (err == NOISE_ERROR_UNKNOWN_ID) - return "UNKNOWN_ID"; + return LOG_STR("UNKNOWN_ID"); if (err == NOISE_ERROR_UNKNOWN_NAME) - return "UNKNOWN_NAME"; + return LOG_STR("UNKNOWN_NAME"); if (err == NOISE_ERROR_MAC_FAILURE) - return "MAC_FAILURE"; + return LOG_STR("MAC_FAILURE"); if (err == NOISE_ERROR_NOT_APPLICABLE) - return "NOT_APPLICABLE"; + return LOG_STR("NOT_APPLICABLE"); if (err == NOISE_ERROR_SYSTEM) - return "SYSTEM"; + return LOG_STR("SYSTEM"); if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED) - return "REMOTE_KEY_REQUIRED"; + return LOG_STR("REMOTE_KEY_REQUIRED"); if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED) - return "LOCAL_KEY_REQUIRED"; + return LOG_STR("LOCAL_KEY_REQUIRED"); if (err == NOISE_ERROR_PSK_REQUIRED) - return "PSK_REQUIRED"; + return LOG_STR("PSK_REQUIRED"); if (err == NOISE_ERROR_INVALID_LENGTH) - return "INVALID_LENGTH"; + return LOG_STR("INVALID_LENGTH"); if (err == NOISE_ERROR_INVALID_PARAM) - return "INVALID_PARAM"; + return LOG_STR("INVALID_PARAM"); if (err == NOISE_ERROR_INVALID_STATE) - return "INVALID_STATE"; + return LOG_STR("INVALID_STATE"); if (err == NOISE_ERROR_INVALID_NONCE) - return "INVALID_NONCE"; + return LOG_STR("INVALID_NONCE"); if (err == NOISE_ERROR_INVALID_PRIVATE_KEY) - return "INVALID_PRIVATE_KEY"; + return LOG_STR("INVALID_PRIVATE_KEY"); if (err == NOISE_ERROR_INVALID_PUBLIC_KEY) - return "INVALID_PUBLIC_KEY"; + return LOG_STR("INVALID_PUBLIC_KEY"); if (err == NOISE_ERROR_INVALID_FORMAT) - return "INVALID_FORMAT"; + return LOG_STR("INVALID_FORMAT"); if (err == NOISE_ERROR_INVALID_SIGNATURE) - return "INVALID_SIGNATURE"; - return to_string(err); + return LOG_STR("INVALID_SIGNATURE"); + return LOG_STR("UNKNOWN"); } /// Initialize the frame helper, returns OK if successful. @@ -95,18 +95,18 @@ APIError APINoiseFrameHelper::init() { // Helper for handling handshake frame errors APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) { if (aerr == APIError::BAD_INDICATOR) { - send_explicit_handshake_reject_("Bad indicator byte"); + send_explicit_handshake_reject_(LOG_STR("Bad indicator byte")); } else if (aerr == APIError::BAD_HANDSHAKE_PACKET_LEN) { - send_explicit_handshake_reject_("Bad handshake packet len"); + send_explicit_handshake_reject_(LOG_STR("Bad handshake packet len")); } return aerr; } // Helper for handling noise library errors -APIError APINoiseFrameHelper::handle_noise_error_(int err, const char *func_name, APIError api_err) { +APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func_name, APIError api_err) { if (err != 0) { state_ = State::FAILED; - HELPER_LOG("%s failed: %s", func_name, noise_err_to_str(err).c_str()); + HELPER_LOG("%s failed: %s", LOG_STR_ARG(func_name), LOG_STR_ARG(noise_err_to_logstr(err))); return api_err; } return APIError::OK; @@ -291,11 +291,11 @@ APIError APINoiseFrameHelper::state_action_() { } if (frame.empty()) { - send_explicit_handshake_reject_("Empty handshake message"); + send_explicit_handshake_reject_(LOG_STR("Empty handshake message")); return APIError::BAD_HANDSHAKE_ERROR_BYTE; } else if (frame[0] != 0x00) { HELPER_LOG("Bad handshake error byte: %u", frame[0]); - send_explicit_handshake_reject_("Bad handshake error byte"); + send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte")); return APIError::BAD_HANDSHAKE_ERROR_BYTE; } @@ -305,8 +305,10 @@ APIError APINoiseFrameHelper::state_action_() { err = noise_handshakestate_read_message(handshake_, &mbuf, nullptr); if (err != 0) { // Special handling for MAC failure - send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? "Handshake MAC failure" : "Handshake error"); - return handle_noise_error_(err, "noise_handshakestate_read_message", APIError::HANDSHAKESTATE_READ_FAILED); + send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure") + : LOG_STR("Handshake error")); + return handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"), + APIError::HANDSHAKESTATE_READ_FAILED); } aerr = check_handshake_finished_(); @@ -319,8 +321,8 @@ APIError APINoiseFrameHelper::state_action_() { noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1); err = noise_handshakestate_write_message(handshake_, &mbuf, nullptr); - APIError aerr_write = - handle_noise_error_(err, "noise_handshakestate_write_message", APIError::HANDSHAKESTATE_WRITE_FAILED); + APIError aerr_write = handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"), + APIError::HANDSHAKESTATE_WRITE_FAILED); if (aerr_write != APIError::OK) return aerr_write; buffer[0] = 0x00; // success @@ -343,15 +345,31 @@ APIError APINoiseFrameHelper::state_action_() { } return APIError::OK; } -void APINoiseFrameHelper::send_explicit_handshake_reject_(const std::string &reason) { +void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) { +#ifdef USE_STORE_LOG_STR_IN_FLASH + // On ESP8266 with flash strings, we need to use PROGMEM-aware functions + size_t reason_len = strlen_P(reinterpret_cast(reason)); std::vector data; - data.resize(reason.length() + 1); + data.resize(reason_len + 1); + data[0] = 0x01; // failure + + // Copy error message from PROGMEM + if (reason_len > 0) { + memcpy_P(data.data() + 1, reinterpret_cast(reason), reason_len); + } +#else + // Normal memory access + const char *reason_str = LOG_STR_ARG(reason); + size_t reason_len = strlen(reason_str); + std::vector data; + data.resize(reason_len + 1); data[0] = 0x01; // failure // Copy error message in bulk - if (!reason.empty()) { - std::memcpy(data.data() + 1, reason.c_str(), reason.length()); + if (reason_len > 0) { + std::memcpy(data.data() + 1, reason_str, reason_len); } +#endif // temporarily remove failed state auto orig_state = state_; @@ -380,7 +398,8 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { noise_buffer_init(mbuf); noise_buffer_set_inout(mbuf, frame.data(), frame.size(), frame.size()); err = noise_cipherstate_decrypt(recv_cipher_, &mbuf); - APIError decrypt_err = handle_noise_error_(err, "noise_cipherstate_decrypt", APIError::CIPHERSTATE_DECRYPT_FAILED); + APIError decrypt_err = + handle_noise_error_(err, LOG_STR("noise_cipherstate_decrypt"), APIError::CIPHERSTATE_DECRYPT_FAILED); if (decrypt_err != APIError::OK) return decrypt_err; @@ -462,7 +481,8 @@ APIError APINoiseFrameHelper::write_protobuf_packets(ProtoWriteBuffer buffer, st 4 + packet.payload_size + frame_footer_size_); int err = noise_cipherstate_encrypt(send_cipher_, &mbuf); - APIError aerr = handle_noise_error_(err, "noise_cipherstate_encrypt", APIError::CIPHERSTATE_ENCRYPT_FAILED); + APIError aerr = + handle_noise_error_(err, LOG_STR("noise_cipherstate_encrypt"), APIError::CIPHERSTATE_ENCRYPT_FAILED); if (aerr != APIError::OK) return aerr; @@ -516,25 +536,27 @@ APIError APINoiseFrameHelper::init_handshake_() { nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0; err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER); - APIError aerr = handle_noise_error_(err, "noise_handshakestate_new_by_id", APIError::HANDSHAKESTATE_SETUP_FAILED); + APIError aerr = + handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) return aerr; const auto &psk = ctx_->get_psk(); err = noise_handshakestate_set_pre_shared_key(handshake_, psk.data(), psk.size()); - aerr = handle_noise_error_(err, "noise_handshakestate_set_pre_shared_key", APIError::HANDSHAKESTATE_SETUP_FAILED); + aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_pre_shared_key"), + APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) return aerr; err = noise_handshakestate_set_prologue(handshake_, prologue_.data(), prologue_.size()); - aerr = handle_noise_error_(err, "noise_handshakestate_set_prologue", APIError::HANDSHAKESTATE_SETUP_FAILED); + aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_prologue"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) return aerr; // set_prologue copies it into handshakestate, so we can get rid of it now prologue_ = {}; err = noise_handshakestate_start(handshake_); - aerr = handle_noise_error_(err, "noise_handshakestate_start", APIError::HANDSHAKESTATE_SETUP_FAILED); + aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) return aerr; return APIError::OK; @@ -552,7 +574,8 @@ APIError APINoiseFrameHelper::check_handshake_finished_() { return APIError::HANDSHAKESTATE_BAD_STATE; } int err = noise_handshakestate_split(handshake_, &send_cipher_, &recv_cipher_); - APIError aerr = handle_noise_error_(err, "noise_handshakestate_split", APIError::HANDSHAKESTATE_SPLIT_FAILED); + APIError aerr = + handle_noise_error_(err, LOG_STR("noise_handshakestate_split"), APIError::HANDSHAKESTATE_SPLIT_FAILED); if (aerr != APIError::OK) return aerr; diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 49bc6f8854..71a217c4ca 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -32,9 +32,9 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError write_frame_(const uint8_t *data, uint16_t len); APIError init_handshake_(); APIError check_handshake_finished_(); - void send_explicit_handshake_reject_(const std::string &reason); + void send_explicit_handshake_reject_(const LogString *reason); APIError handle_handshake_frame_error_(APIError aerr); - APIError handle_noise_error_(int err, const char *func_name, APIError api_err); + APIError handle_noise_error_(int err, const LogString *func_name, APIError api_err); // Pointers first (4 bytes each) NoiseHandshakeState *handshake_{nullptr}; From e018b15641e47e1bf8662253ad29a34ba59284de Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 5 Sep 2025 20:10:48 -0500 Subject: [PATCH 0949/1626] [sen5x] Various optimizing & tidying up (#10602) --- esphome/components/sen5x/sen5x.cpp | 112 +++++++++++++++-------------- esphome/components/sen5x/sen5x.h | 36 ++++++---- 2 files changed, 78 insertions(+), 70 deletions(-) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index f3222221a2..3298a5b8db 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -29,6 +29,19 @@ static const int8_t SEN5X_INDEX_SCALE_FACTOR = 10; // static const int8_t SEN5X_MIN_INDEX_VALUE = 1 * SEN5X_INDEX_SCALE_FACTOR; // must be adjusted by the scale factor static const int16_t SEN5X_MAX_INDEX_VALUE = 500 * SEN5X_INDEX_SCALE_FACTOR; // must be adjusted by the scale factor +static const LogString *rht_accel_mode_to_string(RhtAccelerationMode mode) { + switch (mode) { + case LOW_ACCELERATION: + return LOG_STR("LOW"); + case MEDIUM_ACCELERATION: + return LOG_STR("MEDIUM"); + case HIGH_ACCELERATION: + return LOG_STR("HIGH"); + default: + return LOG_STR("UNKNOWN"); + } +} + void SEN5XComponent::setup() { // the sensor needs 1000 ms to enter the idle state this->set_timeout(1000, [this]() { @@ -50,7 +63,7 @@ void SEN5XComponent::setup() { uint32_t stop_measurement_delay = 0; // In order to query the device periodic measurement must be ceased if (raw_read_status) { - ESP_LOGD(TAG, "Sensor has data available, stopping periodic measurement"); + ESP_LOGD(TAG, "Data is available; stopping periodic measurement"); if (!this->write_command(SEN5X_CMD_STOP_MEASUREMENTS)) { ESP_LOGE(TAG, "Failed to stop measurements"); this->mark_failed(); @@ -71,7 +84,8 @@ void SEN5XComponent::setup() { this->serial_number_[0] = static_cast(uint16_t(raw_serial_number[0]) & 0xFF); this->serial_number_[1] = static_cast(raw_serial_number[0] & 0xFF); this->serial_number_[2] = static_cast(raw_serial_number[1] >> 8); - ESP_LOGD(TAG, "Serial number %02d.%02d.%02d", serial_number_[0], serial_number_[1], serial_number_[2]); + ESP_LOGV(TAG, "Serial number %02d.%02d.%02d", this->serial_number_[0], this->serial_number_[1], + this->serial_number_[2]); uint16_t raw_product_name[16]; if (!this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) { @@ -88,45 +102,43 @@ void SEN5XComponent::setup() { // first char current_char = *current_int >> 8; if (current_char) { - product_name_.push_back(current_char); + this->product_name_.push_back(current_char); // second char current_char = *current_int & 0xFF; if (current_char) { - product_name_.push_back(current_char); + this->product_name_.push_back(current_char); } } current_int++; } while (current_char && --max); Sen5xType sen5x_type = UNKNOWN; - if (product_name_ == "SEN50") { + if (this->product_name_ == "SEN50") { sen5x_type = SEN50; } else { - if (product_name_ == "SEN54") { + if (this->product_name_ == "SEN54") { sen5x_type = SEN54; } else { - if (product_name_ == "SEN55") { + if (this->product_name_ == "SEN55") { sen5x_type = SEN55; } } - ESP_LOGD(TAG, "Productname %s", product_name_.c_str()); + ESP_LOGD(TAG, "Product name: %s", this->product_name_.c_str()); } if (this->humidity_sensor_ && sen5x_type == SEN50) { - ESP_LOGE(TAG, "For Relative humidity a SEN54 OR SEN55 is required. You are using a <%s> sensor", - this->product_name_.c_str()); + ESP_LOGE(TAG, "Relative humidity requires a SEN54 or SEN55"); this->humidity_sensor_ = nullptr; // mark as not used } if (this->temperature_sensor_ && sen5x_type == SEN50) { - ESP_LOGE(TAG, "For Temperature a SEN54 OR SEN55 is required. You are using a <%s> sensor", - this->product_name_.c_str()); + ESP_LOGE(TAG, "Temperature requires a SEN54 or SEN55"); this->temperature_sensor_ = nullptr; // mark as not used } if (this->voc_sensor_ && sen5x_type == SEN50) { - ESP_LOGE(TAG, "For VOC a SEN54 OR SEN55 is required. You are using a <%s> sensor", this->product_name_.c_str()); + ESP_LOGE(TAG, "VOC requires a SEN54 or SEN55"); this->voc_sensor_ = nullptr; // mark as not used } if (this->nox_sensor_ && sen5x_type != SEN55) { - ESP_LOGE(TAG, "For NOx a SEN55 is required. You are using a <%s> sensor", this->product_name_.c_str()); + ESP_LOGE(TAG, "NOx requires a SEN55"); this->nox_sensor_ = nullptr; // mark as not used } @@ -137,7 +149,7 @@ void SEN5XComponent::setup() { return; } this->firmware_version_ >>= 8; - ESP_LOGD(TAG, "Firmware version %d", this->firmware_version_); + ESP_LOGV(TAG, "Firmware version %d", this->firmware_version_); if (this->voc_sensor_ && this->store_baseline_) { uint32_t combined_serial = @@ -150,7 +162,7 @@ void SEN5XComponent::setup() { if (this->pref_.load(&this->voc_baselines_storage_)) { ESP_LOGI(TAG, "Loaded VOC baseline state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, - this->voc_baselines_storage_.state0, voc_baselines_storage_.state1); + this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); } // Initialize storage timestamp @@ -158,13 +170,13 @@ void SEN5XComponent::setup() { if (this->voc_baselines_storage_.state0 > 0 && this->voc_baselines_storage_.state1 > 0) { ESP_LOGI(TAG, "Setting VOC baseline from save state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, - this->voc_baselines_storage_.state0, voc_baselines_storage_.state1); + this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); uint16_t states[4]; - states[0] = voc_baselines_storage_.state0 >> 16; - states[1] = voc_baselines_storage_.state0 & 0xFFFF; - states[2] = voc_baselines_storage_.state1 >> 16; - states[3] = voc_baselines_storage_.state1 & 0xFFFF; + states[0] = this->voc_baselines_storage_.state0 >> 16; + states[1] = this->voc_baselines_storage_.state0 & 0xFFFF; + states[2] = this->voc_baselines_storage_.state1 >> 16; + states[3] = this->voc_baselines_storage_.state1 & 0xFFFF; if (!this->write_command(SEN5X_CMD_VOC_ALGORITHM_STATE, states, 4)) { ESP_LOGE(TAG, "Failed to set VOC baseline from saved state"); @@ -182,11 +194,11 @@ void SEN5XComponent::setup() { delay(20); uint16_t secs[2]; if (this->read_data(secs, 2)) { - auto_cleaning_interval_ = secs[0] << 16 | secs[1]; + this->auto_cleaning_interval_ = secs[0] << 16 | secs[1]; } } - if (acceleration_mode_.has_value()) { - result = this->write_command(SEN5X_CMD_RHT_ACCELERATION_MODE, acceleration_mode_.value()); + if (this->acceleration_mode_.has_value()) { + result = this->write_command(SEN5X_CMD_RHT_ACCELERATION_MODE, this->acceleration_mode_.value()); } else { result = this->write_command(SEN5X_CMD_RHT_ACCELERATION_MODE); } @@ -197,7 +209,7 @@ void SEN5XComponent::setup() { return; } delay(20); - if (!acceleration_mode_.has_value()) { + if (!this->acceleration_mode_.has_value()) { uint16_t mode; if (this->read_data(mode)) { this->acceleration_mode_ = RhtAccelerationMode(mode); @@ -227,19 +239,18 @@ void SEN5XComponent::setup() { } if (!this->write_command(cmd)) { - ESP_LOGE(TAG, "Error starting continuous measurements."); + ESP_LOGE(TAG, "Error starting continuous measurements"); this->error_code_ = MEASUREMENT_INIT_FAILED; this->mark_failed(); return; } - initialized_ = true; - ESP_LOGD(TAG, "Sensor initialized"); + this->initialized_ = true; }); }); } void SEN5XComponent::dump_config() { - ESP_LOGCONFIG(TAG, "sen5x:"); + ESP_LOGCONFIG(TAG, "SEN5X:"); LOG_I2C_DEVICE(this); if (this->is_failed()) { switch (this->error_code_) { @@ -247,16 +258,16 @@ void SEN5XComponent::dump_config() { ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); break; case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement Initialization failed"); + ESP_LOGW(TAG, "Measurement initialization failed"); break; case SERIAL_NUMBER_IDENTIFICATION_FAILED: - ESP_LOGW(TAG, "Unable to read sensor serial id"); + ESP_LOGW(TAG, "Unable to read serial ID"); break; case PRODUCT_NAME_FAILED: ESP_LOGW(TAG, "Unable to read product name"); break; case FIRMWARE_FAILED: - ESP_LOGW(TAG, "Unable to read sensor firmware version"); + ESP_LOGW(TAG, "Unable to read firmware version"); break; default: ESP_LOGW(TAG, "Unknown setup error"); @@ -264,26 +275,17 @@ void SEN5XComponent::dump_config() { } } ESP_LOGCONFIG(TAG, - " Productname: %s\n" + " Product name: %s\n" " Firmware version: %d\n" " Serial number %02d.%02d.%02d", - this->product_name_.c_str(), this->firmware_version_, serial_number_[0], serial_number_[1], - serial_number_[2]); + this->product_name_.c_str(), this->firmware_version_, this->serial_number_[0], this->serial_number_[1], + this->serial_number_[2]); if (this->auto_cleaning_interval_.has_value()) { - ESP_LOGCONFIG(TAG, " Auto cleaning interval %" PRId32 " seconds", auto_cleaning_interval_.value()); + ESP_LOGCONFIG(TAG, " Auto cleaning interval: %" PRId32 "s", this->auto_cleaning_interval_.value()); } if (this->acceleration_mode_.has_value()) { - switch (this->acceleration_mode_.value()) { - case LOW_ACCELERATION: - ESP_LOGCONFIG(TAG, " Low RH/T acceleration mode"); - break; - case MEDIUM_ACCELERATION: - ESP_LOGCONFIG(TAG, " Medium RH/T acceleration mode"); - break; - case HIGH_ACCELERATION: - ESP_LOGCONFIG(TAG, " High RH/T acceleration mode"); - break; - } + ESP_LOGCONFIG(TAG, " RH/T acceleration mode: %s", + LOG_STR_ARG(rht_accel_mode_to_string(this->acceleration_mode_.value()))); } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_); @@ -297,7 +299,7 @@ void SEN5XComponent::dump_config() { } void SEN5XComponent::update() { - if (!initialized_) { + if (!this->initialized_) { return; } @@ -320,8 +322,8 @@ void SEN5XComponent::update() { this->voc_baselines_storage_.state1 = state1; if (this->pref_.save(&this->voc_baselines_storage_)) { - ESP_LOGI(TAG, "Stored VOC baseline state0: 0x%04" PRIX32 " ,state1: 0x%04" PRIX32, - this->voc_baselines_storage_.state0, voc_baselines_storage_.state1); + ESP_LOGI(TAG, "Stored VOC baseline state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, + this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); } else { ESP_LOGW(TAG, "Could not store VOC baselines"); } @@ -333,7 +335,7 @@ void SEN5XComponent::update() { if (!this->write_command(SEN5X_CMD_READ_MEASUREMENT)) { this->status_set_warning(); - ESP_LOGD(TAG, "write error read measurement (%d)", this->last_error_); + ESP_LOGD(TAG, "Write error: read measurement (%d)", this->last_error_); return; } this->set_timeout(20, [this]() { @@ -341,7 +343,7 @@ void SEN5XComponent::update() { if (!this->read_data(measurements, 8)) { this->status_set_warning(); - ESP_LOGD(TAG, "read data error (%d)", this->last_error_); + ESP_LOGD(TAG, "Read data error (%d)", this->last_error_); return; } @@ -413,7 +415,7 @@ bool SEN5XComponent::write_tuning_parameters_(uint16_t i2c_command, const GasTun params[5] = tuning.gain_factor; auto result = write_command(i2c_command, params, 6); if (!result) { - ESP_LOGE(TAG, "set tuning parameters failed. i2c command=%0xX, err=%d", i2c_command, this->last_error_); + ESP_LOGE(TAG, "Set tuning parameters failed (command=%0xX, err=%d)", i2c_command, this->last_error_); } return result; } @@ -424,7 +426,7 @@ bool SEN5XComponent::write_temperature_compensation_(const TemperatureCompensati params[1] = compensation.normalized_offset_slope; params[2] = compensation.time_constant; if (!write_command(SEN5X_CMD_TEMPERATURE_COMPENSATION, params, 3)) { - ESP_LOGE(TAG, "set temperature_compensation failed. Err=%d", this->last_error_); + ESP_LOGE(TAG, "Set temperature_compensation failed (%d)", this->last_error_); return false; } return true; @@ -433,7 +435,7 @@ bool SEN5XComponent::write_temperature_compensation_(const TemperatureCompensati bool SEN5XComponent::start_fan_cleaning() { if (!write_command(SEN5X_CMD_START_CLEANING_FAN)) { this->status_set_warning(); - ESP_LOGE(TAG, "write error start fan (%d)", this->last_error_); + ESP_LOGE(TAG, "Start fan cleaning failed (%d)", this->last_error_); return false; } else { ESP_LOGD(TAG, "Fan auto clean started"); diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index 0fa31605e6..9e5b6bf231 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -9,7 +9,7 @@ namespace esphome { namespace sen5x { -enum ERRORCODE { +enum ERRORCODE : uint8_t { COMMUNICATION_FAILED, SERIAL_NUMBER_IDENTIFICATION_FAILED, MEASUREMENT_INIT_FAILED, @@ -18,19 +18,17 @@ enum ERRORCODE { UNKNOWN }; -// Shortest time interval of 3H for storing baseline values. -// Prevents wear of the flash because of too many write operations -const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 10800; -// Store anyway if the baseline difference exceeds the max storage diff value -const uint32_t MAXIMUM_STORAGE_DIFF = 50; +enum RhtAccelerationMode : uint16_t { + LOW_ACCELERATION = 0, + MEDIUM_ACCELERATION = 1, + HIGH_ACCELERATION = 2, +}; struct Sen5xBaselines { int32_t state0; int32_t state1; } PACKED; // NOLINT -enum RhtAccelerationMode : uint16_t { LOW_ACCELERATION = 0, MEDIUM_ACCELERATION = 1, HIGH_ACCELERATION = 2 }; - struct GasTuning { uint16_t index_offset; uint16_t learning_time_offset_hours; @@ -46,6 +44,12 @@ struct TemperatureCompensation { uint16_t time_constant; }; +// Shortest time interval of 3H for storing baseline values. +// Prevents wear of the flash because of too many write operations +static const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 10800; +// Store anyway if the baseline difference exceeds the max storage diff value +static const uint32_t MAXIMUM_STORAGE_DIFF = 50; + class SEN5XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; @@ -102,8 +106,14 @@ class SEN5XComponent : public PollingComponent, public sensirion_common::Sensiri protected: bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); bool write_temperature_compensation_(const TemperatureCompensation &compensation); + + uint32_t seconds_since_last_store_; + uint16_t firmware_version_; ERRORCODE error_code_; + uint8_t serial_number_[4]; bool initialized_{false}; + bool store_baseline_; + sensor::Sensor *pm_1_0_sensor_{nullptr}; sensor::Sensor *pm_2_5_sensor_{nullptr}; sensor::Sensor *pm_4_0_sensor_{nullptr}; @@ -115,18 +125,14 @@ class SEN5XComponent : public PollingComponent, public sensirion_common::Sensiri // SEN55 only sensor::Sensor *nox_sensor_{nullptr}; - std::string product_name_; - uint8_t serial_number_[4]; - uint16_t firmware_version_; - Sen5xBaselines voc_baselines_storage_; - bool store_baseline_; - uint32_t seconds_since_last_store_; - ESPPreferenceObject pref_; optional acceleration_mode_; optional auto_cleaning_interval_; optional voc_tuning_params_; optional nox_tuning_params_; optional temperature_compensation_; + ESPPreferenceObject pref_; + std::string product_name_; + Sen5xBaselines voc_baselines_storage_; }; } // namespace sen5x From 1108dd8e7828c96a337a303f1ba8327c171e8121 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 21:49:19 -0500 Subject: [PATCH 0950/1626] warnings strings flash --- esphome/components/wifi/wifi_component.cpp | 6 ++--- esphome/core/component.cpp | 26 +++++++++++++++++----- esphome/core/component.h | 6 +++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index d16c94fa13..e57bf25b8c 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -148,7 +148,7 @@ void WiFiComponent::loop() { switch (this->state_) { case WIFI_COMPONENT_STATE_COOLDOWN: { - this->status_set_warning("waiting to reconnect"); + this->status_set_warning(LOG_STR("waiting to reconnect")); if (millis() - this->action_started_ > 5000) { if (this->fast_connect_ || this->retry_hidden_) { if (!this->selected_ap_.get_bssid().has_value()) @@ -161,13 +161,13 @@ void WiFiComponent::loop() { break; } case WIFI_COMPONENT_STATE_STA_SCANNING: { - this->status_set_warning("scanning for networks"); + this->status_set_warning(LOG_STR("scanning for networks")); this->check_scanning_finished(); break; } case WIFI_COMPONENT_STATE_STA_CONNECTING: case WIFI_COMPONENT_STATE_STA_CONNECTING_2: { - this->status_set_warning("associating to network"); + this->status_set_warning(LOG_STR("associating to network")); this->check_connecting_finished(); break; } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 40cda17ca3..5689a059a0 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -16,7 +16,7 @@ namespace esphome { static const char *const TAG = "component"; -static const char *const UNSPECIFIED_MESSAGE = "unspecified"; +static const auto *const UNSPECIFIED_MESSAGE = LOG_STR("unspecified"); // Global vectors for component data that doesn't belong in every instance. // Using vector instead of unordered_map for both because: @@ -143,7 +143,7 @@ void Component::call_dump_config() { } } ESP_LOGE(TAG, " %s is marked FAILED: %s", this->get_component_source(), - error_msg ? error_msg : UNSPECIFIED_MESSAGE); + error_msg ? error_msg : LOG_STR_ARG(UNSPECIFIED_MESSAGE)); } } @@ -280,20 +280,36 @@ bool Component::is_ready() const { bool Component::can_proceed() { return true; } bool Component::status_has_warning() const { return this->component_state_ & STATUS_LED_WARNING; } bool Component::status_has_error() const { return this->component_state_ & STATUS_LED_ERROR; } -void Component::status_set_warning(const char *message) { + +void Component::status_set_warning_flag_() { // Don't spam the log. This risks missing different warning messages though. if ((this->component_state_ & STATUS_LED_WARNING) != 0) return; this->component_state_ |= STATUS_LED_WARNING; App.app_state_ |= STATUS_LED_WARNING; - ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), message ? message : UNSPECIFIED_MESSAGE); } + +void Component::status_set_warning(const char *message) { + this->status_set_warning_flag_(); + if ((this->component_state_ & STATUS_LED_WARNING) != 0) + ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), + message ? message : LOG_STR_ARG(UNSPECIFIED_MESSAGE)); +} +#ifdef USE_STORE_LOG_STR_IN_FLASH +void Component::status_set_warning(const LogString *message) { + this->status_set_warning_flag_(); + if ((this->component_state_ & STATUS_LED_WARNING) != 0) + ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), + message ? LOG_STR_ARG(message) : LOG_STR_ARG(UNSPECIFIED_MESSAGE)); +} +#endif void Component::status_set_error(const char *message) { if ((this->component_state_ & STATUS_LED_ERROR) != 0) return; this->component_state_ |= STATUS_LED_ERROR; App.app_state_ |= STATUS_LED_ERROR; - ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), message ? message : UNSPECIFIED_MESSAGE); + ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), + message ? message : LOG_STR_ARG(UNSPECIFIED_MESSAGE)); if (message != nullptr) { // Lazy allocate the error messages vector if needed if (!component_error_messages) { diff --git a/esphome/core/component.h b/esphome/core/component.h index 096c6f9c69..98a183fc21 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -203,6 +203,9 @@ class Component { bool status_has_error() const; void status_set_warning(const char *message = nullptr); +#ifdef USE_STORE_LOG_STR_IN_FLASH + void status_set_warning(const LogString *message); +#endif void status_set_error(const char *message = nullptr); @@ -239,6 +242,9 @@ class Component { /// Helper to set component state (clears state bits and sets new state) void set_component_state_(uint8_t state); + /// Helper to set warning flag without duplicating logic + void status_set_warning_flag_(); + /** Set an interval function with a unique name. Empty name means no cancelling possible. * * This will call f every interval ms. Can be cancelled via CancelInterval(). From 52fe034bfff2f2218b6782a5c0e11c48ff564763 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 22:01:10 -0500 Subject: [PATCH 0951/1626] wip --- esphome/core/component.cpp | 9 ++++----- esphome/core/component.h | 3 +++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 5689a059a0..f780778cb2 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -16,7 +16,6 @@ namespace esphome { static const char *const TAG = "component"; -static const auto *const UNSPECIFIED_MESSAGE = LOG_STR("unspecified"); // Global vectors for component data that doesn't belong in every instance. // Using vector instead of unordered_map for both because: @@ -143,7 +142,7 @@ void Component::call_dump_config() { } } ESP_LOGE(TAG, " %s is marked FAILED: %s", this->get_component_source(), - error_msg ? error_msg : LOG_STR_ARG(UNSPECIFIED_MESSAGE)); + error_msg ? error_msg : LOG_STR_LITERAL("unspecified")); } } @@ -293,14 +292,14 @@ void Component::status_set_warning(const char *message) { this->status_set_warning_flag_(); if ((this->component_state_ & STATUS_LED_WARNING) != 0) ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), - message ? message : LOG_STR_ARG(UNSPECIFIED_MESSAGE)); + message ? message : LOG_STR_LITERAL("unspecified")); } #ifdef USE_STORE_LOG_STR_IN_FLASH void Component::status_set_warning(const LogString *message) { this->status_set_warning_flag_(); if ((this->component_state_ & STATUS_LED_WARNING) != 0) ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), - message ? LOG_STR_ARG(message) : LOG_STR_ARG(UNSPECIFIED_MESSAGE)); + message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } #endif void Component::status_set_error(const char *message) { @@ -309,7 +308,7 @@ void Component::status_set_error(const char *message) { this->component_state_ |= STATUS_LED_ERROR; App.app_state_ |= STATUS_LED_ERROR; ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), - message ? message : LOG_STR_ARG(UNSPECIFIED_MESSAGE)); + message ? message : LOG_STR_LITERAL("unspecified")); if (message != nullptr) { // Lazy allocate the error messages vector if needed if (!component_error_messages) { diff --git a/esphome/core/component.h b/esphome/core/component.h index 98a183fc21..38d77d1e40 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -9,6 +9,9 @@ namespace esphome { +// Forward declaration for LogString +struct LogString; + /** Default setup priorities for components of different types. * * Components should return one of these setup priorities in get_setup_priority. From 9360601f53d56681e97d13282097a72c23d265ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 22:07:20 -0500 Subject: [PATCH 0952/1626] more --- .../absolute_humidity/absolute_humidity.cpp | 2 +- esphome/components/aht10/aht10.cpp | 6 +++--- .../axs15231/touchscreen/axs15231_touchscreen.cpp | 2 +- esphome/components/bl0942/bl0942.cpp | 2 +- esphome/components/dallas_temp/dallas_temp.cpp | 4 ++-- esphome/components/ethernet/ethernet_component.cpp | 2 +- esphome/components/gdk101/gdk101.cpp | 8 ++++---- .../gt911/touchscreen/gt911_touchscreen.cpp | 2 +- .../components/honeywellabp2_i2c/honeywellabp2.cpp | 8 ++++---- .../binary_sensor/m5stack_8angle_binary_sensor.cpp | 2 +- .../sensor/m5stack_8angle_sensor.cpp | 2 +- esphome/components/max17043/max17043.cpp | 4 ++-- esphome/components/mcp23x08_base/mcp23x08_base.cpp | 2 +- esphome/components/mcp23x17_base/mcp23x17_base.cpp | 4 ++-- esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp | 14 +++++++------- esphome/components/sgp4x/sgp4x.cpp | 4 ++-- esphome/components/sht4x/sht4x.cpp | 2 +- esphome/components/sound_level/sound_level.cpp | 2 +- esphome/components/tca9555/tca9555.cpp | 10 +++++----- esphome/components/tmp1075/tmp1075.cpp | 2 +- esphome/components/udp/udp_component.cpp | 6 +++--- esphome/components/usb_uart/usb_uart.cpp | 2 +- esphome/components/wake_on_lan/wake_on_lan.cpp | 4 ++-- 23 files changed, 48 insertions(+), 48 deletions(-) diff --git a/esphome/components/absolute_humidity/absolute_humidity.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp index 7ba3c5a1ab..2c5603ee3d 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.cpp +++ b/esphome/components/absolute_humidity/absolute_humidity.cpp @@ -64,7 +64,7 @@ void AbsoluteHumidityComponent::loop() { ESP_LOGW(TAG, "No valid state from humidity sensor!"); } this->publish_state(NAN); - this->status_set_warning("Unable to calculate absolute humidity."); + this->status_set_warning(LOG_STR("Unable to calculate absolute humidity.")); return; } diff --git a/esphome/components/aht10/aht10.cpp b/esphome/components/aht10/aht10.cpp index 6202a27c42..53c712a7a7 100644 --- a/esphome/components/aht10/aht10.cpp +++ b/esphome/components/aht10/aht10.cpp @@ -96,7 +96,7 @@ void AHT10Component::read_data_() { ESP_LOGD(TAG, "Read attempt %d at %ums", this->read_count_, (unsigned) (millis() - this->start_time_)); } if (this->read(data, 6) != i2c::ERROR_OK) { - this->status_set_warning("Read failed, will retry"); + this->status_set_warning(LOG_STR("Read failed, will retry")); this->restart_read_(); return; } @@ -113,7 +113,7 @@ void AHT10Component::read_data_() { } else { ESP_LOGD(TAG, "Invalid humidity, retrying"); if (this->write(AHT10_MEASURE_CMD, sizeof(AHT10_MEASURE_CMD)) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } this->restart_read_(); return; @@ -144,7 +144,7 @@ void AHT10Component::update() { return; this->start_time_ = millis(); if (this->write(AHT10_MEASURE_CMD, sizeof(AHT10_MEASURE_CMD)) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->restart_read_(); diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp index 6304516164..ab3f1dad4f 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp @@ -12,7 +12,7 @@ constexpr static const uint8_t AXS_READ_TOUCHPAD[11] = {0xb5, 0xab, 0xa5, 0x5a, #define ERROR_CHECK(err) \ if ((err) != i2c::ERROR_OK) { \ - this->status_set_warning("Failed to communicate"); \ + this->status_set_warning(LOG_STR("Failed to communicate")); \ return; \ } diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 86eff57147..894fcbfbb7 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -149,7 +149,7 @@ void BL0942::setup() { this->write_reg_(BL0942_REG_USR_WRPROT, 0); if (this->read_reg_(BL0942_REG_MODE) != mode) - this->status_set_warning("BL0942 setup failed!"); + this->status_set_warning(LOG_STR("BL0942 setup failed!")); this->flush(); } diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 5cd6063893..a518c96489 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -64,7 +64,7 @@ bool DallasTemperatureSensor::read_scratch_pad_() { } } else { ESP_LOGW(TAG, "'%s' - reading scratch pad failed bus reset", this->get_name().c_str()); - this->status_set_warning("bus reset failed"); + this->status_set_warning(LOG_STR("bus reset failed")); } return success; } @@ -124,7 +124,7 @@ bool DallasTemperatureSensor::check_scratch_pad_() { crc8(this->scratch_pad_, 8)); #endif if (!chksum_validity) { - this->status_set_warning("scratch pad checksum invalid"); + this->status_set_warning(LOG_STR("scratch pad checksum invalid")); ESP_LOGD(TAG, "Scratch pad: %02X.%02X.%02X.%02X.%02X.%02X.%02X.%02X.%02X (%02X)", this->scratch_pad_[0], this->scratch_pad_[1], this->scratch_pad_[2], this->scratch_pad_[3], this->scratch_pad_[4], this->scratch_pad_[5], this->scratch_pad_[6], this->scratch_pad_[7], this->scratch_pad_[8], diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 87913488da..844a30bd8b 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -492,7 +492,7 @@ void EthernetComponent::start_connect_() { global_eth_component->ipv6_count_ = 0; #endif /* USE_NETWORK_IPV6 */ this->connect_begin_ = millis(); - this->status_set_warning("waiting for IP configuration"); + this->status_set_warning(LOG_STR("waiting for IP configuration")); esp_err_t err; err = esp_netif_set_hostname(this->eth_netif_, App.get_name().c_str()); diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index 096b06917a..4c156ab24b 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -11,22 +11,22 @@ static const uint8_t NUMBER_OF_READ_RETRIES = 5; void GDK101Component::update() { uint8_t data[2]; if (!this->read_dose_1m_(data)) { - this->status_set_warning("Failed to read dose 1m"); + this->status_set_warning(LOG_STR("Failed to read dose 1m")); return; } if (!this->read_dose_10m_(data)) { - this->status_set_warning("Failed to read dose 10m"); + this->status_set_warning(LOG_STR("Failed to read dose 10m")); return; } if (!this->read_status_(data)) { - this->status_set_warning("Failed to read status"); + this->status_set_warning(LOG_STR("Failed to read status")); return; } if (!this->read_measurement_duration_(data)) { - this->status_set_warning("Failed to read measurement duration"); + this->status_set_warning(LOG_STR("Failed to read measurement duration")); return; } this->status_clear_warning(); diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 07218843dd..4810867d4b 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -20,7 +20,7 @@ static const size_t MAX_BUTTONS = 4; // max number of buttons scanned #define ERROR_CHECK(err) \ if ((err) != i2c::ERROR_OK) { \ - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); \ + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); \ return; \ } diff --git a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp index 11f5dbc314..f173a1afbd 100644 --- a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp +++ b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp @@ -15,7 +15,7 @@ static const char *const TAG = "honeywellabp2"; void HONEYWELLABP2Sensor::read_sensor_data() { if (this->read(raw_data_, 7) != i2c::ERROR_OK) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning("couldn't read sensor data"); + this->status_set_warning(LOG_STR("couldn't read sensor data")); return; } float press_counts = encode_uint24(raw_data_[1], raw_data_[2], raw_data_[3]); // calculate digital pressure counts @@ -31,7 +31,7 @@ void HONEYWELLABP2Sensor::read_sensor_data() { void HONEYWELLABP2Sensor::start_measurement() { if (this->write(i2c_cmd_, 3) != i2c::ERROR_OK) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning("couldn't start measurement"); + this->status_set_warning(LOG_STR("couldn't start measurement")); return; } this->measurement_running_ = true; @@ -40,7 +40,7 @@ void HONEYWELLABP2Sensor::start_measurement() { bool HONEYWELLABP2Sensor::is_measurement_ready() { if (this->read(raw_data_, 1) != i2c::ERROR_OK) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning("couldn't check measurement"); + this->status_set_warning(LOG_STR("couldn't check measurement")); return false; } if ((raw_data_[0] & (0x1 << STATUS_BIT_BUSY)) > 0) { @@ -53,7 +53,7 @@ bool HONEYWELLABP2Sensor::is_measurement_ready() { void HONEYWELLABP2Sensor::measurement_timeout() { ESP_LOGE(TAG, "Timeout!"); this->measurement_running_ = false; - this->status_set_warning("measurement timed out"); + this->status_set_warning(LOG_STR("measurement timed out")); } float HONEYWELLABP2Sensor::get_pressure() { return this->last_pressure_; } diff --git a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp index 2f68d9f254..3eeba4a644 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp +++ b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp @@ -6,7 +6,7 @@ namespace m5stack_8angle { void M5Stack8AngleSwitchBinarySensor::update() { int8_t out = this->parent_->read_switch(); if (out == -1) { - this->status_set_warning("Could not read binary sensor state from M5Stack 8Angle."); + this->status_set_warning(LOG_STR("Could not read binary sensor state from M5Stack 8Angle.")); return; } this->publish_state(out != 0); diff --git a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp index 5e034f1dd3..d22b345141 100644 --- a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp +++ b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp @@ -7,7 +7,7 @@ void M5Stack8AngleKnobSensor::update() { if (this->parent_ != nullptr) { int32_t raw_pos = this->parent_->read_knob_pos_raw(this->channel_, this->bits_); if (raw_pos == -1) { - this->status_set_warning("Could not read knob position from M5Stack 8Angle."); + this->status_set_warning(LOG_STR("Could not read knob position from M5Stack 8Angle.")); return; } if (this->raw_) { diff --git a/esphome/components/max17043/max17043.cpp b/esphome/components/max17043/max17043.cpp index 8f486de6b7..f605fb1324 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -22,7 +22,7 @@ void MAX17043Component::update() { if (this->voltage_sensor_ != nullptr) { if (!this->read_byte_16(MAX17043_VCELL, &raw_voltage)) { - this->status_set_warning("Unable to read MAX17043_VCELL"); + this->status_set_warning(LOG_STR("Unable to read MAX17043_VCELL")); } else { float voltage = (1.25 * (float) (raw_voltage >> 4)) / 1000.0; this->voltage_sensor_->publish_state(voltage); @@ -31,7 +31,7 @@ void MAX17043Component::update() { } if (this->battery_remaining_sensor_ != nullptr) { if (!this->read_byte_16(MAX17043_SOC, &raw_percent)) { - this->status_set_warning("Unable to read MAX17043_SOC"); + this->status_set_warning(LOG_STR("Unable to read MAX17043_SOC")); } else { float percent = (float) ((raw_percent >> 8) + 0.003906f * (raw_percent & 0x00ff)); this->battery_remaining_sensor_->publish_state(percent); diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.cpp b/esphome/components/mcp23x08_base/mcp23x08_base.cpp index e4fb51174b..1593c376cd 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.cpp +++ b/esphome/components/mcp23x08_base/mcp23x08_base.cpp @@ -8,7 +8,7 @@ static const char *const TAG = "mcp23x08_base"; bool MCP23X08Base::digital_read_hw(uint8_t pin) { if (!this->read_reg(mcp23x08_base::MCP23X08_GPIO, &this->input_mask_)) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } return true; diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index 020b8a5ddf..b1f1f260b4 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -11,13 +11,13 @@ bool MCP23X17Base::digital_read_hw(uint8_t pin) { uint8_t data; if (pin < 8) { if (!this->read_reg(mcp23x17_base::MCP23X17_GPIOA, &data)) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } this->input_mask_ = encode_uint16(this->input_mask_ >> 8, data); } else { if (!this->read_reg(mcp23x17_base::MCP23X17_GPIOB, &data)) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } this->input_mask_ = encode_uint16(data, this->input_mask_ & 0xFF); diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 18acfda934..517ca833e6 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -68,7 +68,7 @@ bool PI4IOE5V6408Component::read_gpio_outputs_() { uint8_t data; if (!this->read_byte(PI4IOE5V6408_REGISTER_OUT_SET, &data)) { - this->status_set_warning("Failed to read output register"); + this->status_set_warning(LOG_STR("Failed to read output register")); return false; } this->output_mask_ = data; @@ -82,7 +82,7 @@ bool PI4IOE5V6408Component::read_gpio_modes_() { uint8_t data; if (!this->read_byte(PI4IOE5V6408_REGISTER_IO_DIR, &data)) { - this->status_set_warning("Failed to read GPIO modes"); + this->status_set_warning(LOG_STR("Failed to read GPIO modes")); return false; } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -99,7 +99,7 @@ bool PI4IOE5V6408Component::digital_read_hw(uint8_t pin) { uint8_t data; if (!this->read_byte(PI4IOE5V6408_REGISTER_IN_STATE, &data)) { - this->status_set_warning("Failed to read GPIO state"); + this->status_set_warning(LOG_STR("Failed to read GPIO state")); return false; } this->input_mask_ = data; @@ -117,7 +117,7 @@ void PI4IOE5V6408Component::digital_write_hw(uint8_t pin, bool value) { this->output_mask_ &= ~(1 << pin); } if (!this->write_byte(PI4IOE5V6408_REGISTER_OUT_SET, this->output_mask_)) { - this->status_set_warning("Failed to write output register"); + this->status_set_warning(LOG_STR("Failed to write output register")); return; } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -131,15 +131,15 @@ bool PI4IOE5V6408Component::write_gpio_modes_() { return false; if (!this->write_byte(PI4IOE5V6408_REGISTER_IO_DIR, this->mode_mask_)) { - this->status_set_warning("Failed to write GPIO modes"); + this->status_set_warning(LOG_STR("Failed to write GPIO modes")); return false; } if (!this->write_byte(PI4IOE5V6408_REGISTER_PULL_SELECT, this->pull_up_down_mask_)) { - this->status_set_warning("Failed to write GPIO pullup/pulldown"); + this->status_set_warning(LOG_STR("Failed to write GPIO pullup/pulldown")); return false; } if (!this->write_byte(PI4IOE5V6408_REGISTER_PULL_ENABLE, this->pull_enable_mask_)) { - this->status_set_warning("Failed to write GPIO pull enable"); + this->status_set_warning(LOG_STR("Failed to write GPIO pull enable")); return false; } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index da52993a87..99d88006f7 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -211,7 +211,7 @@ void SGP4xComponent::measure_raw_() { if (!this->write_command(command, data, 2)) { ESP_LOGD(TAG, "write error (%d)", this->last_error_); - this->status_set_warning("measurement request failed"); + this->status_set_warning(LOG_STR("measurement request failed")); return; } @@ -220,7 +220,7 @@ void SGP4xComponent::measure_raw_() { raw_data[1] = 0; if (!this->read_data(raw_data, response_words)) { ESP_LOGD(TAG, "read error (%d)", this->last_error_); - this->status_set_warning("measurement read failed"); + this->status_set_warning(LOG_STR("measurement read failed")); this->voc_index_ = this->nox_index_ = UINT16_MAX; return; } diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 637c8c1a9d..62b8717ded 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -65,7 +65,7 @@ void SHT4XComponent::update() { // Send command if (!this->write_command(MEASURECOMMANDS[this->precision_])) { // Warning will be printed only if warning status is not set yet - this->status_set_warning("Failed to send measurement command"); + this->status_set_warning(LOG_STR("Failed to send measurement command")); return; } diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index decf630aba..db6b168bbc 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -56,7 +56,7 @@ void SoundLevelComponent::loop() { } } else { if (!this->status_has_warning()) { - this->status_set_warning("Microphone isn't running, can't compute statistics"); + this->status_set_warning(LOG_STR("Microphone isn't running, can't compute statistics")); // Deallocate buffers, if necessary this->stop_(); diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index b4a04d5b0b..c3449ce254 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -50,7 +50,7 @@ bool TCA9555Component::read_gpio_outputs_() { return false; uint8_t data[2]; if (!this->read_bytes(TCA9555_OUTPUT_PORT_REGISTER_0, data, 2)) { - this->status_set_warning("Failed to read output register"); + this->status_set_warning(LOG_STR("Failed to read output register")); return false; } this->output_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); @@ -64,7 +64,7 @@ bool TCA9555Component::read_gpio_modes_() { uint8_t data[2]; bool success = this->read_bytes(TCA9555_CONFIGURATION_PORT_0, data, 2); if (!success) { - this->status_set_warning("Failed to read mode register"); + this->status_set_warning(LOG_STR("Failed to read mode register")); return false; } this->mode_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); @@ -79,7 +79,7 @@ bool TCA9555Component::digital_read_hw(uint8_t pin) { uint8_t bank_number = pin < 8 ? 0 : 1; uint8_t register_to_read = bank_number ? TCA9555_INPUT_PORT_REGISTER_1 : TCA9555_INPUT_PORT_REGISTER_0; if (!this->read_bytes(register_to_read, &data, 1)) { - this->status_set_warning("Failed to read input register"); + this->status_set_warning(LOG_STR("Failed to read input register")); return false; } uint8_t second_half = this->input_mask_ >> 8; @@ -108,7 +108,7 @@ void TCA9555Component::digital_write_hw(uint8_t pin, bool value) { data[0] = this->output_mask_; data[1] = this->output_mask_ >> 8; if (!this->write_bytes(TCA9555_OUTPUT_PORT_REGISTER_0, data, 2)) { - this->status_set_warning("Failed to write output register"); + this->status_set_warning(LOG_STR("Failed to write output register")); return; } @@ -123,7 +123,7 @@ bool TCA9555Component::write_gpio_modes_() { data[0] = this->mode_mask_; data[1] = this->mode_mask_ >> 8; if (!this->write_bytes(TCA9555_CONFIGURATION_PORT_0, data, 2)) { - this->status_set_warning("Failed to write mode register"); + this->status_set_warning(LOG_STR("Failed to write mode register")); return false; } this->status_clear_warning(); diff --git a/esphome/components/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 831f905bd2..1d9b384c66 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -32,7 +32,7 @@ void TMP1075Sensor::update() { uint16_t regvalue; if (!read_byte_16(REG_TEMP, ®value)) { ESP_LOGW(TAG, "'%s' - unable to read temperature register", this->name_.c_str()); - this->status_set_warning("can't read"); + this->status_set_warning(LOG_STR("can't read")); return; } this->status_clear_warning(); diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 62a1189355..8a9ce612b4 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -28,12 +28,12 @@ void UDPComponent::setup() { int enable = 1; auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { - this->status_set_warning("Socket unable to set reuseaddr"); + this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); // we can still continue } err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); if (err != 0) { - this->status_set_warning("Socket unable to set broadcast"); + this->status_set_warning(LOG_STR("Socket unable to set broadcast")); } } // create listening socket if we either want to subscribe to providers, or need to listen @@ -55,7 +55,7 @@ void UDPComponent::setup() { int enable = 1; err = this->listen_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable)); if (err != 0) { - this->status_set_warning("Socket unable to set reuseaddr"); + this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); // we can still continue } struct sockaddr_in server {}; diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 934306f480..bf1c9086f1 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -266,7 +266,7 @@ void USBUartTypeCdcAcm::on_connected() { for (auto *channel : this->channels_) { if (i == cdc_devs.size()) { ESP_LOGE(TAG, "No configuration found for channel %d", channel->index_); - this->status_set_warning("No configuration found for channel"); + this->status_set_warning(LOG_STR("No configuration found for channel")); break; } channel->cdc_dev_ = cdc_devs[i++]; diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index bed098755a..adf5a080e5 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -74,12 +74,12 @@ void WakeOnLanButton::setup() { int enable = 1; auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { - this->status_set_warning("Socket unable to set reuseaddr"); + this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); // we can still continue } err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); if (err != 0) { - this->status_set_warning("Socket unable to set broadcast"); + this->status_set_warning(LOG_STR("Socket unable to set broadcast")); } #endif } From ad58b92abef4faad750a079f39273226057cf544 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 22:08:32 -0500 Subject: [PATCH 0953/1626] more --- esphome/core/component.cpp | 20 +++++++++----------- esphome/core/component.h | 3 --- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index f780778cb2..dd2419c9cb 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -280,26 +280,24 @@ bool Component::can_proceed() { return true; } bool Component::status_has_warning() const { return this->component_state_ & STATUS_LED_WARNING; } bool Component::status_has_error() const { return this->component_state_ & STATUS_LED_ERROR; } -void Component::status_set_warning_flag_() { +void Component::status_set_warning(const char *message) { // Don't spam the log. This risks missing different warning messages though. if ((this->component_state_ & STATUS_LED_WARNING) != 0) return; this->component_state_ |= STATUS_LED_WARNING; App.app_state_ |= STATUS_LED_WARNING; -} - -void Component::status_set_warning(const char *message) { - this->status_set_warning_flag_(); - if ((this->component_state_ & STATUS_LED_WARNING) != 0) - ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), - message ? message : LOG_STR_LITERAL("unspecified")); + ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), + message ? message : LOG_STR_LITERAL("unspecified")); } #ifdef USE_STORE_LOG_STR_IN_FLASH void Component::status_set_warning(const LogString *message) { - this->status_set_warning_flag_(); + // Don't spam the log. This risks missing different warning messages though. if ((this->component_state_ & STATUS_LED_WARNING) != 0) - ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), - message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); + return; + this->component_state_ |= STATUS_LED_WARNING; + App.app_state_ |= STATUS_LED_WARNING; + ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), + message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } #endif void Component::status_set_error(const char *message) { diff --git a/esphome/core/component.h b/esphome/core/component.h index 38d77d1e40..f7d41665dd 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -245,9 +245,6 @@ class Component { /// Helper to set component state (clears state bits and sets new state) void set_component_state_(uint8_t state); - /// Helper to set warning flag without duplicating logic - void status_set_warning_flag_(); - /** Set an interval function with a unique name. Empty name means no cancelling possible. * * This will call f every interval ms. Can be cancelled via CancelInterval(). From ba5324fa2f1cd9c671f147f569475fdd3e6c5fb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 22:15:31 -0500 Subject: [PATCH 0954/1626] merge --- esphome/core/component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 85f433b6cf..a35c121057 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -294,7 +294,7 @@ void Component::status_set_warning(const LogString *message) { return; this->component_state_ |= STATUS_LED_WARNING; App.app_state_ |= STATUS_LED_WARNING; - ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), + ESP_LOGW(TAG, "%s set Warning flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } #endif From 2aadf59219530a2b2754cce835402f565d701905 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 22:22:52 -0500 Subject: [PATCH 0955/1626] cleanup --- esphome/core/component.cpp | 2 -- esphome/core/component.h | 2 -- 2 files changed, 4 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index dd2419c9cb..e30dab2508 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -289,7 +289,6 @@ void Component::status_set_warning(const char *message) { ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), message ? message : LOG_STR_LITERAL("unspecified")); } -#ifdef USE_STORE_LOG_STR_IN_FLASH void Component::status_set_warning(const LogString *message) { // Don't spam the log. This risks missing different warning messages though. if ((this->component_state_ & STATUS_LED_WARNING) != 0) @@ -299,7 +298,6 @@ void Component::status_set_warning(const LogString *message) { ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } -#endif void Component::status_set_error(const char *message) { if ((this->component_state_ & STATUS_LED_ERROR) != 0) return; diff --git a/esphome/core/component.h b/esphome/core/component.h index f7d41665dd..a363fceb85 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -206,9 +206,7 @@ class Component { bool status_has_error() const; void status_set_warning(const char *message = nullptr); -#ifdef USE_STORE_LOG_STR_IN_FLASH void status_set_warning(const LogString *message); -#endif void status_set_error(const char *message = nullptr); From 4d0993232083f9eb1154539977d309ca9614f608 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 6 Sep 2025 13:51:44 +1000 Subject: [PATCH 0956/1626] [kmeteriso] Fix i2c call (#10618) --- esphome/components/kmeteriso/kmeteriso.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/kmeteriso/kmeteriso.cpp b/esphome/components/kmeteriso/kmeteriso.cpp index d20e07460b..36f6d74ba0 100644 --- a/esphome/components/kmeteriso/kmeteriso.cpp +++ b/esphome/components/kmeteriso/kmeteriso.cpp @@ -33,7 +33,7 @@ void KMeterISOComponent::setup() { } uint8_t read_buf[4] = {1}; - if (!this->read_register(KMETER_ERROR_STATUS_REG, read_buf, 1)) { + if (!this->read_bytes(KMETER_ERROR_STATUS_REG, read_buf, 1)) { ESP_LOGCONFIG(TAG, "Could not read from the device."); this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(); From 57fd7552e3c3d59e97d0508463e03f84040d0903 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 Sep 2025 22:52:48 -0500 Subject: [PATCH 0957/1626] [core] Skip redundant process_to_add() call when no scheduler items added --- esphome/core/scheduler.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a907b89b02..65c58e401e 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -326,6 +326,9 @@ void HOT Scheduler::call(uint32_t now) { const auto now_64 = this->millis_64_(now); // 'now' from parameter - fresh from Application::loop() this->process_to_add(); + // Track if we add any interval items during this call + bool added_intervals = false; + #ifdef ESPHOME_DEBUG_SCHEDULER static uint64_t last_print = 0; @@ -470,10 +473,14 @@ void HOT Scheduler::call(uint32_t now) { // since we have the lock held this->to_add_.push_back(std::move(item)); } + + added_intervals |= this->to_add_.empty() == false; } } - this->process_to_add(); + if (added_intervals) { + this->process_to_add(); + } } void HOT Scheduler::process_to_add() { LockGuard guard{this->lock_}; From 4a9cfeddcd36110ddfa312080a1d737892244a3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 Sep 2025 22:56:57 -0500 Subject: [PATCH 0958/1626] better name --- esphome/core/scheduler.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 65c58e401e..cb8f5c75b8 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -326,8 +326,8 @@ void HOT Scheduler::call(uint32_t now) { const auto now_64 = this->millis_64_(now); // 'now' from parameter - fresh from Application::loop() this->process_to_add(); - // Track if we add any interval items during this call - bool added_intervals = false; + // Track if any items were added to to_add_ during this call (intervals or from callbacks) + bool added_items = false; #ifdef ESPHOME_DEBUG_SCHEDULER static uint64_t last_print = 0; @@ -474,11 +474,11 @@ void HOT Scheduler::call(uint32_t now) { this->to_add_.push_back(std::move(item)); } - added_intervals |= this->to_add_.empty() == false; + added_items |= this->to_add_.empty() == false; } } - if (added_intervals) { + if (added_items) { this->process_to_add(); } } From 98f7ae93db69e51cfa0d020ea999f829f3c7fb79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 Sep 2025 23:27:14 -0500 Subject: [PATCH 0959/1626] Update esphome/core/scheduler.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/scheduler.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index cb8f5c75b8..feaa41a97f 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -474,7 +474,7 @@ void HOT Scheduler::call(uint32_t now) { this->to_add_.push_back(std::move(item)); } - added_items |= this->to_add_.empty() == false; + added_items |= !this->to_add_.empty(); } } From fec9e63b0c76ce8a804d9d89cfd933fbf5c5648e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 Sep 2025 23:29:11 -0500 Subject: [PATCH 0960/1626] rename --- esphome/core/scheduler.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index cb8f5c75b8..d0230d46fc 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -327,7 +327,7 @@ void HOT Scheduler::call(uint32_t now) { this->process_to_add(); // Track if any items were added to to_add_ during this call (intervals or from callbacks) - bool added_items = false; + bool has_added_items = false; #ifdef ESPHOME_DEBUG_SCHEDULER static uint64_t last_print = 0; @@ -474,11 +474,11 @@ void HOT Scheduler::call(uint32_t now) { this->to_add_.push_back(std::move(item)); } - added_items |= this->to_add_.empty() == false; + has_added_items |= !this->to_add_.empty(); } } - if (added_items) { + if (has_added_items) { this->process_to_add(); } } From c33bb3a8a9daa6aa4ac6aa34c115b4d2635f6708 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 Sep 2025 23:56:45 -0500 Subject: [PATCH 0961/1626] [esp8266] Store component warning strings in flash to reduce RAM usage (#10623) --- .../absolute_humidity/absolute_humidity.cpp | 2 +- esphome/components/aht10/aht10.cpp | 6 +++--- .../touchscreen/axs15231_touchscreen.cpp | 2 +- esphome/components/bl0942/bl0942.cpp | 2 +- .../components/dallas_temp/dallas_temp.cpp | 4 ++-- .../ethernet/ethernet_component.cpp | 2 +- esphome/components/gdk101/gdk101.cpp | 8 ++++---- .../gt911/touchscreen/gt911_touchscreen.cpp | 2 +- .../honeywellabp2_i2c/honeywellabp2.cpp | 8 ++++---- .../m5stack_8angle_binary_sensor.cpp | 2 +- .../sensor/m5stack_8angle_sensor.cpp | 2 +- esphome/components/max17043/max17043.cpp | 4 ++-- .../mcp23x08_base/mcp23x08_base.cpp | 2 +- .../mcp23x17_base/mcp23x17_base.cpp | 4 ++-- .../components/pi4ioe5v6408/pi4ioe5v6408.cpp | 14 +++++++------- esphome/components/sgp4x/sgp4x.cpp | 4 ++-- esphome/components/sht4x/sht4x.cpp | 2 +- .../components/sound_level/sound_level.cpp | 2 +- esphome/components/tca9555/tca9555.cpp | 10 +++++----- esphome/components/tmp1075/tmp1075.cpp | 2 +- esphome/components/udp/udp_component.cpp | 6 +++--- esphome/components/usb_uart/usb_uart.cpp | 2 +- .../components/wake_on_lan/wake_on_lan.cpp | 4 ++-- esphome/components/wifi/wifi_component.cpp | 6 +++--- esphome/core/component.cpp | 19 +++++++++++++++---- esphome/core/component.h | 4 ++++ 26 files changed, 70 insertions(+), 55 deletions(-) diff --git a/esphome/components/absolute_humidity/absolute_humidity.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp index 7ba3c5a1ab..2c5603ee3d 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.cpp +++ b/esphome/components/absolute_humidity/absolute_humidity.cpp @@ -64,7 +64,7 @@ void AbsoluteHumidityComponent::loop() { ESP_LOGW(TAG, "No valid state from humidity sensor!"); } this->publish_state(NAN); - this->status_set_warning("Unable to calculate absolute humidity."); + this->status_set_warning(LOG_STR("Unable to calculate absolute humidity.")); return; } diff --git a/esphome/components/aht10/aht10.cpp b/esphome/components/aht10/aht10.cpp index 6202a27c42..53c712a7a7 100644 --- a/esphome/components/aht10/aht10.cpp +++ b/esphome/components/aht10/aht10.cpp @@ -96,7 +96,7 @@ void AHT10Component::read_data_() { ESP_LOGD(TAG, "Read attempt %d at %ums", this->read_count_, (unsigned) (millis() - this->start_time_)); } if (this->read(data, 6) != i2c::ERROR_OK) { - this->status_set_warning("Read failed, will retry"); + this->status_set_warning(LOG_STR("Read failed, will retry")); this->restart_read_(); return; } @@ -113,7 +113,7 @@ void AHT10Component::read_data_() { } else { ESP_LOGD(TAG, "Invalid humidity, retrying"); if (this->write(AHT10_MEASURE_CMD, sizeof(AHT10_MEASURE_CMD)) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } this->restart_read_(); return; @@ -144,7 +144,7 @@ void AHT10Component::update() { return; this->start_time_ = millis(); if (this->write(AHT10_MEASURE_CMD, sizeof(AHT10_MEASURE_CMD)) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->restart_read_(); diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp index 6304516164..ab3f1dad4f 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp @@ -12,7 +12,7 @@ constexpr static const uint8_t AXS_READ_TOUCHPAD[11] = {0xb5, 0xab, 0xa5, 0x5a, #define ERROR_CHECK(err) \ if ((err) != i2c::ERROR_OK) { \ - this->status_set_warning("Failed to communicate"); \ + this->status_set_warning(LOG_STR("Failed to communicate")); \ return; \ } diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 86eff57147..894fcbfbb7 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -149,7 +149,7 @@ void BL0942::setup() { this->write_reg_(BL0942_REG_USR_WRPROT, 0); if (this->read_reg_(BL0942_REG_MODE) != mode) - this->status_set_warning("BL0942 setup failed!"); + this->status_set_warning(LOG_STR("BL0942 setup failed!")); this->flush(); } diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 5cd6063893..a518c96489 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -64,7 +64,7 @@ bool DallasTemperatureSensor::read_scratch_pad_() { } } else { ESP_LOGW(TAG, "'%s' - reading scratch pad failed bus reset", this->get_name().c_str()); - this->status_set_warning("bus reset failed"); + this->status_set_warning(LOG_STR("bus reset failed")); } return success; } @@ -124,7 +124,7 @@ bool DallasTemperatureSensor::check_scratch_pad_() { crc8(this->scratch_pad_, 8)); #endif if (!chksum_validity) { - this->status_set_warning("scratch pad checksum invalid"); + this->status_set_warning(LOG_STR("scratch pad checksum invalid")); ESP_LOGD(TAG, "Scratch pad: %02X.%02X.%02X.%02X.%02X.%02X.%02X.%02X.%02X (%02X)", this->scratch_pad_[0], this->scratch_pad_[1], this->scratch_pad_[2], this->scratch_pad_[3], this->scratch_pad_[4], this->scratch_pad_[5], this->scratch_pad_[6], this->scratch_pad_[7], this->scratch_pad_[8], diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 87913488da..844a30bd8b 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -492,7 +492,7 @@ void EthernetComponent::start_connect_() { global_eth_component->ipv6_count_ = 0; #endif /* USE_NETWORK_IPV6 */ this->connect_begin_ = millis(); - this->status_set_warning("waiting for IP configuration"); + this->status_set_warning(LOG_STR("waiting for IP configuration")); esp_err_t err; err = esp_netif_set_hostname(this->eth_netif_, App.get_name().c_str()); diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index 096b06917a..4c156ab24b 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -11,22 +11,22 @@ static const uint8_t NUMBER_OF_READ_RETRIES = 5; void GDK101Component::update() { uint8_t data[2]; if (!this->read_dose_1m_(data)) { - this->status_set_warning("Failed to read dose 1m"); + this->status_set_warning(LOG_STR("Failed to read dose 1m")); return; } if (!this->read_dose_10m_(data)) { - this->status_set_warning("Failed to read dose 10m"); + this->status_set_warning(LOG_STR("Failed to read dose 10m")); return; } if (!this->read_status_(data)) { - this->status_set_warning("Failed to read status"); + this->status_set_warning(LOG_STR("Failed to read status")); return; } if (!this->read_measurement_duration_(data)) { - this->status_set_warning("Failed to read measurement duration"); + this->status_set_warning(LOG_STR("Failed to read measurement duration")); return; } this->status_clear_warning(); diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 07218843dd..4810867d4b 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -20,7 +20,7 @@ static const size_t MAX_BUTTONS = 4; // max number of buttons scanned #define ERROR_CHECK(err) \ if ((err) != i2c::ERROR_OK) { \ - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); \ + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); \ return; \ } diff --git a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp index 11f5dbc314..f173a1afbd 100644 --- a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp +++ b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp @@ -15,7 +15,7 @@ static const char *const TAG = "honeywellabp2"; void HONEYWELLABP2Sensor::read_sensor_data() { if (this->read(raw_data_, 7) != i2c::ERROR_OK) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning("couldn't read sensor data"); + this->status_set_warning(LOG_STR("couldn't read sensor data")); return; } float press_counts = encode_uint24(raw_data_[1], raw_data_[2], raw_data_[3]); // calculate digital pressure counts @@ -31,7 +31,7 @@ void HONEYWELLABP2Sensor::read_sensor_data() { void HONEYWELLABP2Sensor::start_measurement() { if (this->write(i2c_cmd_, 3) != i2c::ERROR_OK) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning("couldn't start measurement"); + this->status_set_warning(LOG_STR("couldn't start measurement")); return; } this->measurement_running_ = true; @@ -40,7 +40,7 @@ void HONEYWELLABP2Sensor::start_measurement() { bool HONEYWELLABP2Sensor::is_measurement_ready() { if (this->read(raw_data_, 1) != i2c::ERROR_OK) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning("couldn't check measurement"); + this->status_set_warning(LOG_STR("couldn't check measurement")); return false; } if ((raw_data_[0] & (0x1 << STATUS_BIT_BUSY)) > 0) { @@ -53,7 +53,7 @@ bool HONEYWELLABP2Sensor::is_measurement_ready() { void HONEYWELLABP2Sensor::measurement_timeout() { ESP_LOGE(TAG, "Timeout!"); this->measurement_running_ = false; - this->status_set_warning("measurement timed out"); + this->status_set_warning(LOG_STR("measurement timed out")); } float HONEYWELLABP2Sensor::get_pressure() { return this->last_pressure_; } diff --git a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp index 2f68d9f254..3eeba4a644 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp +++ b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp @@ -6,7 +6,7 @@ namespace m5stack_8angle { void M5Stack8AngleSwitchBinarySensor::update() { int8_t out = this->parent_->read_switch(); if (out == -1) { - this->status_set_warning("Could not read binary sensor state from M5Stack 8Angle."); + this->status_set_warning(LOG_STR("Could not read binary sensor state from M5Stack 8Angle.")); return; } this->publish_state(out != 0); diff --git a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp index 5e034f1dd3..d22b345141 100644 --- a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp +++ b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp @@ -7,7 +7,7 @@ void M5Stack8AngleKnobSensor::update() { if (this->parent_ != nullptr) { int32_t raw_pos = this->parent_->read_knob_pos_raw(this->channel_, this->bits_); if (raw_pos == -1) { - this->status_set_warning("Could not read knob position from M5Stack 8Angle."); + this->status_set_warning(LOG_STR("Could not read knob position from M5Stack 8Angle.")); return; } if (this->raw_) { diff --git a/esphome/components/max17043/max17043.cpp b/esphome/components/max17043/max17043.cpp index 8f486de6b7..f605fb1324 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -22,7 +22,7 @@ void MAX17043Component::update() { if (this->voltage_sensor_ != nullptr) { if (!this->read_byte_16(MAX17043_VCELL, &raw_voltage)) { - this->status_set_warning("Unable to read MAX17043_VCELL"); + this->status_set_warning(LOG_STR("Unable to read MAX17043_VCELL")); } else { float voltage = (1.25 * (float) (raw_voltage >> 4)) / 1000.0; this->voltage_sensor_->publish_state(voltage); @@ -31,7 +31,7 @@ void MAX17043Component::update() { } if (this->battery_remaining_sensor_ != nullptr) { if (!this->read_byte_16(MAX17043_SOC, &raw_percent)) { - this->status_set_warning("Unable to read MAX17043_SOC"); + this->status_set_warning(LOG_STR("Unable to read MAX17043_SOC")); } else { float percent = (float) ((raw_percent >> 8) + 0.003906f * (raw_percent & 0x00ff)); this->battery_remaining_sensor_->publish_state(percent); diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.cpp b/esphome/components/mcp23x08_base/mcp23x08_base.cpp index e4fb51174b..1593c376cd 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.cpp +++ b/esphome/components/mcp23x08_base/mcp23x08_base.cpp @@ -8,7 +8,7 @@ static const char *const TAG = "mcp23x08_base"; bool MCP23X08Base::digital_read_hw(uint8_t pin) { if (!this->read_reg(mcp23x08_base::MCP23X08_GPIO, &this->input_mask_)) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } return true; diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index 020b8a5ddf..b1f1f260b4 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -11,13 +11,13 @@ bool MCP23X17Base::digital_read_hw(uint8_t pin) { uint8_t data; if (pin < 8) { if (!this->read_reg(mcp23x17_base::MCP23X17_GPIOA, &data)) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } this->input_mask_ = encode_uint16(this->input_mask_ >> 8, data); } else { if (!this->read_reg(mcp23x17_base::MCP23X17_GPIOB, &data)) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } this->input_mask_ = encode_uint16(data, this->input_mask_ & 0xFF); diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 18acfda934..517ca833e6 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -68,7 +68,7 @@ bool PI4IOE5V6408Component::read_gpio_outputs_() { uint8_t data; if (!this->read_byte(PI4IOE5V6408_REGISTER_OUT_SET, &data)) { - this->status_set_warning("Failed to read output register"); + this->status_set_warning(LOG_STR("Failed to read output register")); return false; } this->output_mask_ = data; @@ -82,7 +82,7 @@ bool PI4IOE5V6408Component::read_gpio_modes_() { uint8_t data; if (!this->read_byte(PI4IOE5V6408_REGISTER_IO_DIR, &data)) { - this->status_set_warning("Failed to read GPIO modes"); + this->status_set_warning(LOG_STR("Failed to read GPIO modes")); return false; } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -99,7 +99,7 @@ bool PI4IOE5V6408Component::digital_read_hw(uint8_t pin) { uint8_t data; if (!this->read_byte(PI4IOE5V6408_REGISTER_IN_STATE, &data)) { - this->status_set_warning("Failed to read GPIO state"); + this->status_set_warning(LOG_STR("Failed to read GPIO state")); return false; } this->input_mask_ = data; @@ -117,7 +117,7 @@ void PI4IOE5V6408Component::digital_write_hw(uint8_t pin, bool value) { this->output_mask_ &= ~(1 << pin); } if (!this->write_byte(PI4IOE5V6408_REGISTER_OUT_SET, this->output_mask_)) { - this->status_set_warning("Failed to write output register"); + this->status_set_warning(LOG_STR("Failed to write output register")); return; } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -131,15 +131,15 @@ bool PI4IOE5V6408Component::write_gpio_modes_() { return false; if (!this->write_byte(PI4IOE5V6408_REGISTER_IO_DIR, this->mode_mask_)) { - this->status_set_warning("Failed to write GPIO modes"); + this->status_set_warning(LOG_STR("Failed to write GPIO modes")); return false; } if (!this->write_byte(PI4IOE5V6408_REGISTER_PULL_SELECT, this->pull_up_down_mask_)) { - this->status_set_warning("Failed to write GPIO pullup/pulldown"); + this->status_set_warning(LOG_STR("Failed to write GPIO pullup/pulldown")); return false; } if (!this->write_byte(PI4IOE5V6408_REGISTER_PULL_ENABLE, this->pull_enable_mask_)) { - this->status_set_warning("Failed to write GPIO pull enable"); + this->status_set_warning(LOG_STR("Failed to write GPIO pull enable")); return false; } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index da52993a87..99d88006f7 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -211,7 +211,7 @@ void SGP4xComponent::measure_raw_() { if (!this->write_command(command, data, 2)) { ESP_LOGD(TAG, "write error (%d)", this->last_error_); - this->status_set_warning("measurement request failed"); + this->status_set_warning(LOG_STR("measurement request failed")); return; } @@ -220,7 +220,7 @@ void SGP4xComponent::measure_raw_() { raw_data[1] = 0; if (!this->read_data(raw_data, response_words)) { ESP_LOGD(TAG, "read error (%d)", this->last_error_); - this->status_set_warning("measurement read failed"); + this->status_set_warning(LOG_STR("measurement read failed")); this->voc_index_ = this->nox_index_ = UINT16_MAX; return; } diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 637c8c1a9d..62b8717ded 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -65,7 +65,7 @@ void SHT4XComponent::update() { // Send command if (!this->write_command(MEASURECOMMANDS[this->precision_])) { // Warning will be printed only if warning status is not set yet - this->status_set_warning("Failed to send measurement command"); + this->status_set_warning(LOG_STR("Failed to send measurement command")); return; } diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index decf630aba..db6b168bbc 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -56,7 +56,7 @@ void SoundLevelComponent::loop() { } } else { if (!this->status_has_warning()) { - this->status_set_warning("Microphone isn't running, can't compute statistics"); + this->status_set_warning(LOG_STR("Microphone isn't running, can't compute statistics")); // Deallocate buffers, if necessary this->stop_(); diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index b4a04d5b0b..c3449ce254 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -50,7 +50,7 @@ bool TCA9555Component::read_gpio_outputs_() { return false; uint8_t data[2]; if (!this->read_bytes(TCA9555_OUTPUT_PORT_REGISTER_0, data, 2)) { - this->status_set_warning("Failed to read output register"); + this->status_set_warning(LOG_STR("Failed to read output register")); return false; } this->output_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); @@ -64,7 +64,7 @@ bool TCA9555Component::read_gpio_modes_() { uint8_t data[2]; bool success = this->read_bytes(TCA9555_CONFIGURATION_PORT_0, data, 2); if (!success) { - this->status_set_warning("Failed to read mode register"); + this->status_set_warning(LOG_STR("Failed to read mode register")); return false; } this->mode_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); @@ -79,7 +79,7 @@ bool TCA9555Component::digital_read_hw(uint8_t pin) { uint8_t bank_number = pin < 8 ? 0 : 1; uint8_t register_to_read = bank_number ? TCA9555_INPUT_PORT_REGISTER_1 : TCA9555_INPUT_PORT_REGISTER_0; if (!this->read_bytes(register_to_read, &data, 1)) { - this->status_set_warning("Failed to read input register"); + this->status_set_warning(LOG_STR("Failed to read input register")); return false; } uint8_t second_half = this->input_mask_ >> 8; @@ -108,7 +108,7 @@ void TCA9555Component::digital_write_hw(uint8_t pin, bool value) { data[0] = this->output_mask_; data[1] = this->output_mask_ >> 8; if (!this->write_bytes(TCA9555_OUTPUT_PORT_REGISTER_0, data, 2)) { - this->status_set_warning("Failed to write output register"); + this->status_set_warning(LOG_STR("Failed to write output register")); return; } @@ -123,7 +123,7 @@ bool TCA9555Component::write_gpio_modes_() { data[0] = this->mode_mask_; data[1] = this->mode_mask_ >> 8; if (!this->write_bytes(TCA9555_CONFIGURATION_PORT_0, data, 2)) { - this->status_set_warning("Failed to write mode register"); + this->status_set_warning(LOG_STR("Failed to write mode register")); return false; } this->status_clear_warning(); diff --git a/esphome/components/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 831f905bd2..1d9b384c66 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -32,7 +32,7 @@ void TMP1075Sensor::update() { uint16_t regvalue; if (!read_byte_16(REG_TEMP, ®value)) { ESP_LOGW(TAG, "'%s' - unable to read temperature register", this->name_.c_str()); - this->status_set_warning("can't read"); + this->status_set_warning(LOG_STR("can't read")); return; } this->status_clear_warning(); diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 62a1189355..8a9ce612b4 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -28,12 +28,12 @@ void UDPComponent::setup() { int enable = 1; auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { - this->status_set_warning("Socket unable to set reuseaddr"); + this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); // we can still continue } err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); if (err != 0) { - this->status_set_warning("Socket unable to set broadcast"); + this->status_set_warning(LOG_STR("Socket unable to set broadcast")); } } // create listening socket if we either want to subscribe to providers, or need to listen @@ -55,7 +55,7 @@ void UDPComponent::setup() { int enable = 1; err = this->listen_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable)); if (err != 0) { - this->status_set_warning("Socket unable to set reuseaddr"); + this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); // we can still continue } struct sockaddr_in server {}; diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 934306f480..bf1c9086f1 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -266,7 +266,7 @@ void USBUartTypeCdcAcm::on_connected() { for (auto *channel : this->channels_) { if (i == cdc_devs.size()) { ESP_LOGE(TAG, "No configuration found for channel %d", channel->index_); - this->status_set_warning("No configuration found for channel"); + this->status_set_warning(LOG_STR("No configuration found for channel")); break; } channel->cdc_dev_ = cdc_devs[i++]; diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index bed098755a..adf5a080e5 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -74,12 +74,12 @@ void WakeOnLanButton::setup() { int enable = 1; auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { - this->status_set_warning("Socket unable to set reuseaddr"); + this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); // we can still continue } err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); if (err != 0) { - this->status_set_warning("Socket unable to set broadcast"); + this->status_set_warning(LOG_STR("Socket unable to set broadcast")); } #endif } diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index d16c94fa13..e57bf25b8c 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -148,7 +148,7 @@ void WiFiComponent::loop() { switch (this->state_) { case WIFI_COMPONENT_STATE_COOLDOWN: { - this->status_set_warning("waiting to reconnect"); + this->status_set_warning(LOG_STR("waiting to reconnect")); if (millis() - this->action_started_ > 5000) { if (this->fast_connect_ || this->retry_hidden_) { if (!this->selected_ap_.get_bssid().has_value()) @@ -161,13 +161,13 @@ void WiFiComponent::loop() { break; } case WIFI_COMPONENT_STATE_STA_SCANNING: { - this->status_set_warning("scanning for networks"); + this->status_set_warning(LOG_STR("scanning for networks")); this->check_scanning_finished(); break; } case WIFI_COMPONENT_STATE_STA_CONNECTING: case WIFI_COMPONENT_STATE_STA_CONNECTING_2: { - this->status_set_warning("associating to network"); + this->status_set_warning(LOG_STR("associating to network")); this->check_connecting_finished(); break; } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 40cda17ca3..e30dab2508 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -16,7 +16,6 @@ namespace esphome { static const char *const TAG = "component"; -static const char *const UNSPECIFIED_MESSAGE = "unspecified"; // Global vectors for component data that doesn't belong in every instance. // Using vector instead of unordered_map for both because: @@ -143,7 +142,7 @@ void Component::call_dump_config() { } } ESP_LOGE(TAG, " %s is marked FAILED: %s", this->get_component_source(), - error_msg ? error_msg : UNSPECIFIED_MESSAGE); + error_msg ? error_msg : LOG_STR_LITERAL("unspecified")); } } @@ -280,20 +279,32 @@ bool Component::is_ready() const { bool Component::can_proceed() { return true; } bool Component::status_has_warning() const { return this->component_state_ & STATUS_LED_WARNING; } bool Component::status_has_error() const { return this->component_state_ & STATUS_LED_ERROR; } + void Component::status_set_warning(const char *message) { // Don't spam the log. This risks missing different warning messages though. if ((this->component_state_ & STATUS_LED_WARNING) != 0) return; this->component_state_ |= STATUS_LED_WARNING; App.app_state_ |= STATUS_LED_WARNING; - ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), message ? message : UNSPECIFIED_MESSAGE); + ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), + message ? message : LOG_STR_LITERAL("unspecified")); +} +void Component::status_set_warning(const LogString *message) { + // Don't spam the log. This risks missing different warning messages though. + if ((this->component_state_ & STATUS_LED_WARNING) != 0) + return; + this->component_state_ |= STATUS_LED_WARNING; + App.app_state_ |= STATUS_LED_WARNING; + ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), + message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } void Component::status_set_error(const char *message) { if ((this->component_state_ & STATUS_LED_ERROR) != 0) return; this->component_state_ |= STATUS_LED_ERROR; App.app_state_ |= STATUS_LED_ERROR; - ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), message ? message : UNSPECIFIED_MESSAGE); + ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), + message ? message : LOG_STR_LITERAL("unspecified")); if (message != nullptr) { // Lazy allocate the error messages vector if needed if (!component_error_messages) { diff --git a/esphome/core/component.h b/esphome/core/component.h index 096c6f9c69..a363fceb85 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -9,6 +9,9 @@ namespace esphome { +// Forward declaration for LogString +struct LogString; + /** Default setup priorities for components of different types. * * Components should return one of these setup priorities in get_setup_priority. @@ -203,6 +206,7 @@ class Component { bool status_has_error() const; void status_set_warning(const char *message = nullptr); + void status_set_warning(const LogString *message); void status_set_error(const char *message = nullptr); From c198ef6b0747ae556502377ad13f1bef69c70fdd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 08:21:02 -0500 Subject: [PATCH 0962/1626] [api] Store plaintext error message in PROGMEM on ESP8266 --- .../api/api_frame_helper_plaintext.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index fdaacbd94e..59daf49bfc 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -10,6 +10,10 @@ #include #include +#ifdef USE_ESP8266 +#include +#endif + namespace esphome::api { static const char *const TAG = "api.plaintext"; @@ -197,9 +201,17 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { // We must send at least 3 bytes to be read, so we add // a message after the indicator byte to ensures its long // enough and can aid in debugging. - const char msg[] = "\x00" - "Bad indicator byte"; +#ifdef USE_ESP8266 + static const char msg_progmem[] PROGMEM = "\x00" + "Bad indicator byte"; + char msg[19]; + memcpy_P(msg, msg_progmem, 19); iov[0].iov_base = (void *) msg; +#else + static const char msg[] = "\x00" + "Bad indicator byte"; + iov[0].iov_base = (void *) msg; +#endif iov[0].iov_len = 19; this->write_raw_(iov, 1, 19); } From 28233180c967e84f1efb4188ed3010fe730732dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 08:27:25 -0500 Subject: [PATCH 0963/1626] tidy --- esphome/components/api/api_frame_helper_plaintext.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 59daf49bfc..ceb573d562 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -202,15 +202,15 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { // a message after the indicator byte to ensures its long // enough and can aid in debugging. #ifdef USE_ESP8266 - static const char msg_progmem[] PROGMEM = "\x00" + static const char MSG_PROGMEM[] PROGMEM = "\x00" "Bad indicator byte"; char msg[19]; - memcpy_P(msg, msg_progmem, 19); + memcpy_P(msg, MSG_PROGMEM, 19); iov[0].iov_base = (void *) msg; #else - static const char msg[] = "\x00" + static const char MSG[] = "\x00" "Bad indicator byte"; - iov[0].iov_base = (void *) msg; + iov[0].iov_base = (void *) MSG; #endif iov[0].iov_len = 19; this->write_raw_(iov, 1, 19); From 960a65e2f3a647b279bb1153257f6266824b3067 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 08:56:44 -0500 Subject: [PATCH 0964/1626] [core] Store BASE64 chars in flash memory array --- esphome/core/helpers.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 43d6f1153c..6880f84d9d 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -373,10 +373,11 @@ int8_t step_to_accuracy_decimals(float step) { return str.length() - dot_pos - 1; } -// Use C-style string constant to store in ROM instead of RAM (saves 24 bytes) -static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "abcdefghijklmnopqrstuvwxyz" - "0123456789+/"; +// Store BASE64 characters as array - automatically placed in flash/ROM on embedded platforms +static const char BASE64_CHARS[] = {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/'}; // Helper function to find the index of a base64 character in the lookup table. // Returns the character's position (0-63) if found, or 0 if not found. @@ -386,8 +387,8 @@ static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" // stops processing at the first invalid character due to the is_base64() check in its // while loop condition, making this edge case harmless in practice. static inline uint8_t base64_find_char(char c) { - const char *pos = strchr(BASE64_CHARS, c); - return pos ? (pos - BASE64_CHARS) : 0; + const void *ptr = memchr(BASE64_CHARS, c, sizeof(BASE64_CHARS)); + return ptr ? (static_cast(ptr) - BASE64_CHARS) : 0; } static inline bool is_base64(char c) { return (isalnum(c) || (c == '+') || (c == '/')); } From 0f2a8300b290b5513336d26068e7964fa35de012 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 09:32:02 -0500 Subject: [PATCH 0965/1626] [core] Convert LOG_UPDATE_INTERVAL macro to function to reduce flash usage --- esphome/components/bedjet/bedjet_hub.cpp | 2 +- esphome/components/ccs811/ccs811.cpp | 2 +- .../grove_gas_mc_v2/grove_gas_mc_v2.cpp | 2 +- esphome/components/hlw8012/hlw8012.cpp | 2 +- esphome/components/pulse_width/pulse_width.cpp | 2 +- esphome/components/ufire_ec/ufire_ec.cpp | 2 +- esphome/components/ufire_ise/ufire_ise.cpp | 2 +- .../waveshare_epaper/waveshare_213v3.cpp | 2 +- esphome/core/component.cpp | 12 ++++++++++++ esphome/core/component.h | 15 +++++++-------- 10 files changed, 27 insertions(+), 16 deletions(-) diff --git a/esphome/components/bedjet/bedjet_hub.cpp b/esphome/components/bedjet/bedjet_hub.cpp index 007ca1ca7d..38fcf29b3b 100644 --- a/esphome/components/bedjet/bedjet_hub.cpp +++ b/esphome/components/bedjet/bedjet_hub.cpp @@ -493,7 +493,7 @@ void BedJetHub::dump_config() { " ble_client.app_id: %d\n" " ble_client.conn_id: %d", this->get_name().c_str(), this->parent()->app_id, this->parent()->get_conn_id()); - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Child components (%d):", this->children_.size()); for (auto *child : this->children_) { ESP_LOGCONFIG(TAG, " - %s", child->describe().c_str()); diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index 2617d7577a..40c5318339 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -152,7 +152,7 @@ void CCS811Component::send_env_data_() { void CCS811Component::dump_config() { ESP_LOGCONFIG(TAG, "CCS811"); LOG_I2C_DEVICE(this) - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "CO2 Sensor", this->co2_); LOG_SENSOR(" ", "TVOC Sensor", this->tvoc_); LOG_TEXT_SENSOR(" ", "Firmware Version Sensor", this->version_) diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp index 52ec8433a2..b0f3429314 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp @@ -57,7 +57,7 @@ void GroveGasMultichannelV2Component::update() { void GroveGasMultichannelV2Component::dump_config() { ESP_LOGCONFIG(TAG, "Grove Multichannel Gas Sensor V2"); LOG_I2C_DEVICE(this) - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Nitrogen Dioxide", this->nitrogen_dioxide_sensor_); LOG_SENSOR(" ", "Ethanol", this->ethanol_sensor_); LOG_SENSOR(" ", "Carbon Monoxide", this->carbon_monoxide_sensor_); diff --git a/esphome/components/hlw8012/hlw8012.cpp b/esphome/components/hlw8012/hlw8012.cpp index f293185cce..73696bd2a5 100644 --- a/esphome/components/hlw8012/hlw8012.cpp +++ b/esphome/components/hlw8012/hlw8012.cpp @@ -42,7 +42,7 @@ void HLW8012Component::dump_config() { " Current resistor: %.1f mΩ\n" " Voltage Divider: %.1f", this->change_mode_every_, this->current_resistor_ * 1000.0f, this->voltage_divider_); - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); LOG_SENSOR(" ", "Current", this->current_sensor_); LOG_SENSOR(" ", "Power", this->power_sensor_); diff --git a/esphome/components/pulse_width/pulse_width.cpp b/esphome/components/pulse_width/pulse_width.cpp index c086ceaa23..d083d48b32 100644 --- a/esphome/components/pulse_width/pulse_width.cpp +++ b/esphome/components/pulse_width/pulse_width.cpp @@ -18,7 +18,7 @@ void IRAM_ATTR PulseWidthSensorStore::gpio_intr(PulseWidthSensorStore *arg) { void PulseWidthSensor::dump_config() { LOG_SENSOR("", "Pulse Width", this); - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); LOG_PIN(" Pin: ", this->pin_); } void PulseWidthSensor::update() { diff --git a/esphome/components/ufire_ec/ufire_ec.cpp b/esphome/components/ufire_ec/ufire_ec.cpp index 9e0055a2cc..0a57ecc67b 100644 --- a/esphome/components/ufire_ec/ufire_ec.cpp +++ b/esphome/components/ufire_ec/ufire_ec.cpp @@ -104,7 +104,7 @@ void UFireECComponent::write_data_(uint8_t reg, float data) { void UFireECComponent::dump_config() { ESP_LOGCONFIG(TAG, "uFire-EC"); LOG_I2C_DEVICE(this) - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "EC Sensor", this->ec_sensor_); LOG_SENSOR(" ", "Temperature Sensor", this->temperature_sensor_); LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_); diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index 9e0e7e265d..486a506391 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -141,7 +141,7 @@ void UFireISEComponent::write_data_(uint8_t reg, float data) { void UFireISEComponent::dump_config() { ESP_LOGCONFIG(TAG, "uFire-ISE"); LOG_I2C_DEVICE(this) - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "PH Sensor", this->ph_sensor_); LOG_SENSOR(" ", "Temperature Sensor", this->temperature_sensor_); LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_); diff --git a/esphome/components/waveshare_epaper/waveshare_213v3.cpp b/esphome/components/waveshare_epaper/waveshare_213v3.cpp index 316cd80ccd..068cb91d31 100644 --- a/esphome/components/waveshare_epaper/waveshare_213v3.cpp +++ b/esphome/components/waveshare_epaper/waveshare_213v3.cpp @@ -181,7 +181,7 @@ void WaveshareEPaper2P13InV3::dump_config() { LOG_PIN(" Reset Pin: ", this->reset_pin_) LOG_PIN(" DC Pin: ", this->dc_pin_) LOG_PIN(" Busy Pin: ", this->busy_pin_) - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); } void WaveshareEPaper2P13InV3::set_full_update_every(uint32_t full_update_every) { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index e30dab2508..44a86a4aa5 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -342,6 +342,18 @@ void Component::status_momentary_error(const std::string &name, uint32_t length) this->set_timeout(name, length, [this]() { this->status_clear_error(); }); } void Component::dump_config() {} + +// Function implementation of LOG_UPDATE_INTERVAL macro to reduce code size +void log_update_interval(const char *tag, PollingComponent *component) { + uint32_t update_interval = component->get_update_interval(); + if (update_interval == SCHEDULER_DONT_RUN) { + ESP_LOGCONFIG(tag, " Update Interval: never"); + } else if (update_interval < 100) { + ESP_LOGCONFIG(tag, " Update Interval: %.3fs", update_interval / 1000.0f); + } else { + ESP_LOGCONFIG(tag, " Update Interval: %.1fs", update_interval / 1000.0f); + } +} float Component::get_actual_setup_priority() const { // Check if there's an override in the global vector if (setup_priority_overrides) { diff --git a/esphome/core/component.h b/esphome/core/component.h index a363fceb85..9dfcbb92fa 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -47,14 +47,13 @@ extern const float LATE; static const uint32_t SCHEDULER_DONT_RUN = 4294967295UL; -#define LOG_UPDATE_INTERVAL(this) \ - if (this->get_update_interval() == SCHEDULER_DONT_RUN) { \ - ESP_LOGCONFIG(TAG, " Update Interval: never"); \ - } else if (this->get_update_interval() < 100) { \ - ESP_LOGCONFIG(TAG, " Update Interval: %.3fs", this->get_update_interval() / 1000.0f); \ - } else { \ - ESP_LOGCONFIG(TAG, " Update Interval: %.1fs", this->get_update_interval() / 1000.0f); \ - } +// Forward declaration +class PollingComponent; + +// Function declaration for LOG_UPDATE_INTERVAL +void log_update_interval(const char *tag, PollingComponent *component); + +#define LOG_UPDATE_INTERVAL(this) log_update_interval(TAG, this) extern const uint8_t COMPONENT_STATE_MASK; extern const uint8_t COMPONENT_STATE_CONSTRUCTION; From 7bd8b1d1370686a02621d42fa05bcea188208c5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 09:46:54 -0500 Subject: [PATCH 0966/1626] Update esphome/core/helpers.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/helpers.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 6880f84d9d..67a3101bde 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -374,10 +374,7 @@ int8_t step_to_accuracy_decimals(float step) { } // Store BASE64 characters as array - automatically placed in flash/ROM on embedded platforms -static const char BASE64_CHARS[] = {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', - 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', - 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', - 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/'}; +static const char BASE64_CHARS[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; // Helper function to find the index of a base64 character in the lookup table. // Returns the character's position (0-63) if found, or 0 if not found. From a8b8507ffcfd43ddeb09bd8f2b51daef94681290 Mon Sep 17 00:00:00 2001 From: davidmonro Date: Mon, 8 Sep 2025 06:06:10 +1000 Subject: [PATCH 0967/1626] Atm90e32/26 device class fixes (#10629) --- esphome/components/atm90e26/sensor.py | 2 ++ esphome/components/atm90e32/sensor.py | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/atm90e26/sensor.py b/esphome/components/atm90e26/sensor.py index 42ef259100..4522e94846 100644 --- a/esphome/components/atm90e26/sensor.py +++ b/esphome/components/atm90e26/sensor.py @@ -16,6 +16,7 @@ from esphome.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_VOLTAGE, ICON_CURRENT_AC, ICON_LIGHTBULB, @@ -78,6 +79,7 @@ CONFIG_SCHEMA = ( unit_of_measurement=UNIT_VOLT_AMPS_REACTIVE, icon=ICON_LIGHTBULB, accuracy_decimals=2, + device_class=DEVICE_CLASS_REACTIVE_POWER, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_POWER_FACTOR): sensor.sensor_schema( diff --git a/esphome/components/atm90e32/sensor.py b/esphome/components/atm90e32/sensor.py index 7cdbd69f56..a510095217 100644 --- a/esphome/components/atm90e32/sensor.py +++ b/esphome/components/atm90e32/sensor.py @@ -17,10 +17,12 @@ from esphome.const import ( CONF_REACTIVE_POWER, CONF_REVERSE_ACTIVE_ENERGY, CONF_VOLTAGE, + DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, ENTITY_CATEGORY_DIAGNOSTIC, @@ -100,13 +102,13 @@ ATM90E32_PHASE_SCHEMA = cv.Schema( unit_of_measurement=UNIT_VOLT_AMPS_REACTIVE, icon=ICON_LIGHTBULB, accuracy_decimals=2, - device_class=DEVICE_CLASS_POWER, + device_class=DEVICE_CLASS_REACTIVE_POWER, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_APPARENT_POWER): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS, accuracy_decimals=2, - device_class=DEVICE_CLASS_POWER, + device_class=DEVICE_CLASS_APPARENT_POWER, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_POWER_FACTOR): sensor.sensor_schema( From 0c737fc4df3af24d985dcea10e5ea7d3e250f806 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 16:09:15 -0500 Subject: [PATCH 0968/1626] [core] Convert LOG_UPDATE_INTERVAL macro to function to reduce flash usage (#10636) --- esphome/components/bedjet/bedjet_hub.cpp | 2 +- esphome/components/ccs811/ccs811.cpp | 2 +- .../grove_gas_mc_v2/grove_gas_mc_v2.cpp | 2 +- esphome/components/hlw8012/hlw8012.cpp | 2 +- esphome/components/pulse_width/pulse_width.cpp | 2 +- esphome/components/ufire_ec/ufire_ec.cpp | 2 +- esphome/components/ufire_ise/ufire_ise.cpp | 2 +- .../waveshare_epaper/waveshare_213v3.cpp | 2 +- esphome/core/component.cpp | 12 ++++++++++++ esphome/core/component.h | 15 +++++++-------- 10 files changed, 27 insertions(+), 16 deletions(-) diff --git a/esphome/components/bedjet/bedjet_hub.cpp b/esphome/components/bedjet/bedjet_hub.cpp index 007ca1ca7d..38fcf29b3b 100644 --- a/esphome/components/bedjet/bedjet_hub.cpp +++ b/esphome/components/bedjet/bedjet_hub.cpp @@ -493,7 +493,7 @@ void BedJetHub::dump_config() { " ble_client.app_id: %d\n" " ble_client.conn_id: %d", this->get_name().c_str(), this->parent()->app_id, this->parent()->get_conn_id()); - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Child components (%d):", this->children_.size()); for (auto *child : this->children_) { ESP_LOGCONFIG(TAG, " - %s", child->describe().c_str()); diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index 2617d7577a..40c5318339 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -152,7 +152,7 @@ void CCS811Component::send_env_data_() { void CCS811Component::dump_config() { ESP_LOGCONFIG(TAG, "CCS811"); LOG_I2C_DEVICE(this) - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "CO2 Sensor", this->co2_); LOG_SENSOR(" ", "TVOC Sensor", this->tvoc_); LOG_TEXT_SENSOR(" ", "Firmware Version Sensor", this->version_) diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp index 52ec8433a2..b0f3429314 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp @@ -57,7 +57,7 @@ void GroveGasMultichannelV2Component::update() { void GroveGasMultichannelV2Component::dump_config() { ESP_LOGCONFIG(TAG, "Grove Multichannel Gas Sensor V2"); LOG_I2C_DEVICE(this) - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Nitrogen Dioxide", this->nitrogen_dioxide_sensor_); LOG_SENSOR(" ", "Ethanol", this->ethanol_sensor_); LOG_SENSOR(" ", "Carbon Monoxide", this->carbon_monoxide_sensor_); diff --git a/esphome/components/hlw8012/hlw8012.cpp b/esphome/components/hlw8012/hlw8012.cpp index f293185cce..73696bd2a5 100644 --- a/esphome/components/hlw8012/hlw8012.cpp +++ b/esphome/components/hlw8012/hlw8012.cpp @@ -42,7 +42,7 @@ void HLW8012Component::dump_config() { " Current resistor: %.1f mΩ\n" " Voltage Divider: %.1f", this->change_mode_every_, this->current_resistor_ * 1000.0f, this->voltage_divider_); - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); LOG_SENSOR(" ", "Current", this->current_sensor_); LOG_SENSOR(" ", "Power", this->power_sensor_); diff --git a/esphome/components/pulse_width/pulse_width.cpp b/esphome/components/pulse_width/pulse_width.cpp index c086ceaa23..d083d48b32 100644 --- a/esphome/components/pulse_width/pulse_width.cpp +++ b/esphome/components/pulse_width/pulse_width.cpp @@ -18,7 +18,7 @@ void IRAM_ATTR PulseWidthSensorStore::gpio_intr(PulseWidthSensorStore *arg) { void PulseWidthSensor::dump_config() { LOG_SENSOR("", "Pulse Width", this); - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); LOG_PIN(" Pin: ", this->pin_); } void PulseWidthSensor::update() { diff --git a/esphome/components/ufire_ec/ufire_ec.cpp b/esphome/components/ufire_ec/ufire_ec.cpp index 9e0055a2cc..0a57ecc67b 100644 --- a/esphome/components/ufire_ec/ufire_ec.cpp +++ b/esphome/components/ufire_ec/ufire_ec.cpp @@ -104,7 +104,7 @@ void UFireECComponent::write_data_(uint8_t reg, float data) { void UFireECComponent::dump_config() { ESP_LOGCONFIG(TAG, "uFire-EC"); LOG_I2C_DEVICE(this) - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "EC Sensor", this->ec_sensor_); LOG_SENSOR(" ", "Temperature Sensor", this->temperature_sensor_); LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_); diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index 9e0e7e265d..486a506391 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -141,7 +141,7 @@ void UFireISEComponent::write_data_(uint8_t reg, float data) { void UFireISEComponent::dump_config() { ESP_LOGCONFIG(TAG, "uFire-ISE"); LOG_I2C_DEVICE(this) - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "PH Sensor", this->ph_sensor_); LOG_SENSOR(" ", "Temperature Sensor", this->temperature_sensor_); LOG_SENSOR(" ", "Temperature Sensor external", this->temperature_sensor_external_); diff --git a/esphome/components/waveshare_epaper/waveshare_213v3.cpp b/esphome/components/waveshare_epaper/waveshare_213v3.cpp index 316cd80ccd..068cb91d31 100644 --- a/esphome/components/waveshare_epaper/waveshare_213v3.cpp +++ b/esphome/components/waveshare_epaper/waveshare_213v3.cpp @@ -181,7 +181,7 @@ void WaveshareEPaper2P13InV3::dump_config() { LOG_PIN(" Reset Pin: ", this->reset_pin_) LOG_PIN(" DC Pin: ", this->dc_pin_) LOG_PIN(" Busy Pin: ", this->busy_pin_) - LOG_UPDATE_INTERVAL(this) + LOG_UPDATE_INTERVAL(this); } void WaveshareEPaper2P13InV3::set_full_update_every(uint32_t full_update_every) { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index e30dab2508..44a86a4aa5 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -342,6 +342,18 @@ void Component::status_momentary_error(const std::string &name, uint32_t length) this->set_timeout(name, length, [this]() { this->status_clear_error(); }); } void Component::dump_config() {} + +// Function implementation of LOG_UPDATE_INTERVAL macro to reduce code size +void log_update_interval(const char *tag, PollingComponent *component) { + uint32_t update_interval = component->get_update_interval(); + if (update_interval == SCHEDULER_DONT_RUN) { + ESP_LOGCONFIG(tag, " Update Interval: never"); + } else if (update_interval < 100) { + ESP_LOGCONFIG(tag, " Update Interval: %.3fs", update_interval / 1000.0f); + } else { + ESP_LOGCONFIG(tag, " Update Interval: %.1fs", update_interval / 1000.0f); + } +} float Component::get_actual_setup_priority() const { // Check if there's an override in the global vector if (setup_priority_overrides) { diff --git a/esphome/core/component.h b/esphome/core/component.h index a363fceb85..9dfcbb92fa 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -47,14 +47,13 @@ extern const float LATE; static const uint32_t SCHEDULER_DONT_RUN = 4294967295UL; -#define LOG_UPDATE_INTERVAL(this) \ - if (this->get_update_interval() == SCHEDULER_DONT_RUN) { \ - ESP_LOGCONFIG(TAG, " Update Interval: never"); \ - } else if (this->get_update_interval() < 100) { \ - ESP_LOGCONFIG(TAG, " Update Interval: %.3fs", this->get_update_interval() / 1000.0f); \ - } else { \ - ESP_LOGCONFIG(TAG, " Update Interval: %.1fs", this->get_update_interval() / 1000.0f); \ - } +// Forward declaration +class PollingComponent; + +// Function declaration for LOG_UPDATE_INTERVAL +void log_update_interval(const char *tag, PollingComponent *component); + +#define LOG_UPDATE_INTERVAL(this) log_update_interval(TAG, this) extern const uint8_t COMPONENT_STATE_MASK; extern const uint8_t COMPONENT_STATE_CONSTRUCTION; From b25506b04508a118171db29daefe40a7b487f3b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 16:10:55 -0500 Subject: [PATCH 0969/1626] [core] Skip redundant process_to_add() call when no scheduler items added (#10630) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/scheduler.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a907b89b02..d0230d46fc 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -326,6 +326,9 @@ void HOT Scheduler::call(uint32_t now) { const auto now_64 = this->millis_64_(now); // 'now' from parameter - fresh from Application::loop() this->process_to_add(); + // Track if any items were added to to_add_ during this call (intervals or from callbacks) + bool has_added_items = false; + #ifdef ESPHOME_DEBUG_SCHEDULER static uint64_t last_print = 0; @@ -470,10 +473,14 @@ void HOT Scheduler::call(uint32_t now) { // since we have the lock held this->to_add_.push_back(std::move(item)); } + + has_added_items |= !this->to_add_.empty(); } } - this->process_to_add(); + if (has_added_items) { + this->process_to_add(); + } } void HOT Scheduler::process_to_add() { LockGuard guard{this->lock_}; From 7d65acf7dbfab741a617c49e9d37a5a1f5768c58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 16:48:09 -0500 Subject: [PATCH 0970/1626] use conditional --- .../components/gpio_expander/cached_gpio.h | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index d88b59bb9b..eeff98cb6e 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "esphome/core/hal.h" namespace esphome::gpio_expander { @@ -20,14 +21,18 @@ namespace esphome::gpio_expander { /// Examples: MCP23017 (2x8-bit banks), TCA9555 (2x8-bit banks) /// * uint16_t: For chips that read all pins at once (up to 16 pins) /// Examples: PCF8574/8575 (8/16 pins), PCA9554/9555 (8/16 pins) -/// N - Total number of pins (as uint8_t) -template class CachedGpioExpander { +/// N - Total number of pins (maximum 65535) +/// P - Type for pin number parameters (automatically selected based on N: +/// uint8_t for N<=256, uint16_t for N>256). Can be explicitly specified +/// if needed (e.g., for components like SN74HC165 with >256 pins) +template 256), uint16_t, uint8_t>::type> +class CachedGpioExpander { public: /// @brief Read the state of the given pin. This will invalidate the cache for the given pin number. /// @param pin Pin number to read /// @return Pin state - bool digital_read(uint8_t pin) { - const uint8_t bank = pin / BANK_SIZE; + bool digital_read(P pin) { + const P bank = pin / BANK_SIZE; const T pin_mask = (1 << (pin % BANK_SIZE)); // Check if specific pin cache is valid if (this->read_cache_valid_[bank] & pin_mask) { @@ -43,7 +48,7 @@ template class CachedGpioExpander { return this->digital_read_cache(pin); } - void digital_write(uint8_t pin, bool value) { this->digital_write_hw(pin, value); } + void digital_write(P pin, bool value) { this->digital_write_hw(pin, value); } protected: /// @brief Read GPIO bank from hardware into internal state @@ -51,23 +56,23 @@ template class CachedGpioExpander { /// @return true if read succeeded, false on communication error /// @note This does NOT return the pin state. It returns whether the read operation succeeded. /// The actual pin state should be returned by digital_read_cache(). - virtual bool digital_read_hw(uint8_t pin) = 0; + virtual bool digital_read_hw(P pin) = 0; /// @brief Get cached pin value from internal state /// @param pin Pin number to read /// @return Pin state (true = HIGH, false = LOW) - virtual bool digital_read_cache(uint8_t pin) = 0; + virtual bool digital_read_cache(P pin) = 0; /// @brief Write GPIO state to hardware /// @param pin Pin number to write /// @param value Pin state to write (true = HIGH, false = LOW) - virtual void digital_write_hw(uint8_t pin, bool value) = 0; + virtual void digital_write_hw(P pin, bool value) = 0; /// @brief Invalidate cache. This function should be called in component loop(). void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); } - static constexpr uint8_t BITS_PER_BYTE = 8; - static constexpr uint8_t BANK_SIZE = sizeof(T) * BITS_PER_BYTE; + static constexpr uint16_t BITS_PER_BYTE = 8; + static constexpr uint16_t BANK_SIZE = sizeof(T) * BITS_PER_BYTE; static constexpr size_t BANKS = N / BANK_SIZE; static constexpr size_t CACHE_SIZE_BYTES = BANKS * sizeof(T); From 148fa698cc82d4aea7c928e309c29dc2537651bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 17:25:22 -0500 Subject: [PATCH 0971/1626] Fix DNS resolution inconsistency between logs and OTA operations (#10595) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/__main__.py | 36 ++-- esphome/espota2.py | 10 +- esphome/helpers.py | 141 ++++++------- esphome/resolver.py | 67 +++++++ tests/unit_tests/test_helpers.py | 317 ++++++++++++++++++++++++++++++ tests/unit_tests/test_resolver.py | 169 ++++++++++++++++ 6 files changed, 640 insertions(+), 100 deletions(-) create mode 100644 esphome/resolver.py create mode 100644 tests/unit_tests/test_resolver.py diff --git a/esphome/__main__.py b/esphome/__main__.py index aab3035a5e..70d5cacd72 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -396,7 +396,10 @@ def check_permissions(port: str): ) -def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | str: +def upload_program( + config: ConfigType, args: ArgsProtocol, devices: list[str] +) -> int | str: + host = devices[0] try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): @@ -433,10 +436,10 @@ def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | s remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD, "") + binary = args.file if getattr(args, "file", None) is not None else CORE.firmware_bin # Check if we should use MQTT for address resolution # This happens when no device was specified, or the current host is "MQTT"/"OTA" - devices: list[str] = args.device or [] if ( CONF_MQTT in config # pylint: disable=too-many-boolean-expressions and (not devices or host in ("MQTT", "OTA")) @@ -447,14 +450,13 @@ def upload_program(config: ConfigType, args: ArgsProtocol, host: str) -> int | s ): from esphome import mqtt - host = mqtt.get_esphome_device_ip( - config, args.username, args.password, args.client_id - ) + devices = [ + mqtt.get_esphome_device_ip( + config, args.username, args.password, args.client_id + ) + ] - if getattr(args, "file", None) is not None: - return espota2.run_ota(host, remote_port, password, args.file) - - return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) + return espota2.run_ota(devices, remote_port, password, binary) def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: @@ -551,17 +553,11 @@ def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: purpose="uploading", ) - # Try each device until one succeeds - exit_code = 1 - for device in devices: - _LOGGER.info("Uploading to %s", device) - exit_code = upload_program(config, args, device) - if exit_code == 0: - _LOGGER.info("Successfully uploaded program.") - return 0 - if len(devices) > 1: - _LOGGER.warning("Failed to upload to %s", device) - + exit_code = upload_program(config, args, devices) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + else: + _LOGGER.warning("Failed to upload to %s", devices) return exit_code diff --git a/esphome/espota2.py b/esphome/espota2.py index 279bafee8e..d83f25a303 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -308,8 +308,12 @@ def perform_ota( time.sleep(1) -def run_ota_impl_(remote_host, remote_port, password, filename): +def run_ota_impl_( + remote_host: str | list[str], remote_port: int, password: str, filename: str +) -> int: + # Handle both single host and list of hosts try: + # Resolve all hosts at once for parallel DNS resolution res = resolve_ip_address(remote_host, remote_port) except EsphomeError as err: _LOGGER.error( @@ -350,7 +354,9 @@ def run_ota_impl_(remote_host, remote_port, password, filename): return 1 -def run_ota(remote_host, remote_port, password, filename): +def run_ota( + remote_host: str | list[str], remote_port: int, password: str, filename: str +) -> int: try: return run_ota_impl_(remote_host, remote_port, password, filename) except OTAError as err: diff --git a/esphome/helpers.py b/esphome/helpers.py index 377a4e1717..6beaa24a96 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import codecs from contextlib import suppress import ipaddress @@ -11,6 +13,18 @@ from urllib.parse import urlparse from esphome.const import __version__ as ESPHOME_VERSION +# Type aliases for socket address information +AddrInfo = tuple[ + int, # family (AF_INET, AF_INET6, etc.) + int, # type (SOCK_STREAM, SOCK_DGRAM, etc.) + int, # proto (IPPROTO_TCP, etc.) + str, # canonname + tuple[str, int] | tuple[str, int, int, int], # sockaddr (IPv4 or IPv6) +] +IPv4SockAddr = tuple[str, int] # (host, port) +IPv6SockAddr = tuple[str, int, int, int] # (host, port, flowinfo, scope_id) +SockAddr = IPv4SockAddr | IPv6SockAddr + _LOGGER = logging.getLogger(__name__) IS_MACOS = platform.system() == "Darwin" @@ -147,32 +161,7 @@ def is_ip_address(host): return False -def _resolve_with_zeroconf(host): - from esphome.core import EsphomeError - from esphome.zeroconf import EsphomeZeroconf - - try: - zc = EsphomeZeroconf() - except Exception as err: - raise EsphomeError( - "Cannot start mDNS sockets, is this a docker container without " - "host network mode?" - ) from err - try: - info = zc.resolve_host(f"{host}.") - except Exception as err: - raise EsphomeError(f"Error resolving mDNS hostname: {err}") from err - finally: - zc.close() - if info is None: - raise EsphomeError( - "Error resolving address with mDNS: Did not respond. " - "Maybe the device is offline." - ) - return info - - -def addr_preference_(res): +def addr_preference_(res: AddrInfo) -> int: # Trivial alternative to RFC6724 sorting. Put sane IPv6 first, then # Legacy IP, then IPv6 link-local addresses without an actual link. sa = res[4] @@ -184,66 +173,70 @@ def addr_preference_(res): return 1 -def resolve_ip_address(host, port): +def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: import socket - from esphome.core import EsphomeError - # There are five cases here. The host argument could be one of: # • a *list* of IP addresses discovered by MQTT, # • a single IP address specified by the user, # • a .local hostname to be resolved by mDNS, # • a normal hostname to be resolved in DNS, or # • A URL from which we should extract the hostname. - # - # In each of the first three cases, we end up with IP addresses in - # string form which need to be converted to a 5-tuple to be used - # for the socket connection attempt. The easiest way to construct - # those is to pass the IP address string to getaddrinfo(). Which, - # coincidentally, is how we do hostname lookups in the other cases - # too. So first build a list which contains either IP addresses or - # a single hostname, then call getaddrinfo() on each element of - # that list. - errs = [] + hosts: list[str] if isinstance(host, list): - addr_list = host - elif is_ip_address(host): - addr_list = [host] + hosts = host else: - url = urlparse(host) - if url.scheme != "": - host = url.hostname + if not is_ip_address(host): + url = urlparse(host) + if url.scheme != "": + host = url.hostname + hosts = [host] - addr_list = [] - if host.endswith(".local"): + res: list[AddrInfo] = [] + if all(is_ip_address(h) for h in hosts): + # Fast path: all are IP addresses, use socket.getaddrinfo with AI_NUMERICHOST + for addr in hosts: try: - _LOGGER.info("Resolving IP address of %s in mDNS", host) - addr_list = _resolve_with_zeroconf(host) - except EsphomeError as err: - errs.append(str(err)) + res += socket.getaddrinfo( + addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + except OSError: + _LOGGER.debug("Failed to parse IP address '%s'", addr) + # Sort by preference + res.sort(key=addr_preference_) + return res - # If not mDNS, or if mDNS failed, use normal DNS - if not addr_list: - addr_list = [host] + from esphome.resolver import AsyncResolver - # Now we have a list containing either IP addresses or a hostname - res = [] - for addr in addr_list: - if not is_ip_address(addr): - _LOGGER.info("Resolving IP address of %s", host) - try: - r = socket.getaddrinfo(addr, port, proto=socket.IPPROTO_TCP) - except OSError as err: - errs.append(str(err)) - raise EsphomeError( - f"Error resolving IP address: {', '.join(errs)}" - ) from err + resolver = AsyncResolver(hosts, port) + addr_infos = resolver.resolve() + # Convert aioesphomeapi AddrInfo to our format + for addr_info in addr_infos: + sockaddr = addr_info.sockaddr + if addr_info.family == socket.AF_INET6: + # IPv6 + sockaddr_tuple = ( + sockaddr.address, + sockaddr.port, + sockaddr.flowinfo, + sockaddr.scope_id, + ) + else: + # IPv4 + sockaddr_tuple = (sockaddr.address, sockaddr.port) - res = res + r + res.append( + ( + addr_info.family, + addr_info.type, + addr_info.proto, + "", # canonname + sockaddr_tuple, + ) + ) - # Zeroconf tends to give us link-local IPv6 addresses without specifying - # the link. Put those last in the list to be attempted. + # Sort by preference res.sort(key=addr_preference_) return res @@ -262,15 +255,7 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]: # First "resolve" all the IP addresses to getaddrinfo() tuples of the form # (family, type, proto, canonname, sockaddr) - res: list[ - tuple[ - int, - int, - int, - str | None, - tuple[str, int] | tuple[str, int, int, int], - ] - ] = [] + res: list[AddrInfo] = [] for addr in address_list: # This should always work as these are supposed to be IP addresses try: diff --git a/esphome/resolver.py b/esphome/resolver.py new file mode 100644 index 0000000000..99482aa20e --- /dev/null +++ b/esphome/resolver.py @@ -0,0 +1,67 @@ +"""DNS resolver for ESPHome using aioesphomeapi.""" + +from __future__ import annotations + +import asyncio +import threading + +from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError +import aioesphomeapi.host_resolver as hr + +from esphome.core import EsphomeError + +RESOLVE_TIMEOUT = 10.0 # seconds + + +class AsyncResolver(threading.Thread): + """Resolver using aioesphomeapi that runs in a thread for faster results. + + This resolver uses aioesphomeapi's async_resolve_host to handle DNS resolution, + including proper .local domain fallback. Running in a thread allows us to get + the result immediately without waiting for asyncio.run() to complete its + cleanup cycle, which can take significant time. + """ + + def __init__(self, hosts: list[str], port: int) -> None: + """Initialize the resolver.""" + super().__init__(daemon=True) + self.hosts = hosts + self.port = port + self.result: list[hr.AddrInfo] | None = None + self.exception: Exception | None = None + self.event = threading.Event() + + async def _resolve(self) -> None: + """Resolve hostnames to IP addresses.""" + try: + self.result = await hr.async_resolve_host( + self.hosts, self.port, timeout=RESOLVE_TIMEOUT + ) + except Exception as e: # pylint: disable=broad-except + # We need to catch all exceptions to ensure the event is set + # Otherwise the thread could hang forever + self.exception = e + finally: + self.event.set() + + def run(self) -> None: + """Run the DNS resolution.""" + asyncio.run(self._resolve()) + + def resolve(self) -> list[hr.AddrInfo]: + """Start the thread and wait for the result.""" + self.start() + + if not self.event.wait( + timeout=RESOLVE_TIMEOUT + 1.0 + ): # Give it 1 second more than the resolver timeout + raise EsphomeError("Timeout resolving IP address") + + if exc := self.exception: + if isinstance(exc, ResolveTimeoutAPIError): + raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc + if isinstance(exc, ResolveAPIError): + raise EsphomeError(f"Error resolving IP address: {exc}") from exc + raise exc + + return self.result diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index b353d1aa99..9f51206ff9 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1,8 +1,14 @@ +import logging +import socket +from unittest.mock import patch + +from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr from hypothesis import given from hypothesis.strategies import ip_addresses import pytest from esphome import helpers +from esphome.core import EsphomeError @pytest.mark.parametrize( @@ -277,3 +283,314 @@ def test_sort_ip_addresses(text: list[str], expected: list[str]) -> None: actual = helpers.sort_ip_addresses(text) assert actual == expected + + +# DNS resolution tests +def test_is_ip_address_ipv4() -> None: + """Test is_ip_address with IPv4 addresses.""" + assert helpers.is_ip_address("192.168.1.1") is True + assert helpers.is_ip_address("127.0.0.1") is True + assert helpers.is_ip_address("255.255.255.255") is True + assert helpers.is_ip_address("0.0.0.0") is True + + +def test_is_ip_address_ipv6() -> None: + """Test is_ip_address with IPv6 addresses.""" + assert helpers.is_ip_address("::1") is True + assert helpers.is_ip_address("2001:db8::1") is True + assert helpers.is_ip_address("fe80::1") is True + assert helpers.is_ip_address("::") is True + + +def test_is_ip_address_invalid() -> None: + """Test is_ip_address with non-IP strings.""" + assert helpers.is_ip_address("hostname") is False + assert helpers.is_ip_address("hostname.local") is False + assert helpers.is_ip_address("256.256.256.256") is False + assert helpers.is_ip_address("192.168.1") is False + assert helpers.is_ip_address("") is False + + +def test_resolve_ip_address_single_ipv4() -> None: + """Test resolving a single IPv4 address (fast path).""" + result = helpers.resolve_ip_address("192.168.1.100", 6053) + + assert len(result) == 1 + assert result[0][0] == socket.AF_INET # family + assert result[0][1] in ( + 0, + socket.SOCK_STREAM, + ) # type (0 on Windows with AI_NUMERICHOST) + assert result[0][2] in ( + 0, + socket.IPPROTO_TCP, + ) # proto (0 on Windows with AI_NUMERICHOST) + assert result[0][3] == "" # canonname + assert result[0][4] == ("192.168.1.100", 6053) # sockaddr + + +def test_resolve_ip_address_single_ipv6() -> None: + """Test resolving a single IPv6 address (fast path).""" + result = helpers.resolve_ip_address("::1", 6053) + + assert len(result) == 1 + assert result[0][0] == socket.AF_INET6 # family + assert result[0][1] in ( + 0, + socket.SOCK_STREAM, + ) # type (0 on Windows with AI_NUMERICHOST) + assert result[0][2] in ( + 0, + socket.IPPROTO_TCP, + ) # proto (0 on Windows with AI_NUMERICHOST) + assert result[0][3] == "" # canonname + # IPv6 sockaddr has 4 elements + assert len(result[0][4]) == 4 + assert result[0][4][0] == "::1" # address + assert result[0][4][1] == 6053 # port + + +def test_resolve_ip_address_list_of_ips() -> None: + """Test resolving a list of IP addresses (fast path).""" + ips = ["192.168.1.100", "10.0.0.1", "::1"] + result = helpers.resolve_ip_address(ips, 6053) + + # Should return results sorted by preference (IPv6 first, then IPv4) + assert len(result) >= 2 # At least IPv4 addresses should work + + # Check that results are properly formatted + for addr_info in result: + assert addr_info[0] in (socket.AF_INET, socket.AF_INET6) + assert addr_info[1] in ( + 0, + socket.SOCK_STREAM, + ) # 0 on Windows with AI_NUMERICHOST + assert addr_info[2] in ( + 0, + socket.IPPROTO_TCP, + ) # 0 on Windows with AI_NUMERICHOST + assert addr_info[3] == "" + + +def test_resolve_ip_address_with_getaddrinfo_failure(caplog) -> None: + """Test that getaddrinfo OSError is handled gracefully in fast path.""" + with ( + caplog.at_level(logging.DEBUG), + patch("socket.getaddrinfo") as mock_getaddrinfo, + ): + # First IP succeeds + mock_getaddrinfo.side_effect = [ + [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("192.168.1.100", 6053), + ) + ], + OSError("Failed to resolve"), # Second IP fails + ] + + # Should continue despite one failure + result = helpers.resolve_ip_address(["192.168.1.100", "192.168.1.101"], 6053) + + # Should have result from first IP only + assert len(result) == 1 + assert result[0][4][0] == "192.168.1.100" + + # Verify both IPs were attempted + assert mock_getaddrinfo.call_count == 2 + mock_getaddrinfo.assert_any_call( + "192.168.1.100", 6053, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + mock_getaddrinfo.assert_any_call( + "192.168.1.101", 6053, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + + # Verify the debug log was called for the failed IP + assert "Failed to parse IP address '192.168.1.101'" in caplog.text + + +def test_resolve_ip_address_hostname() -> None: + """Test resolving a hostname (async resolver path).""" + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address("test.local", 6053) + + assert len(result) == 1 + assert result[0][0] == socket.AF_INET + assert result[0][4] == ("192.168.1.100", 6053) + MockResolver.assert_called_once_with(["test.local"], 6053) + mock_resolver.resolve.assert_called_once() + + +def test_resolve_ip_address_mixed_list() -> None: + """Test resolving a mix of IPs and hostnames.""" + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.200", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.return_value = [mock_addr_info] + + # Mix of IP and hostname - should use async resolver + result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053) + + assert len(result) == 1 + assert result[0][4][0] == "192.168.1.200" + MockResolver.assert_called_once_with(["192.168.1.100", "test.local"], 6053) + mock_resolver.resolve.assert_called_once() + + +def test_resolve_ip_address_url() -> None: + """Test extracting hostname from URL.""" + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address("http://test.local", 6053) + + assert len(result) == 1 + MockResolver.assert_called_once_with(["test.local"], 6053) + mock_resolver.resolve.assert_called_once() + + +def test_resolve_ip_address_ipv6_conversion() -> None: + """Test proper IPv6 address info conversion.""" + mock_addr_info = AddrInfo( + family=socket.AF_INET6, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv6Sockaddr(address="2001:db8::1", port=6053, flowinfo=1, scope_id=2), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address("test.local", 6053) + + assert len(result) == 1 + assert result[0][0] == socket.AF_INET6 + assert result[0][4] == ("2001:db8::1", 6053, 1, 2) + + +def test_resolve_ip_address_error_handling() -> None: + """Test error handling from AsyncResolver.""" + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.side_effect = EsphomeError("Resolution failed") + + with pytest.raises(EsphomeError, match="Resolution failed"): + helpers.resolve_ip_address("test.local", 6053) + + +def test_addr_preference_ipv4() -> None: + """Test address preference for IPv4.""" + addr_info = ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("192.168.1.1", 6053), + ) + assert helpers.addr_preference_(addr_info) == 2 + + +def test_addr_preference_ipv6() -> None: + """Test address preference for regular IPv6.""" + addr_info = ( + socket.AF_INET6, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("2001:db8::1", 6053, 0, 0), + ) + assert helpers.addr_preference_(addr_info) == 1 + + +def test_addr_preference_ipv6_link_local_no_scope() -> None: + """Test address preference for link-local IPv6 without scope.""" + addr_info = ( + socket.AF_INET6, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("fe80::1", 6053, 0, 0), # link-local with scope_id=0 + ) + assert helpers.addr_preference_(addr_info) == 3 + + +def test_addr_preference_ipv6_link_local_with_scope() -> None: + """Test address preference for link-local IPv6 with scope.""" + addr_info = ( + socket.AF_INET6, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("fe80::1", 6053, 0, 2), # link-local with scope_id=2 + ) + assert helpers.addr_preference_(addr_info) == 1 # Has scope, so it's usable + + +def test_resolve_ip_address_sorting() -> None: + """Test that results are sorted by preference.""" + # Create multiple address infos with different preferences + mock_addr_infos = [ + AddrInfo( + family=socket.AF_INET6, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv6Sockaddr( + address="fe80::1", port=6053, flowinfo=0, scope_id=0 + ), # Preference 3 (link-local no scope) + ), + AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr( + address="192.168.1.100", port=6053 + ), # Preference 2 (IPv4) + ), + AddrInfo( + family=socket.AF_INET6, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv6Sockaddr( + address="2001:db8::1", port=6053, flowinfo=0, scope_id=0 + ), # Preference 1 (IPv6) + ), + ] + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.return_value = mock_addr_infos + + result = helpers.resolve_ip_address("test.local", 6053) + + # Should be sorted: IPv6 first, then IPv4, then link-local without scope + assert result[0][4][0] == "2001:db8::1" # IPv6 (preference 1) + assert result[1][4][0] == "192.168.1.100" # IPv4 (preference 2) + assert result[2][4][0] == "fe80::1" # Link-local no scope (preference 3) diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py new file mode 100644 index 0000000000..b4cca05d9f --- /dev/null +++ b/tests/unit_tests/test_resolver.py @@ -0,0 +1,169 @@ +"""Tests for the DNS resolver module.""" + +from __future__ import annotations + +import re +import socket +from unittest.mock import patch + +from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError +from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr +import pytest + +from esphome.core import EsphomeError +from esphome.resolver import RESOLVE_TIMEOUT, AsyncResolver + + +@pytest.fixture +def mock_addr_info_ipv4() -> AddrInfo: + """Create a mock IPv4 AddrInfo.""" + return AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + +@pytest.fixture +def mock_addr_info_ipv6() -> AddrInfo: + """Create a mock IPv6 AddrInfo.""" + return AddrInfo( + family=socket.AF_INET6, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv6Sockaddr(address="2001:db8::1", port=6053, flowinfo=0, scope_id=0), + ) + + +def test_async_resolver_successful_resolution(mock_addr_info_ipv4: AddrInfo) -> None: + """Test successful DNS resolution.""" + with patch( + "esphome.resolver.hr.async_resolve_host", + return_value=[mock_addr_info_ipv4], + ) as mock_resolve: + resolver = AsyncResolver(["test.local"], 6053) + result = resolver.resolve() + + assert result == [mock_addr_info_ipv4] + mock_resolve.assert_called_once_with( + ["test.local"], 6053, timeout=RESOLVE_TIMEOUT + ) + + +def test_async_resolver_multiple_hosts( + mock_addr_info_ipv4: AddrInfo, mock_addr_info_ipv6: AddrInfo +) -> None: + """Test resolving multiple hosts.""" + mock_results = [mock_addr_info_ipv4, mock_addr_info_ipv6] + + with patch( + "esphome.resolver.hr.async_resolve_host", + return_value=mock_results, + ) as mock_resolve: + resolver = AsyncResolver(["test1.local", "test2.local"], 6053) + result = resolver.resolve() + + assert result == mock_results + mock_resolve.assert_called_once_with( + ["test1.local", "test2.local"], 6053, timeout=RESOLVE_TIMEOUT + ) + + +def test_async_resolver_resolve_api_error() -> None: + """Test handling of ResolveAPIError.""" + error_msg = "Failed to resolve" + with patch( + "esphome.resolver.hr.async_resolve_host", + side_effect=ResolveAPIError(error_msg), + ): + resolver = AsyncResolver(["test.local"], 6053) + with pytest.raises( + EsphomeError, match=re.escape(f"Error resolving IP address: {error_msg}") + ): + resolver.resolve() + + +def test_async_resolver_timeout_error() -> None: + """Test handling of ResolveTimeoutAPIError.""" + error_msg = "Resolution timed out" + + with patch( + "esphome.resolver.hr.async_resolve_host", + side_effect=ResolveTimeoutAPIError(error_msg), + ): + resolver = AsyncResolver(["test.local"], 6053) + # Match either "Timeout" or "Error" since ResolveTimeoutAPIError is a subclass of ResolveAPIError + # and depending on import order/test execution context, it might be caught as either + with pytest.raises( + EsphomeError, + match=f"(Timeout|Error) resolving IP address: {re.escape(error_msg)}", + ): + resolver.resolve() + + +def test_async_resolver_generic_exception() -> None: + """Test handling of generic exceptions.""" + error = RuntimeError("Unexpected error") + with patch( + "esphome.resolver.hr.async_resolve_host", + side_effect=error, + ): + resolver = AsyncResolver(["test.local"], 6053) + with pytest.raises(RuntimeError, match="Unexpected error"): + resolver.resolve() + + +def test_async_resolver_thread_timeout() -> None: + """Test timeout when thread doesn't complete in time.""" + # Mock the start method to prevent actual thread execution + with ( + patch.object(AsyncResolver, "start"), + patch("esphome.resolver.hr.async_resolve_host"), + ): + resolver = AsyncResolver(["test.local"], 6053) + # Override event.wait to simulate timeout (return False = timeout occurred) + with ( + patch.object(resolver.event, "wait", return_value=False), + pytest.raises( + EsphomeError, match=re.escape("Timeout resolving IP address") + ), + ): + resolver.resolve() + + # Verify thread start was called + resolver.start.assert_called_once() + + +def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: + """Test resolving IP addresses.""" + with patch( + "esphome.resolver.hr.async_resolve_host", + return_value=[mock_addr_info_ipv4], + ) as mock_resolve: + resolver = AsyncResolver(["192.168.1.100"], 6053) + result = resolver.resolve() + + assert result == [mock_addr_info_ipv4] + mock_resolve.assert_called_once_with( + ["192.168.1.100"], 6053, timeout=RESOLVE_TIMEOUT + ) + + +def test_async_resolver_mixed_addresses( + mock_addr_info_ipv4: AddrInfo, mock_addr_info_ipv6: AddrInfo +) -> None: + """Test resolving mix of hostnames and IP addresses.""" + mock_results = [mock_addr_info_ipv4, mock_addr_info_ipv6] + + with patch( + "esphome.resolver.hr.async_resolve_host", + return_value=mock_results, + ) as mock_resolve: + resolver = AsyncResolver(["test.local", "192.168.1.100", "::1"], 6053) + result = resolver.resolve() + + assert result == mock_results + mock_resolve.assert_called_once_with( + ["test.local", "192.168.1.100", "::1"], 6053, timeout=RESOLVE_TIMEOUT + ) From 0065fe15163b37c67cc70d637bef41596bd5e172 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Sep 2025 17:26:06 -0500 Subject: [PATCH 0972/1626] Bump zeroconf from 0.147.0 to 0.147.2 (#10642) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bac5622708..1a836bd64f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 aioesphomeapi==40.0.1 -zeroconf==0.147.0 +zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import esphome-glyphsets==0.2.0 From f24a182ba2a4f8024914a50d4f6cb09a6ab5ccc3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Sep 2025 17:26:31 -0500 Subject: [PATCH 0973/1626] Bump pytest-cov from 6.2.1 to 6.3.0 (#10640) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index eba14fc0b1..1760af75ba 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,7 +6,7 @@ pre-commit # Unit tests pytest==8.4.2 -pytest-cov==6.2.1 +pytest-cov==6.3.0 pytest-mock==3.15.0 pytest-asyncio==1.1.0 pytest-xdist==3.8.0 From 28d16728d3f9b396b819105bffc943425ca23f8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 17:27:58 -0500 Subject: [PATCH 0974/1626] [core] Add memory pool to scheduler to reduce heap fragmentation (#10536) --- esphome/core/scheduler.cpp | 130 ++++++-- esphome/core/scheduler.h | 65 ++-- .../integration/fixtures/scheduler_pool.yaml | 282 ++++++++++++++++++ tests/integration/test_scheduler_pool.py | 209 +++++++++++++ 4 files changed, 633 insertions(+), 53 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_pool.yaml create mode 100644 tests/integration/test_scheduler_pool.py diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d0230d46fc..9a9e61d579 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -14,7 +14,20 @@ namespace esphome { static const char *const TAG = "scheduler"; -static const uint32_t MAX_LOGICALLY_DELETED_ITEMS = 10; +// Memory pool configuration constants +// Pool size of 5 matches typical usage patterns (2-4 active timers) +// - Minimal memory overhead (~250 bytes on ESP32) +// - Sufficient for most configs with a couple sensors/components +// - Still prevents heap fragmentation and allocation stalls +// - Complex setups with many timers will just allocate beyond the pool +// See https://github.com/esphome/backlog/issues/52 +static constexpr size_t MAX_POOL_SIZE = 5; + +// Maximum number of logically deleted (cancelled) items before forcing cleanup. +// Set to 5 to match the pool size - when we have as many cancelled items as our +// pool can hold, it's time to clean up and recycle them. +static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 5; + // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; // max delay to start an interval sequence @@ -79,8 +92,28 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } + // Get fresh timestamp BEFORE taking lock - millis_64_ may need to acquire lock itself + const uint64_t now = this->millis_64_(millis()); + + // Take lock early to protect scheduler_item_pool_ access + LockGuard guard{this->lock_}; + // Create and populate the scheduler item - auto item = make_unique(); + std::unique_ptr item; + if (!this->scheduler_item_pool_.empty()) { + // Reuse from pool + item = std::move(this->scheduler_item_pool_.back()); + this->scheduler_item_pool_.pop_back(); +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_.size()); +#endif + } else { + // Allocate new if pool is empty + item = make_unique(); +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGD(TAG, "Allocated new item (pool empty)"); +#endif + } item->component = component; item->set_name(name_cstr, !is_static_string); item->type = type; @@ -99,7 +132,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Single-core platforms don't need thread-safe defer handling if (delay == 0 && type == SchedulerItem::TIMEOUT) { // Put in defer queue for guaranteed FIFO execution - LockGuard guard{this->lock_}; if (!skip_cancel) { this->cancel_item_locked_(component, name_cstr, type); } @@ -108,9 +140,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } #endif /* not ESPHOME_THREAD_SINGLE */ - // Get fresh timestamp for new timer/interval - ensures accurate scheduling - const auto now = this->millis_64_(millis()); // Fresh millis() call - // Type-specific setup if (type == SchedulerItem::INTERVAL) { item->interval = delay; @@ -142,8 +171,6 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } #endif /* ESPHOME_DEBUG_SCHEDULER */ - LockGuard guard{this->lock_}; - // For retries, check if there's a cancelled timeout first if (is_retry && name_cstr != nullptr && type == SchedulerItem::TIMEOUT && (has_cancelled_timeout_in_container_(this->items_, component, name_cstr, /* match_retry= */ true) || @@ -319,6 +346,8 @@ void HOT Scheduler::call(uint32_t now) { if (!this->should_skip_item_(item.get())) { this->execute_item_(item.get(), now); } + // Recycle the defer item after execution + this->recycle_item_(std::move(item)); } #endif /* not ESPHOME_THREAD_SINGLE */ @@ -338,11 +367,11 @@ void HOT Scheduler::call(uint32_t now) { #ifdef ESPHOME_THREAD_MULTI_ATOMICS const auto last_dbg = this->last_millis_.load(std::memory_order_relaxed); const auto major_dbg = this->millis_major_.load(std::memory_order_relaxed); - ESP_LOGD(TAG, "Items: count=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), now_64, - major_dbg, last_dbg); + ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), + this->scheduler_item_pool_.size(), now_64, major_dbg, last_dbg); #else /* not ESPHOME_THREAD_MULTI_ATOMICS */ - ESP_LOGD(TAG, "Items: count=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), now_64, - this->millis_major_, this->last_millis_); + ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), + this->scheduler_item_pool_.size(), now_64, this->millis_major_, this->last_millis_); #endif /* else ESPHOME_THREAD_MULTI_ATOMICS */ // Cleanup before debug output this->cleanup_(); @@ -355,9 +384,10 @@ void HOT Scheduler::call(uint32_t now) { } const char *name = item->get_name(); - ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64, + bool is_cancelled = is_item_removed_(item.get()); + ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64 "%s", item->get_type_str(), item->get_source(), name ? name : "(null)", item->interval, - item->next_execution_ - now_64, item->next_execution_); + item->next_execution_ - now_64, item->next_execution_, is_cancelled ? " [CANCELLED]" : ""); old_items.push_back(std::move(item)); } @@ -372,8 +402,13 @@ void HOT Scheduler::call(uint32_t now) { } #endif /* ESPHOME_DEBUG_SCHEDULER */ - // If we have too many items to remove - if (this->to_remove_ > MAX_LOGICALLY_DELETED_ITEMS) { + // Cleanup removed items before processing + // First try to clean items from the top of the heap (fast path) + this->cleanup_(); + + // If we still have too many cancelled items, do a full cleanup + // This only happens if cancelled items are stuck in the middle/bottom of the heap + if (this->to_remove_ >= MAX_LOGICALLY_DELETED_ITEMS) { // We hold the lock for the entire cleanup operation because: // 1. We're rebuilding the entire items_ list, so we need exclusive access throughout // 2. Other threads must see either the old state or the new state, not intermediate states @@ -383,10 +418,13 @@ void HOT Scheduler::call(uint32_t now) { std::vector> valid_items; - // Move all non-removed items to valid_items + // Move all non-removed items to valid_items, recycle removed ones for (auto &item : this->items_) { - if (!item->remove) { + if (!is_item_removed_(item.get())) { valid_items.push_back(std::move(item)); + } else { + // Recycle removed items + this->recycle_item_(std::move(item)); } } @@ -396,9 +434,6 @@ void HOT Scheduler::call(uint32_t now) { std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); this->to_remove_ = 0; } - - // Cleanup removed items before processing - this->cleanup_(); while (!this->items_.empty()) { // use scoping to indicate visibility of `item` variable { @@ -472,6 +507,9 @@ void HOT Scheduler::call(uint32_t now) { // Add new item directly to to_add_ // since we have the lock held this->to_add_.push_back(std::move(item)); + } else { + // Timeout completed - recycle it + this->recycle_item_(std::move(item)); } has_added_items |= !this->to_add_.empty(); @@ -485,7 +523,9 @@ void HOT Scheduler::call(uint32_t now) { void HOT Scheduler::process_to_add() { LockGuard guard{this->lock_}; for (auto &it : this->to_add_) { - if (it->remove) { + if (is_item_removed_(it.get())) { + // Recycle cancelled items + this->recycle_item_(std::move(it)); continue; } @@ -525,6 +565,10 @@ size_t HOT Scheduler::cleanup_() { } void HOT Scheduler::pop_raw_() { std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); + + // Instead of destroying, recycle the item + this->recycle_item_(std::move(this->items_.back())); + this->items_.pop_back(); } @@ -559,7 +603,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // Check all containers for matching items #ifndef ESPHOME_THREAD_SINGLE - // Only check defer queue for timeouts (intervals never go there) + // Mark items in defer queue as cancelled (they'll be skipped when processed) if (type == SchedulerItem::TIMEOUT) { for (auto &item : this->defer_queue_) { if (this->matches_item_(item, component, name_cstr, type, match_retry)) { @@ -571,11 +615,22 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c #endif /* not ESPHOME_THREAD_SINGLE */ // Cancel items in the main heap - for (auto &item : this->items_) { - if (this->matches_item_(item, component, name_cstr, type, match_retry)) { - this->mark_item_removed_(item.get()); + // Special case: if the last item in the heap matches, we can remove it immediately + // (removing the last element doesn't break heap structure) + if (!this->items_.empty()) { + auto &last_item = this->items_.back(); + if (this->matches_item_(last_item, component, name_cstr, type, match_retry)) { + this->recycle_item_(std::move(this->items_.back())); + this->items_.pop_back(); total_cancelled++; - this->to_remove_++; // Track removals for heap items + } + // For other items in heap, we can only mark for removal (can't remove from middle of heap) + for (auto &item : this->items_) { + if (this->matches_item_(item, component, name_cstr, type, match_retry)) { + this->mark_item_removed_(item.get()); + total_cancelled++; + this->to_remove_++; // Track removals for heap items + } } } @@ -754,4 +809,25 @@ bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr &a, return a->next_execution_ > b->next_execution_; } +void Scheduler::recycle_item_(std::unique_ptr item) { + if (!item) + return; + + if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) { + // Clear callback to release captured resources + item->callback = nullptr; + // Clear dynamic name if any + item->clear_dynamic_name(); + this->scheduler_item_pool_.push_back(std::move(item)); +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_.size()); +#endif + } else { +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGD(TAG, "Pool full (size: %zu), deleting item", this->scheduler_item_pool_.size()); +#endif + } + // else: unique_ptr will delete the item when it goes out of scope +} + } // namespace esphome diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index f469a60d5c..85cfaab2e0 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -142,11 +142,7 @@ class Scheduler { } // Destructor to clean up dynamic names - ~SchedulerItem() { - if (name_is_dynamic) { - delete[] name_.dynamic_name; - } - } + ~SchedulerItem() { clear_dynamic_name(); } // Delete copy operations to prevent accidental copies SchedulerItem(const SchedulerItem &) = delete; @@ -159,13 +155,19 @@ class Scheduler { // Helper to get the name regardless of storage type const char *get_name() const { return name_is_dynamic ? name_.dynamic_name : name_.static_name; } + // Helper to clear dynamic name if allocated + void clear_dynamic_name() { + if (name_is_dynamic && name_.dynamic_name) { + delete[] name_.dynamic_name; + name_.dynamic_name = nullptr; + name_is_dynamic = false; + } + } + // Helper to set name with proper ownership void set_name(const char *name, bool make_copy = false) { // Clean up old dynamic name if any - if (name_is_dynamic && name_.dynamic_name) { - delete[] name_.dynamic_name; - name_is_dynamic = false; - } + clear_dynamic_name(); if (!name) { // nullptr case - no name provided @@ -214,6 +216,15 @@ class Scheduler { // Common implementation for cancel operations bool cancel_item_(Component *component, bool is_static_string, const void *name_ptr, SchedulerItem::Type type); + // Helper to check if two scheduler item names match + inline bool HOT names_match_(const char *name1, const char *name2) const { + // Check pointer equality first (common for static strings), then string contents + // The core ESPHome codebase uses static strings (const char*) for component names, + // making pointer comparison effective. The std::string overloads exist only for + // compatibility with external components but are rarely used in practice. + return (name1 != nullptr && name2 != nullptr) && ((name1 == name2) || (strcmp(name1, name2) == 0)); + } + // Helper function to check if item matches criteria for cancellation inline bool HOT matches_item_(const std::unique_ptr &item, Component *component, const char *name_cstr, SchedulerItem::Type type, bool match_retry, bool skip_removed = true) const { @@ -221,29 +232,20 @@ class Scheduler { (match_retry && !item->is_retry)) { return false; } - const char *item_name = item->get_name(); - if (item_name == nullptr) { - return false; - } - // Fast path: if pointers are equal - // This is effective because the core ESPHome codebase uses static strings (const char*) - // for component names. The std::string overloads exist only for compatibility with - // external components, but are rarely used in practice. - if (item_name == name_cstr) { - return true; - } - // Slow path: compare string contents - return strcmp(name_cstr, item_name) == 0; + return this->names_match_(item->get_name(), name_cstr); } // Helper to execute a scheduler item void execute_item_(SchedulerItem *item, uint32_t now); // Helper to check if item should be skipped - bool should_skip_item_(const SchedulerItem *item) const { - return item->remove || (item->component != nullptr && item->component->is_failed()); + bool should_skip_item_(SchedulerItem *item) const { + return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); } + // Helper to recycle a SchedulerItem + void recycle_item_(std::unique_ptr item); + // Helper to check if item is marked for removal (platform-specific) // Returns true if item should be skipped, handles platform-specific synchronization // For ESPHOME_THREAD_MULTI_NO_ATOMICS platforms, the caller must hold the scheduler lock before calling this @@ -280,8 +282,9 @@ class Scheduler { bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr, bool match_retry) const { for (const auto &item : container) { - if (item->remove && this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry, - /* skip_removed= */ false)) { + if (is_item_removed_(item.get()) && + this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry, + /* skip_removed= */ false)) { return true; } } @@ -297,6 +300,16 @@ class Scheduler { #endif /* ESPHOME_THREAD_SINGLE */ uint32_t to_remove_{0}; + // Memory pool for recycling SchedulerItem objects to reduce heap churn. + // Design decisions: + // - std::vector is used instead of a fixed array because many systems only need 1-2 scheduler items + // - The vector grows dynamically up to MAX_POOL_SIZE (5) only when needed, saving memory on simple setups + // - Pool size of 5 matches typical usage (2-4 timers) while keeping memory overhead low (~250 bytes on ESP32) + // - The pool significantly reduces heap fragmentation which is critical because heap allocation/deallocation + // can stall the entire system, causing timing issues and dropped events for any components that need + // to synchronize between tasks (see https://github.com/esphome/backlog/issues/52) + std::vector> scheduler_item_pool_; + #ifdef ESPHOME_THREAD_MULTI_ATOMICS /* * Multi-threaded platforms with atomic support: last_millis_ needs atomic for lock-free updates diff --git a/tests/integration/fixtures/scheduler_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml new file mode 100644 index 0000000000..5389125188 --- /dev/null +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -0,0 +1,282 @@ +esphome: + name: scheduler-pool-test + on_boot: + priority: -100 + then: + - logger.log: "Starting scheduler pool tests" + debug_scheduler: true # Enable scheduler debug logging + +host: +api: + services: + - service: run_phase_1 + then: + - script.execute: test_pool_recycling + - service: run_phase_2 + then: + - script.execute: test_sensor_polling + - service: run_phase_3 + then: + - script.execute: test_communication_patterns + - service: run_phase_4 + then: + - script.execute: test_defer_patterns + - service: run_phase_5 + then: + - script.execute: test_pool_reuse_verification + - service: run_phase_6 + then: + - script.execute: test_full_pool_reuse + - service: run_phase_7 + then: + - script.execute: test_same_defer_optimization + - service: run_complete + then: + - script.execute: complete_test +logger: + level: VERY_VERBOSE # Need VERY_VERBOSE to see pool debug messages + +globals: + - id: create_count + type: int + initial_value: '0' + - id: cancel_count + type: int + initial_value: '0' + - id: interval_counter + type: int + initial_value: '0' + - id: pool_test_done + type: bool + initial_value: 'false' + +script: + - id: test_pool_recycling + then: + - logger.log: "Testing scheduler pool recycling with realistic usage patterns" + - lambda: |- + auto *component = id(test_sensor); + + // Simulate realistic component behavior with timeouts that complete naturally + ESP_LOGI("test", "Phase 1: Simulating normal component lifecycle"); + + // Sensor update timeouts (common pattern) + App.scheduler.set_timeout(component, "sensor_init", 10, []() { + ESP_LOGD("test", "Sensor initialized"); + id(create_count)++; + }); + + // Retry timeout (gets cancelled if successful) + App.scheduler.set_timeout(component, "retry_timeout", 50, []() { + ESP_LOGD("test", "Retry timeout executed"); + id(create_count)++; + }); + + // Simulate successful operation - cancel retry + App.scheduler.set_timeout(component, "success_sim", 20, []() { + ESP_LOGD("test", "Operation succeeded, cancelling retry"); + App.scheduler.cancel_timeout(id(test_sensor), "retry_timeout"); + id(cancel_count)++; + }); + + id(create_count) += 3; + ESP_LOGI("test", "Phase 1 complete"); + + - id: test_sensor_polling + then: + - lambda: |- + // Simulate sensor polling pattern + ESP_LOGI("test", "Phase 2: Simulating sensor polling patterns"); + auto *component = id(test_sensor); + + // Multiple sensors with different update intervals + // These should only allocate once and reuse the same item for each interval execution + App.scheduler.set_interval(component, "temp_sensor", 10, []() { + ESP_LOGD("test", "Temperature sensor update"); + id(interval_counter)++; + if (id(interval_counter) >= 3) { + App.scheduler.cancel_interval(id(test_sensor), "temp_sensor"); + ESP_LOGD("test", "Temperature sensor stopped"); + } + }); + + App.scheduler.set_interval(component, "humidity_sensor", 15, []() { + ESP_LOGD("test", "Humidity sensor update"); + id(interval_counter)++; + if (id(interval_counter) >= 5) { + App.scheduler.cancel_interval(id(test_sensor), "humidity_sensor"); + ESP_LOGD("test", "Humidity sensor stopped"); + } + }); + + // Only 2 allocations for the intervals, no matter how many times they execute + id(create_count) += 2; + ESP_LOGD("test", "Created 2 intervals - they will reuse same items for each execution"); + ESP_LOGI("test", "Phase 2 complete"); + + - id: test_communication_patterns + then: + - lambda: |- + // Simulate communication patterns (WiFi/API reconnects, etc) + ESP_LOGI("test", "Phase 3: Simulating communication patterns"); + auto *component = id(test_sensor); + + // Connection timeout pattern + App.scheduler.set_timeout(component, "connect_timeout", 200, []() { + ESP_LOGD("test", "Connection timeout - would retry"); + id(create_count)++; + + // Schedule retry + App.scheduler.set_timeout(id(test_sensor), "connect_retry", 100, []() { + ESP_LOGD("test", "Retrying connection"); + id(create_count)++; + }); + }); + + // Heartbeat pattern + App.scheduler.set_interval(component, "heartbeat", 50, []() { + ESP_LOGD("test", "Heartbeat"); + id(interval_counter)++; + if (id(interval_counter) >= 10) { + App.scheduler.cancel_interval(id(test_sensor), "heartbeat"); + ESP_LOGD("test", "Heartbeat stopped"); + } + }); + + id(create_count) += 2; + ESP_LOGI("test", "Phase 3 complete"); + + - id: test_defer_patterns + then: + - lambda: |- + // Simulate defer patterns (state changes, async operations) + ESP_LOGI("test", "Phase 4: Simulating heavy defer patterns like ratgdo"); + + auto *component = id(test_sensor); + + // Simulate a burst of defer operations like ratgdo does with state updates + // These should execute immediately and recycle quickly to the pool + for (int i = 0; i < 10; i++) { + std::string defer_name = "defer_" + std::to_string(i); + App.scheduler.set_timeout(component, defer_name, 0, [i]() { + ESP_LOGD("test", "Defer %d executed", i); + // Force a small delay between defer executions to see recycling + if (i == 5) { + ESP_LOGI("test", "Half of defers executed, checking pool status"); + } + }); + } + + id(create_count) += 10; + ESP_LOGD("test", "Created 10 defer operations (0ms timeouts)"); + + // Also create some named defers that might get replaced + App.scheduler.set_timeout(component, "state_update", 0, []() { + ESP_LOGD("test", "State update 1"); + }); + + // Replace the same named defer (should cancel previous) + App.scheduler.set_timeout(component, "state_update", 0, []() { + ESP_LOGD("test", "State update 2 (replaced)"); + }); + + id(create_count) += 2; + id(cancel_count) += 1; // One cancelled due to replacement + + ESP_LOGI("test", "Phase 4 complete"); + + - id: test_pool_reuse_verification + then: + - lambda: |- + ESP_LOGI("test", "Phase 5: Verifying pool reuse after everything settles"); + + // Cancel any remaining intervals + auto *component = id(test_sensor); + App.scheduler.cancel_interval(component, "temp_sensor"); + App.scheduler.cancel_interval(component, "humidity_sensor"); + App.scheduler.cancel_interval(component, "heartbeat"); + + ESP_LOGD("test", "Cancelled any remaining intervals"); + + // The pool should have items from completed timeouts in earlier phases. + // Phase 1 had 3 timeouts that completed and were recycled. + // Phase 3 had 1 timeout that completed and was recycled. + // Phase 4 had 3 defers that completed and were recycled. + // So we should have a decent pool size already from naturally completed items. + + // Now create 8 new timeouts - they should reuse from pool when available + int reuse_test_count = 8; + + for (int i = 0; i < reuse_test_count; i++) { + std::string name = "reuse_test_" + std::to_string(i); + App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { + ESP_LOGD("test", "Reuse test %d completed", i); + }); + } + + ESP_LOGI("test", "Created %d items for reuse verification", reuse_test_count); + id(create_count) += reuse_test_count; + ESP_LOGI("test", "Phase 5 complete"); + + - id: test_full_pool_reuse + then: + - lambda: |- + ESP_LOGI("test", "Phase 6: Testing pool size limits after Phase 5 items complete"); + + // At this point, all Phase 5 timeouts should have completed and been recycled. + // The pool should be at its maximum size (5). + // Creating 10 new items tests that: + // - First 5 items reuse from the pool + // - Remaining 5 items allocate new (pool empty) + // - Pool doesn't grow beyond MAX_POOL_SIZE of 5 + + auto *component = id(test_sensor); + int full_reuse_count = 10; + + for (int i = 0; i < full_reuse_count; i++) { + std::string name = "full_reuse_" + std::to_string(i); + App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { + ESP_LOGD("test", "Full reuse test %d completed", i); + }); + } + + ESP_LOGI("test", "Created %d items for full pool reuse verification", full_reuse_count); + id(create_count) += full_reuse_count; + ESP_LOGI("test", "Phase 6 complete"); + + - id: test_same_defer_optimization + then: + - lambda: |- + ESP_LOGI("test", "Phase 7: Testing same-named defer optimization"); + + auto *component = id(test_sensor); + + // Create 10 defers with the same name - should optimize to update callback in-place + // This pattern is common in components like ratgdo that repeatedly defer state updates + for (int i = 0; i < 10; i++) { + App.scheduler.set_timeout(component, "repeated_defer", 0, [i]() { + ESP_LOGD("test", "Repeated defer executed with value: %d", i); + }); + } + + // Only the first should allocate, the rest should update in-place + // We expect only 1 allocation for all 10 operations + id(create_count) += 1; // Only count 1 since others should be optimized + + ESP_LOGD("test", "Created 10 same-named defers (should only allocate once)"); + ESP_LOGI("test", "Phase 7 complete"); + + - id: complete_test + then: + - lambda: |- + ESP_LOGI("test", "Pool recycling test complete - created %d items, cancelled %d, intervals %d", + id(create_count), id(cancel_count), id(interval_counter)); + +sensor: + - platform: template + name: Test Sensor + id: test_sensor + lambda: return 1.0; + update_interval: never + +# No interval - tests will be triggered from Python via API services diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py new file mode 100644 index 0000000000..b5f9f12631 --- /dev/null +++ b/tests/integration/test_scheduler_pool.py @@ -0,0 +1,209 @@ +"""Integration test for scheduler memory pool functionality.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_pool( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that the scheduler memory pool is working correctly with realistic usage. + + This test simulates real-world scheduler usage patterns and verifies that: + 1. Items are recycled to the pool when timeouts complete naturally + 2. Items are recycled when intervals/timeouts are cancelled + 3. Items are reused from the pool for new scheduler operations + 4. The pool grows gradually based on actual usage patterns + 5. Pool operations are logged correctly with debug scheduler enabled + """ + # Track log messages to verify pool behavior + log_lines: list[str] = [] + pool_reuse_count = 0 + pool_recycle_count = 0 + pool_full_count = 0 + new_alloc_count = 0 + + # Patterns to match pool operations + reuse_pattern = re.compile(r"Reused item from pool \(pool size now: (\d+)\)") + recycle_pattern = re.compile(r"Recycled item to pool \(pool size now: (\d+)\)") + pool_full_pattern = re.compile(r"Pool full \(size: (\d+)\), deleting item") + new_alloc_pattern = re.compile(r"Allocated new item \(pool empty\)") + + # Futures to track when test phases complete + loop = asyncio.get_running_loop() + test_complete_future: asyncio.Future[bool] = loop.create_future() + phase_futures = { + 1: loop.create_future(), + 2: loop.create_future(), + 3: loop.create_future(), + 4: loop.create_future(), + 5: loop.create_future(), + 6: loop.create_future(), + 7: loop.create_future(), + } + + def check_output(line: str) -> None: + """Check log output for pool operations and phase completion.""" + nonlocal pool_reuse_count, pool_recycle_count, pool_full_count, new_alloc_count + log_lines.append(line) + + # Track pool operations + if reuse_pattern.search(line): + pool_reuse_count += 1 + + elif recycle_pattern.search(line): + pool_recycle_count += 1 + + elif pool_full_pattern.search(line): + pool_full_count += 1 + + elif new_alloc_pattern.search(line): + new_alloc_count += 1 + + # Track phase completion + for phase_num in range(1, 8): + if ( + f"Phase {phase_num} complete" in line + and phase_num in phase_futures + and not phase_futures[phase_num].done() + ): + phase_futures[phase_num].set_result(True) + + # Check for test completion + if "Pool recycling test complete" in line and not test_complete_future.done(): + test_complete_future.set_result(True) + + # Run the test with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device is running + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-pool-test" + + # Get list of services + entities, services = await client.list_entities_services() + service_names = {s.name for s in services} + + # Verify all test services are available + expected_services = { + "run_phase_1", + "run_phase_2", + "run_phase_3", + "run_phase_4", + "run_phase_5", + "run_phase_6", + "run_phase_7", + "run_complete", + } + assert expected_services.issubset(service_names), ( + f"Missing services: {expected_services - service_names}" + ) + + # Get service objects + phase_services = { + num: next(s for s in services if s.name == f"run_phase_{num}") + for num in range(1, 8) + } + complete_service = next(s for s in services if s.name == "run_complete") + + try: + # Phase 1: Component lifecycle + client.execute_service(phase_services[1], {}) + await asyncio.wait_for(phase_futures[1], timeout=1.0) + await asyncio.sleep(0.05) # Let timeouts complete + + # Phase 2: Sensor polling + client.execute_service(phase_services[2], {}) + await asyncio.wait_for(phase_futures[2], timeout=1.0) + await asyncio.sleep(0.1) # Let intervals run a bit + + # Phase 3: Communication patterns + client.execute_service(phase_services[3], {}) + await asyncio.wait_for(phase_futures[3], timeout=1.0) + await asyncio.sleep(0.1) # Let heartbeat run + + # Phase 4: Defer patterns + client.execute_service(phase_services[4], {}) + await asyncio.wait_for(phase_futures[4], timeout=1.0) + await asyncio.sleep(0.2) # Let everything settle and recycle + + # Phase 5: Pool reuse verification + client.execute_service(phase_services[5], {}) + await asyncio.wait_for(phase_futures[5], timeout=1.0) + await asyncio.sleep(0.1) # Let Phase 5 timeouts complete and recycle + + # Phase 6: Full pool reuse verification + client.execute_service(phase_services[6], {}) + await asyncio.wait_for(phase_futures[6], timeout=1.0) + await asyncio.sleep(0.1) # Let Phase 6 timeouts complete + + # Phase 7: Same-named defer optimization + client.execute_service(phase_services[7], {}) + await asyncio.wait_for(phase_futures[7], timeout=1.0) + await asyncio.sleep(0.05) # Let the single defer execute + + # Complete test + client.execute_service(complete_service, {}) + await asyncio.wait_for(test_complete_future, timeout=0.5) + + except TimeoutError as e: + # Print debug info if test times out + recent_logs = "\n".join(log_lines[-30:]) + phases_completed = [num for num, fut in phase_futures.items() if fut.done()] + pytest.fail( + f"Test timed out waiting for phase/completion. Error: {e}\n" + f" Phases completed: {phases_completed}\n" + f" Pool stats:\n" + f" Reuse count: {pool_reuse_count}\n" + f" Recycle count: {pool_recycle_count}\n" + f" Pool full count: {pool_full_count}\n" + f" New alloc count: {new_alloc_count}\n" + f"Recent logs:\n{recent_logs}" + ) + + # Verify all test phases ran + for phase_num in range(1, 8): + assert phase_futures[phase_num].done(), f"Phase {phase_num} did not complete" + + # Verify pool behavior + assert pool_recycle_count > 0, "Should have recycled items to pool" + + # Check pool metrics + if pool_recycle_count > 0: + max_pool_size = 0 + for line in log_lines: + if match := recycle_pattern.search(line): + size = int(match.group(1)) + max_pool_size = max(max_pool_size, size) + + # Pool can grow up to its maximum of 5 + assert max_pool_size <= 5, f"Pool grew beyond maximum ({max_pool_size})" + + # Log summary for debugging + print("\nScheduler Pool Test Summary (Python Orchestrated):") + print(f" Items recycled to pool: {pool_recycle_count}") + print(f" Items reused from pool: {pool_reuse_count}") + print(f" Pool full events: {pool_full_count}") + print(f" New allocations: {new_alloc_count}") + print(" All phases completed successfully") + + # Verify reuse happened + if pool_reuse_count == 0 and pool_recycle_count > 3: + pytest.fail("Pool had items recycled but none were reused") + + # Success - pool is working + assert pool_recycle_count > 0 or new_alloc_count < 15, ( + "Pool should either recycle items or limit new allocations" + ) From 91228c82e659b42c6d5e3540e2af174e4573e3ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 17:29:45 -0500 Subject: [PATCH 0975/1626] [esp8266][logger] Store LOG_LEVELS strings in PROGMEM to reduce RAM usage (#10569) --- esphome/components/logger/logger.cpp | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 0ade9cedae..5f0e78fc0d 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -246,14 +246,35 @@ void Logger::add_on_log_callback(std::functionlog_callback_.add(std::move(callback)); } float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; } + +#ifdef USE_STORE_LOG_STR_IN_FLASH +// ESP8266: PSTR() cannot be used in array initializers, so we need to declare +// each string separately as a global constant first +static const char LOG_LEVEL_NONE[] PROGMEM = "NONE"; +static const char LOG_LEVEL_ERROR[] PROGMEM = "ERROR"; +static const char LOG_LEVEL_WARN[] PROGMEM = "WARN"; +static const char LOG_LEVEL_INFO[] PROGMEM = "INFO"; +static const char LOG_LEVEL_CONFIG[] PROGMEM = "CONFIG"; +static const char LOG_LEVEL_DEBUG[] PROGMEM = "DEBUG"; +static const char LOG_LEVEL_VERBOSE[] PROGMEM = "VERBOSE"; +static const char LOG_LEVEL_VERY_VERBOSE[] PROGMEM = "VERY_VERBOSE"; + +static const LogString *const LOG_LEVELS[] = { + reinterpret_cast(LOG_LEVEL_NONE), reinterpret_cast(LOG_LEVEL_ERROR), + reinterpret_cast(LOG_LEVEL_WARN), reinterpret_cast(LOG_LEVEL_INFO), + reinterpret_cast(LOG_LEVEL_CONFIG), reinterpret_cast(LOG_LEVEL_DEBUG), + reinterpret_cast(LOG_LEVEL_VERBOSE), reinterpret_cast(LOG_LEVEL_VERY_VERBOSE), +}; +#else static const char *const LOG_LEVELS[] = {"NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"}; +#endif void Logger::dump_config() { ESP_LOGCONFIG(TAG, "Logger:\n" " Max Level: %s\n" " Initial Level: %s", - LOG_LEVELS[ESPHOME_LOG_LEVEL], LOG_LEVELS[this->current_level_]); + LOG_STR_ARG(LOG_LEVELS[ESPHOME_LOG_LEVEL]), LOG_STR_ARG(LOG_LEVELS[this->current_level_])); #ifndef USE_HOST ESP_LOGCONFIG(TAG, " Log Baud Rate: %" PRIu32 "\n" @@ -267,14 +288,14 @@ void Logger::dump_config() { #endif for (auto &it : this->log_levels_) { - ESP_LOGCONFIG(TAG, " Level for '%s': %s", it.first.c_str(), LOG_LEVELS[it.second]); + ESP_LOGCONFIG(TAG, " Level for '%s': %s", it.first.c_str(), LOG_STR_ARG(LOG_LEVELS[it.second])); } } void Logger::set_log_level(uint8_t level) { if (level > ESPHOME_LOG_LEVEL) { level = ESPHOME_LOG_LEVEL; - ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s", LOG_LEVELS[ESPHOME_LOG_LEVEL]); + ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s", LOG_STR_ARG(LOG_LEVELS[ESPHOME_LOG_LEVEL])); } this->current_level_ = level; this->level_callback_.call(level); From 1ac07c96b18f0f8d2402f6c9ccdaa7ea3e3ef5d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 17:30:39 -0500 Subject: [PATCH 0976/1626] [esphome] Store OTA component log strings in flash on ESP8266 (#10570) --- esphome/components/esphome/ota/ota_esphome.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 3a5d9f4f7a..55adec351d 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -5,6 +5,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences.h" +#include "esphome/core/log.h" #include "esphome/components/ota/ota_backend.h" #include "esphome/components/socket/socket.h" From 3cf36e2f94d56a6062fd5d1ea654e9e6b922b9fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Sep 2025 17:32:21 -0500 Subject: [PATCH 0977/1626] Bump aioesphomeapi from 40.0.1 to 40.0.2 (#10641) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1a836bd64f..6021b6de70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==40.0.1 +aioesphomeapi==40.0.2 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 8c28f346c75883713ea00d68c7a077fbd40ac7a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 17:57:02 -0500 Subject: [PATCH 0978/1626] [gpio_expander] Add intelligent pin type selection to CachedGpioExpander template (#10577) --- .../components/gpio_expander/cached_gpio.h | 52 +++++++++++++------ .../gpio_expander_test_component.cpp | 2 + .../__init__.py | 24 +++++++++ .../gpio_expander_test_component_uint16.cpp | 43 +++++++++++++++ .../gpio_expander_test_component_uint16.h | 23 ++++++++ .../fixtures/gpio_expander_cache.yaml | 6 ++- tests/integration/test_gpio_expander_cache.py | 43 +++++++++++---- 7 files changed, 166 insertions(+), 27 deletions(-) create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp create mode 100644 tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index d7230eb0b3..eeff98cb6e 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "esphome/core/hal.h" namespace esphome::gpio_expander { @@ -11,18 +12,27 @@ namespace esphome::gpio_expander { /// @brief A class to cache the read state of a GPIO expander. /// This class caches reads between GPIO Pins which are on the same bank. /// This means that for reading whole Port (ex. 8 pins) component needs only one -/// I2C/SPI read per main loop call. It assumes, that one bit in byte identifies one GPIO pin +/// I2C/SPI read per main loop call. It assumes that one bit in byte identifies one GPIO pin. +/// /// Template parameters: -/// T - Type which represents internal register. Could be uint8_t or uint16_t. Adjust to -/// match size of your internal GPIO bank register. -/// N - Number of pins -template class CachedGpioExpander { +/// T - Type which represents internal bank register. Could be uint8_t or uint16_t. +/// Choose based on how your I/O expander reads pins: +/// * uint8_t: For chips that read banks separately (8 pins at a time) +/// Examples: MCP23017 (2x8-bit banks), TCA9555 (2x8-bit banks) +/// * uint16_t: For chips that read all pins at once (up to 16 pins) +/// Examples: PCF8574/8575 (8/16 pins), PCA9554/9555 (8/16 pins) +/// N - Total number of pins (maximum 65535) +/// P - Type for pin number parameters (automatically selected based on N: +/// uint8_t for N<=256, uint16_t for N>256). Can be explicitly specified +/// if needed (e.g., for components like SN74HC165 with >256 pins) +template 256), uint16_t, uint8_t>::type> +class CachedGpioExpander { public: /// @brief Read the state of the given pin. This will invalidate the cache for the given pin number. /// @param pin Pin number to read /// @return Pin state - bool digital_read(T pin) { - const uint8_t bank = pin / BANK_SIZE; + bool digital_read(P pin) { + const P bank = pin / BANK_SIZE; const T pin_mask = (1 << (pin % BANK_SIZE)); // Check if specific pin cache is valid if (this->read_cache_valid_[bank] & pin_mask) { @@ -38,21 +48,31 @@ template class CachedGpioExpander { return this->digital_read_cache(pin); } - void digital_write(T pin, bool value) { this->digital_write_hw(pin, value); } + void digital_write(P pin, bool value) { this->digital_write_hw(pin, value); } protected: - /// @brief Call component low level function to read GPIO state from device - virtual bool digital_read_hw(T pin) = 0; - /// @brief Call component read function from internal cache. - virtual bool digital_read_cache(T pin) = 0; - /// @brief Call component low level function to write GPIO state to device - virtual void digital_write_hw(T pin, bool value) = 0; + /// @brief Read GPIO bank from hardware into internal state + /// @param pin Pin number (used to determine which bank to read) + /// @return true if read succeeded, false on communication error + /// @note This does NOT return the pin state. It returns whether the read operation succeeded. + /// The actual pin state should be returned by digital_read_cache(). + virtual bool digital_read_hw(P pin) = 0; + + /// @brief Get cached pin value from internal state + /// @param pin Pin number to read + /// @return Pin state (true = HIGH, false = LOW) + virtual bool digital_read_cache(P pin) = 0; + + /// @brief Write GPIO state to hardware + /// @param pin Pin number to write + /// @param value Pin state to write (true = HIGH, false = LOW) + virtual void digital_write_hw(P pin, bool value) = 0; /// @brief Invalidate cache. This function should be called in component loop(). void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); } - static constexpr uint8_t BITS_PER_BYTE = 8; - static constexpr uint8_t BANK_SIZE = sizeof(T) * BITS_PER_BYTE; + static constexpr uint16_t BITS_PER_BYTE = 8; + static constexpr uint16_t BANK_SIZE = sizeof(T) * BITS_PER_BYTE; static constexpr size_t BANKS = N / BANK_SIZE; static constexpr size_t CACHE_SIZE_BYTES = BANKS * sizeof(T); diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp index 7e88950592..6e128687c4 100644 --- a/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component/gpio_expander_test_component.cpp @@ -27,11 +27,13 @@ void GPIOExpanderTestComponent::setup() { bool GPIOExpanderTestComponent::digital_read_hw(uint8_t pin) { ESP_LOGD(TAG, "digital_read_hw pin=%d", pin); + // Return true to indicate successful read operation return true; } bool GPIOExpanderTestComponent::digital_read_cache(uint8_t pin) { ESP_LOGD(TAG, "digital_read_cache pin=%d", pin); + // Return the pin state (always HIGH for testing) return true; } diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py new file mode 100644 index 0000000000..76f20b942c --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +AUTO_LOAD = ["gpio_expander"] + +gpio_expander_test_component_uint16_ns = cg.esphome_ns.namespace( + "gpio_expander_test_component_uint16" +) + +GPIOExpanderTestUint16Component = gpio_expander_test_component_uint16_ns.class_( + "GPIOExpanderTestUint16Component", cg.Component +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(GPIOExpanderTestUint16Component), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp new file mode 100644 index 0000000000..09537c81bb --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.cpp @@ -0,0 +1,43 @@ +#include "gpio_expander_test_component_uint16.h" +#include "esphome/core/log.h" + +namespace esphome::gpio_expander_test_component_uint16 { + +static const char *const TAG = "gpio_expander_test_uint16"; + +void GPIOExpanderTestUint16Component::setup() { + ESP_LOGD(TAG, "Testing uint16_t bank (single 16-pin bank)"); + + // Test reading all 16 pins - first should trigger hw read, rest use cache + for (uint8_t pin = 0; pin < 16; pin++) { + this->digital_read(pin); + } + + // Reset cache and test specific reads + ESP_LOGD(TAG, "Resetting cache for uint16_t test"); + this->reset_pin_cache_(); + + // First read triggers hw for entire bank + this->digital_read(5); + // These should all use cache since they're in the same bank + this->digital_read(10); + this->digital_read(15); + this->digital_read(0); + + ESP_LOGD(TAG, "DONE_UINT16"); +} + +bool GPIOExpanderTestUint16Component::digital_read_hw(uint8_t pin) { + ESP_LOGD(TAG, "uint16_digital_read_hw pin=%d", pin); + // In a real component, this would read from I2C/SPI into internal state + // For testing, we just return true to indicate successful read + return true; // Return true to indicate successful read +} + +bool GPIOExpanderTestUint16Component::digital_read_cache(uint8_t pin) { + ESP_LOGD(TAG, "uint16_digital_read_cache pin=%d", pin); + // Return the actual pin state from our test pattern + return (this->test_state_ >> pin) & 1; +} + +} // namespace esphome::gpio_expander_test_component_uint16 diff --git a/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h new file mode 100644 index 0000000000..be102f9b57 --- /dev/null +++ b/tests/integration/fixtures/external_components/gpio_expander_test_component_uint16/gpio_expander_test_component_uint16.h @@ -0,0 +1,23 @@ +#pragma once + +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/core/component.h" + +namespace esphome::gpio_expander_test_component_uint16 { + +// Test component using uint16_t bank type (single 16-pin bank) +class GPIOExpanderTestUint16Component : public Component, + public esphome::gpio_expander::CachedGpioExpander { + public: + void setup() override; + + protected: + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override{}; + + private: + uint16_t test_state_{0xAAAA}; // Test pattern: alternating bits +}; + +} // namespace esphome::gpio_expander_test_component_uint16 diff --git a/tests/integration/fixtures/gpio_expander_cache.yaml b/tests/integration/fixtures/gpio_expander_cache.yaml index 7d7ca1a876..8b5375af4c 100644 --- a/tests/integration/fixtures/gpio_expander_cache.yaml +++ b/tests/integration/fixtures/gpio_expander_cache.yaml @@ -12,6 +12,10 @@ external_components: - source: type: local path: EXTERNAL_COMPONENT_PATH - components: [gpio_expander_test_component] + components: [gpio_expander_test_component, gpio_expander_test_component_uint16] +# Test with uint8_t (multiple banks) gpio_expander_test_component: + +# Test with uint16_t (single bank) +gpio_expander_test_component_uint16: diff --git a/tests/integration/test_gpio_expander_cache.py b/tests/integration/test_gpio_expander_cache.py index 9353bb1dd6..e5f0f2818f 100644 --- a/tests/integration/test_gpio_expander_cache.py +++ b/tests/integration/test_gpio_expander_cache.py @@ -30,9 +30,15 @@ async def test_gpio_expander_cache( logs_done = asyncio.Event() - # Patterns to match in logs - digital_read_hw_pattern = re.compile(r"digital_read_hw pin=(\d+)") - digital_read_cache_pattern = re.compile(r"digital_read_cache pin=(\d+)") + # Patterns to match in logs - match any variation of digital_read + read_hw_pattern = re.compile(r"(?:uint16_)?digital_read_hw pin=(\d+)") + read_cache_pattern = re.compile(r"(?:uint16_)?digital_read_cache pin=(\d+)") + + # Keep specific patterns for building the expected order + digital_read_hw_pattern = re.compile(r"^digital_read_hw pin=(\d+)") + digital_read_cache_pattern = re.compile(r"^digital_read_cache pin=(\d+)") + uint16_read_hw_pattern = re.compile(r"^uint16_digital_read_hw pin=(\d+)") + uint16_read_cache_pattern = re.compile(r"^uint16_digital_read_cache pin=(\d+)") # ensure logs are in the expected order log_order = [ @@ -59,6 +65,17 @@ async def test_gpio_expander_cache( (digital_read_cache_pattern, 14), (digital_read_hw_pattern, 14), (digital_read_cache_pattern, 14), + # uint16_t component tests (single bank of 16 pins) + (uint16_read_hw_pattern, 0), # First pin triggers hw read + [ + (uint16_read_cache_pattern, i) for i in range(0, 16) + ], # All 16 pins return via cache + # After cache reset + (uint16_read_hw_pattern, 5), # First read after reset triggers hw + (uint16_read_cache_pattern, 5), + (uint16_read_cache_pattern, 10), # These use cache (same bank) + (uint16_read_cache_pattern, 15), + (uint16_read_cache_pattern, 0), ] # Flatten the log order for easier processing log_order: list[tuple[re.Pattern, int]] = [ @@ -77,17 +94,22 @@ async def test_gpio_expander_cache( clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - if "digital_read" in clean_line: + # Extract just the log message part (after the log level) + msg = clean_line.split(": ", 1)[-1] if ": " in clean_line else clean_line + + # Check if this line contains a read operation we're tracking + if read_hw_pattern.search(msg) or read_cache_pattern.search(msg): if index >= len(log_order): - print(f"Received unexpected log line: {clean_line}") + print(f"Received unexpected log line: {msg}") logs_done.set() return pattern, expected_pin = log_order[index] - match = pattern.search(clean_line) + match = pattern.search(msg) if not match: - print(f"Log line did not match next expected pattern: {clean_line}") + print(f"Log line did not match next expected pattern: {msg}") + print(f"Expected pattern: {pattern.pattern}") logs_done.set() return @@ -99,9 +121,10 @@ async def test_gpio_expander_cache( index += 1 - elif "DONE" in clean_line: - # Check if we reached the end of the expected log entries - logs_done.set() + elif "DONE_UINT16" in clean_line: + # uint16 component is done, check if we've seen all expected logs + if index == len(log_order): + logs_done.set() # Run with log monitoring async with ( From 629f1e94f1934a6c57424460dd0417b451103b22 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 8 Sep 2025 10:58:41 +1200 Subject: [PATCH 0979/1626] [ota] Fix duplicate include and sort (#10643) --- esphome/components/esphome/ota/ota_esphome.h | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 55adec351d..f5a3e43ae3 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -2,12 +2,11 @@ #include "esphome/core/defines.h" #ifdef USE_OTA +#include "esphome/components/ota/ota_backend.h" +#include "esphome/components/socket/socket.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences.h" -#include "esphome/core/log.h" -#include "esphome/components/ota/ota_backend.h" -#include "esphome/components/socket/socket.h" namespace esphome { From 0cc0979674566d3a1ca2db86ec364ccbfdbb9823 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 17:59:23 -0500 Subject: [PATCH 0980/1626] [pca6416a] Migrate to CachedGpioExpander to reduce I2C bus usage (#10587) --- esphome/components/pca6416a/__init__.py | 1 + esphome/components/pca6416a/pca6416a.cpp | 25 +++++++++++++++++++----- esphome/components/pca6416a/pca6416a.h | 17 +++++++++++----- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index da6c4623c9..e540edb91f 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -14,6 +14,7 @@ from esphome.const import ( CODEOWNERS = ["@Mat931"] DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["gpio_expander"] MULTI_CONF = True pca6416a_ns = cg.esphome_ns.namespace("pca6416a") diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index 730c494e34..c0056e780b 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -51,6 +51,11 @@ void PCA6416AComponent::setup() { this->status_has_error()); } +void PCA6416AComponent::loop() { + // Invalidate cache at the start of each loop + this->reset_pin_cache_(); +} + void PCA6416AComponent::dump_config() { if (this->has_pullup_) { ESP_LOGCONFIG(TAG, "PCAL6416A:"); @@ -63,15 +68,25 @@ void PCA6416AComponent::dump_config() { } } -bool PCA6416AComponent::digital_read(uint8_t pin) { - uint8_t bit = pin % 8; +bool PCA6416AComponent::digital_read_hw(uint8_t pin) { uint8_t reg_addr = pin < 8 ? PCA6416A_INPUT0 : PCA6416A_INPUT1; uint8_t value = 0; - this->read_register_(reg_addr, &value); - return value & (1 << bit); + if (!this->read_register_(reg_addr, &value)) { + return false; + } + + // Update the appropriate part of input_mask_ + if (pin < 8) { + this->input_mask_ = (this->input_mask_ & 0xFF00) | value; + } else { + this->input_mask_ = (this->input_mask_ & 0x00FF) | (uint16_t(value) << 8); + } + return true; } -void PCA6416AComponent::digital_write(uint8_t pin, bool value) { +bool PCA6416AComponent::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } + +void PCA6416AComponent::digital_write_hw(uint8_t pin, bool value) { uint8_t reg_addr = pin < 8 ? PCA6416A_OUTPUT0 : PCA6416A_OUTPUT1; this->update_register_(pin, value, reg_addr); } diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 1e8015c40a..10a4a64e9b 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -3,20 +3,20 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/gpio_expander/cached_gpio.h" namespace esphome { namespace pca6416a { -class PCA6416AComponent : public Component, public i2c::I2CDevice { +class PCA6416AComponent : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCA6416AComponent() = default; /// Check i2c availability and setup masks void setup() override; - /// Helper function to read the value of a pin. - bool digital_read(uint8_t pin); - /// Helper function to write the value of a pin. - void digital_write(uint8_t pin, bool value); + void loop() override; /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); @@ -25,6 +25,11 @@ class PCA6416AComponent : public Component, public i2c::I2CDevice { void dump_config() override; protected: + // Virtual methods from CachedGpioExpander + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + bool read_register_(uint8_t reg, uint8_t *value); bool write_register_(uint8_t reg, uint8_t value); void update_register_(uint8_t pin, bool pin_value, uint8_t reg_addr); @@ -32,6 +37,8 @@ class PCA6416AComponent : public Component, public i2c::I2CDevice { /// The mask to write as output state - 1 means HIGH, 0 means LOW uint8_t output_0_{0x00}; uint8_t output_1_{0x00}; + /// Cache for input values (16-bit combined for both banks) + uint16_t input_mask_{0x00}; /// Storage for last I2C error seen esphome::i2c::ErrorCode last_error_; /// Only the PCAL6416A has pull-up resistors From 00e54961a2e697bc30e0f437f687e111df2e4d30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 18:05:14 -0500 Subject: [PATCH 0981/1626] fix merge --- esphome/core/scheduler.cpp | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 43e84dd8c4..d53567d975 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -835,25 +835,4 @@ void Scheduler::recycle_item_(std::unique_ptr item) { // else: unique_ptr will delete the item when it goes out of scope } -void Scheduler::recycle_item_(std::unique_ptr item) { - if (!item) - return; - - if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) { - // Clear callback to release captured resources - item->callback = nullptr; - // Clear dynamic name if any - item->clear_dynamic_name(); - this->scheduler_item_pool_.push_back(std::move(item)); -#ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_.size()); -#endif - } else { -#ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Pool full (size: %zu), deleting item", this->scheduler_item_pool_.size()); -#endif - } - // else: unique_ptr will delete the item when it goes out of scope -} - } // namespace esphome From 424e0a97b26c2f8194b3fb73d71cfa06bd6fd3bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 18:23:03 -0500 Subject: [PATCH 0982/1626] const --- esphome/components/api/api_frame_helper_plaintext.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index ceb573d562..859bb26630 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -201,19 +201,20 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { // We must send at least 3 bytes to be read, so we add // a message after the indicator byte to ensures its long // enough and can aid in debugging. + static constexpr uint8_t INDICATOR_MSG_SIZE = 19; #ifdef USE_ESP8266 static const char MSG_PROGMEM[] PROGMEM = "\x00" "Bad indicator byte"; - char msg[19]; - memcpy_P(msg, MSG_PROGMEM, 19); + char msg[INDICATOR_MSG_SIZE]; + memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); iov[0].iov_base = (void *) msg; #else static const char MSG[] = "\x00" "Bad indicator byte"; iov[0].iov_base = (void *) MSG; #endif - iov[0].iov_len = 19; - this->write_raw_(iov, 1, 19); + iov[0].iov_len = INDICATOR_MSG_SIZE; + this->write_raw_(iov, 1, INDICATOR_MSG_SIZE); } return aerr; } From 93da52c4d294561fed5b82bf7d36340b62be098d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 18:26:11 -0500 Subject: [PATCH 0983/1626] [pca9554] Migrate to CachedGpioExpander to reduce I2C bus usage (#10571) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 2 +- esphome/components/pca9554/__init__.py | 3 ++- esphome/components/pca9554/pca9554.cpp | 30 +++++++++++--------------- esphome/components/pca9554/pca9554.h | 19 ++++++++-------- 4 files changed, 25 insertions(+), 29 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 116f35f3b6..c34774fce6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -342,7 +342,7 @@ esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core esphome/components/packet_transport/* @clydebarrow esphome/components/pca6416a/* @Mat931 -esphome/components/pca9554/* @clydebarrow @hwstar +esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman esphome/components/pi4ioe5v6408/* @jesserockz diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 05713cccda..626b08a378 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -11,7 +11,8 @@ from esphome.const import ( CONF_OUTPUT, ) -CODEOWNERS = ["@hwstar", "@clydebarrow"] +CODEOWNERS = ["@hwstar", "@clydebarrow", "@bdraco"] +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] MULTI_CONF = True CONF_PIN_COUNT = "pin_count" diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index 1166cc1a09..e8d49f66e2 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -37,10 +37,9 @@ void PCA9554Component::setup() { } void PCA9554Component::loop() { - // The read_inputs_() method will cache the input values from the chip. - this->read_inputs_(); - // Clear all the previously read flags. - this->was_previously_read_ = 0x00; + // Invalidate the cache at the start of each loop. + // The actual read will happen on demand when digital_read() is called + this->reset_pin_cache_(); } void PCA9554Component::dump_config() { @@ -54,21 +53,17 @@ void PCA9554Component::dump_config() { } } -bool PCA9554Component::digital_read(uint8_t pin) { - // Note: We want to try and avoid doing any I2C bus read transactions here - // to conserve I2C bus bandwidth. So what we do is check to see if we - // have seen a read during the time esphome is running this loop. If we have, - // we do an I2C bus transaction to get the latest value. If we haven't - // we return a cached value which was read at the time loop() was called. - if (this->was_previously_read_ & (1 << pin)) - this->read_inputs_(); // Force a read of a new value - // Indicate we saw a read request for this pin in case a - // read happens later in the same loop. - this->was_previously_read_ |= (1 << pin); +bool PCA9554Component::digital_read_hw(uint8_t pin) { + // Read all pins from hardware into input_mask_ + return this->read_inputs_(); // Return true if I2C read succeeded, false on error +} + +bool PCA9554Component::digital_read_cache(uint8_t pin) { + // Return the cached pin state from input_mask_ return this->input_mask_ & (1 << pin); } -void PCA9554Component::digital_write(uint8_t pin, bool value) { +void PCA9554Component::digital_write_hw(uint8_t pin, bool value) { if (value) { this->output_mask_ |= (1 << pin); } else { @@ -127,8 +122,7 @@ bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) { float PCA9554Component::get_setup_priority() const { return setup_priority::IO; } -// Run our loop() method very early in the loop, so that we cache read values before -// before other components call our digital_read() method. +// Run our loop() method early to invalidate cache before any other components access the pins float PCA9554Component::get_loop_priority() const { return 9.0f; } // Just after WIFI void PCA9554GPIOPin::setup() { pin_mode(flags_); } diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index efeec4d306..7b356b4068 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -3,22 +3,21 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/gpio_expander/cached_gpio.h" namespace esphome { namespace pca9554 { -class PCA9554Component : public Component, public i2c::I2CDevice { +class PCA9554Component : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCA9554Component() = default; /// Check i2c availability and setup masks void setup() override; - /// Poll for input changes periodically + /// Invalidate cache at start of each loop void loop() override; - /// Helper function to read the value of a pin. - bool digital_read(uint8_t pin); - /// Helper function to write the value of a pin. - void digital_write(uint8_t pin, bool value); /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); @@ -32,9 +31,13 @@ class PCA9554Component : public Component, public i2c::I2CDevice { protected: bool read_inputs_(); - bool write_register_(uint8_t reg, uint16_t value); + // Virtual methods from CachedGpioExpander + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + /// number of bits the expander has size_t pin_count_{8}; /// width of registers @@ -45,8 +48,6 @@ class PCA9554Component : public Component, public i2c::I2CDevice { uint16_t output_mask_{0x00}; /// The state of the actual input pin states - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; - /// Flags to check if read previously during this loop - uint16_t was_previously_read_ = {0x00}; /// Storage for last I2C error seen esphome::i2c::ErrorCode last_error_; }; From afa191ae41365918a1a085139d5ea6bb144c5652 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 18:26:22 -0500 Subject: [PATCH 0984/1626] [pcf8574] Migrate to CachedGpioExpander to reduce I2C bus usage (#10573) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/pcf8574/__init__.py | 1 + esphome/components/pcf8574/pcf8574.cpp | 19 ++++++++++++++----- esphome/components/pcf8574/pcf8574.h | 19 +++++++++++++------ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index ff7c314bcd..f387d0a610 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_OUTPUT, ) +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] MULTI_CONF = True diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 848fbed484..72d8865d7f 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -16,6 +16,10 @@ void PCF8574Component::setup() { this->write_gpio_(); this->read_gpio_(); } +void PCF8574Component::loop() { + // Invalidate the cache at the start of each loop + this->reset_pin_cache_(); +} void PCF8574Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF8574:"); LOG_I2C_DEVICE(this) @@ -24,17 +28,19 @@ void PCF8574Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } } -bool PCF8574Component::digital_read(uint8_t pin) { - this->read_gpio_(); - return this->input_mask_ & (1 << pin); +bool PCF8574Component::digital_read_hw(uint8_t pin) { + // Read all pins from hardware into input_mask_ + return this->read_gpio_(); // Return true if I2C read succeeded, false on error } -void PCF8574Component::digital_write(uint8_t pin, bool value) { + +bool PCF8574Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } + +void PCF8574Component::digital_write_hw(uint8_t pin, bool value) { if (value) { this->output_mask_ |= (1 << pin); } else { this->output_mask_ &= ~(1 << pin); } - this->write_gpio_(); } void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) { @@ -91,6 +97,9 @@ bool PCF8574Component::write_gpio_() { } float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } +// Run our loop() method early to invalidate cache before any other components access the pins +float PCF8574Component::get_loop_priority() const { return 9.0f; } // Just after WIFI + void PCF8574GPIOPin::setup() { pin_mode(flags_); } void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 6edc67fc96..fd1ea8af63 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -3,11 +3,16 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/gpio_expander/cached_gpio.h" namespace esphome { namespace pcf8574 { -class PCF8574Component : public Component, public i2c::I2CDevice { +// PCF8574(8 pins)/PCF8575(16 pins) always read/write all pins in a single I2C transaction +// so we use uint16_t as bank type to ensure all pins are in one bank and cached together +class PCF8574Component : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCF8574Component() = default; @@ -15,20 +20,22 @@ class PCF8574Component : public Component, public i2c::I2CDevice { /// Check i2c availability and setup masks void setup() override; - /// Helper function to read the value of a pin. - bool digital_read(uint8_t pin); - /// Helper function to write the value of a pin. - void digital_write(uint8_t pin, bool value); + /// Invalidate cache at start of each loop + void loop() override; /// Helper function to set the pin mode of a pin. void pin_mode(uint8_t pin, gpio::Flags flags); float get_setup_priority() const override; + float get_loop_priority() const override; void dump_config() override; protected: - bool read_gpio_(); + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + bool read_gpio_(); bool write_gpio_(); /// Mask for the pin mode - 1 means output, 0 means input From 6e2bcabbc9ae8d654e47d587c5ae47cfe41a4e6f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 18:26:33 -0500 Subject: [PATCH 0985/1626] [sx1509] Migrate to CachedGpioExpander to reduce I2C bus usage (#10588) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/sx1509/__init__.py | 2 +- esphome/components/sx1509/sx1509.cpp | 19 ++++++++++++------- esphome/components/sx1509/sx1509.h | 15 +++++++++++---- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index 67dc924903..b61b92fd1e 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -25,7 +25,7 @@ CONF_SCAN_TIME = "scan_time" CONF_DEBOUNCE_TIME = "debounce_time" CONF_SX1509_ID = "sx1509_id" -AUTO_LOAD = ["key_provider"] +AUTO_LOAD = ["key_provider", "gpio_expander"] DEPENDENCIES = ["i2c"] MULTI_CONF = True diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 2bf6701dd2..746ec9cda3 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -39,6 +39,9 @@ void SX1509Component::dump_config() { } void SX1509Component::loop() { + // Reset cache at the start of each loop + this->reset_pin_cache_(); + if (this->has_keypad_) { if (millis() - this->last_loop_timestamp_ < min_loop_period_) return; @@ -73,18 +76,20 @@ void SX1509Component::loop() { } } -bool SX1509Component::digital_read(uint8_t pin) { +bool SX1509Component::digital_read_hw(uint8_t pin) { + // Always read all pins when any input pin is accessed + return this->read_byte_16(REG_DATA_B, &this->input_mask_); +} + +bool SX1509Component::digital_read_cache(uint8_t pin) { + // Return cached value for input pins, false for output pins if (this->ddr_mask_ & (1 << pin)) { - uint16_t temp_reg_data; - if (!this->read_byte_16(REG_DATA_B, &temp_reg_data)) - return false; - if (temp_reg_data & (1 << pin)) - return true; + return (this->input_mask_ & (1 << pin)) != 0; } return false; } -void SX1509Component::digital_write(uint8_t pin, bool bit_value) { +void SX1509Component::digital_write_hw(uint8_t pin, bool bit_value) { if ((~this->ddr_mask_) & (1 << pin)) { // If the pin is an output, write high/low uint16_t temp_reg_data = 0; diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index c0e86aa8a1..2afd0d0e4e 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -2,6 +2,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/key_provider/key_provider.h" +#include "esphome/components/gpio_expander/cached_gpio.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "sx1509_gpio_pin.h" @@ -30,7 +31,10 @@ class SX1509Processor { class SX1509KeyTrigger : public Trigger {}; -class SX1509Component : public Component, public i2c::I2CDevice, public key_provider::KeyProvider { +class SX1509Component : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander, + public key_provider::KeyProvider { public: SX1509Component() = default; @@ -39,11 +43,9 @@ class SX1509Component : public Component, public i2c::I2CDevice, public key_prov float get_setup_priority() const override { return setup_priority::HARDWARE; } void loop() override; - bool digital_read(uint8_t pin); uint16_t read_key_data(); void set_pin_value(uint8_t pin, uint8_t i_on) { this->write_byte(REG_I_ON[pin], i_on); }; void pin_mode(uint8_t pin, gpio::Flags flags); - void digital_write(uint8_t pin, bool bit_value); uint32_t get_clock() { return this->clk_x_; }; void set_rows_cols(uint8_t rows, uint8_t cols) { this->rows_ = rows; @@ -61,10 +63,15 @@ class SX1509Component : public Component, public i2c::I2CDevice, public key_prov void setup_led_driver(uint8_t pin); protected: + // Virtual methods from CachedGpioExpander + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + uint32_t clk_x_ = 2000000; uint8_t frequency_ = 0; uint16_t ddr_mask_ = 0x00; - uint16_t input_mask_ = 0x00; + uint16_t input_mask_ = 0x00; // Cache for input values (16-bit for all pins) uint16_t port_mask_ = 0x00; uint16_t output_state_ = 0x00; bool has_keypad_ = false; From 0ff08bbc090bd02cab8d3658d9cd0d8b843c656f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 18:26:49 -0500 Subject: [PATCH 0986/1626] [mcp23016] Migrate to CachedGpioExpander to reduce I2C bus usage (#10581) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/mcp23016/__init__.py | 1 + esphome/components/mcp23016/mcp23016.cpp | 25 +++++++++++++++++++----- esphome/components/mcp23016/mcp23016.h | 14 +++++++++---- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 3333e46c97..5a1f011617 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_OUTPUT, ) +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] MULTI_CONF = True diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index 9d8d6e4dae..be86cb2256 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -22,14 +22,29 @@ void MCP23016::setup() { this->write_reg_(MCP23016_IODIR0, 0xFF); this->write_reg_(MCP23016_IODIR1, 0xFF); } -bool MCP23016::digital_read(uint8_t pin) { - uint8_t bit = pin % 8; + +void MCP23016::loop() { + // Invalidate cache at the start of each loop + this->reset_pin_cache_(); +} +bool MCP23016::digital_read_hw(uint8_t pin) { uint8_t reg_addr = pin < 8 ? MCP23016_GP0 : MCP23016_GP1; uint8_t value = 0; - this->read_reg_(reg_addr, &value); - return value & (1 << bit); + if (!this->read_reg_(reg_addr, &value)) { + return false; + } + + // Update the appropriate part of input_mask_ + if (pin < 8) { + this->input_mask_ = (this->input_mask_ & 0xFF00) | value; + } else { + this->input_mask_ = (this->input_mask_ & 0x00FF) | (uint16_t(value) << 8); + } + return true; } -void MCP23016::digital_write(uint8_t pin, bool value) { + +bool MCP23016::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } +void MCP23016::digital_write_hw(uint8_t pin, bool value) { uint8_t reg_addr = pin < 8 ? MCP23016_OLAT0 : MCP23016_OLAT1; this->update_reg_(pin, value, reg_addr); } diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index e4ed47a3b2..781c207de0 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/gpio_expander/cached_gpio.h" namespace esphome { namespace mcp23016 { @@ -24,19 +25,22 @@ enum MCP23016GPIORegisters { MCP23016_IOCON1 = 0x0B, }; -class MCP23016 : public Component, public i2c::I2CDevice { +class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { public: MCP23016() = default; void setup() override; - - bool digital_read(uint8_t pin); - void digital_write(uint8_t pin, bool value); + void loop() override; void pin_mode(uint8_t pin, gpio::Flags flags); float get_setup_priority() const override; protected: + // Virtual methods from CachedGpioExpander + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + // read a given register bool read_reg_(uint8_t reg, uint8_t *value); // write a value to a given register @@ -46,6 +50,8 @@ class MCP23016 : public Component, public i2c::I2CDevice { uint8_t olat_0_{0x00}; uint8_t olat_1_{0x00}; + // Cache for input values (16-bit combined for both banks) + uint16_t input_mask_{0x00}; }; class MCP23016GPIOPin : public GPIOPin { From 166ad942ef74bbf74123c1126064113022eff23c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 18:51:07 -0500 Subject: [PATCH 0987/1626] [scheduler] Reduce SchedulerItem memory usage by 7.4% on 32-bit platforms (#10553) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/core/scheduler.cpp | 26 +++++++++++++++----------- esphome/core/scheduler.h | 36 +++++++++++++++++++++++++++--------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 9a9e61d579..90d19e1ead 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -27,7 +27,6 @@ static constexpr size_t MAX_POOL_SIZE = 5; // Set to 5 to match the pool size - when we have as many cancelled items as our // pool can hold, it's time to clean up and recycle them. static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 5; - // Half the 32-bit range - used to detect rollovers vs normal time progression static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() / 2; // max delay to start an interval sequence @@ -146,12 +145,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // first execution happens immediately after a random smallish offset // Calculate random offset (0 to min(interval/2, 5s)) uint32_t offset = (uint32_t) (std::min(delay / 2, MAX_INTERVAL_DELAY) * random_float()); - item->next_execution_ = now + offset; + item->set_next_execution(now + offset); ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms", name_cstr ? name_cstr : "", delay, offset); } else { item->interval = 0; - item->next_execution_ = now + delay; + item->set_next_execution(now + delay); } #ifdef ESPHOME_DEBUG_SCHEDULER @@ -167,7 +166,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type name_cstr ? name_cstr : "(null)", type_str, delay); } else { ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ", offset=%" PRIu32 ")", type_str, item->get_source(), - name_cstr ? name_cstr : "(null)", type_str, delay, static_cast(item->next_execution_ - now)); + name_cstr ? name_cstr : "(null)", type_str, delay, + static_cast(item->get_next_execution() - now)); } #endif /* ESPHOME_DEBUG_SCHEDULER */ @@ -312,9 +312,10 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { auto &item = this->items_[0]; // Convert the fresh timestamp from caller (usually Application::loop()) to 64-bit const auto now_64 = this->millis_64_(now); // 'now' from parameter - fresh from caller - if (item->next_execution_ < now_64) + const uint64_t next_exec = item->get_next_execution(); + if (next_exec < now_64) return 0; - return item->next_execution_ - now_64; + return next_exec - now_64; } void HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE @@ -387,7 +388,7 @@ void HOT Scheduler::call(uint32_t now) { bool is_cancelled = is_item_removed_(item.get()); ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64 "%s", item->get_type_str(), item->get_source(), name ? name : "(null)", item->interval, - item->next_execution_ - now_64, item->next_execution_, is_cancelled ? " [CANCELLED]" : ""); + item->get_next_execution() - now_64, item->get_next_execution(), is_cancelled ? " [CANCELLED]" : ""); old_items.push_back(std::move(item)); } @@ -439,7 +440,7 @@ void HOT Scheduler::call(uint32_t now) { { // Don't copy-by value yet auto &item = this->items_[0]; - if (item->next_execution_ > now_64) { + if (item->get_next_execution() > now_64) { // Not reached timeout yet, done for this call break; } @@ -478,7 +479,7 @@ void HOT Scheduler::call(uint32_t now) { const char *item_name = item->get_name(); ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")", item->get_type_str(), item->get_source(), item_name ? item_name : "(null)", item->interval, - item->next_execution_, now_64); + item->get_next_execution(), now_64); #endif /* ESPHOME_DEBUG_SCHEDULER */ // Warning: During callback(), a lot of stuff can happen, including: @@ -503,7 +504,7 @@ void HOT Scheduler::call(uint32_t now) { } if (item->type == SchedulerItem::INTERVAL) { - item->next_execution_ = now_64 + item->interval; + item->set_next_execution(now_64 + item->interval); // Add new item directly to to_add_ // since we have the lock held this->to_add_.push_back(std::move(item)); @@ -806,7 +807,10 @@ uint64_t Scheduler::millis_64_(uint32_t now) { bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr &a, const std::unique_ptr &b) { - return a->next_execution_ > b->next_execution_; + // High bits are almost always equal (change only on 32-bit rollover ~49 days) + // Optimize for common case: check low bits first when high bits are equal + return (a->next_execution_high_ == b->next_execution_high_) ? (a->next_execution_low_ > b->next_execution_low_) + : (a->next_execution_high_ > b->next_execution_high_); } void Scheduler::recycle_item_(std::unique_ptr item) { diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 85cfaab2e0..68ad64b9b1 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -88,19 +88,22 @@ class Scheduler { struct SchedulerItem { // Ordered by size to minimize padding Component *component; - uint32_t interval; - // 64-bit time to handle millis() rollover. The scheduler combines the 32-bit millis() - // with a 16-bit rollover counter to create a 64-bit time that won't roll over for - // billions of years. This ensures correct scheduling even when devices run for months. - uint64_t next_execution_; - // Optimized name storage using tagged union union { const char *static_name; // For string literals (no allocation) char *dynamic_name; // For allocated strings } name_; - + uint32_t interval; + // Split time to handle millis() rollover. The scheduler combines the 32-bit millis() + // with a 16-bit rollover counter to create a 48-bit time space (using 32+16 bits). + // This is intentionally limited to 48 bits, not stored as a full 64-bit value. + // With 49.7 days per 32-bit rollover, the 16-bit counter supports + // 49.7 days × 65536 = ~8900 years. This ensures correct scheduling + // even when devices run for months. Split into two fields for better memory + // alignment on 32-bit systems. + uint32_t next_execution_low_; // Lower 32 bits of execution time (millis value) std::function callback; + uint16_t next_execution_high_; // Upper 16 bits (millis_major counter) #ifdef ESPHOME_THREAD_MULTI_ATOMICS // Multi-threaded with atomics: use atomic for lock-free access @@ -126,7 +129,8 @@ class Scheduler { SchedulerItem() : component(nullptr), interval(0), - next_execution_(0), + next_execution_low_(0), + next_execution_high_(0), #ifdef ESPHOME_THREAD_MULTI_ATOMICS // remove is initialized in the member declaration as std::atomic{false} type(TIMEOUT), @@ -185,7 +189,21 @@ class Scheduler { } static bool cmp(const std::unique_ptr &a, const std::unique_ptr &b); - const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } + + // Note: We use 48 bits total (32 + 16), stored in a 64-bit value for API compatibility. + // The upper 16 bits of the 64-bit value are always zero, which is fine since + // millis_major_ is also 16 bits and they must match. + constexpr uint64_t get_next_execution() const { + return (static_cast(next_execution_high_) << 32) | next_execution_low_; + } + + constexpr void set_next_execution(uint64_t value) { + next_execution_low_ = static_cast(value); + // Cast to uint16_t intentionally truncates to lower 16 bits of the upper 32 bits. + // This is correct because millis_major_ that creates these values is also 16 bits. + next_execution_high_ = static_cast(value >> 32); + } + constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } const char *get_source() const { return component ? component->get_component_source() : "unknown"; } }; From 7eaaa4e4266a22b15a1456b0ac59e8c0411ab3bb Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 8 Sep 2025 10:56:34 +1000 Subject: [PATCH 0988/1626] [mipi_rgb] Unified driver for MIPI RGB displays (#9892) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/mipi/__init__.py | 106 +++-- esphome/components/mipi_rgb/__init__.py | 2 + esphome/components/mipi_rgb/display.py | 321 +++++++++++++++ esphome/components/mipi_rgb/mipi_rgb.cpp | 388 ++++++++++++++++++ esphome/components/mipi_rgb/mipi_rgb.h | 127 ++++++ esphome/components/mipi_rgb/models/guition.py | 24 ++ esphome/components/mipi_rgb/models/lilygo.py | 228 ++++++++++ esphome/components/mipi_rgb/models/rpi.py | 9 + esphome/components/mipi_rgb/models/st7701s.py | 214 ++++++++++ .../components/mipi_rgb/models/waveshare.py | 64 +++ .../mipi_rgb/test.esp32-s3-idf.yaml | 67 +++ 12 files changed, 1520 insertions(+), 31 deletions(-) create mode 100644 esphome/components/mipi_rgb/__init__.py create mode 100644 esphome/components/mipi_rgb/display.py create mode 100644 esphome/components/mipi_rgb/mipi_rgb.cpp create mode 100644 esphome/components/mipi_rgb/mipi_rgb.h create mode 100644 esphome/components/mipi_rgb/models/guition.py create mode 100644 esphome/components/mipi_rgb/models/rpi.py create mode 100644 esphome/components/mipi_rgb/models/st7701s.py create mode 100644 esphome/components/mipi_rgb/models/waveshare.py create mode 100644 tests/components/mipi_rgb/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index c34774fce6..3f963bf960 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -299,6 +299,7 @@ esphome/components/mics_4514/* @jesserockz esphome/components/midea/* @dudanov esphome/components/midea_ir/* @dudanov esphome/components/mipi_dsi/* @clydebarrow +esphome/components/mipi_rgb/* @clydebarrow esphome/components/mipi_spi/* @clydebarrow esphome/components/mitsubishi/* @RubyBailey esphome/components/mixer/speaker/* @kahrendt diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index a9ecb9d79a..8b1ca899df 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -222,6 +222,12 @@ def delay(ms): class DriverChip: + """ + A class representing a MIPI DBI driver chip model. + The parameters supplied as defaults will be used to provide default values for the display configuration. + Setting swap_xy to cv.UNDEFINED will indicate that the model does not support swapping X and Y axes. + """ + models: dict[str, Self] = {} def __init__( @@ -232,7 +238,7 @@ class DriverChip: ): name = name.upper() self.name = name - self.initsequence = initsequence or defaults.get("init_sequence") + self.initsequence = initsequence self.defaults = defaults DriverChip.models[name] = self @@ -246,6 +252,17 @@ class DriverChip: return models def extend(self, name, **kwargs) -> "DriverChip": + """ + Extend the current model with additional parameters or a modified init sequence. + Parameters supplied here will override the defaults of the current model. + if the initsequence is not provided, the current model's initsequence will be used. + If add_init_sequence is provided, it will be appended to the current initsequence. + :param name: + :param kwargs: + :return: + """ + initsequence = list(kwargs.pop("initsequence", self.initsequence)) + initsequence.extend(kwargs.pop("add_init_sequence", ())) defaults = self.defaults.copy() if ( CONF_WIDTH in defaults @@ -260,23 +277,39 @@ class DriverChip: ): defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] defaults.update(kwargs) - return DriverChip(name, initsequence=self.initsequence, **defaults) + return self.__class__(name, initsequence=tuple(initsequence), **defaults) def get_default(self, key, fallback: Any = False) -> Any: return self.defaults.get(key, fallback) + @property + def transforms(self) -> set[str]: + """ + Return the available transforms for this model. + """ + if self.get_default("no_transform", False): + return set() + if self.get_default(CONF_SWAP_XY) != cv.UNDEFINED: + return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} + return {CONF_MIRROR_X, CONF_MIRROR_Y} + def option(self, name, fallback=False) -> cv.Optional: return cv.Optional(name, default=self.get_default(name, fallback)) def rotation_as_transform(self, config) -> bool: """ Check if a rotation can be implemented in hardware using the MADCTL register. - A rotation of 180 is always possible, 90 and 270 are possible if the model supports swapping X and Y. + A rotation of 180 is always possible if x and y mirroring are supported, 90 and 270 are possible if the model supports swapping X and Y. """ + transforms = self.transforms rotation = config.get(CONF_ROTATION, 0) - return rotation and ( - self.get_default(CONF_SWAP_XY) != cv.UNDEFINED or rotation == 180 - ) + if rotation == 0 or not transforms: + return False + if rotation == 180: + return CONF_MIRROR_X in transforms and CONF_MIRROR_Y in transforms + if rotation == 90: + return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms + return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms def get_dimensions(self, config) -> tuple[int, int, int, int]: if CONF_DIMENSIONS in config: @@ -301,10 +334,10 @@ class DriverChip: # if mirroring axes and there are offsets, also mirror the offsets to cater for situations where # the offset is asymmetric - if transform[CONF_MIRROR_X]: + if transform.get(CONF_MIRROR_X): native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2) offset_width = native_width - width - offset_width - if transform[CONF_MIRROR_Y]: + if transform.get(CONF_MIRROR_Y): native_height = self.get_default( CONF_NATIVE_HEIGHT, height + offset_height * 2 ) @@ -314,7 +347,7 @@ class DriverChip: 90, 270, ) - if transform[CONF_SWAP_XY] is True or rotated: + if transform.get(CONF_SWAP_XY) is True or rotated: width, height = height, width offset_height, offset_width = offset_width, offset_height return width, height, offset_width, offset_height @@ -324,27 +357,50 @@ class DriverChip: transform = config.get( CONF_TRANSFORM, { - CONF_MIRROR_X: self.get_default(CONF_MIRROR_X, False), - CONF_MIRROR_Y: self.get_default(CONF_MIRROR_Y, False), - CONF_SWAP_XY: self.get_default(CONF_SWAP_XY, False), + CONF_MIRROR_X: self.get_default(CONF_MIRROR_X), + CONF_MIRROR_Y: self.get_default(CONF_MIRROR_Y), + CONF_SWAP_XY: self.get_default(CONF_SWAP_XY), }, ) + # fill in defaults if not provided + mirror_x = transform.get(CONF_MIRROR_X, self.get_default(CONF_MIRROR_X)) + mirror_y = transform.get(CONF_MIRROR_Y, self.get_default(CONF_MIRROR_Y)) + swap_xy = transform.get(CONF_SWAP_XY, self.get_default(CONF_SWAP_XY)) + transform[CONF_MIRROR_X] = mirror_x + transform[CONF_MIRROR_Y] = mirror_y + transform[CONF_SWAP_XY] = swap_xy # Can we use the MADCTL register to set the rotation? if can_transform and CONF_TRANSFORM not in config: rotation = config[CONF_ROTATION] if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] + transform[CONF_MIRROR_X] = not mirror_x + transform[CONF_MIRROR_Y] = not mirror_y elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] + transform[CONF_SWAP_XY] = not swap_xy + transform[CONF_MIRROR_X] = not mirror_x else: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] + transform[CONF_SWAP_XY] = not swap_xy + transform[CONF_MIRROR_Y] = not mirror_y transform[CONF_TRANSFORM] = True return transform + def add_madctl(self, sequence: list, config: dict): + # Add the MADCTL command to the sequence based on the configuration. + use_flip = config.get(CONF_USE_AXIS_FLIPS) + madctl = 0 + transform = self.get_transform(config) + if transform[CONF_MIRROR_X]: + madctl |= MADCTL_XFLIP if use_flip else MADCTL_MX + if transform[CONF_MIRROR_Y]: + madctl |= MADCTL_YFLIP if use_flip else MADCTL_MY + if transform.get(CONF_SWAP_XY) is True: # Exclude Undefined + madctl |= MADCTL_MV + if config[CONF_COLOR_ORDER] == MODE_BGR: + madctl |= MADCTL_BGR + sequence.append((MADCTL, madctl)) + return madctl + def get_sequence(self, config) -> tuple[tuple[int, ...], int]: """ Create the init sequence for the display. @@ -367,21 +423,9 @@ class DriverChip: pixel_mode = PIXEL_MODES[pixel_mode] sequence.append((PIXFMT, pixel_mode)) - # Does the chip use the flipping bits for mirroring rather than the reverse order bits? - use_flip = config.get(CONF_USE_AXIS_FLIPS) - madctl = 0 - transform = self.get_transform(config) if self.rotation_as_transform(config): LOGGER.info("Using hardware transform to implement rotation") - if transform.get(CONF_MIRROR_X): - madctl |= MADCTL_XFLIP if use_flip else MADCTL_MX - if transform.get(CONF_MIRROR_Y): - madctl |= MADCTL_YFLIP if use_flip else MADCTL_MY - if transform.get(CONF_SWAP_XY) is True: # Exclude Undefined - madctl |= MADCTL_MV - if config[CONF_COLOR_ORDER] == MODE_BGR: - madctl |= MADCTL_BGR - sequence.append((MADCTL, madctl)) + madctl = self.add_madctl(sequence, config) if config[CONF_INVERT_COLORS]: sequence.append((INVON,)) else: diff --git a/esphome/components/mipi_rgb/__init__.py b/esphome/components/mipi_rgb/__init__.py new file mode 100644 index 0000000000..4f9972c6e0 --- /dev/null +++ b/esphome/components/mipi_rgb/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@clydebarrow"] +DOMAIN = "mipi_rgb" diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py new file mode 100644 index 0000000000..3001d33980 --- /dev/null +++ b/esphome/components/mipi_rgb/display.py @@ -0,0 +1,321 @@ +import importlib +import pkgutil + +from esphome import pins +import esphome.codegen as cg +from esphome.components import display, spi +from esphome.components.const import ( + BYTE_ORDER_BIG, + BYTE_ORDER_LITTLE, + CONF_BYTE_ORDER, + CONF_DRAW_ROUNDING, +) +from esphome.components.display import CONF_SHOW_TEST_CARD +from esphome.components.esp32 import const, only_on_variant +from esphome.components.mipi import ( + COLOR_ORDERS, + CONF_DE_PIN, + CONF_HSYNC_BACK_PORCH, + CONF_HSYNC_FRONT_PORCH, + CONF_HSYNC_PULSE_WIDTH, + CONF_PCLK_PIN, + CONF_PIXEL_MODE, + CONF_USE_AXIS_FLIPS, + CONF_VSYNC_BACK_PORCH, + CONF_VSYNC_FRONT_PORCH, + CONF_VSYNC_PULSE_WIDTH, + MODE_BGR, + PIXEL_MODE_16BIT, + PIXEL_MODE_18BIT, + DriverChip, + dimension_schema, + map_sequence, + power_of_two, + requires_buffer, +) +from esphome.components.rpi_dpi_rgb.display import ( + CONF_PCLK_FREQUENCY, + CONF_PCLK_INVERTED, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_BLUE, + CONF_COLOR_ORDER, + CONF_CS_PIN, + CONF_DATA_PINS, + CONF_DATA_RATE, + CONF_DC_PIN, + CONF_DIMENSIONS, + CONF_ENABLE_PIN, + CONF_GREEN, + CONF_HSYNC_PIN, + CONF_ID, + CONF_IGNORE_STRAPPING_WARNING, + CONF_INIT_SEQUENCE, + CONF_INVERT_COLORS, + CONF_LAMBDA, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_MODEL, + CONF_NUMBER, + CONF_RED, + CONF_RESET_PIN, + CONF_ROTATION, + CONF_SPI_ID, + CONF_SWAP_XY, + CONF_TRANSFORM, + CONF_VSYNC_PIN, + CONF_WIDTH, +) +from esphome.final_validate import full_config + +from ..spi import CONF_SPI_MODE, SPI_DATA_RATE_SCHEMA, SPI_MODE_OPTIONS, SPIComponent +from . import models + +DEPENDENCIES = ["esp32", "psram"] + +mipi_rgb_ns = cg.esphome_ns.namespace("mipi_rgb") +mipi_rgb = mipi_rgb_ns.class_("MipiRgb", display.Display, cg.Component) +mipi_rgb_spi = mipi_rgb_ns.class_( + "MipiRgbSpi", mipi_rgb, display.Display, cg.Component, spi.SPIDevice +) +ColorOrder = display.display_ns.enum("ColorMode") + +DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema + +DriverChip("CUSTOM") + +# Import all models dynamically from the models package + +for module_info in pkgutil.iter_modules(models.__path__): + importlib.import_module(f".models.{module_info.name}", package=__package__) + +MODELS = DriverChip.get_models() + + +def data_pin_validate(value): + """ + It is safe to use strapping pins as RGB output data bits, as they are outputs only, + and not initialised until after boot. + """ + if not isinstance(value, dict): + try: + return DATA_PIN_SCHEMA( + {CONF_NUMBER: value, CONF_IGNORE_STRAPPING_WARNING: True} + ) + except cv.Invalid: + pass + return DATA_PIN_SCHEMA(value) + + +def data_pin_set(length): + return cv.All( + [data_pin_validate], + cv.Length(min=length, max=length, msg=f"Exactly {length} data pins required"), + ) + + +def model_schema(config): + model = MODELS[config[CONF_MODEL].upper()] + if transforms := model.transforms: + transform = cv.Schema({cv.Required(x): cv.boolean for x in transforms}) + for x in (CONF_SWAP_XY, CONF_MIRROR_X, CONF_MIRROR_Y): + if x not in transforms: + transform = transform.extend( + {cv.Optional(x): cv.invalid(f"{x} not supported by this model")} + ) + else: + transform = cv.invalid("This model does not support transforms") + + # RPI model does not use an init sequence, indicates with empty list + if model.initsequence is None: + # Custom model requires an init sequence + iseqconf = cv.Required(CONF_INIT_SEQUENCE) + uses_spi = True + else: + iseqconf = cv.Optional(CONF_INIT_SEQUENCE) + uses_spi = CONF_INIT_SEQUENCE in config or len(model.initsequence) != 0 + swap_xy = config.get(CONF_TRANSFORM, {}).get(CONF_SWAP_XY, False) + + # Dimensions are optional if the model has a default width and the swap_xy transform is not overridden + cv_dimensions = ( + cv.Optional if model.get_default(CONF_WIDTH) and not swap_xy else cv.Required + ) + pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_18BIT, "16", "18") + schema = display.FULL_DISPLAY_SCHEMA.extend( + { + model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + cv.GenerateID(): cv.declare_id(mipi_rgb_spi if uses_spi else mipi_rgb), + cv_dimensions(CONF_DIMENSIONS): dimension_schema( + model.get_default(CONF_DRAW_ROUNDING, 1) + ), + model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list( + pins.gpio_output_pin_schema + ), + model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(COLOR_ORDERS, upper=True), + model.option(CONF_DRAW_ROUNDING, 2): power_of_two, + model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of( + *pixel_modes, lower=True + ), + model.option(CONF_TRANSFORM, cv.UNDEFINED): transform, + cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True), + model.option(CONF_INVERT_COLORS, False): cv.boolean, + model.option(CONF_USE_AXIS_FLIPS, True): cv.boolean, + model.option(CONF_PCLK_FREQUENCY, "40MHz"): cv.All( + cv.frequency, cv.Range(min=4e6, max=100e6) + ), + model.option(CONF_PCLK_INVERTED, True): cv.boolean, + iseqconf: cv.ensure_list(map_sequence), + model.option(CONF_BYTE_ORDER, BYTE_ORDER_BIG): cv.one_of( + BYTE_ORDER_LITTLE, BYTE_ORDER_BIG, lower=True + ), + model.option(CONF_HSYNC_PULSE_WIDTH): cv.int_, + model.option(CONF_HSYNC_BACK_PORCH): cv.int_, + model.option(CONF_HSYNC_FRONT_PORCH): cv.int_, + model.option(CONF_VSYNC_PULSE_WIDTH): cv.int_, + model.option(CONF_VSYNC_BACK_PORCH): cv.int_, + model.option(CONF_VSYNC_FRONT_PORCH): cv.int_, + model.option(CONF_DATA_PINS): cv.Any( + data_pin_set(16), + cv.Schema( + { + cv.Required(CONF_RED): data_pin_set(5), + cv.Required(CONF_GREEN): data_pin_set(6), + cv.Required(CONF_BLUE): data_pin_set(5), + } + ), + ), + model.option( + CONF_DE_PIN, cv.UNDEFINED + ): pins.internal_gpio_output_pin_schema, + model.option(CONF_PCLK_PIN): pins.internal_gpio_output_pin_schema, + model.option(CONF_HSYNC_PIN): pins.internal_gpio_output_pin_schema, + model.option(CONF_VSYNC_PIN): pins.internal_gpio_output_pin_schema, + model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + } + ) + if uses_spi: + schema = schema.extend( + { + cv.GenerateID(CONF_SPI_ID): cv.use_id(SPIComponent), + model.option(CONF_DC_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + model.option(CONF_DATA_RATE, "1MHz"): SPI_DATA_RATE_SCHEMA, + model.option(CONF_SPI_MODE, "MODE0"): cv.enum( + SPI_MODE_OPTIONS, upper=True + ), + model.option(CONF_CS_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + } + ) + return schema + + +def _config_schema(config): + config = cv.Schema( + { + cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), + }, + extra=cv.ALLOW_EXTRA, + )(config) + schema = model_schema(config) + return cv.All( + schema, + only_on_variant(supported=[const.VARIANT_ESP32S3]), + cv.only_with_esp_idf, + )(config) + + +CONFIG_SCHEMA = _config_schema + + +def _final_validate(config): + global_config = full_config.get() + + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + + if not requires_buffer(config) and LVGL_DOMAIN not in global_config: + # If no drawing methods are configured, and LVGL is not enabled, show a test card + config[CONF_SHOW_TEST_CARD] = True + if CONF_SPI_ID in config: + config = spi.final_validate_device_schema( + "mipi_rgb", require_miso=False, require_mosi=True + )(config) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + model = MODELS[config[CONF_MODEL].upper()] + width, height, _offset_width, _offset_height = model.get_dimensions(config) + var = cg.new_Pvariable(config[CONF_ID], width, height) + cg.add(var.set_model(model.name)) + if enable_pin := config.get(CONF_ENABLE_PIN): + enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] + cg.add(var.set_enable_pins(enable)) + + if CONF_SPI_ID in config: + await spi.register_spi_device(var, config) + sequence, madctl = model.get_sequence(config) + cg.add(var.set_init_sequence(sequence)) + cg.add(var.set_madctl(madctl)) + + cg.add(var.set_color_mode(COLOR_ORDERS[config[CONF_COLOR_ORDER]])) + cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) + cg.add(var.set_hsync_pulse_width(config[CONF_HSYNC_PULSE_WIDTH])) + cg.add(var.set_hsync_back_porch(config[CONF_HSYNC_BACK_PORCH])) + cg.add(var.set_hsync_front_porch(config[CONF_HSYNC_FRONT_PORCH])) + cg.add(var.set_vsync_pulse_width(config[CONF_VSYNC_PULSE_WIDTH])) + cg.add(var.set_vsync_back_porch(config[CONF_VSYNC_BACK_PORCH])) + cg.add(var.set_vsync_front_porch(config[CONF_VSYNC_FRONT_PORCH])) + cg.add(var.set_pclk_inverted(config[CONF_PCLK_INVERTED])) + cg.add(var.set_pclk_frequency(config[CONF_PCLK_FREQUENCY])) + index = 0 + dpins = [] + if CONF_RED in config[CONF_DATA_PINS]: + red_pins = config[CONF_DATA_PINS][CONF_RED] + green_pins = config[CONF_DATA_PINS][CONF_GREEN] + blue_pins = config[CONF_DATA_PINS][CONF_BLUE] + if config[CONF_COLOR_ORDER] == "BGR": + dpins.extend(red_pins) + dpins.extend(green_pins) + dpins.extend(blue_pins) + else: + dpins.extend(blue_pins) + dpins.extend(green_pins) + dpins.extend(red_pins) + # swap bytes to match big-endian format + dpins = dpins[8:16] + dpins[0:8] + else: + dpins = config[CONF_DATA_PINS] + for index, pin in enumerate(dpins): + data_pin = await cg.gpio_pin_expression(pin) + cg.add(var.add_data_pin(data_pin, index)) + + if dc_pin := config.get(CONF_DC_PIN): + dc = await cg.gpio_pin_expression(dc_pin) + cg.add(var.set_dc_pin(dc)) + + if reset_pin := config.get(CONF_RESET_PIN): + reset = await cg.gpio_pin_expression(reset_pin) + cg.add(var.set_reset_pin(reset)) + + if model.rotation_as_transform(config): + config[CONF_ROTATION] = 0 + + if de_pin := config.get(CONF_DE_PIN): + pin = await cg.gpio_pin_expression(de_pin) + cg.add(var.set_de_pin(pin)) + pin = await cg.gpio_pin_expression(config[CONF_PCLK_PIN]) + cg.add(var.set_pclk_pin(pin)) + pin = await cg.gpio_pin_expression(config[CONF_HSYNC_PIN]) + cg.add(var.set_hsync_pin(pin)) + pin = await cg.gpio_pin_expression(config[CONF_VSYNC_PIN]) + cg.add(var.set_vsync_pin(pin)) + + await display.register_display(var, config) + if lamb := config.get(CONF_LAMBDA): + lambda_ = await cg.process_lambda( + lamb, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp new file mode 100644 index 0000000000..00c9c8cbff --- /dev/null +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -0,0 +1,388 @@ +#ifdef USE_ESP32_VARIANT_ESP32S3 +#include "mipi_rgb.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esp_lcd_panel_rgb.h" + +namespace esphome { +namespace mipi_rgb { + +static const uint8_t DELAY_FLAG = 0xFF; +static constexpr uint8_t MADCTL_MY = 0x80; // Bit 7 Bottom to top +static constexpr uint8_t MADCTL_MX = 0x40; // Bit 6 Right to left +static constexpr uint8_t MADCTL_MV = 0x20; // Bit 5 Swap axes +static constexpr uint8_t MADCTL_ML = 0x10; // Bit 4 Refresh bottom to top +static constexpr uint8_t MADCTL_BGR = 0x08; // Bit 3 Blue-Green-Red pixel order +static constexpr uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally +static constexpr uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically + +void MipiRgb::setup_enables_() { + if (!this->enable_pins_.empty()) { + for (auto *pin : this->enable_pins_) { + pin->setup(); + pin->digital_write(true); + } + delay(10); + } + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(5); + this->reset_pin_->digital_write(false); + delay(5); + this->reset_pin_->digital_write(true); + } +} + +#ifdef USE_SPI +void MipiRgbSpi::setup() { + this->setup_enables_(); + this->spi_setup(); + this->write_init_sequence_(); + this->common_setup_(); +} +void MipiRgbSpi::write_command_(uint8_t value) { + this->enable(); + if (this->dc_pin_ == nullptr) { + this->write(value, 9); + } else { + this->dc_pin_->digital_write(false); + this->write_byte(value); + this->dc_pin_->digital_write(true); + } + this->disable(); +} + +void MipiRgbSpi::write_data_(uint8_t value) { + this->enable(); + if (this->dc_pin_ == nullptr) { + this->write(value | 0x100, 9); + } else { + this->dc_pin_->digital_write(true); + this->write_byte(value); + } + this->disable(); +} + +/** + * this relies upon the init sequence being well-formed, which is guaranteed by the Python init code. + */ + +void MipiRgbSpi::write_init_sequence_() { + size_t index = 0; + auto &vec = this->init_sequence_; + while (index != vec.size()) { + if (vec.size() - index < 2) { + this->mark_failed("Malformed init sequence"); + return; + } + uint8_t cmd = vec[index++]; + uint8_t x = vec[index++]; + if (x == DELAY_FLAG) { + ESP_LOGD(TAG, "Delay %dms", cmd); + delay(cmd); + } else { + uint8_t num_args = x & 0x7F; + if (vec.size() - index < num_args) { + this->mark_failed("Malformed init sequence"); + return; + } + if (cmd == SLEEP_OUT) { + delay(120); // NOLINT + } + const auto *ptr = vec.data() + index; + ESP_LOGD(TAG, "Write command %02X, length %d, byte(s) %s", cmd, num_args, + format_hex_pretty(ptr, num_args, '.', false).c_str()); + index += num_args; + this->write_command_(cmd); + while (num_args-- != 0) + this->write_data_(*ptr++); + if (cmd == SLEEP_OUT) + delay(10); + } + } + // this->spi_teardown(); // SPI not needed after this + this->init_sequence_.clear(); + delay(10); +} + +void MipiRgbSpi::dump_config() { + MipiRgb::dump_config(); + LOG_PIN(" CS Pin: ", this->cs_); + LOG_PIN(" DC Pin: ", this->dc_pin_); + ESP_LOGCONFIG(TAG, + " SPI Data rate: %uMHz" + "\n Mirror X: %s" + "\n Mirror Y: %s" + "\n Swap X/Y: %s" + "\n Color Order: %s", + (unsigned) (this->data_rate_ / 1000000), YESNO(this->madctl_ & (MADCTL_XFLIP | MADCTL_MX)), + YESNO(this->madctl_ & (MADCTL_YFLIP | MADCTL_MY | MADCTL_ML)), YESNO(this->madctl_ & MADCTL_MV), + this->madctl_ & MADCTL_BGR ? "BGR" : "RGB"); +} + +#endif // USE_SPI + +void MipiRgb::setup() { + this->setup_enables_(); + this->common_setup_(); +} + +void MipiRgb::common_setup_() { + esp_lcd_rgb_panel_config_t config{}; + config.flags.fb_in_psram = 1; + config.bounce_buffer_size_px = this->width_ * 10; + config.num_fbs = 1; + config.timings.h_res = this->width_; + config.timings.v_res = this->height_; + config.timings.hsync_pulse_width = this->hsync_pulse_width_; + config.timings.hsync_back_porch = this->hsync_back_porch_; + config.timings.hsync_front_porch = this->hsync_front_porch_; + config.timings.vsync_pulse_width = this->vsync_pulse_width_; + config.timings.vsync_back_porch = this->vsync_back_porch_; + config.timings.vsync_front_porch = this->vsync_front_porch_; + config.timings.flags.pclk_active_neg = this->pclk_inverted_; + config.timings.pclk_hz = this->pclk_frequency_; + config.clk_src = LCD_CLK_SRC_PLL160M; + size_t data_pin_count = sizeof(this->data_pins_) / sizeof(this->data_pins_[0]); + for (size_t i = 0; i != data_pin_count; i++) { + config.data_gpio_nums[i] = this->data_pins_[i]->get_pin(); + } + config.data_width = data_pin_count; + config.disp_gpio_num = -1; + config.hsync_gpio_num = this->hsync_pin_->get_pin(); + config.vsync_gpio_num = this->vsync_pin_->get_pin(); + if (this->de_pin_) { + config.de_gpio_num = this->de_pin_->get_pin(); + } else { + config.de_gpio_num = -1; + } + config.pclk_gpio_num = this->pclk_pin_->get_pin(); + esp_err_t err = esp_lcd_new_rgb_panel(&config, &this->handle_); + if (err == ESP_OK) + err = esp_lcd_panel_reset(this->handle_); + if (err == ESP_OK) + err = esp_lcd_panel_init(this->handle_); + if (err != ESP_OK) { + auto msg = str_sprintf("lcd setup failed: %s", esp_err_to_name(err)); + this->mark_failed(msg.c_str()); + } + ESP_LOGCONFIG(TAG, "MipiRgb setup complete"); +} + +void MipiRgb::loop() { + if (this->handle_ != nullptr) + esp_lcd_rgb_panel_restart(this->handle_); +} + +void MipiRgb::update() { + if (this->is_failed()) + return; + if (this->auto_clear_enabled_) { + this->clear(); + } + if (this->show_test_card_) { + this->test_card(); + } else if (this->page_ != nullptr) { + this->page_->get_writer()(*this); + } else if (this->writer_.has_value()) { + (*this->writer_)(*this); + } else { + this->stop_poller(); + } + if (this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) + return; + ESP_LOGV(TAG, "x_low %d, y_low %d, x_high %d, y_high %d", this->x_low_, this->y_low_, this->x_high_, this->y_high_); + int w = this->x_high_ - this->x_low_ + 1; + int h = this->y_high_ - this->y_low_ + 1; + this->write_to_display_(this->x_low_, this->y_low_, w, h, reinterpret_cast(this->buffer_), + this->x_low_, this->y_low_, this->width_ - w - this->x_low_); + // invalidate watermarks + this->x_low_ = this->width_; + this->y_low_ = this->height_; + this->x_high_ = 0; + this->y_high_ = 0; +} + +void MipiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + if (w <= 0 || h <= 0 || this->is_failed()) + return; + // if color mapping is required, pass the buck. + // note that endianness is not considered here - it is assumed to match! + if (bitness != display::COLOR_BITNESS_565) { + Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + this->write_to_display_(x_start, y_start, w, h, reinterpret_cast(this->buffer_), x_start, y_start, + this->width_ - w - x_start); + } else { + this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); + } +} + +void MipiRgb::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad) { + esp_err_t err = ESP_OK; + auto stride = (x_offset + w + x_pad) * 2; + ptr += y_offset * stride + x_offset * 2; // skip to the first pixel + // x_ and y_offset are offsets into the source buffer, unrelated to our own offsets into the display. + if (x_offset == 0 && x_pad == 0) { + err = esp_lcd_panel_draw_bitmap(this->handle_, x_start, y_start, x_start + w, y_start + h, ptr); + } else { + // draw line by line + for (int y = 0; y != h; y++) { + err = esp_lcd_panel_draw_bitmap(this->handle_, x_start, y + y_start, x_start + w, y + y_start + 1, ptr); + if (err != ESP_OK) + break; + ptr += stride; // next line + } + } + if (err != ESP_OK) + ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err)); +} + +bool MipiRgb::check_buffer_() { + if (this->is_failed()) + return false; + if (this->buffer_ != nullptr) + return true; + // this is dependent on the enum values. + RAMAllocator allocator; + this->buffer_ = allocator.allocate(this->height_ * this->width_); + if (this->buffer_ == nullptr) { + this->mark_failed("Could not allocate buffer for display!"); + return false; + } + return true; +} + +void MipiRgb::draw_pixel_at(int x, int y, Color color) { + if (!this->get_clipping().inside(x, y) || this->is_failed()) + return; + + switch (this->rotation_) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + std::swap(x, y); + x = this->width_ - x - 1; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + x = this->width_ - x - 1; + y = this->height_ - y - 1; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + std::swap(x, y); + y = this->height_ - y - 1; + break; + } + if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) { + return; + } + if (!this->check_buffer_()) + return; + size_t pos = (y * this->width_) + x; + uint8_t hi_byte = static_cast(color.r & 0xF8) | (color.g >> 5); + uint8_t lo_byte = static_cast((color.g & 0x1C) << 3) | (color.b >> 3); + uint16_t new_color = hi_byte | (lo_byte << 8); // big endian + if (this->buffer_[pos] == new_color) + return; + this->buffer_[pos] = new_color; + // low and high watermark may speed up drawing from buffer + if (x < this->x_low_) + this->x_low_ = x; + if (y < this->y_low_) + this->y_low_ = y; + if (x > this->x_high_) + this->x_high_ = x; + if (y > this->y_high_) + this->y_high_ = y; +} +void MipiRgb::fill(Color color) { + if (!this->check_buffer_()) + return; + auto *ptr_16 = reinterpret_cast(this->buffer_); + uint8_t hi_byte = static_cast(color.r & 0xF8) | (color.g >> 5); + uint8_t lo_byte = static_cast((color.g & 0x1C) << 3) | (color.b >> 3); + uint16_t new_color = lo_byte | (hi_byte << 8); // little endian + std::fill_n(ptr_16, this->width_ * this->height_, new_color); +} + +int MipiRgb::get_width() { + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + case display::DISPLAY_ROTATION_270_DEGREES: + return this->get_height_internal(); + case display::DISPLAY_ROTATION_0_DEGREES: + case display::DISPLAY_ROTATION_180_DEGREES: + default: + return this->get_width_internal(); + } +} + +int MipiRgb::get_height() { + switch (this->rotation_) { + case display::DISPLAY_ROTATION_0_DEGREES: + case display::DISPLAY_ROTATION_180_DEGREES: + return this->get_height_internal(); + case display::DISPLAY_ROTATION_90_DEGREES: + case display::DISPLAY_ROTATION_270_DEGREES: + default: + return this->get_width_internal(); + } +} + +static std::string get_pin_name(GPIOPin *pin) { + if (pin == nullptr) + return "None"; + return pin->dump_summary(); +} + +void MipiRgb::dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset) { + for (uint8_t i = start; i != end; i++) { + ESP_LOGCONFIG(TAG, " %s pin %d: %s", name, offset++, this->data_pins_[i]->dump_summary().c_str()); + } +} + +void MipiRgb::dump_config() { + ESP_LOGCONFIG(TAG, + "MIPI_RGB LCD" + "\n Model: %s" + "\n Width: %u" + "\n Height: %u" + "\n Rotation: %d degrees" + "\n HSync Pulse Width: %u" + "\n HSync Back Porch: %u" + "\n HSync Front Porch: %u" + "\n VSync Pulse Width: %u" + "\n VSync Back Porch: %u" + "\n VSync Front Porch: %u" + "\n Invert Colors: %s" + "\n Pixel Clock: %dMHz" + "\n Reset Pin: %s" + "\n DE Pin: %s" + "\n PCLK Pin: %s" + "\n HSYNC Pin: %s" + "\n VSYNC Pin: %s", + this->model_, this->width_, this->height_, this->rotation_, this->hsync_pulse_width_, + this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_, this->vsync_back_porch_, + this->vsync_front_porch_, YESNO(this->invert_colors_), this->pclk_frequency_ / 1000000, + get_pin_name(this->reset_pin_).c_str(), get_pin_name(this->de_pin_).c_str(), + get_pin_name(this->pclk_pin_).c_str(), get_pin_name(this->hsync_pin_).c_str(), + get_pin_name(this->vsync_pin_).c_str()); + + if (this->madctl_ & MADCTL_BGR) { + this->dump_pins_(8, 13, "Blue", 0); + this->dump_pins_(13, 16, "Green", 0); + this->dump_pins_(0, 3, "Green", 3); + this->dump_pins_(3, 8, "Red", 0); + } else { + this->dump_pins_(8, 13, "Red", 0); + this->dump_pins_(13, 16, "Green", 0); + this->dump_pins_(0, 3, "Green", 3); + this->dump_pins_(3, 8, "Blue", 0); + } +} + +} // namespace mipi_rgb +} // namespace esphome +#endif // USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h new file mode 100644 index 0000000000..173e23752d --- /dev/null +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -0,0 +1,127 @@ +#pragma once + +#ifdef USE_ESP32_VARIANT_ESP32S3 +#include "esphome/core/gpio.h" +#include "esphome/components/display/display.h" +#include "esp_lcd_panel_ops.h" +#ifdef USE_SPI +#include "esphome/components/spi/spi.h" +#endif + +namespace esphome { +namespace mipi_rgb { + +constexpr static const char *const TAG = "display.mipi_rgb"; +const uint8_t SW_RESET_CMD = 0x01; +const uint8_t SLEEP_OUT = 0x11; +const uint8_t SDIR_CMD = 0xC7; +const uint8_t MADCTL_CMD = 0x36; +const uint8_t INVERT_OFF = 0x20; +const uint8_t INVERT_ON = 0x21; +const uint8_t DISPLAY_ON = 0x29; +const uint8_t CMD2_BKSEL = 0xFF; +const uint8_t CMD2_BK0[5] = {0x77, 0x01, 0x00, 0x00, 0x10}; + +class MipiRgb : public display::Display { + public: + MipiRgb(int width, int height) : width_(width), height_(height) {} + void setup() override; + void loop() override; + void update() override; + void fill(Color color); + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad); + bool check_buffer_(); + + display::ColorOrder get_color_mode() { return this->color_mode_; } + void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } + void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } + void set_madctl(uint8_t madctl) { this->madctl_ = madctl; } + + void add_data_pin(InternalGPIOPin *data_pin, size_t index) { this->data_pins_[index] = data_pin; }; + void set_de_pin(InternalGPIOPin *de_pin) { this->de_pin_ = de_pin; } + void set_pclk_pin(InternalGPIOPin *pclk_pin) { this->pclk_pin_ = pclk_pin; } + void set_vsync_pin(InternalGPIOPin *vsync_pin) { this->vsync_pin_ = vsync_pin; } + void set_hsync_pin(InternalGPIOPin *hsync_pin) { this->hsync_pin_ = hsync_pin; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + void set_width(uint16_t width) { this->width_ = width; } + void set_pclk_frequency(uint32_t pclk_frequency) { this->pclk_frequency_ = pclk_frequency; } + void set_pclk_inverted(bool inverted) { this->pclk_inverted_ = inverted; } + void set_model(const char *model) { this->model_ = model; } + int get_width() override; + int get_height() override; + void set_hsync_back_porch(uint16_t hsync_back_porch) { this->hsync_back_porch_ = hsync_back_porch; } + void set_hsync_front_porch(uint16_t hsync_front_porch) { this->hsync_front_porch_ = hsync_front_porch; } + void set_hsync_pulse_width(uint16_t hsync_pulse_width) { this->hsync_pulse_width_ = hsync_pulse_width; } + void set_vsync_pulse_width(uint16_t vsync_pulse_width) { this->vsync_pulse_width_ = vsync_pulse_width; } + void set_vsync_back_porch(uint16_t vsync_back_porch) { this->vsync_back_porch_ = vsync_back_porch; } + void set_vsync_front_porch(uint16_t vsync_front_porch) { this->vsync_front_porch_ = vsync_front_porch; } + void set_enable_pins(std::vector enable_pins) { this->enable_pins_ = std::move(enable_pins); } + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return this->width_; } + int get_height_internal() override { return this->height_; } + void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); + void dump_config() override; + void draw_pixel_at(int x, int y, Color color) override; + + // this will be horribly slow. + protected: + void setup_enables_(); + void common_setup_(); + InternalGPIOPin *de_pin_{nullptr}; + InternalGPIOPin *pclk_pin_{nullptr}; + InternalGPIOPin *hsync_pin_{nullptr}; + InternalGPIOPin *vsync_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + InternalGPIOPin *data_pins_[16] = {}; + uint16_t hsync_pulse_width_ = 10; + uint16_t hsync_back_porch_ = 10; + uint16_t hsync_front_porch_ = 20; + uint16_t vsync_pulse_width_ = 10; + uint16_t vsync_back_porch_ = 10; + uint16_t vsync_front_porch_ = 10; + uint32_t pclk_frequency_ = 16 * 1000 * 1000; + bool pclk_inverted_{true}; + uint8_t madctl_{}; + const char *model_{"Unknown"}; + bool invert_colors_{}; + display::ColorOrder color_mode_{display::COLOR_ORDER_BGR}; + size_t width_; + size_t height_; + uint16_t *buffer_{nullptr}; + std::vector enable_pins_{}; + uint16_t x_low_{1}; + uint16_t y_low_{1}; + uint16_t x_high_{0}; + uint16_t y_high_{0}; + + esp_lcd_panel_handle_t handle_{}; +}; + +#ifdef USE_SPI +class MipiRgbSpi : public MipiRgb, + public spi::SPIDevice { + public: + MipiRgbSpi(int width, int height) : MipiRgb(width, height) {} + + void set_init_sequence(const std::vector &init_sequence) { this->init_sequence_ = init_sequence; } + void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } + void setup() override; + + protected: + void write_command_(uint8_t value); + void write_data_(uint8_t value); + void write_init_sequence_(); + void dump_config(); + + GPIOPin *dc_pin_{nullptr}; + std::vector init_sequence_; +}; +#endif + +} // namespace mipi_rgb +} // namespace esphome +#endif diff --git a/esphome/components/mipi_rgb/models/guition.py b/esphome/components/mipi_rgb/models/guition.py new file mode 100644 index 0000000000..da433e686e --- /dev/null +++ b/esphome/components/mipi_rgb/models/guition.py @@ -0,0 +1,24 @@ +from .st7701s import st7701s + +st7701s.extend( + "GUITION-4848S040", + width=480, + height=480, + data_rate="2MHz", + cs_pin=39, + de_pin=18, + hsync_pin=16, + vsync_pin=17, + pclk_pin=21, + pclk_frequency="12MHz", + pixel_mode="18bit", + mirror_x=True, + mirror_y=True, + data_pins={ + "red": [11, 12, 13, 14, 0], + "green": [8, 20, 3, 46, 9, 10], + "blue": [4, 5, 6, 7, 15], + }, + # Additional configuration for Guition 4848S040, 16 bit bus config + add_init_sequence=((0xCD, 0x00),), +) diff --git a/esphome/components/mipi_rgb/models/lilygo.py b/esphome/components/mipi_rgb/models/lilygo.py index e69de29bb2..109dc42af6 100644 --- a/esphome/components/mipi_rgb/models/lilygo.py +++ b/esphome/components/mipi_rgb/models/lilygo.py @@ -0,0 +1,228 @@ +from esphome.config_validation import UNDEFINED + +from .st7701s import ST7701S + +# fmt: off +ST7701S( + "T-PANEL-S3", + width=480, + height=480, + color_order="BGR", + invert_colors=False, + swap_xy=UNDEFINED, + spi_mode="MODE3", + cs_pin={"xl9535": None, "number": 17}, + reset_pin={"xl9535": None, "number": 5}, + hsync_pin=39, + vsync_pin=40, + pclk_pin=41, + data_pins={ + "red": [12, 13, 42, 46, 45], + "green": [6, 7, 8, 9, 10, 11], + "blue": [1, 2, 3, 4, 5], + }, + hsync_front_porch=20, + hsync_back_porch=0, + hsync_pulse_width=2, + vsync_front_porch=30, + vsync_back_porch=1, + vsync_pulse_width=8, + pclk_frequency="6MHz", + pclk_inverted=False, + initsequence=( + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), (0xEF, 0x08), (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), + (0xC0, 0x3B, 0x00), (0xC1, 0x0B, 0x02), (0xC2, 0x30, 0x02, 0x37), (0xCC, 0x10), + (0xB0, 0x00, 0x0F, 0x16, 0x0E, 0x11, 0x07, 0x09, 0x09, 0x08, 0x23, 0x05, 0x11, 0x0F, 0x28, 0x2D, 0x18), + (0xB1, 0x00, 0x0F, 0x16, 0x0E, 0x11, 0x07, 0x09, 0x08, 0x09, 0x23, 0x05, 0x11, 0x0F, 0x28, 0x2D, 0x18), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x11), + (0xB0, 0x4D), (0xB1, 0x33), (0xB2, 0x87), (0xB5, 0x4B), (0xB7, 0x8C), (0xB8, 0x20), (0xC1, 0x78), + (0xC2, 0x78), (0xD0, 0x88), (0xE0, 0x00, 0x00, 0x02), + (0xE1, 0x02, 0xF0, 0x00, 0x00, 0x03, 0xF0, 0x00, 0x00, 0x00, 0x44, 0x44), + (0xE2, 0x10, 0x10, 0x40, 0x40, 0xF2, 0xF0, 0x00, 0x00, 0xF2, 0xF0, 0x00, 0x00), + (0xE3, 0x00, 0x00, 0x11, 0x11), (0xE4, 0x44, 0x44), + (0xE5, 0x07, 0xEF, 0xF0, 0xF0, 0x09, 0xF1, 0xF0, 0xF0, 0x03, 0xF3, 0xF0, 0xF0, 0x05, 0xED, 0xF0, 0xF0), + (0xE6, 0x00, 0x00, 0x11, 0x11), (0xE7, 0x44, 0x44), + (0xE8, 0x08, 0xF0, 0xF0, 0xF0, 0x0A, 0xF2, 0xF0, 0xF0, 0x04, 0xF4, 0xF0, 0xF0, 0x06, 0xEE, 0xF0, 0xF0), + (0xEB, 0x00, 0x00, 0xE4, 0xE4, 0x44, 0x88, 0x40), + (0xEC, 0x78, 0x00), + (0xED, 0x20, 0xF9, 0x87, 0x76, 0x65, 0x54, 0x4F, 0xFF, 0xFF, 0xF4, 0x45, 0x56, 0x67, 0x78, 0x9F, 0x02), + (0xEF, 0x10, 0x0D, 0x04, 0x08, 0x3F, 0x1F), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), + ), +) + + +t_rgb = ST7701S( + "T-RGB-2.1", + width=480, + height=480, + color_order="BGR", + pixel_mode="18bit", + invert_colors=False, + swap_xy=UNDEFINED, + spi_mode="MODE3", + cs_pin={"xl9535": None, "number": 3}, + de_pin=45, + hsync_pin=47, + vsync_pin=41, + pclk_pin=42, + data_pins={ + "red": [7, 6, 5, 3, 2], + "green": [14, 13, 12, 11, 10, 9], + "blue": [21, 18, 17, 16, 15], + }, + hsync_front_porch=50, + hsync_pulse_width=1, + hsync_back_porch=30, + vsync_front_porch=20, + vsync_pulse_width=1, + vsync_back_porch=30, + pclk_frequency="12MHz", + pclk_inverted=False, + initsequence=( + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), + + (0xC0, 0x3B, 0x00), + (0xC1, 0x0B, 0x02), + (0xC2, 0x07, 0x02), + (0xCC, 0x10), + (0xCD, 0x08), + + (0xB0, + 0x00, 0x11, 0x16, 0x0e, + 0x11, 0x06, 0x05, 0x09, + 0x08, 0x21, 0x06, 0x13, + 0x10, 0x29, 0x31, 0x18), + + (0xB1, + 0x00, 0x11, 0x16, 0x0e, + 0x11, 0x07, 0x05, 0x09, + 0x09, 0x21, 0x05, 0x13, + 0x11, 0x2a, 0x31, 0x18), + + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x11), + + (0xB0, 0x6D), + (0xB1, 0x37), + (0xB2, 0x81), + (0xB3, 0x80), + (0xB5, 0x43), + (0xB7, 0x85), + (0xB8, 0x20), + + (0xC1, 0x78), + (0xC2, 0x78), + (0xC3, 0x8C), + + (0xD0, 0x88), + + (0xE0, 0x00, 0x00, 0x02), + (0xE1, + 0x03, 0xA0, 0x00, 0x00, + 0x04, 0xA0, 0x00, 0x00, + 0x00, 0x20, 0x20), + + (0xE2, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00), + + (0xE3, 0x00, 0x00, 0x11, 0x00), + (0xE4, 0x22, 0x00), + + (0xE5, + 0x05, 0xEC, 0xA0, 0xA0, + 0x07, 0xEE, 0xA0, 0xA0, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00), + + (0xE6, 0x00, 0x00, 0x11, 0x00), + (0xE7, 0x22, 0x00), + + (0xE8, + 0x06, 0xED, 0xA0, 0xA0, + 0x08, 0xEF, 0xA0, 0xA0, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00), + + (0xEB, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00, 0x10), + + (0xED, + 0xFF, 0xFF, 0xFF, 0xBA, + 0x0A, 0xBF, 0x45, 0xFF, + 0xFF, 0x54, 0xFB, 0xA0, + 0xAB, 0xFF, 0xFF, 0xFF), + + (0xEF, 0x10, 0x0D, 0x04, 0x08, 0x3F, 0x1F), + + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), + (0xEF, 0x08), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10) + ) + +) +t_rgb.extend( + "T-RGB-2.8", + initsequence=( + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), + (0xEF, 0x08), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), + (0xC0, 0x3B, 0x00), + (0xC1, 0x10, 0x0C), + (0xC2, 0x07, 0x0A), + (0xC7, 0x00), + (0xC7, 0x10), + (0xCD, 0x08), + (0xB0, + 0x05, 0x12, 0x98, 0x0e, 0x0F, + 0x07, 0x07, 0x09, 0x09, 0x23, + 0x05, 0x52, 0x0F, 0x67, 0x2C, 0x11), + (0xB1, + 0x0B, 0x11, 0x97, 0x0C, 0x12, + 0x06, 0x06, 0x08, 0x08, 0x22, + 0x03, 0x51, 0x11, 0x66, 0x2B, 0x0F), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x11), + (0xB0, 0x5D), + (0xB1, 0x2D), + (0xB2, 0x81), + (0xB3, 0x80), + (0xB5, 0x4E), + (0xB7, 0x85), + (0xB8, 0x20), + (0xC1, 0x78), + (0xC2, 0x78), + (0xD0, 0x88), + (0xE0, 0x00, 0x00, 0x02), + (0xE1, + 0x06, 0x30, 0x08, 0x30, 0x05, + 0x30, 0x07, 0x30, 0x00, 0x33, + 0x33), + (0xE2, + 0x11, 0x11, 0x33, 0x33, 0xf4, + 0x00, 0x00, 0x00, 0xf4, 0x00, + 0x00, 0x00), + (0xE3, 0x00, 0x00, 0x11, 0x11), + (0xE4, 0x44, 0x44), + (0xE5, + 0x0d, 0xf5, 0x30, 0xf0, 0x0f, + 0xf7, 0x30, 0xf0, 0x09, 0xf1, + 0x30, 0xf0, 0x0b, 0xf3, 0x30, 0xf0), + (0xE6, 0x00, 0x00, 0x11, 0x11), + (0xE7, 0x44, 0x44), + (0xE8, + 0x0c, 0xf4, 0x30, 0xf0, + 0x0e, 0xf6, 0x30, 0xf0, + 0x08, 0xf0, 0x30, 0xf0, + 0x0a, 0xf2, 0x30, 0xf0), + (0xe9, 0x36), + (0xEB, 0x00, 0x01, 0xe4, 0xe4, 0x44, 0x88, 0x40), + (0xED, + 0xff, 0x10, 0xaf, 0x76, + 0x54, 0x2b, 0xcf, 0xff, + 0xff, 0xfc, 0xb2, 0x45, + 0x67, 0xfa, 0x01, 0xff), + (0xEF, 0x08, 0x08, 0x08, 0x45, 0x3f, 0x54), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), + ) +) diff --git a/esphome/components/mipi_rgb/models/rpi.py b/esphome/components/mipi_rgb/models/rpi.py new file mode 100644 index 0000000000..076d96b658 --- /dev/null +++ b/esphome/components/mipi_rgb/models/rpi.py @@ -0,0 +1,9 @@ +from esphome.components.mipi import DriverChip +from esphome.config_validation import UNDEFINED + +# A driver chip for Raspberry Pi MIPI RGB displays. These require no init sequence +DriverChip( + "RPI", + swap_xy=UNDEFINED, + initsequence=(), +) diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py new file mode 100644 index 0000000000..bfd1c9aa3f --- /dev/null +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -0,0 +1,214 @@ +from esphome.components.mipi import ( + MADCTL, + MADCTL_ML, + MADCTL_XFLIP, + MODE_BGR, + DriverChip, +) +from esphome.config_validation import UNDEFINED +from esphome.const import CONF_COLOR_ORDER, CONF_HEIGHT, CONF_MIRROR_X, CONF_MIRROR_Y + +SDIR_CMD = 0xC7 + + +class ST7701S(DriverChip): + # The ST7701s does not use the standard MADCTL bits for x/y mirroring + def add_madctl(self, sequence: list, config: dict): + transform = self.get_transform(config) + madctl = 0x00 + if config[CONF_COLOR_ORDER] == MODE_BGR: + madctl |= 0x08 + if transform.get(CONF_MIRROR_Y): + madctl |= MADCTL_ML + sequence.append((MADCTL, madctl)) + sdir = 0 + if transform.get(CONF_MIRROR_X): + sdir |= 0x04 + madctl |= MADCTL_XFLIP + sequence.append((SDIR_CMD, sdir)) + return madctl + + @property + def transforms(self) -> set[str]: + """ + The ST7701 never supports axis swapping, and mirroring the y-axis only works for full height. + """ + if self.get_default(CONF_HEIGHT) != 864: + return {CONF_MIRROR_X} + return {CONF_MIRROR_X, CONF_MIRROR_Y} + + +# fmt: off +st7701s = ST7701S( + "ST7701S", + width=480, + height=864, + swap_xy=UNDEFINED, + hsync_front_porch=20, + hsync_back_porch=10, + hsync_pulse_width=10, + vsync_front_porch=10, + vsync_back_porch=10, + vsync_pulse_width=10, + pclk_frequency="16MHz", + pclk_inverted=True, + initsequence=( + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), # Page 0 + (0xC0, 0x3B, 0x00), (0xC1, 0x0D, 0x02), (0xC2, 0x31, 0x05), + (0xB0, 0x00, 0x11, 0x18, 0x0E, 0x11, 0x06, 0x07, 0x08, 0x07, 0x22, 0x04, 0x12, 0x0F, 0xAA, 0x31, 0x18,), + (0xB1, 0x00, 0x11, 0x19, 0x0E, 0x12, 0x07, 0x08, 0x08, 0x08, 0x22, 0x04, 0x11, 0x11, 0xA9, 0x32, 0x18,), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x11), # page 1 + (0xB0, 0x60), (0xB1, 0x32), (0xB2, 0x07), (0xB3, 0x80), (0xB5, 0x49), (0xB7, 0x85), (0xB8, 0x21), (0xC1, 0x78), + (0xC2, 0x78), (0xE0, 0x00, 0x1B, 0x02), + (0xE1, 0x08, 0xA0, 0x00, 0x00, 0x07, 0xA0, 0x00, 0x00, 0x00, 0x44, 0x44), + (0xE2, 0x11, 0x11, 0x44, 0x44, 0xED, 0xA0, 0x00, 0x00, 0xEC, 0xA0, 0x00, 0x00), + (0xE3, 0x00, 0x00, 0x11, 0x11), + (0xE4, 0x44, 0x44), + (0xE5, 0x0A, 0xE9, 0xD8, 0xA0, 0x0C, 0xEB, 0xD8, 0xA0, 0x0E, 0xED, 0xD8, 0xA0, 0x10, 0xEF, 0xD8, 0xA0,), + (0xE6, 0x00, 0x00, 0x11, 0x11), (0xE7, 0x44, 0x44), + (0xE8, 0x09, 0xE8, 0xD8, 0xA0, 0x0B, 0xEA, 0xD8, 0xA0, 0x0D, 0xEC, 0xD8, 0xA0, 0x0F, 0xEE, 0xD8, 0xA0,), + (0xEB, 0x02, 0x00, 0xE4, 0xE4, 0x88, 0x00, 0x40), (0xEC, 0x3C, 0x00), + (0xED, 0xAB, 0x89, 0x76, 0x54, 0x02, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x20, 0x45, 0x67, 0x98, 0xBA,), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), # Page 3 + (0xE5, 0xE4), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), # Page 0 + (0xCD, 0x08), + ) +) + +st7701s.extend( + "MAKERFABS-4", + width=480, + height=480, + color_order="RGB", + invert_colors=True, + pixel_mode="18bit", + cs_pin=1, + de_pin={ + "number": 45, + "ignore_strapping_warning": True + }, + hsync_pin=5, + vsync_pin=4, + pclk_pin=21, + data_pins={ + "red": [39, 40, 41, 42, 2], + "green": [0, 9, 14, 47, 48, 3], + "blue": [6, 7, 15, 16, 8] + } +) + +st7701s.extend( + "SEEED-INDICATOR-D1", + width=480, + height=480, + mirror_x=True, + mirror_y=True, + invert_colors=True, + pixel_mode="18bit", + spi_mode="MODE3", + data_rate="2MHz", + hsync_front_porch=10, + hsync_pulse_width=8, + hsync_back_porch=50, + vsync_front_porch=10, + vsync_pulse_width=8, + vsync_back_porch=20, + cs_pin={"pca9554": None, "number": 4}, + de_pin=18, + hsync_pin=16, + vsync_pin=17, + pclk_pin=21, + pclk_inverted=False, + data_pins={ + "red": [4, 3, 2, 1, 0], + "green": [10, 9, 8, 7, 6, 5], + "blue": [15, 14, 13, 12, 11] + }, +) + +st7701s.extend( + "UEDX48480021-MD80ET", + width=480, + height=480, + pixel_mode="18bit", + cs_pin=18, + reset_pin=8, + de_pin=17, + vsync_pin={"number": 3, "ignore_strapping_warning": True}, + hsync_pin={"number": 46, "ignore_strapping_warning": True}, + pclk_pin=9, + data_pins={ + "red": [40, 41, 42, 2, 1], + "green": [21, 47, 48, 45, 38, 39], + "blue": [10, 11, {"number": 12, "allow_other_uses": True}, {"number": 13, "allow_other_uses": True}, 14] + }, + initsequence=( + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), (0xEF, 0x08), (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), + (0xC0, 0x3B, 0x00), (0xC1, 0x0B, 0x02), (0xC2, 0x07, 0x02), (0xC7, 0x00), (0xCC, 0x10), (0xCD, 0x08), + (0xB0, 0x00, 0x11, 0x16, 0x0E, 0x11, 0x06, 0x05, 0x09, 0x08, 0x21, 0x06, 0x13, 0x10, 0x29, 0x31, 0x18), + (0xB1, 0x00, 0x11, 0x16, 0x0E, 0x11, 0x07, 0x05, 0x09, 0x09, 0x21, 0x05, 0x13, 0x11, 0x2A, 0x31, 0x18), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x11), + (0xB0, 0x6D), (0xB1, 0x37), (0xB2, 0x8B), (0xB3, 0x80), (0xB5, 0x43), (0xB7, 0x85), + (0xB8, 0x20), (0xC0, 0x09), (0xC1, 0x78), (0xC2, 0x78), (0xD0, 0x88), + (0xE0, 0x00, 0x00, 0x02), + (0xE1, 0x03, 0xA0, 0x00, 0x00, 0x04, 0xA0, 0x00, 0x00, 0x00, 0x20, 0x20), + (0xE2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + (0xE3, 0x00, 0x00, 0x11, 0x00), + (0xE4, 0x22, 0x00), + (0xE5, 0x05, 0xEC, 0xF6, 0xCA, 0x07, 0xEE, 0xF6, 0xCA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + (0xE6, 0x00, 0x00, 0x11, 0x00), + (0xE7, 0x22, 0x00), + (0xE8, 0x06, 0xED, 0xF6, 0xCA, 0x08, 0xEF, 0xF6, 0xCA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + (0xE9, 0x36, 0x00), + (0xEB, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00), + (0xED, 0xFF, 0xFF, 0xFF, 0xBA, 0x0A, 0xFF, 0x45, 0xFF, 0xFF, 0x54, 0xFF, 0xA0, 0xAB, 0xFF, 0xFF, 0xFF), + (0xEF, 0x08, 0x08, 0x08, 0x45, 0x3F, 0x54), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), (0xE8, 0x00, 0x0E), (0xFF, 0x77, 0x01, 0x00, 0x00, 0x00), + (0x11, 0x00), (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), (0xE8, 0x00, 0x0C), + (0xE8, 0x00, 0x00), (0xFF, 0x77, 0x01, 0x00, 0x00, 0x00) + ) +) + +st7701s.extend( + "ZX2D10GE01R-V4848", + width=480, + height=480, + pixel_mode="18bit", + cs_pin=21, + de_pin=39, + vsync_pin=48, + hsync_pin=40, + pclk_pin={"number": 45, "ignore_strapping_warning": True}, + pclk_frequency="15MHz", + pclk_inverted=True, + hsync_pulse_width=10, + hsync_back_porch=10, + hsync_front_porch=10, + vsync_pulse_width=2, + vsync_back_porch=12, + vsync_front_porch=14, + data_pins={ + "red": [10, 16, 9, 15, 46], + "green": [8, 13, 18, 12, 11, 17], + "blue": [{"number": 47, "allow_other_uses": True}, {"number": 41, "allow_other_uses": True}, 0, 42, 14] + }, + initsequence=( + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), (0xEF, 0x08), (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), (0xC0, 0x3B, 0x00), + (0xC1, 0x0B, 0x02), (0xC2, 0x07, 0x02), (0xCC, 0x10), (0xCD, 0x08), + (0xB0, 0x00, 0x11, 0x16, 0x0e, 0x11, 0x06, 0x05, 0x09, 0x08, 0x21, 0x06, 0x13, 0x10, 0x29, 0x31, 0x18), + (0xB1, 0x00, 0x11, 0x16, 0x0e, 0x11, 0x07, 0x05, 0x09, 0x09, 0x21, 0x05, 0x13, 0x11, 0x2a, 0x31, 0x18), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x11), (0xB0, 0x6d), (0xB1, 0x37), (0xB2, 0x81), (0xB3, 0x80), (0xB5, 0x43), + (0xB7, 0x85), (0xB8, 0x20), (0xC1, 0x78), (0xC2, 0x78), (0xD0, 0x88), (0xE0, 0x00, 0x00, 0x02), + (0xE1, 0x03, 0xA0, 0x00, 0x00, 0x04, 0xA0, 0x00, 0x00, 0x00, 0x20, 0x20), + (0xE2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + (0xE3, 0x00, 0x00, 0x11, 0x00), (0xE4, 0x22, 0x00), + (0xE5, 0x05, 0xEC, 0xA0, 0xA0, 0x07, 0xEE, 0xA0, 0xA0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + (0xE6, 0x00, 0x00, 0x11, 0x00), (0xE7, 0x22, 0x00), + (0xE8, 0x06, 0xED, 0xA0, 0xA0, 0x08, 0xEF, 0xA0, 0xA0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + (0xEB, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00), + (0xED, 0xFF, 0xFF, 0xFF, 0xBA, 0x0A, 0xBF, 0x45, 0xFF, 0xFF, 0x54, 0xFB, 0xA0, 0xAB, 0xFF, 0xFF, 0xFF), + (0xEF, 0x10, 0x0D, 0x04, 0x08, 0x3F, 0x1F), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), (0xEF, 0x08), (0xFF, 0x77, 0x01, 0x00, 0x00, 0x00) + ) +) diff --git a/esphome/components/mipi_rgb/models/waveshare.py b/esphome/components/mipi_rgb/models/waveshare.py new file mode 100644 index 0000000000..49a75da232 --- /dev/null +++ b/esphome/components/mipi_rgb/models/waveshare.py @@ -0,0 +1,64 @@ +from esphome.components.mipi import DriverChip +from esphome.config_validation import UNDEFINED + +from .st7701s import st7701s + +wave_4_3 = DriverChip( + "ESP32-S3-TOUCH-LCD-4.3", + swap_xy=UNDEFINED, + initsequence=(), + width=800, + height=480, + pclk_frequency="16MHz", + reset_pin={"ch422g": None, "number": 3}, + enable_pin={"ch422g": None, "number": 2}, + de_pin=5, + hsync_pin={"number": 46, "ignore_strapping_warning": True}, + vsync_pin={"number": 3, "ignore_strapping_warning": True}, + pclk_pin=7, + pclk_inverted=True, + hsync_front_porch=210, + hsync_pulse_width=30, + hsync_back_porch=30, + vsync_front_porch=4, + vsync_pulse_width=4, + vsync_back_porch=4, + data_pins={ + "red": [1, 2, 42, 41, 40], + "green": [39, 0, 45, 48, 47, 21], + "blue": [14, 38, 18, 17, 10], + }, +) +wave_4_3.extend( + "ESP32-S3-TOUCH-LCD-7-800X480", + enable_pin=[{"ch422g": None, "number": 2}, {"ch422g": None, "number": 6}], + hsync_back_porch=8, + hsync_front_porch=8, + hsync_pulse_width=4, + vsync_back_porch=16, + vsync_front_porch=16, + vsync_pulse_width=4, +) + +st7701s.extend( + "WAVESHARE-4-480x480", + data_rate="2MHz", + spi_mode="MODE3", + color_order="BGR", + pixel_mode="18bit", + width=480, + height=480, + invert_colors=True, + cs_pin=42, + de_pin=40, + hsync_pin=38, + vsync_pin=39, + pclk_pin=41, + pclk_frequency="12MHz", + pclk_inverted=False, + data_pins={ + "red": [46, 3, 8, 18, 17], + "green": [14, 13, 12, 11, 10, 9], + "blue": [5, 45, 48, 47, 21], + }, +) diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..8d0e20d6f5 --- /dev/null +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -0,0 +1,67 @@ +psram: + mode: octal + +spi: + - clk_pin: + number: 47 + allow_other_uses: true + mosi_pin: + number: 41 + allow_other_uses: true + +display: + - platform: mipi_rgb + model: ZX2D10GE01R-V4848 + update_interval: 1s + color_order: BGR + draw_rounding: 2 + pixel_mode: 18bit + invert_colors: false + use_axis_flips: true + pclk_frequency: 15000000.0 + pclk_inverted: true + byte_order: big_endian + hsync_pulse_width: 10 + hsync_back_porch: 10 + hsync_front_porch: 10 + vsync_pulse_width: 2 + vsync_back_porch: 12 + vsync_front_porch: 14 + data_pins: + red: + - number: 10 + - number: 16 + - number: 9 + - number: 15 + - number: 46 + ignore_strapping_warning: true + green: + - number: 8 + - number: 13 + - number: 18 + - number: 12 + - number: 11 + - number: 17 + blue: + - number: 47 + allow_other_uses: true + - number: 41 + allow_other_uses: true + - number: 0 + ignore_strapping_warning: true + - number: 42 + - number: 14 + de_pin: + number: 39 + pclk_pin: + number: 45 + ignore_strapping_warning: true + hsync_pin: + number: 40 + vsync_pin: + number: 48 + data_rate: 1000000.0 + spi_mode: MODE0 + cs_pin: + number: 21 + show_test_card: true From 666e33e70ba7a59b70cac7127be9c1ac949dcc59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 20:09:47 -0500 Subject: [PATCH 0989/1626] [api] Store plaintext error message in PROGMEM on ESP8266 (#10634) --- .../api/api_frame_helper_plaintext.cpp | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index fdaacbd94e..859bb26630 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -10,6 +10,10 @@ #include #include +#ifdef USE_ESP8266 +#include +#endif + namespace esphome::api { static const char *const TAG = "api.plaintext"; @@ -197,11 +201,20 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { // We must send at least 3 bytes to be read, so we add // a message after the indicator byte to ensures its long // enough and can aid in debugging. - const char msg[] = "\x00" - "Bad indicator byte"; + static constexpr uint8_t INDICATOR_MSG_SIZE = 19; +#ifdef USE_ESP8266 + static const char MSG_PROGMEM[] PROGMEM = "\x00" + "Bad indicator byte"; + char msg[INDICATOR_MSG_SIZE]; + memcpy_P(msg, MSG_PROGMEM, INDICATOR_MSG_SIZE); iov[0].iov_base = (void *) msg; - iov[0].iov_len = 19; - this->write_raw_(iov, 1, 19); +#else + static const char MSG[] = "\x00" + "Bad indicator byte"; + iov[0].iov_base = (void *) MSG; +#endif + iov[0].iov_len = INDICATOR_MSG_SIZE; + this->write_raw_(iov, 1, INDICATOR_MSG_SIZE); } return aerr; } From 8d90f13e9723747052136dd4782210be777a6a03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 20:10:00 -0500 Subject: [PATCH 0990/1626] [core] Store component source strings in flash on ESP8266 (breaking change) (#10621) --- esphome/components/debug/debug_esp32.cpp | 2 +- .../runtime_stats/runtime_stats.cpp | 33 ++++++----------- .../components/runtime_stats/runtime_stats.h | 14 +++----- esphome/core/application.cpp | 8 ++--- esphome/core/component.cpp | 35 +++++++++---------- esphome/core/component.h | 11 +++--- esphome/core/scheduler.cpp | 8 ++--- esphome/core/scheduler.h | 2 +- esphome/cpp_generator.py | 13 +++++++ esphome/cpp_helpers.py | 4 +-- 10 files changed, 62 insertions(+), 68 deletions(-) diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 37990aeec5..b1dfe1bc9a 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -52,7 +52,7 @@ void DebugComponent::on_shutdown() { char buffer[REBOOT_MAX_LEN]{}; auto pref = global_preferences->make_preference(REBOOT_MAX_LEN, fnv1_hash(REBOOT_KEY + App.get_name())); if (component != nullptr) { - strncpy(buffer, component->get_component_source(), REBOOT_MAX_LEN - 1); + strncpy(buffer, LOG_STR_ARG(component->get_component_log_str()), REBOOT_MAX_LEN - 1); buffer[REBOOT_MAX_LEN - 1] = '\0'; } ESP_LOGD(TAG, "Storing reboot source: %s", buffer); diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 8f5d5daf01..f95be5291f 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -17,16 +17,8 @@ void RuntimeStatsCollector::record_component_time(Component *component, uint32_t if (component == nullptr) return; - // Check if we have cached the name for this component - auto name_it = this->component_names_cache_.find(component); - if (name_it == this->component_names_cache_.end()) { - // First time seeing this component, cache its name - const char *source = component->get_component_source(); - this->component_names_cache_[component] = source; - this->component_stats_[source].record_time(duration_ms); - } else { - this->component_stats_[name_it->second].record_time(duration_ms); - } + // Record stats using component pointer as key + this->component_stats_[component].record_time(duration_ms); if (this->next_log_time_ == 0) { this->next_log_time_ = current_time + this->log_interval_; @@ -42,9 +34,10 @@ void RuntimeStatsCollector::log_stats_() { std::vector stats_to_display; for (const auto &it : this->component_stats_) { + Component *component = it.first; const ComponentRuntimeStats &stats = it.second; if (stats.get_period_count() > 0) { - ComponentStatPair pair = {it.first, &stats}; + ComponentStatPair pair = {component, &stats}; stats_to_display.push_back(pair); } } @@ -54,12 +47,9 @@ void RuntimeStatsCollector::log_stats_() { // Log top components by period runtime for (const auto &it : stats_to_display) { - const char *source = it.name; - const ComponentRuntimeStats *stats = it.stats; - - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", source, - stats->get_period_count(), stats->get_period_avg_time_ms(), stats->get_period_max_time_ms(), - stats->get_period_time_ms()); + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", + LOG_STR_ARG(it.component->get_component_log_str()), it.stats->get_period_count(), + it.stats->get_period_avg_time_ms(), it.stats->get_period_max_time_ms(), it.stats->get_period_time_ms()); } // Log total stats since boot @@ -72,12 +62,9 @@ void RuntimeStatsCollector::log_stats_() { }); for (const auto &it : stats_to_display) { - const char *source = it.name; - const ComponentRuntimeStats *stats = it.stats; - - ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", source, - stats->get_total_count(), stats->get_total_avg_time_ms(), stats->get_total_max_time_ms(), - stats->get_total_time_ms()); + ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.2fms, max=%" PRIu32 "ms, total=%" PRIu32 "ms", + LOG_STR_ARG(it.component->get_component_log_str()), it.stats->get_total_count(), + it.stats->get_total_avg_time_ms(), it.stats->get_total_max_time_ms(), it.stats->get_total_time_ms()); } } diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index e2f8bee563..56122364c2 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -79,7 +79,7 @@ class ComponentRuntimeStats { // For sorting components by run time struct ComponentStatPair { - const char *name; + Component *component; const ComponentRuntimeStats *stats; bool operator>(const ComponentStatPair &other) const { @@ -109,15 +109,9 @@ class RuntimeStatsCollector { } } - // Use const char* keys for efficiency - // Custom comparator for const char* keys in map - // Without this, std::map would compare pointer addresses instead of string contents, - // causing identical component names at different addresses to be treated as different keys - struct CStrCompare { - bool operator()(const char *a, const char *b) const { return std::strcmp(a, b) < 0; } - }; - std::map component_stats_; - std::map component_names_cache_; + // Map from component to its stats + // We use Component* as the key since each component is unique + std::map component_stats_; uint32_t log_interval_; uint32_t next_log_time_; }; diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index dc745a2a46..b78f6fb903 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -80,7 +80,7 @@ void Application::register_component_(Component *comp) { for (auto *c : this->components_) { if (comp == c) { - ESP_LOGW(TAG, "Component %s already registered! (%p)", c->get_component_source(), c); + ESP_LOGW(TAG, "Component %s already registered! (%p)", LOG_STR_ARG(c->get_component_log_str()), c); return; } } @@ -340,8 +340,8 @@ void Application::teardown_components(uint32_t timeout_ms) { // Note: At this point, connections are either disconnected or in a bad state, // so this warning will only appear via serial rather than being transmitted to clients for (size_t i = 0; i < pending_count; ++i) { - ESP_LOGW(TAG, "%s did not complete teardown within %" PRIu32 " ms", pending_components[i]->get_component_source(), - timeout_ms); + ESP_LOGW(TAG, "%s did not complete teardown within %" PRIu32 " ms", + LOG_STR_ARG(pending_components[i]->get_component_log_str()), timeout_ms); } } } @@ -473,7 +473,7 @@ void Application::enable_pending_loops_() { // Clear the pending flag and enable the loop component->pending_enable_loop_ = false; - ESP_LOGVV(TAG, "%s loop enabled from ISR", component->get_component_source()); + ESP_LOGVV(TAG, "%s loop enabled from ISR", LOG_STR_ARG(component->get_component_log_str())); component->component_state_ &= ~COMPONENT_STATE_MASK; component->component_state_ |= COMPONENT_STATE_LOOP; diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 44a86a4aa5..ce4e2bf788 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -141,7 +141,7 @@ void Component::call_dump_config() { } } } - ESP_LOGE(TAG, " %s is marked FAILED: %s", this->get_component_source(), + ESP_LOGE(TAG, " %s is marked FAILED: %s", LOG_STR_ARG(this->get_component_log_str()), error_msg ? error_msg : LOG_STR_LITERAL("unspecified")); } } @@ -153,14 +153,14 @@ void Component::call() { case COMPONENT_STATE_CONSTRUCTION: { // State Construction: Call setup and set state to setup this->set_component_state_(COMPONENT_STATE_SETUP); - ESP_LOGV(TAG, "Setup %s", this->get_component_source()); + ESP_LOGV(TAG, "Setup %s", LOG_STR_ARG(this->get_component_log_str())); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t start_time = millis(); #endif this->call_setup(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t setup_time = millis() - start_time; - ESP_LOGCONFIG(TAG, "Setup %s took %ums", this->get_component_source(), (unsigned) setup_time); + ESP_LOGCONFIG(TAG, "Setup %s took %ums", LOG_STR_ARG(this->get_component_log_str()), (unsigned) setup_time); #endif break; } @@ -181,10 +181,8 @@ void Component::call() { break; } } -const char *Component::get_component_source() const { - if (this->component_source_ == nullptr) - return ""; - return this->component_source_; +const LogString *Component::get_component_log_str() const { + return this->component_source_ == nullptr ? LOG_STR("") : this->component_source_; } bool Component::should_warn_of_blocking(uint32_t blocking_time) { if (blocking_time > this->warn_if_blocking_over_) { @@ -200,7 +198,7 @@ bool Component::should_warn_of_blocking(uint32_t blocking_time) { return false; } void Component::mark_failed() { - ESP_LOGE(TAG, "%s was marked as failed", this->get_component_source()); + ESP_LOGE(TAG, "%s was marked as failed", LOG_STR_ARG(this->get_component_log_str())); this->set_component_state_(COMPONENT_STATE_FAILED); this->status_set_error(); // Also remove from loop since failed components shouldn't loop @@ -212,14 +210,14 @@ void Component::set_component_state_(uint8_t state) { } void Component::disable_loop() { if ((this->component_state_ & COMPONENT_STATE_MASK) != COMPONENT_STATE_LOOP_DONE) { - ESP_LOGVV(TAG, "%s loop disabled", this->get_component_source()); + ESP_LOGVV(TAG, "%s loop disabled", LOG_STR_ARG(this->get_component_log_str())); this->set_component_state_(COMPONENT_STATE_LOOP_DONE); App.disable_component_loop_(this); } } void Component::enable_loop() { if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { - ESP_LOGVV(TAG, "%s loop enabled", this->get_component_source()); + ESP_LOGVV(TAG, "%s loop enabled", LOG_STR_ARG(this->get_component_log_str())); this->set_component_state_(COMPONENT_STATE_LOOP); App.enable_component_loop_(this); } @@ -239,7 +237,7 @@ void IRAM_ATTR HOT Component::enable_loop_soon_any_context() { } void Component::reset_to_construction_state() { if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED) { - ESP_LOGI(TAG, "%s is being reset to construction state", this->get_component_source()); + ESP_LOGI(TAG, "%s is being reset to construction state", LOG_STR_ARG(this->get_component_log_str())); this->set_component_state_(COMPONENT_STATE_CONSTRUCTION); // Clear error status when resetting this->status_clear_error(); @@ -286,7 +284,7 @@ void Component::status_set_warning(const char *message) { return; this->component_state_ |= STATUS_LED_WARNING; App.app_state_ |= STATUS_LED_WARNING; - ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), + ESP_LOGW(TAG, "%s set Warning flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? message : LOG_STR_LITERAL("unspecified")); } void Component::status_set_warning(const LogString *message) { @@ -295,7 +293,7 @@ void Component::status_set_warning(const LogString *message) { return; this->component_state_ |= STATUS_LED_WARNING; App.app_state_ |= STATUS_LED_WARNING; - ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), + ESP_LOGW(TAG, "%s set Warning flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } void Component::status_set_error(const char *message) { @@ -303,7 +301,7 @@ void Component::status_set_error(const char *message) { return; this->component_state_ |= STATUS_LED_ERROR; App.app_state_ |= STATUS_LED_ERROR; - ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), + ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? message : LOG_STR_LITERAL("unspecified")); if (message != nullptr) { // Lazy allocate the error messages vector if needed @@ -325,13 +323,13 @@ void Component::status_clear_warning() { if ((this->component_state_ & STATUS_LED_WARNING) == 0) return; this->component_state_ &= ~STATUS_LED_WARNING; - ESP_LOGW(TAG, "%s cleared Warning flag", this->get_component_source()); + ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_clear_error() { if ((this->component_state_ & STATUS_LED_ERROR) == 0) return; this->component_state_ &= ~STATUS_LED_ERROR; - ESP_LOGE(TAG, "%s cleared Error flag", this->get_component_source()); + ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_momentary_warning(const std::string &name, uint32_t length) { this->status_set_warning(); @@ -442,8 +440,9 @@ uint32_t WarnIfComponentBlockingGuard::finish() { should_warn = blocking_time > WARN_IF_BLOCKING_OVER_MS; } if (should_warn) { - const char *src = component_ == nullptr ? "" : component_->get_component_source(); - ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms)", src, blocking_time); + ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms)", + component_ == nullptr ? LOG_STR_LITERAL("") : LOG_STR_ARG(component_->get_component_log_str()), + blocking_time); ESP_LOGW(TAG, "Components should block for at most 30 ms"); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 9dfcbb92fa..e97941374d 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -5,6 +5,7 @@ #include #include +#include "esphome/core/log.h" #include "esphome/core/optional.h" namespace esphome { @@ -223,12 +224,12 @@ class Component { * * This is set by the ESPHome core, and should not be called manually. */ - void set_component_source(const char *source) { component_source_ = source; } - /** Get the integration where this component was declared as a string. + void set_component_source(const LogString *source) { component_source_ = source; } + /** Get the integration where this component was declared as a LogString for logging. * - * Returns "" if source not set + * Returns LOG_STR("") if source not set */ - const char *get_component_source() const; + const LogString *get_component_log_str() const; bool should_warn_of_blocking(uint32_t blocking_time); @@ -408,7 +409,7 @@ class Component { bool cancel_defer(const std::string &name); // NOLINT // Ordered for optimal packing on 32-bit systems - const char *component_source_{nullptr}; + const LogString *component_source_{nullptr}; uint16_t warn_if_blocking_over_{WARN_IF_BLOCKING_OVER_MS}; ///< Warn if blocked for this many ms (max 65.5s) /// State of this component - each bit has a purpose: /// Bits 0-2: Component state (0x00=CONSTRUCTION, 0x01=SETUP, 0x02=LOOP, 0x03=FAILED, 0x04=LOOP_DONE) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 90d19e1ead..262349b6f9 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -162,10 +162,10 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Debug logging const char *type_str = (type == SchedulerItem::TIMEOUT) ? "timeout" : "interval"; if (type == SchedulerItem::TIMEOUT) { - ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ")", type_str, item->get_source(), + ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ")", type_str, LOG_STR_ARG(item->get_source()), name_cstr ? name_cstr : "(null)", type_str, delay); } else { - ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ", offset=%" PRIu32 ")", type_str, item->get_source(), + ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ", offset=%" PRIu32 ")", type_str, LOG_STR_ARG(item->get_source()), name_cstr ? name_cstr : "(null)", type_str, delay, static_cast(item->get_next_execution() - now)); } @@ -387,7 +387,7 @@ void HOT Scheduler::call(uint32_t now) { const char *name = item->get_name(); bool is_cancelled = is_item_removed_(item.get()); ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64 "%s", - item->get_type_str(), item->get_source(), name ? name : "(null)", item->interval, + item->get_type_str(), LOG_STR_ARG(item->get_source()), name ? name : "(null)", item->interval, item->get_next_execution() - now_64, item->get_next_execution(), is_cancelled ? " [CANCELLED]" : ""); old_items.push_back(std::move(item)); @@ -478,7 +478,7 @@ void HOT Scheduler::call(uint32_t now) { #ifdef ESPHOME_DEBUG_SCHEDULER const char *item_name = item->get_name(); ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")", - item->get_type_str(), item->get_source(), item_name ? item_name : "(null)", item->interval, + item->get_type_str(), LOG_STR_ARG(item->get_source()), item_name ? item_name : "(null)", item->interval, item->get_next_execution(), now_64); #endif /* ESPHOME_DEBUG_SCHEDULER */ diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 68ad64b9b1..301342e8c2 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -204,7 +204,7 @@ class Scheduler { next_execution_high_ = static_cast(value >> 32); } constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } - const char *get_source() const { return component ? component->get_component_source() : "unknown"; } + const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); } }; // Common implementation for both timeout and interval diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 34e4eec1ee..291592dd2b 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -253,6 +253,19 @@ class StringLiteral(Literal): return cpp_string_escape(self.string) +class LogStringLiteral(Literal): + """A string literal that uses LOG_STR() macro for flash storage on ESP8266.""" + + __slots__ = ("string",) + + def __init__(self, string: str) -> None: + super().__init__() + self.string = string + + def __str__(self) -> str: + return f"LOG_STR({cpp_string_escape(self.string)})" + + class IntLiteral(Literal): __slots__ = ("i",) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index b61b215bdc..2698b9b3d5 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -9,7 +9,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, coroutine from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import add, get_variable +from esphome.cpp_generator import LogStringLiteral, add, get_variable from esphome.cpp_types import App from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry @@ -76,7 +76,7 @@ async def register_component(var, config): "Error while finding name of component, please report this", exc_info=e ) if name is not None: - add(var.set_component_source(name)) + add(var.set_component_source(LogStringLiteral(name))) add(App.register_component(var)) return var From 9a9783bb21116f96dc659959e006b8bcc0da55a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Sep 2025 22:02:03 -0500 Subject: [PATCH 0991/1626] [core] Reduce unnecessary nesting in scheduler loop --- esphome/core/scheduler.cpp | 122 ++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 64 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 262349b6f9..68da0a56ca 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -436,85 +436,79 @@ void HOT Scheduler::call(uint32_t now) { this->to_remove_ = 0; } while (!this->items_.empty()) { - // use scoping to indicate visibility of `item` variable - { - // Don't copy-by value yet - auto &item = this->items_[0]; - if (item->get_next_execution() > now_64) { - // Not reached timeout yet, done for this call - break; - } - // Don't run on failed components - if (item->component != nullptr && item->component->is_failed()) { - LockGuard guard{this->lock_}; - this->pop_raw_(); - continue; - } + // Don't copy-by value yet + auto &item = this->items_[0]; + if (item->get_next_execution() > now_64) { + // Not reached timeout yet, done for this call + break; + } + // Don't run on failed components + if (item->component != nullptr && item->component->is_failed()) { + LockGuard guard{this->lock_}; + this->pop_raw_(); + continue; + } - // Check if item is marked for removal - // This handles two cases: - // 1. Item was marked for removal after cleanup_() but before we got here - // 2. Item is marked for removal but wasn't at the front of the heap during cleanup_() + // Check if item is marked for removal + // This handles two cases: + // 1. Item was marked for removal after cleanup_() but before we got here + // 2. Item is marked for removal but wasn't at the front of the heap during cleanup_() #ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS - // Multi-threaded platforms without atomics: must take lock to safely read remove flag - { - LockGuard guard{this->lock_}; - if (is_item_removed_(item.get())) { - this->pop_raw_(); - this->to_remove_--; - continue; - } - } -#else - // Single-threaded or multi-threaded with atomics: can check without lock + // Multi-threaded platforms without atomics: must take lock to safely read remove flag + { + LockGuard guard{this->lock_}; if (is_item_removed_(item.get())) { - LockGuard guard{this->lock_}; this->pop_raw_(); this->to_remove_--; continue; } + } +#else + // Single-threaded or multi-threaded with atomics: can check without lock + if (is_item_removed_(item.get())) { + LockGuard guard{this->lock_}; + this->pop_raw_(); + this->to_remove_--; + continue; + } #endif #ifdef ESPHOME_DEBUG_SCHEDULER - const char *item_name = item->get_name(); - ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")", - item->get_type_str(), LOG_STR_ARG(item->get_source()), item_name ? item_name : "(null)", item->interval, - item->get_next_execution(), now_64); + const char *item_name = item->get_name(); + ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")", + item->get_type_str(), LOG_STR_ARG(item->get_source()), item_name ? item_name : "(null)", item->interval, + item->get_next_execution(), now_64); #endif /* ESPHOME_DEBUG_SCHEDULER */ - // Warning: During callback(), a lot of stuff can happen, including: - // - timeouts/intervals get added, potentially invalidating vector pointers - // - timeouts/intervals get cancelled - this->execute_item_(item.get(), now); + // Warning: During callback(), a lot of stuff can happen, including: + // - timeouts/intervals get added, potentially invalidating vector pointers + // - timeouts/intervals get cancelled + this->execute_item_(item.get(), now); + + LockGuard guard{this->lock_}; + + auto executed_item = std::move(this->items_[0]); + // Only pop after function call, this ensures we were reachable + // during the function call and know if we were cancelled. + this->pop_raw_(); + + if (executed_item->remove) { + // We were removed/cancelled in the function call, stop + this->to_remove_--; + continue; } - { - LockGuard guard{this->lock_}; - - // new scope, item from before might have been moved in the vector - auto item = std::move(this->items_[0]); - // Only pop after function call, this ensures we were reachable - // during the function call and know if we were cancelled. - this->pop_raw_(); - - if (item->remove) { - // We were removed/cancelled in the function call, stop - this->to_remove_--; - continue; - } - - if (item->type == SchedulerItem::INTERVAL) { - item->set_next_execution(now_64 + item->interval); - // Add new item directly to to_add_ - // since we have the lock held - this->to_add_.push_back(std::move(item)); - } else { - // Timeout completed - recycle it - this->recycle_item_(std::move(item)); - } - - has_added_items |= !this->to_add_.empty(); + if (executed_item->type == SchedulerItem::INTERVAL) { + executed_item->set_next_execution(now_64 + executed_item->interval); + // Add new item directly to to_add_ + // since we have the lock held + this->to_add_.push_back(std::move(executed_item)); + } else { + // Timeout completed - recycle it + this->recycle_item_(std::move(executed_item)); } + + has_added_items |= !this->to_add_.empty(); } if (has_added_items) { From e5bba00deb724808260d67387d4afd4a9ada5e92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Sep 2025 08:46:30 -0500 Subject: [PATCH 0992/1626] [esp32] Reduce GPIO memory usage by 50% through bit-packing (#10556) --- esphome/components/esp32/gpio.cpp | 50 +++++++++++++++++-------------- esphome/components/esp32/gpio.h | 37 ++++++++++++++++------- 2 files changed, 53 insertions(+), 34 deletions(-) diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 27572063ca..a98245b889 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -54,13 +54,13 @@ struct ISRPinArg { ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) - arg->pin = this->pin_; + arg->pin = this->get_pin_num(); arg->flags = gpio::FLAG_NONE; - arg->inverted = inverted_; + arg->inverted = this->pin_flags_.inverted; #if defined(USE_ESP32_VARIANT_ESP32) - arg->use_rtc = rtc_gpio_is_valid_gpio(this->pin_); + arg->use_rtc = rtc_gpio_is_valid_gpio(this->get_pin_num()); if (arg->use_rtc) - arg->rtc_pin = rtc_io_number_get(this->pin_); + arg->rtc_pin = rtc_io_number_get(this->get_pin_num()); #endif return ISRInternalGPIOPin((void *) arg); } @@ -69,23 +69,23 @@ void ESP32InternalGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpi gpio_int_type_t idf_type = GPIO_INTR_ANYEDGE; switch (type) { case gpio::INTERRUPT_RISING_EDGE: - idf_type = inverted_ ? GPIO_INTR_NEGEDGE : GPIO_INTR_POSEDGE; + idf_type = this->pin_flags_.inverted ? GPIO_INTR_NEGEDGE : GPIO_INTR_POSEDGE; break; case gpio::INTERRUPT_FALLING_EDGE: - idf_type = inverted_ ? GPIO_INTR_POSEDGE : GPIO_INTR_NEGEDGE; + idf_type = this->pin_flags_.inverted ? GPIO_INTR_POSEDGE : GPIO_INTR_NEGEDGE; break; case gpio::INTERRUPT_ANY_EDGE: idf_type = GPIO_INTR_ANYEDGE; break; case gpio::INTERRUPT_LOW_LEVEL: - idf_type = inverted_ ? GPIO_INTR_HIGH_LEVEL : GPIO_INTR_LOW_LEVEL; + idf_type = this->pin_flags_.inverted ? GPIO_INTR_HIGH_LEVEL : GPIO_INTR_LOW_LEVEL; break; case gpio::INTERRUPT_HIGH_LEVEL: - idf_type = inverted_ ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL; + idf_type = this->pin_flags_.inverted ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL; break; } - gpio_set_intr_type(pin_, idf_type); - gpio_intr_enable(pin_); + gpio_set_intr_type(this->get_pin_num(), idf_type); + gpio_intr_enable(this->get_pin_num()); if (!isr_service_installed) { auto res = gpio_install_isr_service(ESP_INTR_FLAG_LEVEL3); if (res != ESP_OK) { @@ -94,31 +94,31 @@ void ESP32InternalGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpi } isr_service_installed = true; } - gpio_isr_handler_add(pin_, func, arg); + gpio_isr_handler_add(this->get_pin_num(), func, arg); } std::string ESP32InternalGPIOPin::dump_summary() const { char buffer[32]; - snprintf(buffer, sizeof(buffer), "GPIO%" PRIu32, static_cast(pin_)); + snprintf(buffer, sizeof(buffer), "GPIO%" PRIu32, static_cast(this->pin_)); return buffer; } void ESP32InternalGPIOPin::setup() { gpio_config_t conf{}; - conf.pin_bit_mask = 1ULL << static_cast(pin_); - conf.mode = flags_to_mode(flags_); - conf.pull_up_en = flags_ & gpio::FLAG_PULLUP ? GPIO_PULLUP_ENABLE : GPIO_PULLUP_DISABLE; - conf.pull_down_en = flags_ & gpio::FLAG_PULLDOWN ? GPIO_PULLDOWN_ENABLE : GPIO_PULLDOWN_DISABLE; + conf.pin_bit_mask = 1ULL << static_cast(this->pin_); + conf.mode = flags_to_mode(this->flags_); + conf.pull_up_en = this->flags_ & gpio::FLAG_PULLUP ? GPIO_PULLUP_ENABLE : GPIO_PULLUP_DISABLE; + conf.pull_down_en = this->flags_ & gpio::FLAG_PULLDOWN ? GPIO_PULLDOWN_ENABLE : GPIO_PULLDOWN_DISABLE; conf.intr_type = GPIO_INTR_DISABLE; gpio_config(&conf); - if (flags_ & gpio::FLAG_OUTPUT) { - gpio_set_drive_capability(pin_, drive_strength_); + if (this->flags_ & gpio::FLAG_OUTPUT) { + gpio_set_drive_capability(this->get_pin_num(), this->get_drive_strength()); } } void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { // can't call gpio_config here because that logs in esp-idf which may cause issues - gpio_set_direction(pin_, flags_to_mode(flags)); + gpio_set_direction(this->get_pin_num(), flags_to_mode(flags)); gpio_pull_mode_t pull_mode = GPIO_FLOATING; if ((flags & gpio::FLAG_PULLUP) && (flags & gpio::FLAG_PULLDOWN)) { pull_mode = GPIO_PULLUP_PULLDOWN; @@ -127,12 +127,16 @@ void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { } else if (flags & gpio::FLAG_PULLDOWN) { pull_mode = GPIO_PULLDOWN_ONLY; } - gpio_set_pull_mode(pin_, pull_mode); + gpio_set_pull_mode(this->get_pin_num(), pull_mode); } -bool ESP32InternalGPIOPin::digital_read() { return bool(gpio_get_level(pin_)) != inverted_; } -void ESP32InternalGPIOPin::digital_write(bool value) { gpio_set_level(pin_, value != inverted_ ? 1 : 0); } -void ESP32InternalGPIOPin::detach_interrupt() const { gpio_intr_disable(pin_); } +bool ESP32InternalGPIOPin::digital_read() { + return bool(gpio_get_level(this->get_pin_num())) != this->pin_flags_.inverted; +} +void ESP32InternalGPIOPin::digital_write(bool value) { + gpio_set_level(this->get_pin_num(), value != this->pin_flags_.inverted ? 1 : 0); +} +void ESP32InternalGPIOPin::detach_interrupt() const { gpio_intr_disable(this->get_pin_num()); } } // namespace esp32 diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index 0fefc1c058..565e276ea8 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -7,12 +7,18 @@ namespace esphome { namespace esp32 { +// Static assertions to ensure our bit-packed fields can hold the enum values +static_assert(GPIO_NUM_MAX <= 256, "gpio_num_t has too many values for uint8_t"); +static_assert(GPIO_DRIVE_CAP_MAX <= 4, "gpio_drive_cap_t has too many values for 2-bit field"); + class ESP32InternalGPIOPin : public InternalGPIOPin { public: - void set_pin(gpio_num_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_drive_strength(gpio_drive_cap_t drive_strength) { drive_strength_ = drive_strength; } - void set_flags(gpio::Flags flags) { flags_ = flags; } + void set_pin(gpio_num_t pin) { this->pin_ = static_cast(pin); } + void set_inverted(bool inverted) { this->pin_flags_.inverted = inverted; } + void set_drive_strength(gpio_drive_cap_t drive_strength) { + this->pin_flags_.drive_strength = static_cast(drive_strength); + } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } void setup() override; void pin_mode(gpio::Flags flags) override; @@ -21,17 +27,26 @@ class ESP32InternalGPIOPin : public InternalGPIOPin { std::string dump_summary() const override; void detach_interrupt() const override; ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return (uint8_t) pin_; } - gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + uint8_t get_pin() const override { return this->pin_; } + gpio::Flags get_flags() const override { return this->flags_; } + bool is_inverted() const override { return this->pin_flags_.inverted; } + gpio_num_t get_pin_num() const { return static_cast(this->pin_); } + gpio_drive_cap_t get_drive_strength() const { return static_cast(this->pin_flags_.drive_strength); } protected: void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; - gpio_num_t pin_; - gpio_drive_cap_t drive_strength_; - gpio::Flags flags_; - bool inverted_; + // Memory layout: 8 bytes total on 32-bit systems + // - 3 bytes for members below + // - 1 byte padding for alignment + // - 4 bytes for vtable pointer + uint8_t pin_; // GPIO pin number (0-255, actual max ~54 on ESP32) + gpio::Flags flags_; // GPIO flags (1 byte) + struct PinFlags { + uint8_t inverted : 1; // Invert pin logic (1 bit) + uint8_t drive_strength : 2; // Drive strength 0-3 (2 bits) + uint8_t reserved : 5; // Reserved for future use (5 bits) + } pin_flags_; // Total: 1 byte // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static bool isr_service_installed; }; From 8179495fd739949d4402e15335df72c1ea5d378e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Sep 2025 10:03:56 -0500 Subject: [PATCH 0993/1626] [core] Fix serial upload regression from DNS resolution PR #10595 --- esphome/__main__.py | 41 +++++++++++++++++------------------------ esphome/espota2.py | 13 +++++++------ 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 70d5cacd72..bbcde527e3 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -398,28 +398,29 @@ def check_permissions(port: str): def upload_program( config: ConfigType, args: ArgsProtocol, devices: list[str] -) -> int | str: +) -> tuple[int, str | None]: host = devices[0] try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): - return 0 + return 0, host except AttributeError: pass if get_port_type(host) == "SERIAL": check_permissions(host) + + exit_code = 1 if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266): file = getattr(args, "file", None) - return upload_using_esptool(config, host, file, args.upload_speed) + exit_code = upload_using_esptool(config, host, file, args.upload_speed) + elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny: + exit_code = upload_using_platformio(config, host) + else: + # Unknown target platform + pass - if CORE.target_platform in (PLATFORM_RP2040): - return upload_using_platformio(config, host) - - if CORE.is_libretiny: - return upload_using_platformio(config, host) - - return 1 # Unknown target platform + return exit_code, host if exit_code == 0 else None ota_conf = {} for ota_item in config.get(CONF_OTA, []): @@ -553,7 +554,7 @@ def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: purpose="uploading", ) - exit_code = upload_program(config, args, devices) + exit_code, successful_device = upload_program(config, args, devices) if exit_code == 0: _LOGGER.info("Successfully uploaded program.") else: @@ -610,19 +611,11 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: purpose="uploading", ) - # Try each device for upload until one succeeds - successful_device: str | None = None - for device in devices: - _LOGGER.info("Uploading to %s", device) - exit_code = upload_program(config, args, device) - if exit_code == 0: - _LOGGER.info("Successfully uploaded program.") - successful_device = device - break - if len(devices) > 1: - _LOGGER.warning("Failed to upload to %s", device) - - if successful_device is None: + exit_code, successful_device = upload_program(config, args, devices) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + else: + _LOGGER.warning("Failed to upload to %s", devices) return exit_code if args.no_logs: diff --git a/esphome/espota2.py b/esphome/espota2.py index d83f25a303..3d25af985b 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -310,7 +310,7 @@ def perform_ota( def run_ota_impl_( remote_host: str | list[str], remote_port: int, password: str, filename: str -) -> int: +) -> tuple[int, str | None]: # Handle both single host and list of hosts try: # Resolve all hosts at once for parallel DNS resolution @@ -344,21 +344,22 @@ def run_ota_impl_( perform_ota(sock, password, file_handle, filename) except OTAError as err: _LOGGER.error(str(err)) - return 1 + return 1, None finally: sock.close() - return 0 + # Successfully uploaded to sa[0] + return 0, sa[0] _LOGGER.error("Connection failed.") - return 1 + return 1, None def run_ota( remote_host: str | list[str], remote_port: int, password: str, filename: str -) -> int: +) -> tuple[int, str | None]: try: return run_ota_impl_(remote_host, remote_port, password, filename) except OTAError as err: _LOGGER.error(err) - return 1 + return 1, None From 75c9430d91ffde5a1febfe6520686b14c6b6e68a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Sep 2025 10:41:03 -0500 Subject: [PATCH 0994/1626] [core] Fix serial upload regression from DNS resolution PR #10595 (#10648) --- esphome/__main__.py | 39 +++++++++++++++------------------------ esphome/espota2.py | 13 +++++++------ 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 70d5cacd72..e1f683397f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -398,28 +398,27 @@ def check_permissions(port: str): def upload_program( config: ConfigType, args: ArgsProtocol, devices: list[str] -) -> int | str: +) -> tuple[int, str | None]: host = devices[0] try: module = importlib.import_module("esphome.components." + CORE.target_platform) if getattr(module, "upload_program")(config, args, host): - return 0 + return 0, host except AttributeError: pass if get_port_type(host) == "SERIAL": check_permissions(host) + + exit_code = 1 if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266): file = getattr(args, "file", None) - return upload_using_esptool(config, host, file, args.upload_speed) + exit_code = upload_using_esptool(config, host, file, args.upload_speed) + elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny: + exit_code = upload_using_platformio(config, host) + # else: Unknown target platform, exit_code remains 1 - if CORE.target_platform in (PLATFORM_RP2040): - return upload_using_platformio(config, host) - - if CORE.is_libretiny: - return upload_using_platformio(config, host) - - return 1 # Unknown target platform + return exit_code, host if exit_code == 0 else None ota_conf = {} for ota_item in config.get(CONF_OTA, []): @@ -553,7 +552,7 @@ def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: purpose="uploading", ) - exit_code = upload_program(config, args, devices) + exit_code, _ = upload_program(config, args, devices) if exit_code == 0: _LOGGER.info("Successfully uploaded program.") else: @@ -610,19 +609,11 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: purpose="uploading", ) - # Try each device for upload until one succeeds - successful_device: str | None = None - for device in devices: - _LOGGER.info("Uploading to %s", device) - exit_code = upload_program(config, args, device) - if exit_code == 0: - _LOGGER.info("Successfully uploaded program.") - successful_device = device - break - if len(devices) > 1: - _LOGGER.warning("Failed to upload to %s", device) - - if successful_device is None: + exit_code, successful_device = upload_program(config, args, devices) + if exit_code == 0: + _LOGGER.info("Successfully uploaded program.") + else: + _LOGGER.warning("Failed to upload to %s", devices) return exit_code if args.no_logs: diff --git a/esphome/espota2.py b/esphome/espota2.py index d83f25a303..3d25af985b 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -310,7 +310,7 @@ def perform_ota( def run_ota_impl_( remote_host: str | list[str], remote_port: int, password: str, filename: str -) -> int: +) -> tuple[int, str | None]: # Handle both single host and list of hosts try: # Resolve all hosts at once for parallel DNS resolution @@ -344,21 +344,22 @@ def run_ota_impl_( perform_ota(sock, password, file_handle, filename) except OTAError as err: _LOGGER.error(str(err)) - return 1 + return 1, None finally: sock.close() - return 0 + # Successfully uploaded to sa[0] + return 0, sa[0] _LOGGER.error("Connection failed.") - return 1 + return 1, None def run_ota( remote_host: str | list[str], remote_port: int, password: str, filename: str -) -> int: +) -> tuple[int, str | None]: try: return run_ota_impl_(remote_host, remote_port, password, filename) except OTAError as err: _LOGGER.error(err) - return 1 + return 1, None From 703b5927938d5cb6859f8b5c1a16dca2e5e093a6 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Mon, 8 Sep 2025 20:03:41 +0200 Subject: [PATCH 0995/1626] Add I2S Audio Port for ESP32-C5/C6/H2 (#10414) --- esphome/components/i2s_audio/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index bfa1b726f1..cff91a546f 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -4,6 +4,9 @@ from esphome.components.esp32 import add_idf_sdkconfig_option, get_esp32_variant from esphome.components.esp32.const import ( VARIANT_ESP32, VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, @@ -62,12 +65,15 @@ I2S_ROLE_OPTIONS = { CONF_SECONDARY: i2s_role_t.I2S_ROLE_SLAVE, # NOLINT } -# https://github.com/espressif/esp-idf/blob/master/components/soc/{variant}/include/soc/soc_caps.h +# https://github.com/espressif/esp-idf/blob/master/components/soc/{variant}/include/soc/soc_caps.h (SOC_I2S_NUM) I2S_PORTS = { VARIANT_ESP32: 2, VARIANT_ESP32S2: 1, VARIANT_ESP32S3: 2, VARIANT_ESP32C3: 1, + VARIANT_ESP32C5: 1, + VARIANT_ESP32C6: 1, + VARIANT_ESP32H2: 1, VARIANT_ESP32P4: 3, } From 5cc0e21bc7d4114ead57cfed0a4fdf894efb3c70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Sep 2025 16:04:07 -0500 Subject: [PATCH 0996/1626] [core] Reduce unnecessary nesting in scheduler loop (#10644) --- esphome/core/scheduler.cpp | 122 ++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 64 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 262349b6f9..68da0a56ca 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -436,85 +436,79 @@ void HOT Scheduler::call(uint32_t now) { this->to_remove_ = 0; } while (!this->items_.empty()) { - // use scoping to indicate visibility of `item` variable - { - // Don't copy-by value yet - auto &item = this->items_[0]; - if (item->get_next_execution() > now_64) { - // Not reached timeout yet, done for this call - break; - } - // Don't run on failed components - if (item->component != nullptr && item->component->is_failed()) { - LockGuard guard{this->lock_}; - this->pop_raw_(); - continue; - } + // Don't copy-by value yet + auto &item = this->items_[0]; + if (item->get_next_execution() > now_64) { + // Not reached timeout yet, done for this call + break; + } + // Don't run on failed components + if (item->component != nullptr && item->component->is_failed()) { + LockGuard guard{this->lock_}; + this->pop_raw_(); + continue; + } - // Check if item is marked for removal - // This handles two cases: - // 1. Item was marked for removal after cleanup_() but before we got here - // 2. Item is marked for removal but wasn't at the front of the heap during cleanup_() + // Check if item is marked for removal + // This handles two cases: + // 1. Item was marked for removal after cleanup_() but before we got here + // 2. Item is marked for removal but wasn't at the front of the heap during cleanup_() #ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS - // Multi-threaded platforms without atomics: must take lock to safely read remove flag - { - LockGuard guard{this->lock_}; - if (is_item_removed_(item.get())) { - this->pop_raw_(); - this->to_remove_--; - continue; - } - } -#else - // Single-threaded or multi-threaded with atomics: can check without lock + // Multi-threaded platforms without atomics: must take lock to safely read remove flag + { + LockGuard guard{this->lock_}; if (is_item_removed_(item.get())) { - LockGuard guard{this->lock_}; this->pop_raw_(); this->to_remove_--; continue; } + } +#else + // Single-threaded or multi-threaded with atomics: can check without lock + if (is_item_removed_(item.get())) { + LockGuard guard{this->lock_}; + this->pop_raw_(); + this->to_remove_--; + continue; + } #endif #ifdef ESPHOME_DEBUG_SCHEDULER - const char *item_name = item->get_name(); - ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")", - item->get_type_str(), LOG_STR_ARG(item->get_source()), item_name ? item_name : "(null)", item->interval, - item->get_next_execution(), now_64); + const char *item_name = item->get_name(); + ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")", + item->get_type_str(), LOG_STR_ARG(item->get_source()), item_name ? item_name : "(null)", item->interval, + item->get_next_execution(), now_64); #endif /* ESPHOME_DEBUG_SCHEDULER */ - // Warning: During callback(), a lot of stuff can happen, including: - // - timeouts/intervals get added, potentially invalidating vector pointers - // - timeouts/intervals get cancelled - this->execute_item_(item.get(), now); + // Warning: During callback(), a lot of stuff can happen, including: + // - timeouts/intervals get added, potentially invalidating vector pointers + // - timeouts/intervals get cancelled + this->execute_item_(item.get(), now); + + LockGuard guard{this->lock_}; + + auto executed_item = std::move(this->items_[0]); + // Only pop after function call, this ensures we were reachable + // during the function call and know if we were cancelled. + this->pop_raw_(); + + if (executed_item->remove) { + // We were removed/cancelled in the function call, stop + this->to_remove_--; + continue; } - { - LockGuard guard{this->lock_}; - - // new scope, item from before might have been moved in the vector - auto item = std::move(this->items_[0]); - // Only pop after function call, this ensures we were reachable - // during the function call and know if we were cancelled. - this->pop_raw_(); - - if (item->remove) { - // We were removed/cancelled in the function call, stop - this->to_remove_--; - continue; - } - - if (item->type == SchedulerItem::INTERVAL) { - item->set_next_execution(now_64 + item->interval); - // Add new item directly to to_add_ - // since we have the lock held - this->to_add_.push_back(std::move(item)); - } else { - // Timeout completed - recycle it - this->recycle_item_(std::move(item)); - } - - has_added_items |= !this->to_add_.empty(); + if (executed_item->type == SchedulerItem::INTERVAL) { + executed_item->set_next_execution(now_64 + executed_item->interval); + // Add new item directly to to_add_ + // since we have the lock held + this->to_add_.push_back(std::move(executed_item)); + } else { + // Timeout completed - recycle it + this->recycle_item_(std::move(executed_item)); } + + has_added_items |= !this->to_add_.empty(); } if (has_added_items) { From f6d69231e891d84b0c56a0dae14037edd66aec98 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 9 Sep 2025 01:10:29 +0200 Subject: [PATCH 0997/1626] [light] add missing header (#10590) --- esphome/components/light/light_state.h | 1 + .../components/light/test.nrf52-adafruit.yaml | 19 +++++++++++++++++++ tests/components/light/test.nrf52-mcumgr.yaml | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 tests/components/light/test.nrf52-adafruit.yaml create mode 100644 tests/components/light/test.nrf52-mcumgr.yaml diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 48323dd3c3..1427c02c35 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -12,6 +12,7 @@ #include "light_transformer.h" #include +#include namespace esphome { namespace light { diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..cb421ed4bb --- /dev/null +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -0,0 +1,19 @@ +esphome: + on_boot: + then: + - light.toggle: test_binary_light + +output: + - platform: gpio + id: test_binary + pin: 0 + +light: + - platform: binary + id: test_binary_light + name: Binary Light + output: test_binary + effects: + - strobe: + on_state: + - logger.log: Binary light state changed diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..cb421ed4bb --- /dev/null +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -0,0 +1,19 @@ +esphome: + on_boot: + then: + - light.toggle: test_binary_light + +output: + - platform: gpio + id: test_binary + pin: 0 + +light: + - platform: binary + id: test_binary_light + name: Binary Light + output: test_binary + effects: + - strobe: + on_state: + - logger.log: Binary light state changed From 0495856f61835fef322a96074145fe11dce63dcb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Sep 2025 19:53:08 -0500 Subject: [PATCH 0998/1626] [core] Reduce flash usage by refactoring looping component partitioning --- esphome/core/application.cpp | 13 ++++++------- esphome/core/application.h | 1 + 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index b78f6fb903..5371d1b56f 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -361,20 +361,19 @@ void Application::calculate_looping_components_() { // Add all components with loop override that aren't already LOOP_DONE // Some components (like logger) may call disable_loop() during initialization // before setup runs, so we need to respect their LOOP_DONE state - for (auto *obj : this->components_) { - if (obj->has_overridden_loop() && - (obj->get_component_state() & COMPONENT_STATE_MASK) != COMPONENT_STATE_LOOP_DONE) { - this->looping_components_.push_back(obj); - } - } + this->add_looping_components_by_state_(false); this->looping_components_active_end_ = this->looping_components_.size(); // Then add any components that are already LOOP_DONE to the inactive section // This handles components that called disable_loop() during initialization + this->add_looping_components_by_state_(true); +} + +void Application::add_looping_components_by_state_(bool match_loop_done) { for (auto *obj : this->components_) { if (obj->has_overridden_loop() && - (obj->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { + ((obj->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) == match_loop_done) { this->looping_components_.push_back(obj); } } diff --git a/esphome/core/application.h b/esphome/core/application.h index 9cb2a4c638..1f22499051 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -431,6 +431,7 @@ class Application { void register_component_(Component *comp); void calculate_looping_components_(); + void add_looping_components_by_state_(bool match_loop_done); // These methods are called by Component::disable_loop() and Component::enable_loop() // Components should not call these directly - use this->disable_loop() or this->enable_loop() From 90c2fdd5652acfdfe9fc5c8909211c86362b8439 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Tue, 9 Sep 2025 02:56:18 +0200 Subject: [PATCH 0999/1626] [adc] Fix autorange negative coefficient bug causing incorrect voltage readings (#10549) --- esphome/components/adc/adc_sensor_esp32.cpp | 35 +++++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index 87d4ddd35f..ab6a89fce0 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -241,6 +241,8 @@ float ADCSensor::sample_autorange_() { cali_config.bitwidth = ADC_BITWIDTH_DEFAULT; err = adc_cali_create_scheme_curve_fitting(&cali_config, &handle); + ESP_LOGVV(TAG, "Autorange atten=%d: Calibration handle creation %s (err=%d)", atten, + (err == ESP_OK) ? "SUCCESS" : "FAILED", err); #else adc_cali_line_fitting_config_t cali_config = { .unit_id = this->adc_unit_, @@ -251,10 +253,14 @@ float ADCSensor::sample_autorange_() { #endif }; err = adc_cali_create_scheme_line_fitting(&cali_config, &handle); + ESP_LOGVV(TAG, "Autorange atten=%d: Calibration handle creation %s (err=%d)", atten, + (err == ESP_OK) ? "SUCCESS" : "FAILED", err); #endif int raw; err = adc_oneshot_read(this->adc_handle_, this->channel_, &raw); + ESP_LOGVV(TAG, "Autorange atten=%d: Raw ADC read %s, value=%d (err=%d)", atten, + (err == ESP_OK) ? "SUCCESS" : "FAILED", raw, err); if (err != ESP_OK) { ESP_LOGW(TAG, "ADC read failed in autorange with error %d", err); @@ -275,8 +281,10 @@ float ADCSensor::sample_autorange_() { err = adc_cali_raw_to_voltage(handle, raw, &voltage_mv); if (err == ESP_OK) { voltage = voltage_mv / 1000.0f; + ESP_LOGVV(TAG, "Autorange atten=%d: CALIBRATED - raw=%d -> %dmV -> %.6fV", atten, raw, voltage_mv, voltage); } else { voltage = raw * 3.3f / 4095.0f; + ESP_LOGVV(TAG, "Autorange atten=%d: UNCALIBRATED FALLBACK - raw=%d -> %.6fV (3.3V ref)", atten, raw, voltage); } // Clean up calibration handle #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ @@ -287,6 +295,7 @@ float ADCSensor::sample_autorange_() { #endif } else { voltage = raw * 3.3f / 4095.0f; + ESP_LOGVV(TAG, "Autorange atten=%d: NO CALIBRATION - raw=%d -> %.6fV (3.3V ref)", atten, raw, voltage); } return {raw, voltage}; @@ -324,18 +333,32 @@ float ADCSensor::sample_autorange_() { } const int adc_half = 2048; - uint32_t c12 = std::min(raw12, adc_half); - uint32_t c6 = adc_half - std::abs(raw6 - adc_half); - uint32_t c2 = adc_half - std::abs(raw2 - adc_half); - uint32_t c0 = std::min(4095 - raw0, adc_half); - uint32_t csum = c12 + c6 + c2 + c0; + const uint32_t c12 = std::min(raw12, adc_half); + + const int32_t c6_signed = adc_half - std::abs(raw6 - adc_half); + const uint32_t c6 = (c6_signed > 0) ? c6_signed : 0; // Clamp to prevent underflow + + const int32_t c2_signed = adc_half - std::abs(raw2 - adc_half); + const uint32_t c2 = (c2_signed > 0) ? c2_signed : 0; // Clamp to prevent underflow + + const uint32_t c0 = std::min(4095 - raw0, adc_half); + const uint32_t csum = c12 + c6 + c2 + c0; + + ESP_LOGVV(TAG, "Autorange summary:"); + ESP_LOGVV(TAG, " Raw readings: 12db=%d, 6db=%d, 2.5db=%d, 0db=%d", raw12, raw6, raw2, raw0); + ESP_LOGVV(TAG, " Voltages: 12db=%.6f, 6db=%.6f, 2.5db=%.6f, 0db=%.6f", mv12, mv6, mv2, mv0); + ESP_LOGVV(TAG, " Coefficients: c12=%u, c6=%u, c2=%u, c0=%u, sum=%u", c12, c6, c2, c0, csum); if (csum == 0) { ESP_LOGE(TAG, "Invalid weight sum in autorange calculation"); return NAN; } - return (mv12 * c12 + mv6 * c6 + mv2 * c2 + mv0 * c0) / csum; + const float final_result = (mv12 * c12 + mv6 * c6 + mv2 * c2 + mv0 * c0) / csum; + ESP_LOGV(TAG, "Autorange final: (%.6f*%u + %.6f*%u + %.6f*%u + %.6f*%u)/%u = %.6fV", mv12, c12, mv6, c6, mv2, c2, mv0, + c0, csum, final_result); + + return final_result; } } // namespace adc From f5f84fe825f86c5f5d3d9d240eb08d996efd9caf Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Tue, 9 Sep 2025 02:57:24 +0200 Subject: [PATCH 1000/1626] [nextion] Increase delay before reboot to prevent TFT upload interruption (#10402) --- esphome/components/nextion/nextion_upload.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/nextion/nextion_upload.cpp b/esphome/components/nextion/nextion_upload.cpp index c47b393f99..7ddd7a2f08 100644 --- a/esphome/components/nextion/nextion_upload.cpp +++ b/esphome/components/nextion/nextion_upload.cpp @@ -11,7 +11,10 @@ static const char *const TAG = "nextion.upload"; bool Nextion::upload_end_(bool successful) { if (successful) { ESP_LOGD(TAG, "Upload successful"); - delay(1500); // NOLINT + for (uint8_t i = 0; i <= 5; i++) { + delay(1000); // NOLINT + App.feed_wdt(); // Feed the watchdog timer. + } App.safe_reboot(); } else { ESP_LOGE(TAG, "Upload failed"); From c0cab7ded30a62400b0cddd7ca94d5432e45ffb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Sep 2025 20:30:38 -0500 Subject: [PATCH 1001/1626] [core] Refactor insertion sort functions to eliminate code duplication --- esphome/core/application.cpp | 33 +++++++++------------------------ 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index b78f6fb903..00eaed00a5 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -34,37 +34,20 @@ namespace esphome { static const char *const TAG = "app"; -// Helper function for insertion sort of components by setup priority +// Helper function for insertion sort of components by priority // Using insertion sort instead of std::stable_sort saves ~1.3KB of flash // by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) // IMPORTANT: This sort is stable (preserves relative order of equal elements), // which is necessary to maintain user-defined component order for same priority -template static void insertion_sort_by_setup_priority(Iterator first, Iterator last) { +template +static void insertion_sort_by_priority(Iterator first, Iterator last) { for (auto it = first + 1; it != last; ++it) { auto key = *it; - float key_priority = key->get_actual_setup_priority(); + float key_priority = (key->*GetPriority)(); auto j = it - 1; // Using '<' (not '<=') ensures stability - equal priority components keep their order - while (j >= first && (*j)->get_actual_setup_priority() < key_priority) { - *(j + 1) = *j; - j--; - } - *(j + 1) = key; - } -} - -// Helper function for insertion sort of components by loop priority -// IMPORTANT: This sort is stable (preserves relative order of equal elements), -// which is required when components are re-sorted during setup() if they block -template static void insertion_sort_by_loop_priority(Iterator first, Iterator last) { - for (auto it = first + 1; it != last; ++it) { - auto key = *it; - float key_priority = key->get_loop_priority(); - auto j = it - 1; - - // Using '<' (not '<=') ensures stability - equal priority components keep their order - while (j >= first && (*j)->get_loop_priority() < key_priority) { + while (j >= first && ((*j)->*GetPriority)() < key_priority) { *(j + 1) = *j; j--; } @@ -91,7 +74,8 @@ void Application::setup() { ESP_LOGV(TAG, "Sorting components by setup priority"); // Sort by setup priority using our helper function - insertion_sort_by_setup_priority(this->components_.begin(), this->components_.end()); + insertion_sort_by_prioritycomponents_.begin()), &Component::get_actual_setup_priority>( + this->components_.begin(), this->components_.end()); // Initialize looping_components_ early so enable_pending_loops_() works during setup this->calculate_looping_components_(); @@ -108,7 +92,8 @@ void Application::setup() { continue; // Sort components 0 through i by loop priority - insertion_sort_by_loop_priority(this->components_.begin(), this->components_.begin() + i + 1); + insertion_sort_by_prioritycomponents_.begin()), &Component::get_loop_priority>( + this->components_.begin(), this->components_.begin() + i + 1); do { uint8_t new_app_state = STATUS_LED_WARNING; From 59e62a1f44373186dfe65c5d15c76abc04d3e41f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 9 Sep 2025 14:26:22 +1200 Subject: [PATCH 1002/1626] Sort codeowners using case-insensitive (#10651) Co-authored-by: J. Nick Koston --- CODEOWNERS | 26 +++++++++++++------------- script/build_codeowners.py | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3f963bf960..a77a7ba86e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -88,7 +88,7 @@ esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid esphome/components/button/* @esphome/core esphome/components/bytebuffer/* @clydebarrow -esphome/components/camera/* @DT-art1 @bdraco +esphome/components/camera/* @bdraco @DT-art1 esphome/components/camera_encoder/* @DT-art1 esphome/components/canbus/* @danielschramm @mvturnho esphome/components/cap1188/* @mreditor97 @@ -145,9 +145,9 @@ esphome/components/es8156/* @kbx81 esphome/components/es8311/* @kahrendt @kroimon esphome/components/es8388/* @P4uLT esphome/components/esp32/* @esphome/core -esphome/components/esp32_ble/* @Rapsssito @bdraco @jesserockz +esphome/components/esp32_ble/* @bdraco @jesserockz @Rapsssito esphome/components/esp32_ble_client/* @bdraco @jesserockz -esphome/components/esp32_ble_server/* @Rapsssito @clydebarrow @jesserockz +esphome/components/esp32_ble_server/* @clydebarrow @jesserockz @Rapsssito esphome/components/esp32_ble_tracker/* @bdraco esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron @@ -167,7 +167,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi -esphome/components/fingerprint_grow/* @OnFreund @alexborro @loongyh +esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core esphome/components/fs3000/* @kahrendt esphome/components/ft5x06/* @clydebarrow @@ -203,7 +203,7 @@ esphome/components/heatpumpir/* @rob-deutsch esphome/components/hitachi_ac424/* @sourabhjaiswal esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 -esphome/components/homeassistant/* @OttoWinter @esphome/core +esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/switch/* @Links2004 esphome/components/honeywell_hih_i2c/* @Benichou34 @@ -228,7 +228,7 @@ esphome/components/iaqcore/* @yozik04 esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core esphome/components/improv_serial/* @esphome/core -esphome/components/ina226/* @Sergio303 @latonita +esphome/components/ina226/* @latonita @Sergio303 esphome/components/ina260/* @mreditor97 esphome/components/ina2xx_base/* @latonita esphome/components/ina2xx_i2c/* @latonita @@ -277,8 +277,8 @@ esphome/components/max7219digit/* @rspaargaren esphome/components/max9611/* @mckaymatthew esphome/components/mcp23008/* @jesserockz esphome/components/mcp23017/* @jesserockz -esphome/components/mcp23s08/* @SenexCrenshaw @jesserockz -esphome/components/mcp23s17/* @SenexCrenshaw @jesserockz +esphome/components/mcp23s08/* @jesserockz @SenexCrenshaw +esphome/components/mcp23s17/* @jesserockz @SenexCrenshaw esphome/components/mcp23x08_base/* @jesserockz esphome/components/mcp23x17_base/* @jesserockz esphome/components/mcp23xxx_base/* @jesserockz @@ -354,9 +354,9 @@ esphome/components/pm2005/* @andrewjswan esphome/components/pmsa003i/* @sjtrny esphome/components/pmsx003/* @ximex esphome/components/pmwcs3/* @SeByDocKy -esphome/components/pn532/* @OttoWinter @jesserockz -esphome/components/pn532_i2c/* @OttoWinter @jesserockz -esphome/components/pn532_spi/* @OttoWinter @jesserockz +esphome/components/pn532/* @jesserockz @OttoWinter +esphome/components/pn532_i2c/* @jesserockz @OttoWinter +esphome/components/pn532_spi/* @jesserockz @OttoWinter esphome/components/pn7150/* @jesserockz @kbx81 esphome/components/pn7150_i2c/* @jesserockz @kbx81 esphome/components/pn7160/* @jesserockz @kbx81 @@ -365,7 +365,7 @@ esphome/components/pn7160_spi/* @jesserockz @kbx81 esphome/components/power_supply/* @esphome/core esphome/components/preferences/* @esphome/core esphome/components/psram/* @esphome/core -esphome/components/pulse_meter/* @TrentHouliston @cstaahl @stevebaxter +esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pvvx_mithermometer/* @pasiz esphome/components/pylontech/* @functionpointer esphome/components/qmp6988/* @andrewpc @@ -406,7 +406,7 @@ esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/sfa30/* @ghsensdev esphome/components/sgp40/* @SenexCrenshaw -esphome/components/sgp4x/* @SenexCrenshaw @martgras +esphome/components/sgp4x/* @martgras @SenexCrenshaw esphome/components/shelly_dimmer/* @edge90 @rnauber esphome/components/sht3xd/* @mrtoy-me esphome/components/sht4x/* @sjtrny diff --git a/script/build_codeowners.py b/script/build_codeowners.py index 4581620095..27ea82611b 100755 --- a/script/build_codeowners.py +++ b/script/build_codeowners.py @@ -82,7 +82,7 @@ for path in components_dir.iterdir(): for path, owners in sorted(codeowners.items()): - owners = sorted(set(owners)) + owners = sorted(set(owners), key=str.casefold) if not owners: continue for owner in owners: From dd8815ec9d4c61d3e509862b77e9614004f61fe0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Sep 2025 01:17:30 -0500 Subject: [PATCH 1003/1626] [core] Reduce flash usage by refactoring looping component partitioning (#10652) --- esphome/core/application.cpp | 13 ++++++------- esphome/core/application.h | 1 + 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index b78f6fb903..5371d1b56f 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -361,20 +361,19 @@ void Application::calculate_looping_components_() { // Add all components with loop override that aren't already LOOP_DONE // Some components (like logger) may call disable_loop() during initialization // before setup runs, so we need to respect their LOOP_DONE state - for (auto *obj : this->components_) { - if (obj->has_overridden_loop() && - (obj->get_component_state() & COMPONENT_STATE_MASK) != COMPONENT_STATE_LOOP_DONE) { - this->looping_components_.push_back(obj); - } - } + this->add_looping_components_by_state_(false); this->looping_components_active_end_ = this->looping_components_.size(); // Then add any components that are already LOOP_DONE to the inactive section // This handles components that called disable_loop() during initialization + this->add_looping_components_by_state_(true); +} + +void Application::add_looping_components_by_state_(bool match_loop_done) { for (auto *obj : this->components_) { if (obj->has_overridden_loop() && - (obj->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { + ((obj->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) == match_loop_done) { this->looping_components_.push_back(obj); } } diff --git a/esphome/core/application.h b/esphome/core/application.h index 9cb2a4c638..1f22499051 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -431,6 +431,7 @@ class Application { void register_component_(Component *comp); void calculate_looping_components_(); + void add_looping_components_by_state_(bool match_loop_done); // These methods are called by Component::disable_loop() and Component::enable_loop() // Components should not call these directly - use this->disable_loop() or this->enable_loop() From 7adad0ee4920e47ef0735cf4a5f5a0b18bff6ef4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Sep 2025 02:03:35 -0500 Subject: [PATCH 1004/1626] [core] Refactor insertion sort functions to eliminate code duplication (#10653) --- esphome/core/application.cpp | 33 +++++++++------------------------ 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 5371d1b56f..1be193bb7e 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -34,37 +34,20 @@ namespace esphome { static const char *const TAG = "app"; -// Helper function for insertion sort of components by setup priority +// Helper function for insertion sort of components by priority // Using insertion sort instead of std::stable_sort saves ~1.3KB of flash // by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) // IMPORTANT: This sort is stable (preserves relative order of equal elements), // which is necessary to maintain user-defined component order for same priority -template static void insertion_sort_by_setup_priority(Iterator first, Iterator last) { +template +static void insertion_sort_by_priority(Iterator first, Iterator last) { for (auto it = first + 1; it != last; ++it) { auto key = *it; - float key_priority = key->get_actual_setup_priority(); + float key_priority = (key->*GetPriority)(); auto j = it - 1; // Using '<' (not '<=') ensures stability - equal priority components keep their order - while (j >= first && (*j)->get_actual_setup_priority() < key_priority) { - *(j + 1) = *j; - j--; - } - *(j + 1) = key; - } -} - -// Helper function for insertion sort of components by loop priority -// IMPORTANT: This sort is stable (preserves relative order of equal elements), -// which is required when components are re-sorted during setup() if they block -template static void insertion_sort_by_loop_priority(Iterator first, Iterator last) { - for (auto it = first + 1; it != last; ++it) { - auto key = *it; - float key_priority = key->get_loop_priority(); - auto j = it - 1; - - // Using '<' (not '<=') ensures stability - equal priority components keep their order - while (j >= first && (*j)->get_loop_priority() < key_priority) { + while (j >= first && ((*j)->*GetPriority)() < key_priority) { *(j + 1) = *j; j--; } @@ -91,7 +74,8 @@ void Application::setup() { ESP_LOGV(TAG, "Sorting components by setup priority"); // Sort by setup priority using our helper function - insertion_sort_by_setup_priority(this->components_.begin(), this->components_.end()); + insertion_sort_by_prioritycomponents_.begin()), &Component::get_actual_setup_priority>( + this->components_.begin(), this->components_.end()); // Initialize looping_components_ early so enable_pending_loops_() works during setup this->calculate_looping_components_(); @@ -108,7 +92,8 @@ void Application::setup() { continue; // Sort components 0 through i by loop priority - insertion_sort_by_loop_priority(this->components_.begin(), this->components_.begin() + i + 1); + insertion_sort_by_prioritycomponents_.begin()), &Component::get_loop_priority>( + this->components_.begin(), this->components_.begin() + i + 1); do { uint8_t new_app_state = STATUS_LED_WARNING; From 8993f4e6b43dd1d88fbecdfa7d4584fdfcc98606 Mon Sep 17 00:00:00 2001 From: Mischa Siekmann <45062894+gnumpi@users.noreply.github.com> Date: Tue, 9 Sep 2025 15:39:47 +0200 Subject: [PATCH 1005/1626] RingBuffer: Make partial writing optional (#10302) --- esphome/core/ring_buffer.cpp | 8 ++++++-- esphome/core/ring_buffer.h | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/esphome/core/ring_buffer.cpp b/esphome/core/ring_buffer.cpp index b77a02b2a7..6a2232599f 100644 --- a/esphome/core/ring_buffer.cpp +++ b/esphome/core/ring_buffer.cpp @@ -78,9 +78,13 @@ size_t RingBuffer::write(const void *data, size_t len) { return this->write_without_replacement(data, len, 0); } -size_t RingBuffer::write_without_replacement(const void *data, size_t len, TickType_t ticks_to_wait) { +size_t RingBuffer::write_without_replacement(const void *data, size_t len, TickType_t ticks_to_wait, + bool write_partial) { if (!xRingbufferSend(this->handle_, data, len, ticks_to_wait)) { - // Couldn't fit all the data, so only write what will fit + if (!write_partial) { + return 0; // Not enough space available and not allowed to write partial data + } + // Couldn't fit all the data, write what will fit size_t free = std::min(this->free(), len); if (xRingbufferSend(this->handle_, data, free, 0)) { return free; diff --git a/esphome/core/ring_buffer.h b/esphome/core/ring_buffer.h index bad96d3181..98a273781f 100644 --- a/esphome/core/ring_buffer.h +++ b/esphome/core/ring_buffer.h @@ -50,7 +50,8 @@ class RingBuffer { * @param ticks_to_wait Maximum number of FreeRTOS ticks to wait (default: 0) * @return Number of bytes written */ - size_t write_without_replacement(const void *data, size_t len, TickType_t ticks_to_wait = 0); + size_t write_without_replacement(const void *data, size_t len, TickType_t ticks_to_wait = 0, + bool write_partial = true); /** * @brief Returns the number of available bytes in the ring buffer. From 39212f0d7f09fed94b41a049e0e464b77f8eff31 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 9 Sep 2025 18:45:42 +0200 Subject: [PATCH 1006/1626] allow to implement show_logs as external component (#10523) --- esphome/__main__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/__main__.py b/esphome/__main__.py index e1f683397f..280f491924 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -459,6 +459,13 @@ def upload_program( def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: + try: + module = importlib.import_module("esphome.components." + CORE.target_platform) + if getattr(module, "show_logs")(config, args, devices): + return 0 + except AttributeError: + pass + if "logger" not in config: raise EsphomeError("Logger is not configured!") From 01ff09064d4707d673d8ba74f537217283e9b720 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 9 Sep 2025 21:29:49 +0200 Subject: [PATCH 1007/1626] [nrf52] add more tests (#10591) --- tests/components/alarm_control_panel/test.nrf52-adafruit.yaml | 1 + tests/components/alarm_control_panel/test.nrf52-mcumgr.yaml | 1 + tests/components/binary_sensor/test.nrf52-adafruit.yaml | 1 + tests/components/binary_sensor/test.nrf52-mcumgr.yaml | 1 + tests/components/button/test.nrf52-adafruit.yaml | 1 + tests/components/button/test.nrf52-mcumgr.yaml | 1 + tests/components/duty_cycle/test.nrf52-adafruit.yaml | 1 + tests/components/duty_cycle/test.nrf52-mcumgr.yaml | 1 + tests/components/duty_time/test.nrf52-adafruit.yaml | 1 + tests/components/duty_time/test.nrf52-mcumgr.yaml | 1 + tests/components/esphome/test.nrf52-adafruit.yaml | 1 + tests/components/esphome/test.nrf52-mcumgr.yaml | 1 + tests/components/event/test.nrf52-adafruit.yaml | 1 + tests/components/event/test.nrf52-mcumgr.yaml | 1 + tests/components/interval/test.nrf52-adafruit.yaml | 1 + tests/components/interval/test.nrf52-mcumgr.yaml | 1 + tests/components/lock/test.nrf52-adafruit.yaml | 1 + tests/components/lock/test.nrf52-mcumgr.yaml | 1 + tests/components/power_supply/test.nrf52-adafruit.yaml | 1 + tests/components/power_supply/test.nrf52-mcumgr.yaml | 1 + tests/components/pulse_counter/test.nrf52-adafruit.yaml | 1 + tests/components/pulse_counter/test.nrf52-mcumgr.yaml | 1 + tests/components/pulse_meter/test.nrf52-adafruit.yaml | 1 + tests/components/pulse_meter/test.nrf52-mcumgr.yaml | 1 + tests/components/pulse_width/test.nrf52-adafruit.yaml | 1 + tests/components/pulse_width/test.nrf52-mcumgr.yaml | 1 + tests/components/switch/test.nrf52-adafruit.yaml | 1 + tests/components/switch/test.nrf52-mcumgr.yaml | 1 + tests/components/version/test.nrf52-adafruit.yaml | 1 + tests/components/version/test.nrf52-mcumgr.yaml | 1 + 30 files changed, 30 insertions(+) create mode 100644 tests/components/alarm_control_panel/test.nrf52-adafruit.yaml create mode 100644 tests/components/alarm_control_panel/test.nrf52-mcumgr.yaml create mode 100644 tests/components/binary_sensor/test.nrf52-adafruit.yaml create mode 100644 tests/components/binary_sensor/test.nrf52-mcumgr.yaml create mode 100644 tests/components/button/test.nrf52-adafruit.yaml create mode 100644 tests/components/button/test.nrf52-mcumgr.yaml create mode 100644 tests/components/duty_cycle/test.nrf52-adafruit.yaml create mode 100644 tests/components/duty_cycle/test.nrf52-mcumgr.yaml create mode 100644 tests/components/duty_time/test.nrf52-adafruit.yaml create mode 100644 tests/components/duty_time/test.nrf52-mcumgr.yaml create mode 100644 tests/components/esphome/test.nrf52-adafruit.yaml create mode 100644 tests/components/esphome/test.nrf52-mcumgr.yaml create mode 100644 tests/components/event/test.nrf52-adafruit.yaml create mode 100644 tests/components/event/test.nrf52-mcumgr.yaml create mode 100644 tests/components/interval/test.nrf52-adafruit.yaml create mode 100644 tests/components/interval/test.nrf52-mcumgr.yaml create mode 100644 tests/components/lock/test.nrf52-adafruit.yaml create mode 100644 tests/components/lock/test.nrf52-mcumgr.yaml create mode 100644 tests/components/power_supply/test.nrf52-adafruit.yaml create mode 100644 tests/components/power_supply/test.nrf52-mcumgr.yaml create mode 100644 tests/components/pulse_counter/test.nrf52-adafruit.yaml create mode 100644 tests/components/pulse_counter/test.nrf52-mcumgr.yaml create mode 100644 tests/components/pulse_meter/test.nrf52-adafruit.yaml create mode 100644 tests/components/pulse_meter/test.nrf52-mcumgr.yaml create mode 100644 tests/components/pulse_width/test.nrf52-adafruit.yaml create mode 100644 tests/components/pulse_width/test.nrf52-mcumgr.yaml create mode 100644 tests/components/switch/test.nrf52-adafruit.yaml create mode 100644 tests/components/switch/test.nrf52-mcumgr.yaml create mode 100644 tests/components/version/test.nrf52-adafruit.yaml create mode 100644 tests/components/version/test.nrf52-mcumgr.yaml diff --git a/tests/components/alarm_control_panel/test.nrf52-adafruit.yaml b/tests/components/alarm_control_panel/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/alarm_control_panel/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/alarm_control_panel/test.nrf52-mcumgr.yaml b/tests/components/alarm_control_panel/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/alarm_control_panel/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/binary_sensor/test.nrf52-adafruit.yaml b/tests/components/binary_sensor/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/binary_sensor/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/binary_sensor/test.nrf52-mcumgr.yaml b/tests/components/binary_sensor/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/binary_sensor/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/button/test.nrf52-adafruit.yaml b/tests/components/button/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/button/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/button/test.nrf52-mcumgr.yaml b/tests/components/button/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/button/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/duty_cycle/test.nrf52-adafruit.yaml b/tests/components/duty_cycle/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/duty_cycle/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/duty_cycle/test.nrf52-mcumgr.yaml b/tests/components/duty_cycle/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/duty_cycle/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/duty_time/test.nrf52-adafruit.yaml b/tests/components/duty_time/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/duty_time/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/duty_time/test.nrf52-mcumgr.yaml b/tests/components/duty_time/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/duty_time/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/esphome/test.nrf52-adafruit.yaml b/tests/components/esphome/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/esphome/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/esphome/test.nrf52-mcumgr.yaml b/tests/components/esphome/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/esphome/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/event/test.nrf52-adafruit.yaml b/tests/components/event/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/event/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/event/test.nrf52-mcumgr.yaml b/tests/components/event/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/event/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/interval/test.nrf52-adafruit.yaml b/tests/components/interval/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/interval/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/interval/test.nrf52-mcumgr.yaml b/tests/components/interval/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/interval/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/lock/test.nrf52-adafruit.yaml b/tests/components/lock/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/lock/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/lock/test.nrf52-mcumgr.yaml b/tests/components/lock/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/lock/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/power_supply/test.nrf52-adafruit.yaml b/tests/components/power_supply/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/power_supply/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/power_supply/test.nrf52-mcumgr.yaml b/tests/components/power_supply/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/power_supply/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/pulse_counter/test.nrf52-adafruit.yaml b/tests/components/pulse_counter/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/pulse_counter/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/pulse_counter/test.nrf52-mcumgr.yaml b/tests/components/pulse_counter/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/pulse_counter/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/pulse_meter/test.nrf52-adafruit.yaml b/tests/components/pulse_meter/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/pulse_meter/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/pulse_meter/test.nrf52-mcumgr.yaml b/tests/components/pulse_meter/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/pulse_meter/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/pulse_width/test.nrf52-adafruit.yaml b/tests/components/pulse_width/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/pulse_width/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/pulse_width/test.nrf52-mcumgr.yaml b/tests/components/pulse_width/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/pulse_width/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/switch/test.nrf52-adafruit.yaml b/tests/components/switch/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/switch/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/switch/test.nrf52-mcumgr.yaml b/tests/components/switch/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/switch/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/version/test.nrf52-adafruit.yaml b/tests/components/version/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/version/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/version/test.nrf52-mcumgr.yaml b/tests/components/version/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/version/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 8976ea243602db975a241c9d28b6513578a8df7f Mon Sep 17 00:00:00 2001 From: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com> Date: Wed, 10 Sep 2025 05:31:06 +1000 Subject: [PATCH 1008/1626] [ms5611] remove delay in setup (#10658) --- esphome/components/ms5611/ms5611.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/ms5611/ms5611.cpp b/esphome/components/ms5611/ms5611.cpp index 8f8c05eb7d..5a7622e783 100644 --- a/esphome/components/ms5611/ms5611.cpp +++ b/esphome/components/ms5611/ms5611.cpp @@ -19,13 +19,14 @@ void MS5611Component::setup() { this->mark_failed(); return; } - delay(100); // NOLINT - for (uint8_t offset = 0; offset < 6; offset++) { - if (!this->read_byte_16(MS5611_CMD_READ_PROM + (offset * 2), &this->prom_[offset])) { - this->mark_failed(); - return; + this->set_timeout(100, [this]() { + for (uint8_t offset = 0; offset < 6; offset++) { + if (!this->read_byte_16(MS5611_CMD_READ_PROM + (offset * 2), &this->prom_[offset])) { + this->mark_failed(); + return; + } } - } + }); } void MS5611Component::dump_config() { ESP_LOGCONFIG(TAG, "MS5611:"); From cfb90b7b1830403bb88c9b7de141acd101c8d9e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 15:15:53 -0500 Subject: [PATCH 1009/1626] Bump pytest-cov from 6.3.0 to 7.0.0 (#10660) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 1760af75ba..bae9246768 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,7 +6,7 @@ pre-commit # Unit tests pytest==8.4.2 -pytest-cov==6.3.0 +pytest-cov==7.0.0 pytest-mock==3.15.0 pytest-asyncio==1.1.0 pytest-xdist==3.8.0 From e972e1f8c21735d5bceaad51efd00932ed4f0653 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 20:33:36 +0000 Subject: [PATCH 1010/1626] Bump aioesphomeapi from 40.0.2 to 40.1.0 (#10662) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6021b6de70..0b9a37005d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==40.0.2 +aioesphomeapi==40.1.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 422d2097864235f7ef10a56efea443b2b699f2db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Sep 2025 15:54:50 -0500 Subject: [PATCH 1011/1626] [api] Add timezone support to GetTimeResponse for automatic timezone synchronization (#10661) --- esphome/components/api/api.proto | 1 + esphome/components/api/api_connection.cpp | 8 +++++++- esphome/components/api/api_pb2.cpp | 20 ++++++++++++++++++-- esphome/components/api/api_pb2.h | 6 +++++- esphome/components/api/api_pb2_dump.cpp | 12 +++++++++++- 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 9707e714e7..208187d598 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -818,6 +818,7 @@ message GetTimeResponse { option (no_delay) = true; fixed32 epoch_seconds = 1; + string timezone = 2; } // ==================== USER-DEFINES SERVICES ==================== diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 02b1d61368..99a0bc9044 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1070,8 +1070,14 @@ void APIConnection::camera_image(const CameraImageRequest &msg) { #ifdef USE_HOMEASSISTANT_TIME void APIConnection::on_get_time_response(const GetTimeResponse &value) { - if (homeassistant::global_homeassistant_time != nullptr) + if (homeassistant::global_homeassistant_time != nullptr) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); +#ifdef USE_TIME_TIMEZONE + if (!value.timezone.empty() && value.timezone != homeassistant::global_homeassistant_time->get_timezone()) { + homeassistant::global_homeassistant_time->set_timezone(value.timezone); + } +#endif + } } #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index de60ed3fdb..022ac55cf3 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -901,6 +901,16 @@ bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDel return true; } #endif +bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 2: + this->timezone = value.as_string(); + break; + default: + return false; + } + return true; +} bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { switch (field_id) { case 1: @@ -911,8 +921,14 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { } return true; } -void GetTimeResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->epoch_seconds); } -void GetTimeResponse::calculate_size(ProtoSize &size) const { size.add_fixed32(1, this->epoch_seconds); } +void GetTimeResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_fixed32(1, this->epoch_seconds); + buffer.encode_string(2, this->timezone_ref_); +} +void GetTimeResponse::calculate_size(ProtoSize &size) const { + size.add_fixed32(1, this->epoch_seconds); + size.add_length(1, this->timezone_ref_.size()); +} #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 3f2c2ea763..fd124e7bfe 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1174,11 +1174,14 @@ class GetTimeRequest final : public ProtoMessage { class GetTimeResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 37; - static constexpr uint8_t ESTIMATED_SIZE = 5; + static constexpr uint8_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "get_time_response"; } #endif uint32_t epoch_seconds{0}; + std::string timezone{}; + StringRef timezone_ref_{}; + void set_timezone(const StringRef &ref) { this->timezone_ref_ = ref; } void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1187,6 +1190,7 @@ class GetTimeResponse final : public ProtoDecodableMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; #ifdef USE_API_SERVICES class ListEntitiesServicesArgument final : public ProtoMessage { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 1d7d315419..9795999953 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1110,7 +1110,17 @@ void HomeAssistantStateResponse::dump_to(std::string &out) const { } #endif void GetTimeRequest::dump_to(std::string &out) const { out.append("GetTimeRequest {}"); } -void GetTimeResponse::dump_to(std::string &out) const { dump_field(out, "epoch_seconds", this->epoch_seconds); } +void GetTimeResponse::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "GetTimeResponse"); + dump_field(out, "epoch_seconds", this->epoch_seconds); + out.append(" timezone: "); + if (!this->timezone_ref_.empty()) { + out.append("'").append(this->timezone_ref_.c_str()).append("'"); + } else { + out.append("'").append(this->timezone).append("'"); + } + out.append("\n"); +} #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::dump_to(std::string &out) const { MessageDumpHelper helper(out, "ListEntitiesServicesArgument"); From e218f16f0fc538fb5f03fbef7fa281ddd986266f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Sep 2025 19:06:59 -0400 Subject: [PATCH 1012/1626] Allow both files and directories to be passed to update-all (#10575) Co-authored-by: J. Nick Koston --- esphome/util.py | 15 ++-- tests/unit_tests/test_util.py | 143 ++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 tests/unit_tests/test_util.py diff --git a/esphome/util.py b/esphome/util.py index 6362260fde..23a66be4eb 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -272,12 +272,15 @@ class OrderedDict(collections.OrderedDict): return dict(self).__repr__() -def list_yaml_files(folders: list[str]) -> list[str]: - files = filter_yaml_files( - [os.path.join(folder, p) for folder in folders for p in os.listdir(folder)] - ) - files.sort() - return files +def list_yaml_files(configs: list[str]) -> list[str]: + files: list[str] = [] + for config in configs: + if os.path.isfile(config): + files.append(config) + else: + files.extend(os.path.join(config, p) for p in os.listdir(config)) + files = filter_yaml_files(files) + return sorted(files) def filter_yaml_files(files: list[str]) -> list[str]: diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py new file mode 100644 index 0000000000..74d6a74709 --- /dev/null +++ b/tests/unit_tests/test_util.py @@ -0,0 +1,143 @@ +"""Tests for esphome.util module.""" + +from pathlib import Path + +import pytest + +from esphome import util + + +def test_list_yaml_files_with_files_and_directories(tmp_path: Path) -> None: + """Test that list_yaml_files handles both files and directories.""" + # Create directory structure + dir1 = tmp_path / "configs" + dir1.mkdir() + dir2 = tmp_path / "more_configs" + dir2.mkdir() + + # Create YAML files in directories + (dir1 / "config1.yaml").write_text("test: 1") + (dir1 / "config2.yml").write_text("test: 2") + (dir1 / "not_yaml.txt").write_text("not yaml") + + (dir2 / "config3.yaml").write_text("test: 3") + + # Create standalone YAML files + standalone1 = tmp_path / "standalone.yaml" + standalone1.write_text("test: 4") + standalone2 = tmp_path / "another.yml" + standalone2.write_text("test: 5") + + # Test with mixed input (directories and files) + configs = [ + str(dir1), + str(standalone1), + str(dir2), + str(standalone2), + ] + + result = util.list_yaml_files(configs) + + # Should include all YAML files but not the .txt file + assert set(result) == { + str(dir1 / "config1.yaml"), + str(dir1 / "config2.yml"), + str(dir2 / "config3.yaml"), + str(standalone1), + str(standalone2), + } + # Check that results are sorted + assert result == sorted(result) + + +def test_list_yaml_files_only_directories(tmp_path: Path) -> None: + """Test list_yaml_files with only directories.""" + dir1 = tmp_path / "dir1" + dir1.mkdir() + dir2 = tmp_path / "dir2" + dir2.mkdir() + + (dir1 / "a.yaml").write_text("test: a") + (dir1 / "b.yml").write_text("test: b") + (dir2 / "c.yaml").write_text("test: c") + + result = util.list_yaml_files([str(dir1), str(dir2)]) + + assert set(result) == { + str(dir1 / "a.yaml"), + str(dir1 / "b.yml"), + str(dir2 / "c.yaml"), + } + assert result == sorted(result) + + +def test_list_yaml_files_only_files(tmp_path: Path) -> None: + """Test list_yaml_files with only files.""" + file1 = tmp_path / "file1.yaml" + file2 = tmp_path / "file2.yml" + file3 = tmp_path / "file3.yaml" + non_yaml = tmp_path / "not_yaml.json" + + file1.write_text("test: 1") + file2.write_text("test: 2") + file3.write_text("test: 3") + non_yaml.write_text("{}") + + # Include a non-YAML file to test filtering + result = util.list_yaml_files( + [ + str(file1), + str(file2), + str(file3), + str(non_yaml), + ] + ) + + assert set(result) == { + str(file1), + str(file2), + str(file3), + } + assert result == sorted(result) + + +def test_list_yaml_files_empty_directory(tmp_path: Path) -> None: + """Test list_yaml_files with an empty directory.""" + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + + result = util.list_yaml_files([str(empty_dir)]) + + assert result == [] + + +def test_list_yaml_files_nonexistent_path(tmp_path: Path) -> None: + """Test list_yaml_files with a nonexistent path raises an error.""" + nonexistent = tmp_path / "nonexistent" + existing = tmp_path / "existing.yaml" + existing.write_text("test: 1") + + # Should raise an error for non-existent directory + with pytest.raises(FileNotFoundError): + util.list_yaml_files([str(nonexistent), str(existing)]) + + +def test_list_yaml_files_mixed_extensions(tmp_path: Path) -> None: + """Test that both .yaml and .yml extensions are recognized.""" + dir1 = tmp_path / "configs" + dir1.mkdir() + + yaml_file = dir1 / "config.yaml" + yml_file = dir1 / "config.yml" + other_file = dir1 / "config.txt" + + yaml_file.write_text("test: yaml") + yml_file.write_text("test: yml") + other_file.write_text("test: txt") + + result = util.list_yaml_files([str(dir1)]) + + assert set(result) == { + str(yaml_file), + str(yml_file), + } From d9f625e5c889ce0f91b73998e8a2ba3e17a2fda8 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 9 Sep 2025 20:24:50 -0500 Subject: [PATCH 1013/1626] [thermostat] General clean-up, optimization, properly support "auto" mode (#10561) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/thermostat/climate.py | 18 +- .../thermostat/thermostat_climate.cpp | 313 ++++++++++-------- .../thermostat/thermostat_climate.h | 73 ++-- esphome/const.py | 1 + 4 files changed, 220 insertions(+), 185 deletions(-) diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 5d0d9442e8..57abbc67b9 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -32,6 +32,7 @@ from esphome.const import ( CONF_FAN_WITH_COOLING, CONF_FAN_WITH_HEATING, CONF_HEAT_ACTION, + CONF_HEAT_COOL_MODE, CONF_HEAT_DEADBAND, CONF_HEAT_MODE, CONF_HEAT_OVERRUN, @@ -150,7 +151,7 @@ def generate_comparable_preset(config, name): def validate_thermostat(config): # verify corresponding action(s) exist(s) for any defined climate mode or action requirements = { - CONF_AUTO_MODE: [ + CONF_HEAT_COOL_MODE: [ CONF_COOL_ACTION, CONF_HEAT_ACTION, CONF_MIN_COOLING_OFF_TIME, @@ -540,6 +541,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FAN_ONLY_MODE): automation.validate_automation( single=True ), + cv.Optional(CONF_HEAT_COOL_MODE): automation.validate_automation( + single=True + ), cv.Optional(CONF_HEAT_MODE): automation.validate_automation(single=True), cv.Optional(CONF_OFF_MODE): automation.validate_automation(single=True), cv.Optional(CONF_FAN_MODE_ON_ACTION): automation.validate_automation( @@ -644,7 +648,6 @@ async def to_code(config): var = await climate.new_climate(config) await cg.register_component(var, config) - heat_cool_mode_available = CONF_HEAT_ACTION in config and CONF_COOL_ACTION in config two_points_available = CONF_HEAT_ACTION in config and ( CONF_COOL_ACTION in config or (config[CONF_FAN_ONLY_COOLING] and CONF_FAN_ONLY_ACTION in config) @@ -739,11 +742,6 @@ async def to_code(config): var.get_idle_action_trigger(), [], config[CONF_IDLE_ACTION] ) - if heat_cool_mode_available is True: - cg.add(var.set_supports_heat_cool(True)) - else: - cg.add(var.set_supports_heat_cool(False)) - if CONF_COOL_ACTION in config: await automation.build_automation( var.get_cool_action_trigger(), [], config[CONF_COOL_ACTION] @@ -780,6 +778,7 @@ async def to_code(config): await automation.build_automation( var.get_auto_mode_trigger(), [], config[CONF_AUTO_MODE] ) + cg.add(var.set_supports_auto(True)) if CONF_COOL_MODE in config: await automation.build_automation( var.get_cool_mode_trigger(), [], config[CONF_COOL_MODE] @@ -800,6 +799,11 @@ async def to_code(config): var.get_heat_mode_trigger(), [], config[CONF_HEAT_MODE] ) cg.add(var.set_supports_heat(True)) + if CONF_HEAT_COOL_MODE in config: + await automation.build_automation( + var.get_heat_cool_mode_trigger(), [], config[CONF_HEAT_COOL_MODE] + ) + cg.add(var.set_supports_heat_cool(True)) if CONF_OFF_MODE in config: await automation.build_automation( var.get_off_mode_trigger(), [], config[CONF_OFF_MODE] diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index 404e585aff..f05730b369 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -1,4 +1,6 @@ #include "thermostat_climate.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -64,7 +66,7 @@ void ThermostatClimate::setup() { void ThermostatClimate::loop() { for (auto &timer : this->timer_) { - if (timer.active && (timer.started + timer.time < millis())) { + if (timer.active && (timer.started + timer.time < App.get_loop_component_start_time())) { timer.active = false; timer.func(); } @@ -127,26 +129,35 @@ bool ThermostatClimate::hysteresis_valid() { return true; } +bool ThermostatClimate::limit_setpoints_for_heat_cool() { + return this->mode == climate::CLIMATE_MODE_HEAT_COOL || + (this->mode == climate::CLIMATE_MODE_AUTO && this->supports_heat_cool_); +} + void ThermostatClimate::validate_target_temperature() { if (std::isnan(this->target_temperature)) { + // default to the midpoint between visual min and max this->target_temperature = ((this->get_traits().get_visual_max_temperature() - this->get_traits().get_visual_min_temperature()) / 2) + this->get_traits().get_visual_min_temperature(); } else { // target_temperature must be between the visual minimum and the visual maximum - if (this->target_temperature < this->get_traits().get_visual_min_temperature()) - this->target_temperature = this->get_traits().get_visual_min_temperature(); - if (this->target_temperature > this->get_traits().get_visual_max_temperature()) - this->target_temperature = this->get_traits().get_visual_max_temperature(); + this->target_temperature = clamp(this->target_temperature, this->get_traits().get_visual_min_temperature(), + this->get_traits().get_visual_max_temperature()); } } -void ThermostatClimate::validate_target_temperatures() { - if (this->supports_two_points_) { +void ThermostatClimate::validate_target_temperatures(const bool pin_target_temperature_high) { + if (!this->supports_two_points_) { + this->validate_target_temperature(); + } else if (pin_target_temperature_high) { + // if target_temperature_high is set less than target_temperature_low, move down target_temperature_low this->validate_target_temperature_low(); this->validate_target_temperature_high(); } else { - this->validate_target_temperature(); + // if target_temperature_low is set greater than target_temperature_high, move up target_temperature_high + this->validate_target_temperature_high(); + this->validate_target_temperature_low(); } } @@ -154,18 +165,13 @@ void ThermostatClimate::validate_target_temperature_low() { if (std::isnan(this->target_temperature_low)) { this->target_temperature_low = this->get_traits().get_visual_min_temperature(); } else { - // target_temperature_low must not be lower than the visual minimum - if (this->target_temperature_low < this->get_traits().get_visual_min_temperature()) - this->target_temperature_low = this->get_traits().get_visual_min_temperature(); - // target_temperature_low must not be greater than the visual maximum minus set_point_minimum_differential_ - if (this->target_temperature_low > - this->get_traits().get_visual_max_temperature() - this->set_point_minimum_differential_) { - this->target_temperature_low = - this->get_traits().get_visual_max_temperature() - this->set_point_minimum_differential_; - } - // if target_temperature_low is set greater than target_temperature_high, move up target_temperature_high - if (this->target_temperature_low > this->target_temperature_high - this->set_point_minimum_differential_) - this->target_temperature_high = this->target_temperature_low + this->set_point_minimum_differential_; + float target_temperature_low_upper_limit = + this->limit_setpoints_for_heat_cool() + ? clamp(this->target_temperature_high - this->set_point_minimum_differential_, + this->get_traits().get_visual_min_temperature(), this->get_traits().get_visual_max_temperature()) + : this->get_traits().get_visual_max_temperature(); + this->target_temperature_low = clamp(this->target_temperature_low, this->get_traits().get_visual_min_temperature(), + target_temperature_low_upper_limit); } } @@ -173,62 +179,64 @@ void ThermostatClimate::validate_target_temperature_high() { if (std::isnan(this->target_temperature_high)) { this->target_temperature_high = this->get_traits().get_visual_max_temperature(); } else { - // target_temperature_high must not be lower than the visual maximum - if (this->target_temperature_high > this->get_traits().get_visual_max_temperature()) - this->target_temperature_high = this->get_traits().get_visual_max_temperature(); - // target_temperature_high must not be lower than the visual minimum plus set_point_minimum_differential_ - if (this->target_temperature_high < - this->get_traits().get_visual_min_temperature() + this->set_point_minimum_differential_) { - this->target_temperature_high = - this->get_traits().get_visual_min_temperature() + this->set_point_minimum_differential_; - } - // if target_temperature_high is set less than target_temperature_low, move down target_temperature_low - if (this->target_temperature_high < this->target_temperature_low + this->set_point_minimum_differential_) - this->target_temperature_low = this->target_temperature_high - this->set_point_minimum_differential_; + float target_temperature_high_lower_limit = + this->limit_setpoints_for_heat_cool() + ? clamp(this->target_temperature_low + this->set_point_minimum_differential_, + this->get_traits().get_visual_min_temperature(), this->get_traits().get_visual_max_temperature()) + : this->get_traits().get_visual_min_temperature(); + this->target_temperature_high = clamp(this->target_temperature_high, target_temperature_high_lower_limit, + this->get_traits().get_visual_max_temperature()); } } void ThermostatClimate::control(const climate::ClimateCall &call) { + bool target_temperature_high_changed = false; + if (call.get_preset().has_value()) { // setup_complete_ blocks modifying/resetting the temps immediately after boot if (this->setup_complete_) { - this->change_preset_(*call.get_preset()); + this->change_preset_(call.get_preset().value()); } else { - this->preset = *call.get_preset(); + this->preset = call.get_preset().value(); } } if (call.get_custom_preset().has_value()) { // setup_complete_ blocks modifying/resetting the temps immediately after boot if (this->setup_complete_) { - this->change_custom_preset_(*call.get_custom_preset()); + this->change_custom_preset_(call.get_custom_preset().value()); } else { - this->custom_preset = *call.get_custom_preset(); + this->custom_preset = call.get_custom_preset().value(); } } - if (call.get_mode().has_value()) - this->mode = *call.get_mode(); - if (call.get_fan_mode().has_value()) - this->fan_mode = *call.get_fan_mode(); - if (call.get_swing_mode().has_value()) - this->swing_mode = *call.get_swing_mode(); + if (call.get_mode().has_value()) { + this->mode = call.get_mode().value(); + } + if (call.get_fan_mode().has_value()) { + this->fan_mode = call.get_fan_mode().value(); + } + if (call.get_swing_mode().has_value()) { + this->swing_mode = call.get_swing_mode().value(); + } if (this->supports_two_points_) { if (call.get_target_temperature_low().has_value()) { - this->target_temperature_low = *call.get_target_temperature_low(); - validate_target_temperature_low(); + this->target_temperature_low = call.get_target_temperature_low().value(); } if (call.get_target_temperature_high().has_value()) { - this->target_temperature_high = *call.get_target_temperature_high(); - validate_target_temperature_high(); + target_temperature_high_changed = this->target_temperature_high != call.get_target_temperature_high().value(); + this->target_temperature_high = call.get_target_temperature_high().value(); } + // ensure the two set points are valid and adjust one of them if necessary + this->validate_target_temperatures(target_temperature_high_changed || + (this->prev_mode_ == climate::CLIMATE_MODE_COOL)); } else { if (call.get_target_temperature().has_value()) { - this->target_temperature = *call.get_target_temperature(); - validate_target_temperature(); + this->target_temperature = call.get_target_temperature().value(); + this->validate_target_temperature(); } } // make any changes happen - refresh(); + this->refresh(); } climate::ClimateTraits ThermostatClimate::traits() { @@ -237,47 +245,47 @@ climate::ClimateTraits ThermostatClimate::traits() { if (this->humidity_sensor_ != nullptr) traits.set_supports_current_humidity(true); - if (supports_auto_) + if (this->supports_auto_) traits.add_supported_mode(climate::CLIMATE_MODE_AUTO); - if (supports_heat_cool_) + if (this->supports_heat_cool_) traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL); - if (supports_cool_) + if (this->supports_cool_) traits.add_supported_mode(climate::CLIMATE_MODE_COOL); - if (supports_dry_) + if (this->supports_dry_) traits.add_supported_mode(climate::CLIMATE_MODE_DRY); - if (supports_fan_only_) + if (this->supports_fan_only_) traits.add_supported_mode(climate::CLIMATE_MODE_FAN_ONLY); - if (supports_heat_) + if (this->supports_heat_) traits.add_supported_mode(climate::CLIMATE_MODE_HEAT); - if (supports_fan_mode_on_) + if (this->supports_fan_mode_on_) traits.add_supported_fan_mode(climate::CLIMATE_FAN_ON); - if (supports_fan_mode_off_) + if (this->supports_fan_mode_off_) traits.add_supported_fan_mode(climate::CLIMATE_FAN_OFF); - if (supports_fan_mode_auto_) + if (this->supports_fan_mode_auto_) traits.add_supported_fan_mode(climate::CLIMATE_FAN_AUTO); - if (supports_fan_mode_low_) + if (this->supports_fan_mode_low_) traits.add_supported_fan_mode(climate::CLIMATE_FAN_LOW); - if (supports_fan_mode_medium_) + if (this->supports_fan_mode_medium_) traits.add_supported_fan_mode(climate::CLIMATE_FAN_MEDIUM); - if (supports_fan_mode_high_) + if (this->supports_fan_mode_high_) traits.add_supported_fan_mode(climate::CLIMATE_FAN_HIGH); - if (supports_fan_mode_middle_) + if (this->supports_fan_mode_middle_) traits.add_supported_fan_mode(climate::CLIMATE_FAN_MIDDLE); - if (supports_fan_mode_focus_) + if (this->supports_fan_mode_focus_) traits.add_supported_fan_mode(climate::CLIMATE_FAN_FOCUS); - if (supports_fan_mode_diffuse_) + if (this->supports_fan_mode_diffuse_) traits.add_supported_fan_mode(climate::CLIMATE_FAN_DIFFUSE); - if (supports_fan_mode_quiet_) + if (this->supports_fan_mode_quiet_) traits.add_supported_fan_mode(climate::CLIMATE_FAN_QUIET); - if (supports_swing_mode_both_) + if (this->supports_swing_mode_both_) traits.add_supported_swing_mode(climate::CLIMATE_SWING_BOTH); - if (supports_swing_mode_horizontal_) + if (this->supports_swing_mode_horizontal_) traits.add_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL); - if (supports_swing_mode_off_) + if (this->supports_swing_mode_off_) traits.add_supported_swing_mode(climate::CLIMATE_SWING_OFF); - if (supports_swing_mode_vertical_) + if (this->supports_swing_mode_vertical_) traits.add_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); for (auto &it : this->preset_config_) { @@ -300,13 +308,13 @@ climate::ClimateAction ThermostatClimate::compute_action_(const bool ignore_time } // do not change the action if an "ON" timer is running if ((!ignore_timers) && - (timer_active_(thermostat::TIMER_IDLE_ON) || timer_active_(thermostat::TIMER_COOLING_ON) || - timer_active_(thermostat::TIMER_FANNING_ON) || timer_active_(thermostat::TIMER_HEATING_ON))) { + (this->timer_active_(thermostat::TIMER_IDLE_ON) || this->timer_active_(thermostat::TIMER_COOLING_ON) || + this->timer_active_(thermostat::TIMER_FANNING_ON) || this->timer_active_(thermostat::TIMER_HEATING_ON))) { return this->action; } // ensure set point(s) is/are valid before computing the action - this->validate_target_temperatures(); + this->validate_target_temperatures(this->prev_mode_ == climate::CLIMATE_MODE_COOL); // everything has been validated so we can now safely compute the action switch (this->mode) { // if the climate mode is OFF then the climate action must be OFF @@ -340,6 +348,22 @@ climate::ClimateAction ThermostatClimate::compute_action_(const bool ignore_time target_action = climate::CLIMATE_ACTION_HEATING; } break; + case climate::CLIMATE_MODE_AUTO: + if (this->supports_two_points_) { + if (this->cooling_required_() && this->heating_required_()) { + // this is bad and should never happen, so just stop. + // target_action = climate::CLIMATE_ACTION_IDLE; + } else if (this->cooling_required_()) { + target_action = climate::CLIMATE_ACTION_COOLING; + } else if (this->heating_required_()) { + target_action = climate::CLIMATE_ACTION_HEATING; + } + } else if (this->supports_cool_ && this->cooling_required_()) { + target_action = climate::CLIMATE_ACTION_COOLING; + } else if (this->supports_heat_ && this->heating_required_()) { + target_action = climate::CLIMATE_ACTION_HEATING; + } + break; default: break; } @@ -362,7 +386,7 @@ climate::ClimateAction ThermostatClimate::compute_supplemental_action_() { } // ensure set point(s) is/are valid before computing the action - this->validate_target_temperatures(); + this->validate_target_temperatures(this->prev_mode_ == climate::CLIMATE_MODE_COOL); // everything has been validated so we can now safely compute the action switch (this->mode) { // if the climate mode is OFF then the climate action must be OFF @@ -653,13 +677,13 @@ void ThermostatClimate::switch_to_mode_(climate::ClimateMode mode, bool publish_ this->prev_mode_trigger_->stop_action(); this->prev_mode_trigger_ = nullptr; } - Trigger<> *trig = this->auto_mode_trigger_; + Trigger<> *trig = this->off_mode_trigger_; switch (mode) { - case climate::CLIMATE_MODE_OFF: - trig = this->off_mode_trigger_; + case climate::CLIMATE_MODE_AUTO: + trig = this->auto_mode_trigger_; break; case climate::CLIMATE_MODE_HEAT_COOL: - // trig = this->auto_mode_trigger_; + trig = this->heat_cool_mode_trigger_; break; case climate::CLIMATE_MODE_COOL: trig = this->cool_mode_trigger_; @@ -673,11 +697,12 @@ void ThermostatClimate::switch_to_mode_(climate::ClimateMode mode, bool publish_ case climate::CLIMATE_MODE_DRY: trig = this->dry_mode_trigger_; break; + case climate::CLIMATE_MODE_OFF: default: // we cannot report an invalid mode back to HA (even if it asked for one) // and must assume some valid value - mode = climate::CLIMATE_MODE_HEAT_COOL; - // trig = this->auto_mode_trigger_; + mode = climate::CLIMATE_MODE_OFF; + // trig = this->off_mode_trigger_; } if (trig != nullptr) { trig->trigger(); @@ -685,8 +710,9 @@ void ThermostatClimate::switch_to_mode_(climate::ClimateMode mode, bool publish_ this->mode = mode; this->prev_mode_ = mode; this->prev_mode_trigger_ = trig; - if (publish_state) + if (publish_state) { this->publish_state(); + } } void ThermostatClimate::switch_to_swing_mode_(climate::ClimateSwingMode swing_mode, bool publish_state) { @@ -958,37 +984,25 @@ bool ThermostatClimate::supplemental_heating_required_() { (this->supplemental_action_ == climate::CLIMATE_ACTION_HEATING)); } -void ThermostatClimate::dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config, - bool is_default_preset) { - ESP_LOGCONFIG(TAG, " %s Is Default: %s", preset_name, YESNO(is_default_preset)); - +void ThermostatClimate::dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config) { if (this->supports_heat_) { - if (this->supports_two_points_) { - ESP_LOGCONFIG(TAG, " %s Default Target Temperature Low: %.1f°C", preset_name, - config.default_temperature_low); - } else { - ESP_LOGCONFIG(TAG, " %s Default Target Temperature Low: %.1f°C", preset_name, config.default_temperature); - } + ESP_LOGCONFIG(TAG, " Default Target Temperature Low: %.1f°C", + this->supports_two_points_ ? config.default_temperature_low : config.default_temperature); } if ((this->supports_cool_) || (this->supports_fan_only_)) { - if (this->supports_two_points_) { - ESP_LOGCONFIG(TAG, " %s Default Target Temperature High: %.1f°C", preset_name, - config.default_temperature_high); - } else { - ESP_LOGCONFIG(TAG, " %s Default Target Temperature High: %.1f°C", preset_name, config.default_temperature); - } + ESP_LOGCONFIG(TAG, " Default Target Temperature High: %.1f°C", + this->supports_two_points_ ? config.default_temperature_high : config.default_temperature); } if (config.mode_.has_value()) { - ESP_LOGCONFIG(TAG, " %s Default Mode: %s", preset_name, - LOG_STR_ARG(climate::climate_mode_to_string(*config.mode_))); + ESP_LOGCONFIG(TAG, " Default Mode: %s", LOG_STR_ARG(climate::climate_mode_to_string(*config.mode_))); } if (config.fan_mode_.has_value()) { - ESP_LOGCONFIG(TAG, " %s Default Fan Mode: %s", preset_name, + ESP_LOGCONFIG(TAG, " Default Fan Mode: %s", LOG_STR_ARG(climate::climate_fan_mode_to_string(*config.fan_mode_))); } if (config.swing_mode_.has_value()) { - ESP_LOGCONFIG(TAG, " %s Default Swing Mode: %s", preset_name, + ESP_LOGCONFIG(TAG, " Default Swing Mode: %s", LOG_STR_ARG(climate::climate_swing_mode_to_string(*config.swing_mode_))); } } @@ -1106,6 +1120,7 @@ ThermostatClimate::ThermostatClimate() heat_action_trigger_(new Trigger<>()), supplemental_heat_action_trigger_(new Trigger<>()), heat_mode_trigger_(new Trigger<>()), + heat_cool_mode_trigger_(new Trigger<>()), auto_mode_trigger_(new Trigger<>()), idle_action_trigger_(new Trigger<>()), off_mode_trigger_(new Trigger<>()), @@ -1274,6 +1289,7 @@ Trigger<> *ThermostatClimate::get_cool_mode_trigger() const { return this->cool_ Trigger<> *ThermostatClimate::get_dry_mode_trigger() const { return this->dry_mode_trigger_; } Trigger<> *ThermostatClimate::get_fan_only_mode_trigger() const { return this->fan_only_mode_trigger_; } Trigger<> *ThermostatClimate::get_heat_mode_trigger() const { return this->heat_mode_trigger_; } +Trigger<> *ThermostatClimate::get_heat_cool_mode_trigger() const { return this->heat_cool_mode_trigger_; } Trigger<> *ThermostatClimate::get_off_mode_trigger() const { return this->off_mode_trigger_; } Trigger<> *ThermostatClimate::get_fan_mode_on_trigger() const { return this->fan_mode_on_trigger_; } Trigger<> *ThermostatClimate::get_fan_mode_off_trigger() const { return this->fan_mode_off_trigger_; } @@ -1295,52 +1311,55 @@ Trigger<> *ThermostatClimate::get_preset_change_trigger() const { return this->p void ThermostatClimate::dump_config() { LOG_CLIMATE("", "Thermostat", this); + ESP_LOGCONFIG(TAG, + " On boot, restore from: %s\n" + " Use Start-up Delay: %s", + this->on_boot_restore_from_ == thermostat::DEFAULT_PRESET ? "DEFAULT_PRESET" : "MEMORY", + YESNO(this->use_startup_delay_)); if (this->supports_two_points_) { ESP_LOGCONFIG(TAG, " Minimum Set Point Differential: %.1f°C", this->set_point_minimum_differential_); } - ESP_LOGCONFIG(TAG, " Use Start-up Delay: %s", YESNO(this->use_startup_delay_)); if (this->supports_cool_) { ESP_LOGCONFIG(TAG, " Cooling Parameters:\n" " Deadband: %.1f°C\n" - " Overrun: %.1f°C", - this->cooling_deadband_, this->cooling_overrun_); - if ((this->supplemental_cool_delta_ > 0) || (this->timer_duration_(thermostat::TIMER_COOLING_MAX_RUN_TIME) > 0)) { - ESP_LOGCONFIG(TAG, - " Supplemental Delta: %.1f°C\n" - " Maximum Run Time: %" PRIu32 "s", - this->supplemental_cool_delta_, - this->timer_duration_(thermostat::TIMER_COOLING_MAX_RUN_TIME) / 1000); - } - ESP_LOGCONFIG(TAG, + " Overrun: %.1f°C\n" " Minimum Off Time: %" PRIu32 "s\n" " Minimum Run Time: %" PRIu32 "s", + this->cooling_deadband_, this->cooling_overrun_, this->timer_duration_(thermostat::TIMER_COOLING_OFF) / 1000, this->timer_duration_(thermostat::TIMER_COOLING_ON) / 1000); + if ((this->supplemental_cool_delta_ > 0) || (this->timer_duration_(thermostat::TIMER_COOLING_MAX_RUN_TIME) > 0)) { + ESP_LOGCONFIG(TAG, + " Maximum Run Time: %" PRIu32 "s\n" + " Supplemental Delta: %.1f°C", + this->timer_duration_(thermostat::TIMER_COOLING_MAX_RUN_TIME) / 1000, + this->supplemental_cool_delta_); + } } if (this->supports_heat_) { ESP_LOGCONFIG(TAG, " Heating Parameters:\n" " Deadband: %.1f°C\n" - " Overrun: %.1f°C", - this->heating_deadband_, this->heating_overrun_); - if ((this->supplemental_heat_delta_ > 0) || (this->timer_duration_(thermostat::TIMER_HEATING_MAX_RUN_TIME) > 0)) { - ESP_LOGCONFIG(TAG, - " Supplemental Delta: %.1f°C\n" - " Maximum Run Time: %" PRIu32 "s", - this->supplemental_heat_delta_, - this->timer_duration_(thermostat::TIMER_HEATING_MAX_RUN_TIME) / 1000); - } - ESP_LOGCONFIG(TAG, + " Overrun: %.1f°C\n" " Minimum Off Time: %" PRIu32 "s\n" " Minimum Run Time: %" PRIu32 "s", + this->heating_deadband_, this->heating_overrun_, this->timer_duration_(thermostat::TIMER_HEATING_OFF) / 1000, this->timer_duration_(thermostat::TIMER_HEATING_ON) / 1000); + if ((this->supplemental_heat_delta_ > 0) || (this->timer_duration_(thermostat::TIMER_HEATING_MAX_RUN_TIME) > 0)) { + ESP_LOGCONFIG(TAG, + " Maximum Run Time: %" PRIu32 "s\n" + " Supplemental Delta: %.1f°C", + this->timer_duration_(thermostat::TIMER_HEATING_MAX_RUN_TIME) / 1000, + this->supplemental_heat_delta_); + } } if (this->supports_fan_only_) { ESP_LOGCONFIG(TAG, - " Fanning Minimum Off Time: %" PRIu32 "s\n" - " Fanning Minimum Run Time: %" PRIu32 "s", + " Fan Parameters:\n" + " Minimum Off Time: %" PRIu32 "s\n" + " Minimum Run Time: %" PRIu32 "s", this->timer_duration_(thermostat::TIMER_FANNING_OFF) / 1000, this->timer_duration_(thermostat::TIMER_FANNING_ON) / 1000); } @@ -1351,8 +1370,8 @@ void ThermostatClimate::dump_config() { ESP_LOGCONFIG(TAG, " Minimum Fan Mode Switching Time: %" PRIu32 "s", this->timer_duration_(thermostat::TIMER_FAN_MODE) / 1000); } - ESP_LOGCONFIG(TAG, " Minimum Idle Time: %" PRIu32 "s", this->timer_[thermostat::TIMER_IDLE_ON].time / 1000); ESP_LOGCONFIG(TAG, + " Minimum Idle Time: %" PRIu32 "s\n" " Supported MODES:\n" " AUTO: %s\n" " HEAT/COOL: %s\n" @@ -1362,8 +1381,9 @@ void ThermostatClimate::dump_config() { " FAN_ONLY: %s\n" " FAN_ONLY_ACTION_USES_FAN_MODE_TIMER: %s\n" " FAN_ONLY_COOLING: %s", - YESNO(this->supports_auto_), YESNO(this->supports_heat_cool_), YESNO(this->supports_heat_), - YESNO(this->supports_cool_), YESNO(this->supports_dry_), YESNO(this->supports_fan_only_), + this->timer_[thermostat::TIMER_IDLE_ON].time / 1000, YESNO(this->supports_auto_), + YESNO(this->supports_heat_cool_), YESNO(this->supports_heat_), YESNO(this->supports_cool_), + YESNO(this->supports_dry_), YESNO(this->supports_fan_only_), YESNO(this->supports_fan_only_action_uses_fan_mode_timer_), YESNO(this->supports_fan_only_cooling_)); if (this->supports_cool_) { ESP_LOGCONFIG(TAG, " FAN_WITH_COOLING: %s", YESNO(this->supports_fan_with_cooling_)); @@ -1382,40 +1402,39 @@ void ThermostatClimate::dump_config() { " MIDDLE: %s\n" " FOCUS: %s\n" " DIFFUSE: %s\n" - " QUIET: %s", - YESNO(this->supports_fan_mode_on_), YESNO(this->supports_fan_mode_off_), - YESNO(this->supports_fan_mode_auto_), YESNO(this->supports_fan_mode_low_), - YESNO(this->supports_fan_mode_medium_), YESNO(this->supports_fan_mode_high_), - YESNO(this->supports_fan_mode_middle_), YESNO(this->supports_fan_mode_focus_), - YESNO(this->supports_fan_mode_diffuse_), YESNO(this->supports_fan_mode_quiet_)); - ESP_LOGCONFIG(TAG, + " QUIET: %s\n" " Supported SWING MODES:\n" " BOTH: %s\n" " OFF: %s\n" " HORIZONTAL: %s\n" " VERTICAL: %s\n" " Supports TWO SET POINTS: %s", + YESNO(this->supports_fan_mode_on_), YESNO(this->supports_fan_mode_off_), + YESNO(this->supports_fan_mode_auto_), YESNO(this->supports_fan_mode_low_), + YESNO(this->supports_fan_mode_medium_), YESNO(this->supports_fan_mode_high_), + YESNO(this->supports_fan_mode_middle_), YESNO(this->supports_fan_mode_focus_), + YESNO(this->supports_fan_mode_diffuse_), YESNO(this->supports_fan_mode_quiet_), YESNO(this->supports_swing_mode_both_), YESNO(this->supports_swing_mode_off_), YESNO(this->supports_swing_mode_horizontal_), YESNO(this->supports_swing_mode_vertical_), YESNO(this->supports_two_points_)); - ESP_LOGCONFIG(TAG, " Supported PRESETS: "); - for (auto &it : this->preset_config_) { - const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(it.first)); - - ESP_LOGCONFIG(TAG, " Supports %s: %s", preset_name, YESNO(true)); - this->dump_preset_config_(preset_name, it.second, it.first == this->default_preset_); + if (!this->preset_config_.empty()) { + ESP_LOGCONFIG(TAG, " Supported PRESETS:"); + for (auto &it : this->preset_config_) { + const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(it.first)); + ESP_LOGCONFIG(TAG, " %s:%s", preset_name, it.first == this->default_preset_ ? " (default)" : ""); + this->dump_preset_config_(preset_name, it.second); + } } - ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS: "); - for (auto &it : this->custom_preset_config_) { - const auto *preset_name = it.first.c_str(); - - ESP_LOGCONFIG(TAG, " Supports %s: %s", preset_name, YESNO(true)); - this->dump_preset_config_(preset_name, it.second, it.first == this->default_custom_preset_); + if (!this->custom_preset_config_.empty()) { + ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS:"); + for (auto &it : this->custom_preset_config_) { + const auto *preset_name = it.first.c_str(); + ESP_LOGCONFIG(TAG, " %s:%s", preset_name, it.first == this->default_custom_preset_ ? " (default)" : ""); + this->dump_preset_config_(preset_name, it.second); + } } - ESP_LOGCONFIG(TAG, " On boot, restore from: %s", - this->on_boot_restore_from_ == thermostat::DEFAULT_PRESET ? "DEFAULT_PRESET" : "MEMORY"); } ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig() = default; diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 007d7297d5..e2c7b00266 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -6,9 +6,9 @@ #include "esphome/components/climate/climate.h" #include "esphome/components/sensor/sensor.h" +#include #include #include -#include namespace esphome { namespace thermostat { @@ -24,6 +24,7 @@ enum ThermostatClimateTimerIndex : uint8_t { TIMER_HEATING_OFF = 7, TIMER_HEATING_ON = 8, TIMER_IDLE_ON = 9, + TIMER_COUNT = 10, }; enum OnBootRestoreFrom : uint8_t { @@ -131,6 +132,7 @@ class ThermostatClimate : public climate::Climate, public Component { Trigger<> *get_dry_mode_trigger() const; Trigger<> *get_fan_only_mode_trigger() const; Trigger<> *get_heat_mode_trigger() const; + Trigger<> *get_heat_cool_mode_trigger() const; Trigger<> *get_off_mode_trigger() const; Trigger<> *get_fan_mode_on_trigger() const; Trigger<> *get_fan_mode_off_trigger() const; @@ -163,9 +165,10 @@ class ThermostatClimate : public climate::Climate, public Component { /// Returns the fan mode that is locked in (check fan_mode_change_delayed(), first!) climate::ClimateFanMode locked_fan_mode(); /// Set point and hysteresis validation - bool hysteresis_valid(); // returns true if valid + bool hysteresis_valid(); // returns true if valid + bool limit_setpoints_for_heat_cool(); // returns true if set points should be further limited within visual range void validate_target_temperature(); - void validate_target_temperatures(); + void validate_target_temperatures(bool pin_target_temperature_high); void validate_target_temperature_low(); void validate_target_temperature_high(); @@ -241,12 +244,28 @@ class ThermostatClimate : public climate::Climate, public Component { bool supplemental_cooling_required_(); bool supplemental_heating_required_(); - void dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config, - bool is_default_preset); + void dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config); /// Minimum allowable duration in seconds for action timers const uint8_t min_timer_duration_{1}; + /// Store previously-known states + /// + /// These are used to determine when a trigger/action needs to be called + climate::ClimateFanMode prev_fan_mode_{climate::CLIMATE_FAN_ON}; + climate::ClimateMode prev_mode_{climate::CLIMATE_MODE_OFF}; + climate::ClimateSwingMode prev_swing_mode_{climate::CLIMATE_SWING_OFF}; + + /// The current supplemental action + climate::ClimateAction supplemental_action_{climate::CLIMATE_ACTION_OFF}; + + /// Default standard preset to use on start up + climate::ClimatePreset default_preset_{}; + + /// If set to DEFAULT_PRESET then the default preset is always used. When MEMORY prior + /// state will attempt to be restored if possible + OnBootRestoreFrom on_boot_restore_from_{OnBootRestoreFrom::MEMORY}; + /// Whether the controller supports auto/cooling/drying/fanning/heating. /// /// A false value for any given attribute means that the controller has no such action @@ -362,9 +381,15 @@ class ThermostatClimate : public climate::Climate, public Component { Trigger<> *supplemental_heat_action_trigger_{nullptr}; Trigger<> *heat_mode_trigger_{nullptr}; + /// The trigger to call when the controller should switch to heat/cool mode. + /// + /// In heat/cool mode, the controller will enable heating/cooling as necessary and switch + /// to idle when the temperature is within the thresholds/set points. + Trigger<> *heat_cool_mode_trigger_{nullptr}; + /// The trigger to call when the controller should switch to auto mode. /// - /// In auto mode, the controller will enable heating/cooling as necessary and switch + /// In auto mode, the controller will enable heating/cooling as supported/necessary and switch /// to idle when the temperature is within the thresholds/set points. Trigger<> *auto_mode_trigger_{nullptr}; @@ -438,35 +463,21 @@ class ThermostatClimate : public climate::Climate, public Component { Trigger<> *prev_mode_trigger_{nullptr}; Trigger<> *prev_swing_mode_trigger_{nullptr}; - /// If set to DEFAULT_PRESET then the default preset is always used. When MEMORY prior - /// state will attempt to be restored if possible - OnBootRestoreFrom on_boot_restore_from_{OnBootRestoreFrom::MEMORY}; - - /// Store previously-known states - /// - /// These are used to determine when a trigger/action needs to be called - climate::ClimateAction supplemental_action_{climate::CLIMATE_ACTION_OFF}; - climate::ClimateFanMode prev_fan_mode_{climate::CLIMATE_FAN_ON}; - climate::ClimateMode prev_mode_{climate::CLIMATE_MODE_OFF}; - climate::ClimateSwingMode prev_swing_mode_{climate::CLIMATE_SWING_OFF}; - - /// Default standard preset to use on start up - climate::ClimatePreset default_preset_{}; /// Default custom preset to use on start up std::string default_custom_preset_{}; /// Climate action timers - std::vector timer_{ - {false, 0, 0, std::bind(&ThermostatClimate::cooling_max_run_time_timer_callback_, this)}, - {false, 0, 0, std::bind(&ThermostatClimate::cooling_off_timer_callback_, this)}, - {false, 0, 0, std::bind(&ThermostatClimate::cooling_on_timer_callback_, this)}, - {false, 0, 0, std::bind(&ThermostatClimate::fan_mode_timer_callback_, this)}, - {false, 0, 0, std::bind(&ThermostatClimate::fanning_off_timer_callback_, this)}, - {false, 0, 0, std::bind(&ThermostatClimate::fanning_on_timer_callback_, this)}, - {false, 0, 0, std::bind(&ThermostatClimate::heating_max_run_time_timer_callback_, this)}, - {false, 0, 0, std::bind(&ThermostatClimate::heating_off_timer_callback_, this)}, - {false, 0, 0, std::bind(&ThermostatClimate::heating_on_timer_callback_, this)}, - {false, 0, 0, std::bind(&ThermostatClimate::idle_on_timer_callback_, this)}, + std::array timer_{ + ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::cooling_max_run_time_timer_callback_, this)), + ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::cooling_off_timer_callback_, this)), + ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::cooling_on_timer_callback_, this)), + ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::fan_mode_timer_callback_, this)), + ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::fanning_off_timer_callback_, this)), + ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::fanning_on_timer_callback_, this)), + ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::heating_max_run_time_timer_callback_, this)), + ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::heating_off_timer_callback_, this)), + ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::heating_on_timer_callback_, this)), + ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::idle_on_timer_callback_, this)), }; /// The set of standard preset configurations this thermostat supports (Eg. AWAY, ECO, etc) diff --git a/esphome/const.py b/esphome/const.py index f27adff9be..fae6020b88 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -424,6 +424,7 @@ CONF_HEAD = "head" CONF_HEADING = "heading" CONF_HEARTBEAT = "heartbeat" CONF_HEAT_ACTION = "heat_action" +CONF_HEAT_COOL_MODE = "heat_cool_mode" CONF_HEAT_DEADBAND = "heat_deadband" CONF_HEAT_MODE = "heat_mode" CONF_HEAT_OVERRUN = "heat_overrun" From ac61b8f8936c5e50c454ee8bfd82574dca72d23f Mon Sep 17 00:00:00 2001 From: Daniel M Date: Wed, 10 Sep 2025 13:50:49 +1200 Subject: [PATCH 1014/1626] [bl0940] extend configuration options of bl0940 device (#8158) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- CODEOWNERS | 2 +- esphome/components/bl0940/__init__.py | 7 +- esphome/components/bl0940/bl0940.cpp | 219 ++++++++++++++---- esphome/components/bl0940/bl0940.h | 185 +++++++++------ esphome/components/bl0940/button/__init__.py | 27 +++ .../button/calibration_reset_button.cpp | 20 ++ .../bl0940/button/calibration_reset_button.h | 19 ++ esphome/components/bl0940/number/__init__.py | 94 ++++++++ .../bl0940/number/calibration_number.cpp | 29 +++ .../bl0940/number/calibration_number.h | 26 +++ esphome/components/bl0940/sensor.py | 153 +++++++++++- tests/components/bl0940/common.yaml | 21 ++ 12 files changed, 687 insertions(+), 115 deletions(-) create mode 100644 esphome/components/bl0940/button/__init__.py create mode 100644 esphome/components/bl0940/button/calibration_reset_button.cpp create mode 100644 esphome/components/bl0940/button/calibration_reset_button.h create mode 100644 esphome/components/bl0940/number/__init__.py create mode 100644 esphome/components/bl0940/number/calibration_number.cpp create mode 100644 esphome/components/bl0940/number/calibration_number.h diff --git a/CODEOWNERS b/CODEOWNERS index a77a7ba86e..acf8acb2ab 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -66,7 +66,7 @@ esphome/components/binary_sensor/* @esphome/core esphome/components/bk72xx/* @kuba2k2 esphome/components/bl0906/* @athom-tech @jesserockz @tarontop esphome/components/bl0939/* @ziceva -esphome/components/bl0940/* @tobias- +esphome/components/bl0940/* @dan-s-github @tobias- esphome/components/bl0942/* @dbuezas @dwmw2 esphome/components/ble_client/* @buxtronix @clydebarrow esphome/components/bluetooth_proxy/* @bdraco @jesserockz diff --git a/esphome/components/bl0940/__init__.py b/esphome/components/bl0940/__init__.py index 087626a4e7..066c2818b6 100644 --- a/esphome/components/bl0940/__init__.py +++ b/esphome/components/bl0940/__init__.py @@ -1 +1,6 @@ -CODEOWNERS = ["@tobias-"] +import esphome.codegen as cg + +CODEOWNERS = ["@tobias-", "@dan-s-github"] + +CONF_BL0940_ID = "bl0940_id" +bl0940_ns = cg.esphome_ns.namespace("bl0940") diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index 24990d5482..42e20eb69b 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -7,28 +7,26 @@ namespace bl0940 { static const char *const TAG = "bl0940"; -static const uint8_t BL0940_READ_COMMAND = 0x50; // 0x58 according to documentation static const uint8_t BL0940_FULL_PACKET = 0xAA; -static const uint8_t BL0940_PACKET_HEADER = 0x55; // 0x58 according to documentation +static const uint8_t BL0940_PACKET_HEADER = 0x55; // 0x58 according to en doc but 0x55 in cn doc -static const uint8_t BL0940_WRITE_COMMAND = 0xA0; // 0xA8 according to documentation static const uint8_t BL0940_REG_I_FAST_RMS_CTRL = 0x10; static const uint8_t BL0940_REG_MODE = 0x18; static const uint8_t BL0940_REG_SOFT_RESET = 0x19; static const uint8_t BL0940_REG_USR_WRPROT = 0x1A; static const uint8_t BL0940_REG_TPS_CTRL = 0x1B; -const uint8_t BL0940_INIT[5][6] = { +static const uint8_t BL0940_INIT[5][5] = { // Reset to default - {BL0940_WRITE_COMMAND, BL0940_REG_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x38}, + {BL0940_REG_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x38}, // Enable User Operation Write - {BL0940_WRITE_COMMAND, BL0940_REG_USR_WRPROT, 0x55, 0x00, 0x00, 0xF0}, + {BL0940_REG_USR_WRPROT, 0x55, 0x00, 0x00, 0xF0}, // 0x0100 = CF_UNABLE energy pulse, AC_FREQ_SEL 50Hz, RMS_UPDATE_SEL 800mS - {BL0940_WRITE_COMMAND, BL0940_REG_MODE, 0x00, 0x10, 0x00, 0x37}, + {BL0940_REG_MODE, 0x00, 0x10, 0x00, 0x37}, // 0x47FF = Over-current and leakage alarm on, Automatic temperature measurement, Interval 100mS - {BL0940_WRITE_COMMAND, BL0940_REG_TPS_CTRL, 0xFF, 0x47, 0x00, 0xFE}, + {BL0940_REG_TPS_CTRL, 0xFF, 0x47, 0x00, 0xFE}, // 0x181C = Half cycle, Fast RMS threshold 6172 - {BL0940_WRITE_COMMAND, BL0940_REG_I_FAST_RMS_CTRL, 0x1C, 0x18, 0x00, 0x1B}}; + {BL0940_REG_I_FAST_RMS_CTRL, 0x1C, 0x18, 0x00, 0x1B}}; void BL0940::loop() { DataPacket buffer; @@ -36,8 +34,8 @@ void BL0940::loop() { return; } if (read_array((uint8_t *) &buffer, sizeof(buffer))) { - if (validate_checksum(&buffer)) { - received_package_(&buffer); + if (this->validate_checksum_(&buffer)) { + this->received_package_(&buffer); } } else { ESP_LOGW(TAG, "Junk on wire. Throwing away partial message"); @@ -46,35 +44,151 @@ void BL0940::loop() { } } -bool BL0940::validate_checksum(const DataPacket *data) { - uint8_t checksum = BL0940_READ_COMMAND; +bool BL0940::validate_checksum_(DataPacket *data) { + uint8_t checksum = this->read_command_; // Whole package but checksum - for (uint32_t i = 0; i < sizeof(data->raw) - 1; i++) { - checksum += data->raw[i]; + uint8_t *raw = (uint8_t *) data; + for (uint32_t i = 0; i < sizeof(*data) - 1; i++) { + checksum += raw[i]; } checksum ^= 0xFF; if (checksum != data->checksum) { - ESP_LOGW(TAG, "BL0940 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); + ESP_LOGW(TAG, "Invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } return checksum == data->checksum; } void BL0940::update() { this->flush(); - this->write_byte(BL0940_READ_COMMAND); + this->write_byte(this->read_command_); this->write_byte(BL0940_FULL_PACKET); } void BL0940::setup() { +#ifdef USE_NUMBER + // add calibration callbacks + if (this->voltage_calibration_number_ != nullptr) { + this->voltage_calibration_number_->add_on_state_callback( + [this](float state) { this->voltage_calibration_callback_(state); }); + if (this->voltage_calibration_number_->has_state()) { + this->voltage_calibration_callback_(this->voltage_calibration_number_->state); + } + } + + if (this->current_calibration_number_ != nullptr) { + this->current_calibration_number_->add_on_state_callback( + [this](float state) { this->current_calibration_callback_(state); }); + if (this->current_calibration_number_->has_state()) { + this->current_calibration_callback_(this->current_calibration_number_->state); + } + } + + if (this->power_calibration_number_ != nullptr) { + this->power_calibration_number_->add_on_state_callback( + [this](float state) { this->power_calibration_callback_(state); }); + if (this->power_calibration_number_->has_state()) { + this->power_calibration_callback_(this->power_calibration_number_->state); + } + } + + if (this->energy_calibration_number_ != nullptr) { + this->energy_calibration_number_->add_on_state_callback( + [this](float state) { this->energy_calibration_callback_(state); }); + if (this->energy_calibration_number_->has_state()) { + this->energy_calibration_callback_(this->energy_calibration_number_->state); + } + } +#endif + + // calculate calibrated reference values + this->voltage_reference_cal_ = this->voltage_reference_ / this->voltage_cal_; + this->current_reference_cal_ = this->current_reference_ / this->current_cal_; + this->power_reference_cal_ = this->power_reference_ / this->power_cal_; + this->energy_reference_cal_ = this->energy_reference_ / this->energy_cal_; + for (auto *i : BL0940_INIT) { - this->write_array(i, 6); + this->write_byte(this->write_command_), this->write_array(i, 5); delay(1); } this->flush(); } -float BL0940::update_temp_(sensor::Sensor *sensor, ube16_t temperature) const { - auto tb = (float) (temperature.h << 8 | temperature.l); +float BL0940::calculate_power_reference_() { + // calculate power reference based on voltage and current reference + return this->voltage_reference_cal_ * this->current_reference_cal_ * 4046 / 324004 / 79931; +} + +float BL0940::calculate_energy_reference_() { + // formula: 3600000 * 4046 * RL * R1 * 1000 / (1638.4 * 256) / Vref² / (R1 + R2) + // or: power_reference_ * 3600000 / (1638.4 * 256) + return this->power_reference_cal_ * 3600000 / (1638.4 * 256); +} + +float BL0940::calculate_calibration_value_(float state) { return (100 + state) / 100; } + +void BL0940::reset_calibration() { +#ifdef USE_NUMBER + if (this->current_calibration_number_ != nullptr && this->current_cal_ != 1) { + this->current_calibration_number_->make_call().set_value(0).perform(); + } + if (this->voltage_calibration_number_ != nullptr && this->voltage_cal_ != 1) { + this->voltage_calibration_number_->make_call().set_value(0).perform(); + } + if (this->power_calibration_number_ != nullptr && this->power_cal_ != 1) { + this->power_calibration_number_->make_call().set_value(0).perform(); + } + if (this->energy_calibration_number_ != nullptr && this->energy_cal_ != 1) { + this->energy_calibration_number_->make_call().set_value(0).perform(); + } +#endif + ESP_LOGD(TAG, "external calibration values restored to initial state"); +} + +void BL0940::current_calibration_callback_(float state) { + this->current_cal_ = this->calculate_calibration_value_(state); + ESP_LOGV(TAG, "update current calibration state: %f", this->current_cal_); + this->recalibrate_(); +} +void BL0940::voltage_calibration_callback_(float state) { + this->voltage_cal_ = this->calculate_calibration_value_(state); + ESP_LOGV(TAG, "update voltage calibration state: %f", this->voltage_cal_); + this->recalibrate_(); +} +void BL0940::power_calibration_callback_(float state) { + this->power_cal_ = this->calculate_calibration_value_(state); + ESP_LOGV(TAG, "update power calibration state: %f", this->power_cal_); + this->recalibrate_(); +} +void BL0940::energy_calibration_callback_(float state) { + this->energy_cal_ = this->calculate_calibration_value_(state); + ESP_LOGV(TAG, "update energy calibration state: %f", this->energy_cal_); + this->recalibrate_(); +} + +void BL0940::recalibrate_() { + ESP_LOGV(TAG, "Recalibrating reference values"); + this->voltage_reference_cal_ = this->voltage_reference_ / this->voltage_cal_; + this->current_reference_cal_ = this->current_reference_ / this->current_cal_; + + if (this->voltage_cal_ != 1 || this->current_cal_ != 1) { + this->power_reference_ = this->calculate_power_reference_(); + } + this->power_reference_cal_ = this->power_reference_ / this->power_cal_; + + if (this->voltage_cal_ != 1 || this->current_cal_ != 1 || this->power_cal_ != 1) { + this->energy_reference_ = this->calculate_energy_reference_(); + } + this->energy_reference_cal_ = this->energy_reference_ / this->energy_cal_; + + ESP_LOGD(TAG, + "Recalibrated reference values:\n" + "Voltage: %f\n, Current: %f\n, Power: %f\n, Energy: %f\n", + this->voltage_reference_cal_, this->current_reference_cal_, this->power_reference_cal_, + this->energy_reference_cal_); +} + +float BL0940::update_temp_(sensor::Sensor *sensor, uint16_le_t temperature) const { + auto tb = (float) temperature; float converted_temp = ((float) 170 / 448) * (tb / 2 - 32) - 45; if (sensor != nullptr) { if (sensor->has_state() && std::abs(converted_temp - sensor->get_state()) > max_temperature_diff_) { @@ -87,33 +201,40 @@ float BL0940::update_temp_(sensor::Sensor *sensor, ube16_t temperature) const { return converted_temp; } -void BL0940::received_package_(const DataPacket *data) const { +void BL0940::received_package_(DataPacket *data) { // Bad header if (data->frame_header != BL0940_PACKET_HEADER) { ESP_LOGI(TAG, "Invalid data. Header mismatch: %d", data->frame_header); return; } - float v_rms = (float) to_uint32_t(data->v_rms) / voltage_reference_; - float i_rms = (float) to_uint32_t(data->i_rms) / current_reference_; - float watt = (float) to_int32_t(data->watt) / power_reference_; - uint32_t cf_cnt = to_uint32_t(data->cf_cnt); - float total_energy_consumption = (float) cf_cnt / energy_reference_; + // cf_cnt is only 24 bits, so track overflows + uint32_t cf_cnt = (uint24_t) data->cf_cnt; + cf_cnt |= this->prev_cf_cnt_ & 0xff000000; + if (cf_cnt < this->prev_cf_cnt_) { + cf_cnt += 0x1000000; + } + this->prev_cf_cnt_ = cf_cnt; - float tps1 = update_temp_(internal_temperature_sensor_, data->tps1); - float tps2 = update_temp_(external_temperature_sensor_, data->tps2); + float v_rms = (uint24_t) data->v_rms / this->voltage_reference_cal_; + float i_rms = (uint24_t) data->i_rms / this->current_reference_cal_; + float watt = (int24_t) data->watt / this->power_reference_cal_; + float total_energy_consumption = cf_cnt / this->energy_reference_cal_; - if (voltage_sensor_ != nullptr) { - voltage_sensor_->publish_state(v_rms); + float tps1 = update_temp_(this->internal_temperature_sensor_, data->tps1); + float tps2 = update_temp_(this->external_temperature_sensor_, data->tps2); + + if (this->voltage_sensor_ != nullptr) { + this->voltage_sensor_->publish_state(v_rms); } - if (current_sensor_ != nullptr) { - current_sensor_->publish_state(i_rms); + if (this->current_sensor_ != nullptr) { + this->current_sensor_->publish_state(i_rms); } - if (power_sensor_ != nullptr) { - power_sensor_->publish_state(watt); + if (this->power_sensor_ != nullptr) { + this->power_sensor_->publish_state(watt); } - if (energy_sensor_ != nullptr) { - energy_sensor_->publish_state(total_energy_consumption); + if (this->energy_sensor_ != nullptr) { + this->energy_sensor_->publish_state(total_energy_consumption); } ESP_LOGV(TAG, "BL0940: U %fV, I %fA, P %fW, Cnt %" PRId32 ", ∫P %fkWh, T1 %f°C, T2 %f°C", v_rms, i_rms, watt, cf_cnt, @@ -121,7 +242,27 @@ void BL0940::received_package_(const DataPacket *data) const { } void BL0940::dump_config() { // NOLINT(readability-function-cognitive-complexity) - ESP_LOGCONFIG(TAG, "BL0940:"); + ESP_LOGCONFIG(TAG, + "BL0940:\n" + " LEGACY MODE: %s\n" + " READ CMD: 0x%02X\n" + " WRITE CMD: 0x%02X\n" + " ------------------\n" + " Current reference: %f\n" + " Energy reference: %f\n" + " Power reference: %f\n" + " Voltage reference: %f\n", + TRUEFALSE(this->legacy_mode_enabled_), this->read_command_, this->write_command_, + this->current_reference_, this->energy_reference_, this->power_reference_, this->voltage_reference_); +#ifdef USE_NUMBER + ESP_LOGCONFIG(TAG, + "BL0940:\n" + " Current calibration: %f\n" + " Energy calibration: %f\n" + " Power calibration: %f\n" + " Voltage calibration: %f\n", + this->current_cal_, this->energy_cal_, this->power_cal_, this->voltage_cal_); +#endif LOG_SENSOR("", "Voltage", this->voltage_sensor_); LOG_SENSOR("", "Current", this->current_sensor_); LOG_SENSOR("", "Power", this->power_sensor_); @@ -130,9 +271,5 @@ void BL0940::dump_config() { // NOLINT(readability-function-cognitive-complexit LOG_SENSOR("", "External temperature", this->external_temperature_sensor_); } -uint32_t BL0940::to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; } - -int32_t BL0940::to_int32_t(sbe24_t input) { return input.h << 16 | input.m << 8 | input.l; } - } // namespace bl0940 } // namespace esphome diff --git a/esphome/components/bl0940/bl0940.h b/esphome/components/bl0940/bl0940.h index 2d4e7ccaac..93d54003f5 100644 --- a/esphome/components/bl0940/bl0940.h +++ b/esphome/components/bl0940/bl0940.h @@ -1,66 +1,48 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/uart/uart.h" +#include "esphome/core/datatypes.h" +#include "esphome/core/defines.h" +#ifdef USE_BUTTON +#include "esphome/components/button/button.h" +#endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif #include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" namespace esphome { namespace bl0940 { -static const float BL0940_PREF = 1430; -static const float BL0940_UREF = 33000; -static const float BL0940_IREF = 275000; // 2750 from tasmota. Seems to generate values 100 times too high - -// Measured to 297J per click according to power consumption of 5 minutes -// Converted to kWh (3.6MJ per kwH). Used to be 256 * 1638.4 -static const float BL0940_EREF = 3.6e6 / 297; - -struct ube24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align) - uint8_t l; - uint8_t m; - uint8_t h; -} __attribute__((packed)); - -struct ube16_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align) - uint8_t l; - uint8_t h; -} __attribute__((packed)); - -struct sbe24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align) - uint8_t l; - uint8_t m; - int8_t h; -} __attribute__((packed)); - // Caveat: All these values are big endian (low - middle - high) - -union DataPacket { // NOLINT(altera-struct-pack-align) - uint8_t raw[35]; - struct { - uint8_t frame_header; // value of 0x58 according to docs. 0x55 according to Tasmota real world tests. Reality wins. - ube24_t i_fast_rms; // 0x00 - ube24_t i_rms; // 0x04 - ube24_t RESERVED0; // reserved - ube24_t v_rms; // 0x06 - ube24_t RESERVED1; // reserved - sbe24_t watt; // 0x08 - ube24_t RESERVED2; // reserved - ube24_t cf_cnt; // 0x0A - ube24_t RESERVED3; // reserved - ube16_t tps1; // 0x0c - uint8_t RESERVED4; // value of 0x00 - ube16_t tps2; // 0x0c - uint8_t RESERVED5; // value of 0x00 - uint8_t checksum; // checksum - }; +struct DataPacket { + uint8_t frame_header; // Packet header (0x58 in EN docs, 0x55 in CN docs and Tasmota tests) + uint24_le_t i_fast_rms; // Fast RMS current + uint24_le_t i_rms; // RMS current + uint24_t RESERVED0; // Reserved + uint24_le_t v_rms; // RMS voltage + uint24_t RESERVED1; // Reserved + int24_le_t watt; // Active power (can be negative for bidirectional measurement) + uint24_t RESERVED2; // Reserved + uint24_le_t cf_cnt; // Energy pulse count + uint24_t RESERVED3; // Reserved + uint16_le_t tps1; // Internal temperature sensor 1 + uint8_t RESERVED4; // Reserved (should be 0x00) + uint16_le_t tps2; // Internal temperature sensor 2 + uint8_t RESERVED5; // Reserved (should be 0x00) + uint8_t checksum; // Packet checksum } __attribute__((packed)); class BL0940 : public PollingComponent, public uart::UARTDevice { public: + // Sensor setters void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } void set_power_sensor(sensor::Sensor *power_sensor) { power_sensor_ = power_sensor; } void set_energy_sensor(sensor::Sensor *energy_sensor) { energy_sensor_ = energy_sensor; } + + // Temperature sensor setters void set_internal_temperature_sensor(sensor::Sensor *internal_temperature_sensor) { internal_temperature_sensor_ = internal_temperature_sensor; } @@ -68,42 +50,105 @@ class BL0940 : public PollingComponent, public uart::UARTDevice { external_temperature_sensor_ = external_temperature_sensor; } - void loop() override; + // Configuration setters + void set_legacy_mode(bool enable) { this->legacy_mode_enabled_ = enable; } + void set_read_command(uint8_t read_command) { this->read_command_ = read_command; } + void set_write_command(uint8_t write_command) { this->write_command_ = write_command; } + // Reference value setters (used for calibration and conversion) + void set_current_reference(float current_ref) { this->current_reference_ = current_ref; } + void set_energy_reference(float energy_ref) { this->energy_reference_ = energy_ref; } + void set_power_reference(float power_ref) { this->power_reference_ = power_ref; } + void set_voltage_reference(float voltage_ref) { this->voltage_reference_ = voltage_ref; } + +#ifdef USE_NUMBER + // Calibration number setters (for Home Assistant number entities) + void set_current_calibration_number(number::Number *num) { this->current_calibration_number_ = num; } + void set_voltage_calibration_number(number::Number *num) { this->voltage_calibration_number_ = num; } + void set_power_calibration_number(number::Number *num) { this->power_calibration_number_ = num; } + void set_energy_calibration_number(number::Number *num) { this->energy_calibration_number_ = num; } +#endif + +#ifdef USE_BUTTON + // Resets all calibration values to defaults (can be triggered by a button) + void reset_calibration(); +#endif + + // Core component methods + void loop() override; void update() override; void setup() override; void dump_config() override; protected: - sensor::Sensor *voltage_sensor_{nullptr}; - sensor::Sensor *current_sensor_{nullptr}; - // NB This may be negative as the circuits is seemingly able to measure - // power in both directions - sensor::Sensor *power_sensor_{nullptr}; - sensor::Sensor *energy_sensor_{nullptr}; - sensor::Sensor *internal_temperature_sensor_{nullptr}; - sensor::Sensor *external_temperature_sensor_{nullptr}; + // --- Sensor pointers --- + sensor::Sensor *voltage_sensor_{nullptr}; // Voltage sensor + sensor::Sensor *current_sensor_{nullptr}; // Current sensor + sensor::Sensor *power_sensor_{nullptr}; // Power sensor (can be negative for bidirectional) + sensor::Sensor *energy_sensor_{nullptr}; // Energy sensor + sensor::Sensor *internal_temperature_sensor_{nullptr}; // Internal temperature sensor + sensor::Sensor *external_temperature_sensor_{nullptr}; // External temperature sensor - // Max difference between two measurements of the temperature. Used to avoid noise. - float max_temperature_diff_{0}; - // Divide by this to turn into Watt - float power_reference_ = BL0940_PREF; - // Divide by this to turn into Volt - float voltage_reference_ = BL0940_UREF; - // Divide by this to turn into Ampere - float current_reference_ = BL0940_IREF; - // Divide by this to turn into kWh - float energy_reference_ = BL0940_EREF; +#ifdef USE_NUMBER + // --- Calibration number entities (for dynamic calibration via HA UI) --- + number::Number *voltage_calibration_number_{nullptr}; + number::Number *current_calibration_number_{nullptr}; + number::Number *power_calibration_number_{nullptr}; + number::Number *energy_calibration_number_{nullptr}; +#endif - float update_temp_(sensor::Sensor *sensor, ube16_t packed_temperature) const; + // --- Internal state --- + uint32_t prev_cf_cnt_ = 0; // Previous energy pulse count (for energy calculation) + float max_temperature_diff_{0}; // Max allowed temperature difference between two measurements (noise filter) - static uint32_t to_uint32_t(ube24_t input); + // --- Reference values for conversion --- + float power_reference_; // Divider for raw power to get Watts + float power_reference_cal_; // Calibrated power reference + float voltage_reference_; // Divider for raw voltage to get Volts + float voltage_reference_cal_; // Calibrated voltage reference + float current_reference_; // Divider for raw current to get Amperes + float current_reference_cal_; // Calibrated current reference + float energy_reference_; // Divider for raw energy to get kWh + float energy_reference_cal_; // Calibrated energy reference - static int32_t to_int32_t(sbe24_t input); + // --- Home Assistant calibration values (multipliers, default 1) --- + float current_cal_{1}; + float voltage_cal_{1}; + float power_cal_{1}; + float energy_cal_{1}; - static bool validate_checksum(const DataPacket *data); + // --- Protocol commands --- + uint8_t read_command_; + uint8_t write_command_; - void received_package_(const DataPacket *data) const; + // --- Mode flags --- + bool legacy_mode_enabled_ = true; + + // --- Methods --- + // Converts packed temperature value to float and updates the sensor + float update_temp_(sensor::Sensor *sensor, uint16_le_t packed_temperature) const; + + // Validates the checksum of a received data packet + bool validate_checksum_(DataPacket *data); + + // Handles a received data packet + void received_package_(DataPacket *data); + + // Calculates reference values for calibration and conversion + float calculate_energy_reference_(); + float calculate_power_reference_(); + float calculate_calibration_value_(float state); + + // Calibration update callbacks (used with number entities) + void current_calibration_callback_(float state); + void voltage_calibration_callback_(float state); + void power_calibration_callback_(float state); + void energy_calibration_callback_(float state); + void reset_calibration_callback_(); + + // Recalculates all reference values after calibration changes + void recalibrate_(); }; + } // namespace bl0940 } // namespace esphome diff --git a/esphome/components/bl0940/button/__init__.py b/esphome/components/bl0940/button/__init__.py new file mode 100644 index 0000000000..04d11e6e30 --- /dev/null +++ b/esphome/components/bl0940/button/__init__.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART + +from .. import CONF_BL0940_ID, bl0940_ns +from ..sensor import BL0940 + +CalibrationResetButton = bl0940_ns.class_( + "CalibrationResetButton", button.Button, cg.Component +) + +CONFIG_SCHEMA = cv.All( + button.button_schema( + CalibrationResetButton, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RESTART, + ) + .extend({cv.GenerateID(CONF_BL0940_ID): cv.use_id(BL0940)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = await button.new_button(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_BL0940_ID]) diff --git a/esphome/components/bl0940/button/calibration_reset_button.cpp b/esphome/components/bl0940/button/calibration_reset_button.cpp new file mode 100644 index 0000000000..79a6b872d8 --- /dev/null +++ b/esphome/components/bl0940/button/calibration_reset_button.cpp @@ -0,0 +1,20 @@ +#include "calibration_reset_button.h" +#include "../bl0940.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" + +namespace esphome { +namespace bl0940 { + +static const char *const TAG = "bl0940.button.calibration_reset"; + +void CalibrationResetButton::dump_config() { LOG_BUTTON("", "Calibration Reset Button", this); } + +void CalibrationResetButton::press_action() { + ESP_LOGI(TAG, "Resetting calibration defaults..."); + this->parent_->reset_calibration(); +} + +} // namespace bl0940 +} // namespace esphome diff --git a/esphome/components/bl0940/button/calibration_reset_button.h b/esphome/components/bl0940/button/calibration_reset_button.h new file mode 100644 index 0000000000..6ea3b35cb4 --- /dev/null +++ b/esphome/components/bl0940/button/calibration_reset_button.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/button/button.h" + +namespace esphome { +namespace bl0940 { + +class BL0940; // Forward declaration of BL0940 class + +class CalibrationResetButton : public button::Button, public Component, public Parented { + public: + void dump_config() override; + + void press_action() override; +}; + +} // namespace bl0940 +} // namespace esphome diff --git a/esphome/components/bl0940/number/__init__.py b/esphome/components/bl0940/number/__init__.py new file mode 100644 index 0000000000..a640c2ae08 --- /dev/null +++ b/esphome/components/bl0940/number/__init__.py @@ -0,0 +1,94 @@ +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + CONF_MAX_VALUE, + CONF_MIN_VALUE, + CONF_MODE, + CONF_RESTORE_VALUE, + CONF_STEP, + ENTITY_CATEGORY_CONFIG, + UNIT_PERCENT, +) + +from .. import CONF_BL0940_ID, bl0940_ns +from ..sensor import BL0940 + +# Define calibration types +CONF_CURRENT_CALIBRATION = "current_calibration" +CONF_VOLTAGE_CALIBRATION = "voltage_calibration" +CONF_POWER_CALIBRATION = "power_calibration" +CONF_ENERGY_CALIBRATION = "energy_calibration" + +BL0940Number = bl0940_ns.class_("BL0940Number") + +CalibrationNumber = bl0940_ns.class_( + "CalibrationNumber", number.Number, cg.PollingComponent +) + + +def validate_min_max(config): + if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]: + raise cv.Invalid("max_value must be greater than min_value") + return config + + +CALIBRATION_SCHEMA = cv.All( + number.number_schema( + CalibrationNumber, + entity_category=ENTITY_CATEGORY_CONFIG, + unit_of_measurement=UNIT_PERCENT, + ) + .extend( + { + cv.Optional(CONF_MODE, default="BOX"): cv.enum(number.NUMBER_MODES), + cv.Optional(CONF_MAX_VALUE, default=10): cv.All( + cv.float_, cv.Range(max=50) + ), + cv.Optional(CONF_MIN_VALUE, default=-10): cv.All( + cv.float_, cv.Range(min=-50) + ), + cv.Optional(CONF_STEP, default=0.1): cv.positive_float, + cv.Optional(CONF_RESTORE_VALUE): cv.boolean, + } + ) + .extend(cv.COMPONENT_SCHEMA), + validate_min_max, +) + +# Configuration schema for BL0940 numbers +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(BL0940Number), + cv.GenerateID(CONF_BL0940_ID): cv.use_id(BL0940), + cv.Optional(CONF_CURRENT_CALIBRATION): CALIBRATION_SCHEMA, + cv.Optional(CONF_VOLTAGE_CALIBRATION): CALIBRATION_SCHEMA, + cv.Optional(CONF_POWER_CALIBRATION): CALIBRATION_SCHEMA, + cv.Optional(CONF_ENERGY_CALIBRATION): CALIBRATION_SCHEMA, + } +) + + +async def to_code(config): + # Get the BL0940 component instance + bl0940 = await cg.get_variable(config[CONF_BL0940_ID]) + + # Process all calibration types + for cal_type, setter_method in [ + (CONF_CURRENT_CALIBRATION, "set_current_calibration_number"), + (CONF_VOLTAGE_CALIBRATION, "set_voltage_calibration_number"), + (CONF_POWER_CALIBRATION, "set_power_calibration_number"), + (CONF_ENERGY_CALIBRATION, "set_energy_calibration_number"), + ]: + if conf := config.get(cal_type): + var = await number.new_number( + conf, + min_value=conf.get(CONF_MIN_VALUE), + max_value=conf.get(CONF_MAX_VALUE), + step=conf.get(CONF_STEP), + ) + await cg.register_component(var, conf) + + if restore_value := config.get(CONF_RESTORE_VALUE): + cg.add(var.set_restore_value(restore_value)) + cg.add(getattr(bl0940, setter_method)(var)) diff --git a/esphome/components/bl0940/number/calibration_number.cpp b/esphome/components/bl0940/number/calibration_number.cpp new file mode 100644 index 0000000000..cdb26cd298 --- /dev/null +++ b/esphome/components/bl0940/number/calibration_number.cpp @@ -0,0 +1,29 @@ +#include "calibration_number.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace bl0940 { + +static const char *const TAG = "bl0940.number"; + +void CalibrationNumber::setup() { + float value = 0.0f; + if (this->restore_value_) { + this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + if (!this->pref_.load(&value)) { + value = 0.0f; + } + } + this->publish_state(value); +} + +void CalibrationNumber::control(float value) { + this->publish_state(value); + if (this->restore_value_) + this->pref_.save(&value); +} + +void CalibrationNumber::dump_config() { LOG_NUMBER("", "Calibration Number", this); } + +} // namespace bl0940 +} // namespace esphome diff --git a/esphome/components/bl0940/number/calibration_number.h b/esphome/components/bl0940/number/calibration_number.h new file mode 100644 index 0000000000..3a19e36dc9 --- /dev/null +++ b/esphome/components/bl0940/number/calibration_number.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "esphome/core/component.h" +#include "esphome/core/preferences.h" + +namespace esphome { +namespace bl0940 { + +class CalibrationNumber : public number::Number, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } + + protected: + void control(float value) override; + bool restore_value_{true}; + + ESPPreferenceObject pref_; +}; + +} // namespace bl0940 +} // namespace esphome diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index f49e961f0a..d2e0ea435d 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_ID, CONF_INTERNAL_TEMPERATURE, CONF_POWER, + CONF_REFERENCE_VOLTAGE, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -23,12 +24,133 @@ from esphome.const import ( UNIT_WATT, ) +from . import bl0940_ns + DEPENDENCIES = ["uart"] - -bl0940_ns = cg.esphome_ns.namespace("bl0940") BL0940 = bl0940_ns.class_("BL0940", cg.PollingComponent, uart.UARTDevice) +CONF_LEGACY_MODE = "legacy_mode" + +CONF_READ_COMMAND = "read_command" +CONF_WRITE_COMMAND = "write_command" + +CONF_RESISTOR_SHUNT = "resistor_shunt" +CONF_RESISTOR_ONE = "resistor_one" +CONF_RESISTOR_TWO = "resistor_two" + +CONF_CURRENT_REFERENCE = "current_reference" +CONF_ENERGY_REFERENCE = "energy_reference" +CONF_POWER_REFERENCE = "power_reference" +CONF_VOLTAGE_REFERENCE = "voltage_reference" + +DEFAULT_BL0940_READ_COMMAND = 0x58 +DEFAULT_BL0940_WRITE_COMMAND = 0xA1 + +# Values according to BL0940 application note: +# https://www.belling.com.cn/media/file_object/bel_product/BL0940/guide/BL0940_APPNote_TSSOP14_V1.04_EN.pdf +DEFAULT_BL0940_VREF = 1.218 # Vref = 1.218 +DEFAULT_BL0940_RL = 1 # RL = 1 mΩ +DEFAULT_BL0940_R1 = 0.51 # R1 = 0.51 kΩ +DEFAULT_BL0940_R2 = 1950 # R2 = 5 x 390 kΩ -> 1950 kΩ + +# ---------------------------------------------------- +# values from initial implementation +DEFAULT_BL0940_LEGACY_READ_COMMAND = 0x50 +DEFAULT_BL0940_LEGACY_WRITE_COMMAND = 0xA0 + +DEFAULT_BL0940_LEGACY_UREF = 33000 +DEFAULT_BL0940_LEGACY_IREF = 275000 +DEFAULT_BL0940_LEGACY_PREF = 1430 +# Measured to 297J per click according to power consumption of 5 minutes +# Converted to kWh (3.6MJ per kwH). Used to be 256 * 1638.4 +DEFAULT_BL0940_LEGACY_EREF = 3.6e6 / 297 +# ---------------------------------------------------- + + +# methods to calculate voltage and current reference values +def calculate_voltage_reference(vref, r_one, r_two): + # formula: 79931 / Vref * (R1 * 1000) / (R1 + R2) + return 79931 / vref * (r_one * 1000) / (r_one + r_two) + + +def calculate_current_reference(vref, r_shunt): + # formula: 324004 * RL / Vref + return 324004 * r_shunt / vref + + +def calculate_power_reference(voltage_reference, current_reference): + # calculate power reference based on voltage and current reference + return voltage_reference * current_reference * 4046 / 324004 / 79931 + + +def calculate_energy_reference(power_reference): + # formula: power_reference * 3600000 / (1638.4 * 256) + return power_reference * 3600000 / (1638.4 * 256) + + +def validate_legacy_mode(config): + # Only allow schematic calibration options if legacy_mode is False + if config.get(CONF_LEGACY_MODE, True): + forbidden = [ + CONF_REFERENCE_VOLTAGE, + CONF_RESISTOR_SHUNT, + CONF_RESISTOR_ONE, + CONF_RESISTOR_TWO, + ] + for key in forbidden: + if key in config: + raise cv.Invalid( + f"Option '{key}' is only allowed when legacy_mode: false" + ) + return config + + +def set_command_defaults(config): + # Set defaults for read_command and write_command based on legacy_mode + legacy = config.get(CONF_LEGACY_MODE, True) + if legacy: + config.setdefault(CONF_READ_COMMAND, DEFAULT_BL0940_LEGACY_READ_COMMAND) + config.setdefault(CONF_WRITE_COMMAND, DEFAULT_BL0940_LEGACY_WRITE_COMMAND) + else: + config.setdefault(CONF_READ_COMMAND, DEFAULT_BL0940_READ_COMMAND) + config.setdefault(CONF_WRITE_COMMAND, DEFAULT_BL0940_WRITE_COMMAND) + return config + + +def set_reference_values(config): + # Set default reference values based on legacy_mode + if config.get(CONF_LEGACY_MODE, True): + config.setdefault(CONF_VOLTAGE_REFERENCE, DEFAULT_BL0940_LEGACY_UREF) + config.setdefault(CONF_CURRENT_REFERENCE, DEFAULT_BL0940_LEGACY_IREF) + config.setdefault(CONF_POWER_REFERENCE, DEFAULT_BL0940_LEGACY_PREF) + config.setdefault(CONF_ENERGY_REFERENCE, DEFAULT_BL0940_LEGACY_PREF) + else: + vref = config.get(CONF_VOLTAGE_REFERENCE, DEFAULT_BL0940_VREF) + r_one = config.get(CONF_RESISTOR_ONE, DEFAULT_BL0940_R1) + r_two = config.get(CONF_RESISTOR_TWO, DEFAULT_BL0940_R2) + r_shunt = config.get(CONF_RESISTOR_SHUNT, DEFAULT_BL0940_RL) + + config.setdefault( + CONF_VOLTAGE_REFERENCE, calculate_voltage_reference(vref, r_one, r_two) + ) + config.setdefault( + CONF_CURRENT_REFERENCE, calculate_current_reference(vref, r_shunt) + ) + config.setdefault( + CONF_POWER_REFERENCE, + calculate_power_reference( + config.get(CONF_VOLTAGE_REFERENCE), config.get(CONF_CURRENT_REFERENCE) + ), + ) + config.setdefault( + CONF_ENERGY_REFERENCE, + calculate_energy_reference(config.get(CONF_POWER_REFERENCE)), + ) + + return config + + CONFIG_SCHEMA = ( cv.Schema( { @@ -69,10 +191,24 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_LEGACY_MODE, default=True): cv.boolean, + cv.Optional(CONF_READ_COMMAND): cv.hex_uint8_t, + cv.Optional(CONF_WRITE_COMMAND): cv.hex_uint8_t, + cv.Optional(CONF_REFERENCE_VOLTAGE): cv.float_, + cv.Optional(CONF_RESISTOR_SHUNT): cv.float_, + cv.Optional(CONF_RESISTOR_ONE): cv.float_, + cv.Optional(CONF_RESISTOR_TWO): cv.float_, + cv.Optional(CONF_CURRENT_REFERENCE): cv.float_, + cv.Optional(CONF_ENERGY_REFERENCE): cv.float_, + cv.Optional(CONF_POWER_REFERENCE): cv.float_, + cv.Optional(CONF_VOLTAGE_REFERENCE): cv.float_, } ) .extend(cv.polling_component_schema("60s")) .extend(uart.UART_DEVICE_SCHEMA) + .add_extra(validate_legacy_mode) + .add_extra(set_command_defaults) + .add_extra(set_reference_values) ) @@ -99,3 +235,16 @@ async def to_code(config): if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): sens = await sensor.new_sensor(external_temperature_config) cg.add(var.set_external_temperature_sensor(sens)) + + # enable legacy mode + cg.add(var.set_legacy_mode(config.get(CONF_LEGACY_MODE))) + + # Set bl0940 commands after validator has determined which defaults to use if not set + cg.add(var.set_read_command(config.get(CONF_READ_COMMAND))) + cg.add(var.set_write_command(config.get(CONF_WRITE_COMMAND))) + + # Set reference values after validator has set the values either from defaults or calculated + cg.add(var.set_current_reference(config.get(CONF_CURRENT_REFERENCE))) + cg.add(var.set_voltage_reference(config.get(CONF_VOLTAGE_REFERENCE))) + cg.add(var.set_power_reference(config.get(CONF_POWER_REFERENCE))) + cg.add(var.set_energy_reference(config.get(CONF_ENERGY_REFERENCE))) diff --git a/tests/components/bl0940/common.yaml b/tests/components/bl0940/common.yaml index 97a997d2b4..443f3b0ff0 100644 --- a/tests/components/bl0940/common.yaml +++ b/tests/components/bl0940/common.yaml @@ -4,8 +4,14 @@ uart: rx_pin: ${rx_pin} baud_rate: 9600 +button: + - platform: bl0940 + bl0940_id: test_id + name: Cal Reset + sensor: - platform: bl0940 + id: test_id voltage: name: BL0940 Voltage current: @@ -18,3 +24,18 @@ sensor: name: BL0940 Internal temperature external_temperature: name: BL0940 External temperature + +number: + - platform: bl0940 + id: bl0940_number_id + bl0940_id: test_id + current_calibration: + name: Cal Current + min_value: -5 + max_value: 5 + voltage_calibration: + name: Cal Voltage + step: 0.01 + power_calibration: + name: Cal Power + disabled_by_default: true From 972aa691e44b1ad946a7e74d92802cccbb0450e9 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 5 Sep 2025 01:37:57 -0500 Subject: [PATCH 1015/1626] [sen5x] Fix initialization (#10603) --- esphome/components/sen5x/sen5x.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 0f27ec1b10..f3222221a2 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -38,6 +38,7 @@ void SEN5XComponent::setup() { this->mark_failed(); return; } + delay(20); // per datasheet uint16_t raw_read_status; if (!this->read_data(raw_read_status)) { From 82d2e367d4c9e4b284c2f1f74433f64f0fa4d3b3 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 6 Sep 2025 13:51:44 +1000 Subject: [PATCH 1016/1626] [kmeteriso] Fix i2c call (#10618) --- esphome/components/kmeteriso/kmeteriso.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/kmeteriso/kmeteriso.cpp b/esphome/components/kmeteriso/kmeteriso.cpp index d20e07460b..36f6d74ba0 100644 --- a/esphome/components/kmeteriso/kmeteriso.cpp +++ b/esphome/components/kmeteriso/kmeteriso.cpp @@ -33,7 +33,7 @@ void KMeterISOComponent::setup() { } uint8_t read_buf[4] = {1}; - if (!this->read_register(KMETER_ERROR_STATUS_REG, read_buf, 1)) { + if (!this->read_bytes(KMETER_ERROR_STATUS_REG, read_buf, 1)) { ESP_LOGCONFIG(TAG, "Could not read from the device."); this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(); From 7969627d3eab1720e5856d3686c3e095daf48632 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 9 Sep 2025 01:10:29 +0200 Subject: [PATCH 1017/1626] [light] add missing header (#10590) --- esphome/components/light/light_state.h | 1 + .../components/light/test.nrf52-adafruit.yaml | 19 +++++++++++++++++++ tests/components/light/test.nrf52-mcumgr.yaml | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 tests/components/light/test.nrf52-adafruit.yaml create mode 100644 tests/components/light/test.nrf52-mcumgr.yaml diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 94b81dee61..f25f870715 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -12,6 +12,7 @@ #include "light_transformer.h" #include +#include namespace esphome { namespace light { diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..cb421ed4bb --- /dev/null +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -0,0 +1,19 @@ +esphome: + on_boot: + then: + - light.toggle: test_binary_light + +output: + - platform: gpio + id: test_binary + pin: 0 + +light: + - platform: binary + id: test_binary_light + name: Binary Light + output: test_binary + effects: + - strobe: + on_state: + - logger.log: Binary light state changed diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..cb421ed4bb --- /dev/null +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -0,0 +1,19 @@ +esphome: + on_boot: + then: + - light.toggle: test_binary_light + +output: + - platform: gpio + id: test_binary + pin: 0 + +light: + - platform: binary + id: test_binary_light + name: Binary Light + output: test_binary + effects: + - strobe: + on_state: + - logger.log: Binary light state changed From 9d7fc111089fee63720e6b36f20481aaef0c2f7a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 10 Sep 2025 13:56:50 +1200 Subject: [PATCH 1018/1626] Bump version to 2025.8.4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index ab6a0e8252..956cf7c97a 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.8.3 +PROJECT_NUMBER = 2025.8.4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 5ed9092454..49b9a83c7f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.8.3" +__version__ = "2025.8.4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 52a7e26c6de63cabb0758027b05121958fd6bbbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josip=20=C5=A0imun=20Ku=C4=8Di?= <131808461+JosipKuci@users.noreply.github.com> Date: Wed, 10 Sep 2025 08:46:58 +0200 Subject: [PATCH 1019/1626] [inkplate] Rename component and fix grayscale (#10200) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 2 +- esphome/components/inkplate/__init__.py | 1 + esphome/components/inkplate/const.py | 105 ++++++++ esphome/components/inkplate/display.py | 238 ++++++++++++++++++ .../{inkplate6 => inkplate}/inkplate.cpp | 230 +++++++++++------ .../{inkplate6 => inkplate}/inkplate.h | 96 +++---- esphome/components/inkplate6/__init__.py | 1 - esphome/components/inkplate6/display.py | 215 +--------------- .../{inkplate6 => inkplate}/common.yaml | 4 +- .../test.esp32-ard.yaml | 0 .../test.esp32-idf.yaml | 0 11 files changed, 534 insertions(+), 358 deletions(-) create mode 100644 esphome/components/inkplate/__init__.py create mode 100644 esphome/components/inkplate/const.py create mode 100644 esphome/components/inkplate/display.py rename esphome/components/{inkplate6 => inkplate}/inkplate.cpp (82%) rename esphome/components/{inkplate6 => inkplate}/inkplate.h (56%) rename tests/components/{inkplate6 => inkplate}/common.yaml (96%) rename tests/components/{inkplate6 => inkplate}/test.esp32-ard.yaml (100%) rename tests/components/{inkplate6 => inkplate}/test.esp32-idf.yaml (100%) diff --git a/CODEOWNERS b/CODEOWNERS index acf8acb2ab..dc567ca5c0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -234,7 +234,7 @@ esphome/components/ina2xx_base/* @latonita esphome/components/ina2xx_i2c/* @latonita esphome/components/ina2xx_spi/* @latonita esphome/components/inkbird_ibsth1_mini/* @fkirill -esphome/components/inkplate6/* @jesserockz +esphome/components/inkplate/* @jesserockz @JosipKuci esphome/components/integration/* @OttoWinter esphome/components/internal_temperature/* @Mat931 esphome/components/interval/* @esphome/core diff --git a/esphome/components/inkplate/__init__.py b/esphome/components/inkplate/__init__.py new file mode 100644 index 0000000000..1c6013793a --- /dev/null +++ b/esphome/components/inkplate/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@jesserockz", "@JosipKuci"] diff --git a/esphome/components/inkplate/const.py b/esphome/components/inkplate/const.py new file mode 100644 index 0000000000..77bf933320 --- /dev/null +++ b/esphome/components/inkplate/const.py @@ -0,0 +1,105 @@ +WAVEFORMS = { + "inkplate_6": ( + (0, 1, 1, 0, 0, 1, 1, 0, 0), + (0, 1, 2, 1, 1, 2, 1, 0, 0), + (1, 1, 1, 2, 2, 1, 0, 0, 0), + (0, 0, 0, 1, 1, 1, 2, 0, 0), + (2, 1, 1, 1, 2, 1, 2, 0, 0), + (2, 2, 1, 1, 2, 1, 2, 0, 0), + (1, 1, 1, 2, 1, 2, 2, 0, 0), + (0, 0, 0, 0, 0, 0, 2, 0, 0), + ), + "inkplate_10": ( + (0, 0, 0, 0, 0, 0, 0, 1, 0), + (0, 0, 0, 2, 2, 2, 1, 1, 0), + (0, 0, 2, 1, 1, 2, 2, 1, 0), + (0, 1, 2, 2, 1, 2, 2, 1, 0), + (0, 0, 2, 1, 2, 2, 2, 1, 0), + (0, 2, 2, 2, 2, 2, 2, 1, 0), + (0, 0, 0, 0, 0, 2, 1, 2, 0), + (0, 0, 0, 2, 2, 2, 2, 2, 0), + ), + "inkplate_6_plus": ( + (0, 0, 0, 0, 0, 2, 1, 1, 0), + (0, 0, 2, 1, 1, 1, 2, 1, 0), + (0, 2, 2, 2, 1, 1, 2, 1, 0), + (0, 0, 2, 2, 2, 1, 2, 1, 0), + (0, 0, 0, 0, 2, 2, 2, 1, 0), + (0, 0, 2, 1, 2, 1, 1, 2, 0), + (0, 0, 2, 2, 2, 1, 1, 2, 0), + (0, 0, 0, 0, 2, 2, 2, 2, 0), + ), + "inkplate_6_v2": ( + (1, 0, 1, 0, 1, 1, 1, 0, 0), + (0, 0, 0, 1, 1, 1, 1, 0, 0), + (1, 1, 1, 1, 0, 2, 1, 0, 0), + (1, 1, 1, 2, 2, 1, 1, 0, 0), + (1, 1, 1, 1, 2, 2, 1, 0, 0), + (0, 1, 1, 1, 2, 2, 1, 0, 0), + (0, 0, 0, 0, 1, 1, 2, 0, 0), + (0, 0, 0, 0, 0, 1, 2, 0, 0), + ), + "inkplate_5": ( + (0, 0, 1, 1, 0, 1, 1, 1, 0), + (0, 1, 1, 1, 1, 2, 0, 1, 0), + (1, 2, 2, 0, 2, 1, 1, 1, 0), + (1, 1, 1, 2, 0, 1, 1, 2, 0), + (0, 1, 1, 1, 2, 0, 1, 2, 0), + (0, 0, 0, 1, 1, 2, 1, 2, 0), + (1, 1, 1, 2, 0, 2, 1, 2, 0), + (0, 0, 0, 0, 0, 0, 0, 0, 0), + ), + "inkplate_5_v2": ( + (0, 0, 1, 1, 2, 1, 1, 1, 0), + (1, 1, 2, 2, 1, 2, 1, 1, 0), + (0, 1, 2, 2, 1, 1, 2, 1, 0), + (0, 0, 1, 1, 1, 1, 1, 2, 0), + (1, 2, 1, 2, 1, 1, 1, 2, 0), + (0, 1, 1, 1, 2, 0, 1, 2, 0), + (1, 1, 1, 2, 2, 2, 1, 2, 0), + (0, 0, 0, 0, 0, 0, 0, 0, 0), + ), +} + +INKPLATE_10_CUSTOM_WAVEFORMS = ( + ( + (0, 0, 0, 0, 0, 0, 0, 0, 0), + (0, 0, 0, 2, 1, 2, 1, 1, 0), + (0, 0, 0, 2, 2, 1, 2, 1, 0), + (0, 0, 2, 2, 1, 2, 2, 1, 0), + (0, 0, 0, 2, 1, 1, 1, 2, 0), + (0, 0, 2, 2, 2, 1, 1, 2, 0), + (0, 0, 0, 0, 0, 1, 2, 2, 0), + (0, 0, 0, 0, 2, 2, 2, 2, 0), + ), + ( + (0, 3, 3, 3, 3, 3, 3, 3, 0), + (0, 1, 2, 1, 1, 2, 2, 1, 0), + (0, 2, 2, 2, 1, 2, 2, 1, 0), + (0, 0, 2, 2, 2, 2, 2, 1, 0), + (0, 3, 3, 2, 1, 1, 1, 2, 0), + (0, 3, 3, 2, 2, 1, 1, 2, 0), + (0, 2, 1, 2, 1, 2, 1, 2, 0), + (0, 3, 3, 3, 2, 2, 2, 2, 0), + ), + ( + (0, 0, 0, 0, 0, 0, 0, 1, 0), + (0, 0, 0, 2, 2, 2, 1, 1, 0), + (0, 0, 2, 1, 1, 2, 2, 1, 0), + (1, 1, 2, 2, 1, 2, 2, 1, 0), + (0, 0, 2, 1, 2, 2, 2, 1, 0), + (0, 1, 2, 2, 2, 2, 2, 1, 0), + (0, 0, 0, 2, 2, 2, 1, 2, 0), + (0, 0, 0, 2, 2, 2, 2, 2, 0), + ), + ( + (0, 0, 0, 0, 0, 0, 0, 1, 0), + (0, 0, 0, 2, 2, 2, 1, 1, 0), + (2, 2, 2, 1, 0, 2, 1, 0, 0), + (2, 1, 1, 2, 1, 1, 1, 2, 0), + (2, 2, 2, 1, 1, 1, 0, 2, 0), + (2, 2, 2, 1, 1, 2, 1, 2, 0), + (0, 0, 0, 0, 2, 1, 2, 2, 0), + (0, 0, 0, 0, 2, 2, 2, 2, 0), + ), +) diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py new file mode 100644 index 0000000000..a0b0265cf1 --- /dev/null +++ b/esphome/components/inkplate/display.py @@ -0,0 +1,238 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import display, i2c +from esphome.components.esp32 import CONF_CPU_FREQUENCY +import esphome.config_validation as cv +from esphome.const import ( + CONF_FULL_UPDATE_EVERY, + CONF_ID, + CONF_LAMBDA, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_MODEL, + CONF_OE_PIN, + CONF_PAGES, + CONF_TRANSFORM, + CONF_WAKEUP_PIN, + PLATFORM_ESP32, +) +import esphome.final_validate as fv + +from .const import INKPLATE_10_CUSTOM_WAVEFORMS, WAVEFORMS + +DEPENDENCIES = ["i2c", "esp32"] +AUTO_LOAD = ["psram"] + +CONF_DISPLAY_DATA_0_PIN = "display_data_0_pin" +CONF_DISPLAY_DATA_1_PIN = "display_data_1_pin" +CONF_DISPLAY_DATA_2_PIN = "display_data_2_pin" +CONF_DISPLAY_DATA_3_PIN = "display_data_3_pin" +CONF_DISPLAY_DATA_4_PIN = "display_data_4_pin" +CONF_DISPLAY_DATA_5_PIN = "display_data_5_pin" +CONF_DISPLAY_DATA_6_PIN = "display_data_6_pin" +CONF_DISPLAY_DATA_7_PIN = "display_data_7_pin" + +CONF_CL_PIN = "cl_pin" +CONF_CKV_PIN = "ckv_pin" +CONF_GREYSCALE = "greyscale" +CONF_GMOD_PIN = "gmod_pin" +CONF_GPIO0_ENABLE_PIN = "gpio0_enable_pin" +CONF_LE_PIN = "le_pin" +CONF_PARTIAL_UPDATING = "partial_updating" +CONF_POWERUP_PIN = "powerup_pin" +CONF_SPH_PIN = "sph_pin" +CONF_SPV_PIN = "spv_pin" +CONF_VCOM_PIN = "vcom_pin" + +inkplate_ns = cg.esphome_ns.namespace("inkplate") +Inkplate = inkplate_ns.class_( + "Inkplate", + cg.PollingComponent, + i2c.I2CDevice, + display.Display, + display.DisplayBuffer, +) + +InkplateModel = inkplate_ns.enum("InkplateModel") + +MODELS = { + "inkplate_6": InkplateModel.INKPLATE_6, + "inkplate_10": InkplateModel.INKPLATE_10, + "inkplate_6_plus": InkplateModel.INKPLATE_6_PLUS, + "inkplate_6_v2": InkplateModel.INKPLATE_6_V2, + "inkplate_5": InkplateModel.INKPLATE_5, + "inkplate_5_v2": InkplateModel.INKPLATE_5_V2, +} + +CONF_CUSTOM_WAVEFORM = "custom_waveform" + + +def _validate_custom_waveform(config): + if CONF_CUSTOM_WAVEFORM in config and config[CONF_MODEL] != "inkplate_10": + raise cv.Invalid("Custom waveforms are only supported on the Inkplate 10") + return config + + +CONFIG_SCHEMA = cv.All( + display.FULL_DISPLAY_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(Inkplate), + cv.Optional(CONF_GREYSCALE, default=False): cv.boolean, + cv.Optional(CONF_CUSTOM_WAVEFORM): cv.All( + cv.uint8_t, cv.Range(min=1, max=len(INKPLATE_10_CUSTOM_WAVEFORMS)) + ), + cv.Optional(CONF_TRANSFORM): cv.Schema( + { + cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, + cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean, + } + ), + cv.Optional(CONF_PARTIAL_UPDATING, default=True): cv.boolean, + cv.Optional(CONF_FULL_UPDATE_EVERY, default=10): cv.uint32_t, + cv.Optional(CONF_MODEL, default="inkplate_6"): cv.enum( + MODELS, lower=True, space="_" + ), + # Control pins + cv.Required(CONF_CKV_PIN): pins.gpio_output_pin_schema, + cv.Required(CONF_GMOD_PIN): pins.gpio_output_pin_schema, + cv.Required(CONF_GPIO0_ENABLE_PIN): pins.gpio_output_pin_schema, + cv.Required(CONF_OE_PIN): pins.gpio_output_pin_schema, + cv.Required(CONF_POWERUP_PIN): pins.gpio_output_pin_schema, + cv.Required(CONF_SPH_PIN): pins.gpio_output_pin_schema, + cv.Required(CONF_SPV_PIN): pins.gpio_output_pin_schema, + cv.Required(CONF_VCOM_PIN): pins.gpio_output_pin_schema, + cv.Required(CONF_WAKEUP_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_CL_PIN, default=0): pins.internal_gpio_output_pin_schema, + cv.Optional(CONF_LE_PIN, default=2): pins.internal_gpio_output_pin_schema, + # Data pins + cv.Optional( + CONF_DISPLAY_DATA_0_PIN, default=4 + ): pins.internal_gpio_output_pin_schema, + cv.Optional( + CONF_DISPLAY_DATA_1_PIN, default=5 + ): pins.internal_gpio_output_pin_schema, + cv.Optional( + CONF_DISPLAY_DATA_2_PIN, default=18 + ): pins.internal_gpio_output_pin_schema, + cv.Optional( + CONF_DISPLAY_DATA_3_PIN, default=19 + ): pins.internal_gpio_output_pin_schema, + cv.Optional( + CONF_DISPLAY_DATA_4_PIN, default=23 + ): pins.internal_gpio_output_pin_schema, + cv.Optional( + CONF_DISPLAY_DATA_5_PIN, default=25 + ): pins.internal_gpio_output_pin_schema, + cv.Optional( + CONF_DISPLAY_DATA_6_PIN, default=26 + ): pins.internal_gpio_output_pin_schema, + cv.Optional( + CONF_DISPLAY_DATA_7_PIN, default=27 + ): pins.internal_gpio_output_pin_schema, + } + ) + .extend(cv.polling_component_schema("5s")) + .extend(i2c.i2c_device_schema(0x48)), + cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA), + _validate_custom_waveform, +) + + +def _validate_cpu_frequency(config): + esp32_config = fv.full_config.get()[PLATFORM_ESP32] + if esp32_config[CONF_CPU_FREQUENCY] != "240MHZ": + raise cv.Invalid( + "Inkplate requires 240MHz CPU frequency (set in esp32 component)" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _validate_cpu_frequency + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + + await display.register_display(var, config) + await i2c.register_i2c_device(var, config) + + if CONF_LAMBDA in config: + lambda_ = await cg.process_lambda( + config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) + + cg.add(var.set_greyscale(config[CONF_GREYSCALE])) + if transform := config.get(CONF_TRANSFORM): + cg.add(var.set_mirror_x(transform[CONF_MIRROR_X])) + cg.add(var.set_mirror_y(transform[CONF_MIRROR_Y])) + cg.add(var.set_partial_updating(config[CONF_PARTIAL_UPDATING])) + cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) + + cg.add(var.set_model(config[CONF_MODEL])) + + if custom_waveform := config.get(CONF_CUSTOM_WAVEFORM): + waveform = INKPLATE_10_CUSTOM_WAVEFORMS[custom_waveform - 1] + waveform = [element for tupl in waveform for element in tupl] + cg.add(var.set_waveform(waveform, True)) + else: + waveform = WAVEFORMS[config[CONF_MODEL]] + waveform = [element for tupl in waveform for element in tupl] + cg.add(var.set_waveform(waveform, False)) + + ckv = await cg.gpio_pin_expression(config[CONF_CKV_PIN]) + cg.add(var.set_ckv_pin(ckv)) + + gmod = await cg.gpio_pin_expression(config[CONF_GMOD_PIN]) + cg.add(var.set_gmod_pin(gmod)) + + gpio0_enable = await cg.gpio_pin_expression(config[CONF_GPIO0_ENABLE_PIN]) + cg.add(var.set_gpio0_enable_pin(gpio0_enable)) + + oe = await cg.gpio_pin_expression(config[CONF_OE_PIN]) + cg.add(var.set_oe_pin(oe)) + + powerup = await cg.gpio_pin_expression(config[CONF_POWERUP_PIN]) + cg.add(var.set_powerup_pin(powerup)) + + sph = await cg.gpio_pin_expression(config[CONF_SPH_PIN]) + cg.add(var.set_sph_pin(sph)) + + spv = await cg.gpio_pin_expression(config[CONF_SPV_PIN]) + cg.add(var.set_spv_pin(spv)) + + vcom = await cg.gpio_pin_expression(config[CONF_VCOM_PIN]) + cg.add(var.set_vcom_pin(vcom)) + + wakeup = await cg.gpio_pin_expression(config[CONF_WAKEUP_PIN]) + cg.add(var.set_wakeup_pin(wakeup)) + + cl = await cg.gpio_pin_expression(config[CONF_CL_PIN]) + cg.add(var.set_cl_pin(cl)) + + le = await cg.gpio_pin_expression(config[CONF_LE_PIN]) + cg.add(var.set_le_pin(le)) + + display_data_0 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_0_PIN]) + cg.add(var.set_display_data_0_pin(display_data_0)) + + display_data_1 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_1_PIN]) + cg.add(var.set_display_data_1_pin(display_data_1)) + + display_data_2 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_2_PIN]) + cg.add(var.set_display_data_2_pin(display_data_2)) + + display_data_3 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_3_PIN]) + cg.add(var.set_display_data_3_pin(display_data_3)) + + display_data_4 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_4_PIN]) + cg.add(var.set_display_data_4_pin(display_data_4)) + + display_data_5 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_5_PIN]) + cg.add(var.set_display_data_5_pin(display_data_5)) + + display_data_6 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_6_PIN]) + cg.add(var.set_display_data_6_pin(display_data_6)) + + display_data_7 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_7_PIN]) + cg.add(var.set_display_data_7_pin(display_data_7)) diff --git a/esphome/components/inkplate6/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp similarity index 82% rename from esphome/components/inkplate6/inkplate.cpp rename to esphome/components/inkplate/inkplate.cpp index b3d0b87e83..f96fb6905e 100644 --- a/esphome/components/inkplate6/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -6,11 +6,11 @@ #include namespace esphome { -namespace inkplate6 { +namespace inkplate { static const char *const TAG = "inkplate"; -void Inkplate6::setup() { +void Inkplate::setup() { for (uint32_t i = 0; i < 256; i++) { this->pin_lut_[i] = ((i & 0b00000011) << 4) | (((i & 0b00001100) >> 2) << 18) | (((i & 0b00010000) >> 4) << 23) | (((i & 0b11100000) >> 5) << 25); @@ -56,7 +56,7 @@ void Inkplate6::setup() { /** * Allocate buffers. May be called after setup to re-initialise if e.g. greyscale is changed. */ -void Inkplate6::initialize_() { +void Inkplate::initialize_() { RAMAllocator allocator; RAMAllocator allocator32; uint32_t buffer_size = this->get_buffer_length_(); @@ -81,29 +81,25 @@ void Inkplate6::initialize_() { return; } if (this->greyscale_) { - uint8_t glut_size = 9; - - this->glut_ = allocator32.allocate(256 * glut_size); + this->glut_ = allocator32.allocate(256 * GLUT_SIZE); if (this->glut_ == nullptr) { ESP_LOGE(TAG, "Could not allocate glut!"); this->mark_failed(); return; } - this->glut2_ = allocator32.allocate(256 * glut_size); + this->glut2_ = allocator32.allocate(256 * GLUT_SIZE); if (this->glut2_ == nullptr) { ESP_LOGE(TAG, "Could not allocate glut2!"); this->mark_failed(); return; } - const auto *const waveform3_bit = waveform3BitAll[this->model_]; - - for (int i = 0; i < glut_size; i++) { + for (uint8_t i = 0; i < GLUT_SIZE; i++) { for (uint32_t j = 0; j < 256; j++) { - uint8_t z = (waveform3_bit[j & 0x07][i] << 2) | (waveform3_bit[(j >> 4) & 0x07][i]); + uint8_t z = (this->waveform_[j & 0x07][i] << 2) | (this->waveform_[(j >> 4) & 0x07][i]); this->glut_[i * 256 + j] = ((z & 0b00000011) << 4) | (((z & 0b00001100) >> 2) << 18) | (((z & 0b00010000) >> 4) << 23) | (((z & 0b11100000) >> 5) << 25); - z = ((waveform3_bit[j & 0x07][i] << 2) | (waveform3_bit[(j >> 4) & 0x07][i])) << 4; + z = ((this->waveform_[j & 0x07][i] << 2) | (this->waveform_[(j >> 4) & 0x07][i])) << 4; this->glut2_[i * 256 + j] = ((z & 0b00000011) << 4) | (((z & 0b00001100) >> 2) << 18) | (((z & 0b00010000) >> 4) << 23) | (((z & 0b11100000) >> 5) << 25); } @@ -130,9 +126,9 @@ void Inkplate6::initialize_() { memset(this->buffer_, 0, buffer_size); } -float Inkplate6::get_setup_priority() const { return setup_priority::PROCESSOR; } +float Inkplate::get_setup_priority() const { return setup_priority::PROCESSOR; } -size_t Inkplate6::get_buffer_length_() { +size_t Inkplate::get_buffer_length_() { if (this->greyscale_) { return size_t(this->get_width_internal()) * size_t(this->get_height_internal()) / 2u; } else { @@ -140,7 +136,7 @@ size_t Inkplate6::get_buffer_length_() { } } -void Inkplate6::update() { +void Inkplate::update() { this->do_update_(); if (this->full_update_every_ > 0 && this->partial_updates_ >= this->full_update_every_) { @@ -150,7 +146,7 @@ void Inkplate6::update() { this->display(); } -void HOT Inkplate6::draw_absolute_pixel_internal(int x, int y, Color color) { +void HOT Inkplate::draw_absolute_pixel_internal(int x, int y, Color color) { if (x >= this->get_width_internal() || y >= this->get_height_internal() || x < 0 || y < 0) return; @@ -171,18 +167,18 @@ void HOT Inkplate6::draw_absolute_pixel_internal(int x, int y, Color color) { // uint8_t gs = (uint8_t)(px*7); uint8_t gs = ((color.red * 2126 / 10000) + (color.green * 7152 / 10000) + (color.blue * 722 / 10000)) >> 5; - this->buffer_[pos] = (pixelMaskGLUT[x_sub] & current) | (x_sub ? gs : gs << 4); + this->buffer_[pos] = (PIXEL_MASK_GLUT[x_sub] & current) | (x_sub ? gs : gs << 4); } else { int x1 = x / 8; int x_sub = x % 8; uint32_t pos = (x1 + y * (this->get_width_internal() / 8)); uint8_t current = this->partial_buffer_[pos]; - this->partial_buffer_[pos] = (~pixelMaskLUT[x_sub] & current) | (color.is_on() ? 0 : pixelMaskLUT[x_sub]); + this->partial_buffer_[pos] = (~PIXEL_MASK_LUT[x_sub] & current) | (color.is_on() ? 0 : PIXEL_MASK_LUT[x_sub]); } } -void Inkplate6::dump_config() { +void Inkplate::dump_config() { LOG_DISPLAY("", "Inkplate", this); ESP_LOGCONFIG(TAG, " Greyscale: %s\n" @@ -214,7 +210,7 @@ void Inkplate6::dump_config() { LOG_UPDATE_INTERVAL(this); } -void Inkplate6::eink_off_() { +void Inkplate::eink_off_() { ESP_LOGV(TAG, "Eink off called"); if (!panel_on_) return; @@ -242,7 +238,7 @@ void Inkplate6::eink_off_() { pins_z_state_(); } -void Inkplate6::eink_on_() { +void Inkplate::eink_on_() { ESP_LOGV(TAG, "Eink on called"); if (panel_on_) return; @@ -284,7 +280,7 @@ void Inkplate6::eink_on_() { this->oe_pin_->digital_write(true); } -bool Inkplate6::read_power_status_() { +bool Inkplate::read_power_status_() { uint8_t data; auto err = this->read_register(0x0F, &data, 1); if (err == i2c::ERROR_OK) { @@ -293,7 +289,7 @@ bool Inkplate6::read_power_status_() { return false; } -void Inkplate6::fill(Color color) { +void Inkplate::fill(Color color) { ESP_LOGV(TAG, "Fill called"); uint32_t start_time = millis(); @@ -308,7 +304,7 @@ void Inkplate6::fill(Color color) { ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time); } -void Inkplate6::display() { +void Inkplate::display() { ESP_LOGV(TAG, "Display called"); uint32_t start_time = millis(); @@ -324,7 +320,7 @@ void Inkplate6::display() { ESP_LOGV(TAG, "Display finished (full) (%ums)", millis() - start_time); } -void Inkplate6::display1b_() { +void Inkplate::display1b_() { ESP_LOGV(TAG, "Display1b called"); uint32_t start_time = millis(); @@ -334,32 +330,71 @@ void Inkplate6::display1b_() { uint8_t buffer_value; const uint8_t *buffer_ptr; eink_on_(); - if (this->model_ == INKPLATE_6_PLUS) { - clean_fast_(0, 1); - clean_fast_(1, 15); - clean_fast_(2, 1); - clean_fast_(0, 5); - clean_fast_(2, 1); - clean_fast_(1, 15); - } else { - clean_fast_(0, 1); - clean_fast_(1, 21); - clean_fast_(2, 1); - clean_fast_(0, 12); - clean_fast_(2, 1); - clean_fast_(1, 21); - clean_fast_(2, 1); - clean_fast_(0, 12); - clean_fast_(2, 1); + uint8_t rep = 4; + switch (this->model_) { + case INKPLATE_10: + clean_fast_(0, 1); + clean_fast_(1, 10); + clean_fast_(2, 1); + clean_fast_(0, 10); + clean_fast_(2, 1); + clean_fast_(1, 10); + clean_fast_(2, 1); + clean_fast_(0, 10); + rep = 5; + break; + case INKPLATE_6_PLUS: + clean_fast_(0, 1); + clean_fast_(1, 15); + clean_fast_(2, 1); + clean_fast_(0, 5); + clean_fast_(2, 1); + clean_fast_(1, 15); + break; + case INKPLATE_6: + case INKPLATE_6_V2: + clean_fast_(0, 1); + clean_fast_(1, 18); + clean_fast_(2, 1); + clean_fast_(0, 18); + clean_fast_(2, 1); + clean_fast_(1, 18); + clean_fast_(2, 1); + clean_fast_(0, 18); + clean_fast_(2, 1); + if (this->model_ == INKPLATE_6_V2) + rep = 5; + break; + case INKPLATE_5: + clean_fast_(0, 1); + clean_fast_(1, 14); + clean_fast_(2, 1); + clean_fast_(0, 14); + clean_fast_(2, 1); + clean_fast_(1, 14); + clean_fast_(2, 1); + clean_fast_(0, 14); + clean_fast_(2, 1); + rep = 5; + break; + case INKPLATE_5_V2: + clean_fast_(0, 1); + clean_fast_(1, 11); + clean_fast_(2, 1); + clean_fast_(0, 11); + clean_fast_(2, 1); + clean_fast_(1, 11); + clean_fast_(2, 1); + clean_fast_(0, 11); + rep = 3; + break; } uint32_t clock = (1 << this->cl_pin_->get_pin()); uint32_t data_mask = this->get_data_pin_mask_(); ESP_LOGV(TAG, "Display1b start loops (%ums)", millis() - start_time); - int rep = (this->model_ == INKPLATE_6_V2) ? 5 : 4; - - for (int k = 0; k < rep; k++) { + for (uint8_t k = 0; k < rep; k++) { buffer_ptr = &this->buffer_[this->get_buffer_length_() - 1]; vscan_start_(); for (int i = 0, im = this->get_height_internal(); i < im; i++) { @@ -452,28 +487,75 @@ void Inkplate6::display1b_() { ESP_LOGV(TAG, "Display1b finished (%ums)", millis() - start_time); } -void Inkplate6::display3b_() { +void Inkplate::display3b_() { ESP_LOGV(TAG, "Display3b called"); uint32_t start_time = millis(); eink_on_(); - if (this->model_ == INKPLATE_6_PLUS) { - clean_fast_(0, 1); - clean_fast_(1, 15); - clean_fast_(2, 1); - clean_fast_(0, 5); - clean_fast_(2, 1); - clean_fast_(1, 15); - } else { - clean_fast_(0, 1); - clean_fast_(1, 21); - clean_fast_(2, 1); - clean_fast_(0, 12); - clean_fast_(2, 1); - clean_fast_(1, 21); - clean_fast_(2, 1); - clean_fast_(0, 12); - clean_fast_(2, 1); + + switch (this->model_) { + case INKPLATE_10: + if (this->custom_waveform_) { + clean_fast_(1, 1); + clean_fast_(0, 7); + clean_fast_(2, 1); + clean_fast_(1, 12); + clean_fast_(2, 1); + clean_fast_(0, 7); + clean_fast_(2, 1); + clean_fast_(1, 12); + } else { + clean_fast_(1, 1); + clean_fast_(0, 10); + clean_fast_(2, 1); + clean_fast_(1, 10); + clean_fast_(2, 1); + clean_fast_(0, 10); + clean_fast_(2, 1); + clean_fast_(1, 10); + } + break; + case INKPLATE_6_PLUS: + clean_fast_(0, 1); + clean_fast_(1, 15); + clean_fast_(2, 1); + clean_fast_(0, 5); + clean_fast_(2, 1); + clean_fast_(1, 15); + break; + case INKPLATE_6: + case INKPLATE_6_V2: + clean_fast_(0, 1); + clean_fast_(1, 18); + clean_fast_(2, 1); + clean_fast_(0, 18); + clean_fast_(2, 1); + clean_fast_(1, 18); + clean_fast_(2, 1); + clean_fast_(0, 18); + clean_fast_(2, 1); + break; + case INKPLATE_5: + clean_fast_(0, 1); + clean_fast_(1, 14); + clean_fast_(2, 1); + clean_fast_(0, 14); + clean_fast_(2, 1); + clean_fast_(1, 14); + clean_fast_(2, 1); + clean_fast_(0, 14); + clean_fast_(2, 1); + break; + case INKPLATE_5_V2: + clean_fast_(0, 1); + clean_fast_(1, 11); + clean_fast_(2, 1); + clean_fast_(0, 11); + clean_fast_(2, 1); + clean_fast_(1, 11); + clean_fast_(2, 1); + clean_fast_(0, 11); + break; } uint32_t clock = (1 << this->cl_pin_->get_pin()); @@ -518,7 +600,7 @@ void Inkplate6::display3b_() { ESP_LOGV(TAG, "Display3b finished (%ums)", millis() - start_time); } -bool Inkplate6::partial_update_() { +bool Inkplate::partial_update_() { ESP_LOGV(TAG, "Partial update called"); uint32_t start_time = millis(); if (this->greyscale_) @@ -560,7 +642,7 @@ bool Inkplate6::partial_update_() { GPIO.out_w1ts = this->pin_lut_[data] | clock; GPIO.out_w1tc = data_mask | clock; } - // New Inkplate6 panel doesn't need last clock + // New Inkplate panel doesn't need last clock if (this->model_ != INKPLATE_6_V2) { GPIO.out_w1ts = clock; GPIO.out_w1tc = data_mask | clock; @@ -580,7 +662,7 @@ bool Inkplate6::partial_update_() { return true; } -void Inkplate6::vscan_start_() { +void Inkplate::vscan_start_() { this->ckv_pin_->digital_write(true); delayMicroseconds(7); this->spv_pin_->digital_write(false); @@ -604,7 +686,7 @@ void Inkplate6::vscan_start_() { this->ckv_pin_->digital_write(true); } -void Inkplate6::hscan_start_(uint32_t d) { +void Inkplate::hscan_start_(uint32_t d) { uint8_t clock = (1 << this->cl_pin_->get_pin()); this->sph_pin_->digital_write(false); GPIO.out_w1ts = d | clock; @@ -613,14 +695,14 @@ void Inkplate6::hscan_start_(uint32_t d) { this->ckv_pin_->digital_write(true); } -void Inkplate6::vscan_end_() { +void Inkplate::vscan_end_() { this->ckv_pin_->digital_write(false); this->le_pin_->digital_write(true); this->le_pin_->digital_write(false); delayMicroseconds(0); } -void Inkplate6::clean() { +void Inkplate::clean() { ESP_LOGV(TAG, "Clean called"); uint32_t start_time = millis(); @@ -634,7 +716,7 @@ void Inkplate6::clean() { ESP_LOGV(TAG, "Clean finished (%ums)", millis() - start_time); } -void Inkplate6::clean_fast_(uint8_t c, uint8_t rep) { +void Inkplate::clean_fast_(uint8_t c, uint8_t rep) { ESP_LOGV(TAG, "Clean fast called with: (%d, %d)", c, rep); uint32_t start_time = millis(); @@ -666,7 +748,7 @@ void Inkplate6::clean_fast_(uint8_t c, uint8_t rep) { GPIO.out_w1ts = clock; GPIO.out_w1tc = clock; } - // New Inkplate6 panel doesn't need last clock + // New Inkplate panel doesn't need last clock if (this->model_ != INKPLATE_6_V2) { GPIO.out_w1ts = send | clock; GPIO.out_w1tc = clock; @@ -679,7 +761,7 @@ void Inkplate6::clean_fast_(uint8_t c, uint8_t rep) { ESP_LOGV(TAG, "Clean fast finished (%ums)", millis() - start_time); } -void Inkplate6::pins_z_state_() { +void Inkplate::pins_z_state_() { this->cl_pin_->pin_mode(gpio::FLAG_INPUT); this->le_pin_->pin_mode(gpio::FLAG_INPUT); this->ckv_pin_->pin_mode(gpio::FLAG_INPUT); @@ -699,7 +781,7 @@ void Inkplate6::pins_z_state_() { this->display_data_7_pin_->pin_mode(gpio::FLAG_INPUT); } -void Inkplate6::pins_as_outputs_() { +void Inkplate::pins_as_outputs_() { this->cl_pin_->pin_mode(gpio::FLAG_OUTPUT); this->le_pin_->pin_mode(gpio::FLAG_OUTPUT); this->ckv_pin_->pin_mode(gpio::FLAG_OUTPUT); @@ -719,5 +801,5 @@ void Inkplate6::pins_as_outputs_() { this->display_data_7_pin_->pin_mode(gpio::FLAG_OUTPUT); } -} // namespace inkplate6 +} // namespace inkplate } // namespace esphome diff --git a/esphome/components/inkplate6/inkplate.h b/esphome/components/inkplate/inkplate.h similarity index 56% rename from esphome/components/inkplate6/inkplate.h rename to esphome/components/inkplate/inkplate.h index d8918bdf2a..fb4674b522 100644 --- a/esphome/components/inkplate6/inkplate.h +++ b/esphome/components/inkplate/inkplate.h @@ -5,8 +5,10 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include + namespace esphome { -namespace inkplate6 { +namespace inkplate { enum InkplateModel : uint8_t { INKPLATE_6 = 0, @@ -17,79 +19,35 @@ enum InkplateModel : uint8_t { INKPLATE_5_V2 = 5, }; -class Inkplate6 : public display::DisplayBuffer, public i2c::I2CDevice { +static constexpr uint8_t GLUT_SIZE = 9; +static constexpr uint8_t GLUT_COUNT = 8; + +static constexpr uint8_t LUT2[16] = {0xAA, 0xA9, 0xA6, 0xA5, 0x9A, 0x99, 0x96, 0x95, + 0x6A, 0x69, 0x66, 0x65, 0x5A, 0x59, 0x56, 0x55}; +static constexpr uint8_t LUTW[16] = {0xFF, 0xFE, 0xFB, 0xFA, 0xEF, 0xEE, 0xEB, 0xEA, + 0xBF, 0xBE, 0xBB, 0xBA, 0xAF, 0xAE, 0xAB, 0xAA}; +static constexpr uint8_t LUTB[16] = {0xFF, 0xFD, 0xF7, 0xF5, 0xDF, 0xDD, 0xD7, 0xD5, + 0x7F, 0x7D, 0x77, 0x75, 0x5F, 0x5D, 0x57, 0x55}; + +static constexpr uint8_t PIXEL_MASK_LUT[8] = {0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80}; +static constexpr uint8_t PIXEL_MASK_GLUT[2] = {0x0F, 0xF0}; + +class Inkplate : public display::DisplayBuffer, public i2c::I2CDevice { public: - const uint8_t LUT2[16] = {0xAA, 0xA9, 0xA6, 0xA5, 0x9A, 0x99, 0x96, 0x95, - 0x6A, 0x69, 0x66, 0x65, 0x5A, 0x59, 0x56, 0x55}; - const uint8_t LUTW[16] = {0xFF, 0xFE, 0xFB, 0xFA, 0xEF, 0xEE, 0xEB, 0xEA, - 0xBF, 0xBE, 0xBB, 0xBA, 0xAF, 0xAE, 0xAB, 0xAA}; - const uint8_t LUTB[16] = {0xFF, 0xFD, 0xF7, 0xF5, 0xDF, 0xDD, 0xD7, 0xD5, - 0x7F, 0x7D, 0x77, 0x75, 0x5F, 0x5D, 0x57, 0x55}; - - const uint8_t pixelMaskLUT[8] = {0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80}; - const uint8_t pixelMaskGLUT[2] = {0x0F, 0xF0}; - - const uint8_t waveform3BitAll[6][8][9] = {// INKPLATE_6 - {{0, 1, 1, 0, 0, 1, 1, 0, 0}, - {0, 1, 2, 1, 1, 2, 1, 0, 0}, - {1, 1, 1, 2, 2, 1, 0, 0, 0}, - {0, 0, 0, 1, 1, 1, 2, 0, 0}, - {2, 1, 1, 1, 2, 1, 2, 0, 0}, - {2, 2, 1, 1, 2, 1, 2, 0, 0}, - {1, 1, 1, 2, 1, 2, 2, 0, 0}, - {0, 0, 0, 0, 0, 0, 2, 0, 0}}, - // INKPLATE_10 - {{0, 0, 0, 0, 0, 0, 0, 1, 0}, - {0, 0, 0, 2, 2, 2, 1, 1, 0}, - {0, 0, 2, 1, 1, 2, 2, 1, 0}, - {0, 1, 2, 2, 1, 2, 2, 1, 0}, - {0, 0, 2, 1, 2, 2, 2, 1, 0}, - {0, 2, 2, 2, 2, 2, 2, 1, 0}, - {0, 0, 0, 0, 0, 2, 1, 2, 0}, - {0, 0, 0, 2, 2, 2, 2, 2, 0}}, - // INKPLATE_6_PLUS - {{0, 0, 0, 0, 0, 2, 1, 1, 0}, - {0, 0, 2, 1, 1, 1, 2, 1, 0}, - {0, 2, 2, 2, 1, 1, 2, 1, 0}, - {0, 0, 2, 2, 2, 1, 2, 1, 0}, - {0, 0, 0, 0, 2, 2, 2, 1, 0}, - {0, 0, 2, 1, 2, 1, 1, 2, 0}, - {0, 0, 2, 2, 2, 1, 1, 2, 0}, - {0, 0, 0, 0, 2, 2, 2, 2, 0}}, - // INKPLATE_6_V2 - {{1, 0, 1, 0, 1, 1, 1, 0, 0}, - {0, 0, 0, 1, 1, 1, 1, 0, 0}, - {1, 1, 1, 1, 0, 2, 1, 0, 0}, - {1, 1, 1, 2, 2, 1, 1, 0, 0}, - {1, 1, 1, 1, 2, 2, 1, 0, 0}, - {0, 1, 1, 1, 2, 2, 1, 0, 0}, - {0, 0, 0, 0, 1, 1, 2, 0, 0}, - {0, 0, 0, 0, 0, 1, 2, 0, 0}}, - // INKPLATE_5 - {{0, 0, 1, 1, 0, 1, 1, 1, 0}, - {0, 1, 1, 1, 1, 2, 0, 1, 0}, - {1, 2, 2, 0, 2, 1, 1, 1, 0}, - {1, 1, 1, 2, 0, 1, 1, 2, 0}, - {0, 1, 1, 1, 2, 0, 1, 2, 0}, - {0, 0, 0, 1, 1, 2, 1, 2, 0}, - {1, 1, 1, 2, 0, 2, 1, 2, 0}, - {0, 0, 0, 0, 0, 0, 0, 0, 0}}, - // INKPLATE_5_V2 - {{0, 0, 1, 1, 2, 1, 1, 1, 0}, - {1, 1, 2, 2, 1, 2, 1, 1, 0}, - {0, 1, 2, 2, 1, 1, 2, 1, 0}, - {0, 0, 1, 1, 1, 1, 1, 2, 0}, - {1, 2, 1, 2, 1, 1, 1, 2, 0}, - {0, 1, 1, 1, 2, 0, 1, 2, 0}, - {1, 1, 1, 2, 2, 2, 1, 2, 0}, - {0, 0, 0, 0, 0, 0, 0, 0, 0}}}; - void set_greyscale(bool greyscale) { this->greyscale_ = greyscale; this->block_partial_ = true; if (this->is_ready()) this->initialize_(); } + + void set_waveform(const std::array &waveform, bool is_custom) { + static_assert(sizeof(this->waveform_) == sizeof(uint8_t) * GLUT_COUNT * GLUT_SIZE, + "waveform_ buffer size must match input waveform array size"); + memmove(this->waveform_, waveform.data(), sizeof(this->waveform_)); + this->custom_waveform_ = is_custom; + } + void set_mirror_y(bool mirror_y) { this->mirror_y_ = mirror_y; } void set_mirror_x(bool mirror_x) { this->mirror_x_ = mirror_x; } @@ -225,6 +183,8 @@ class Inkplate6 : public display::DisplayBuffer, public i2c::I2CDevice { bool mirror_y_{false}; bool mirror_x_{false}; bool partial_updating_; + bool custom_waveform_{false}; + uint8_t waveform_[GLUT_COUNT][GLUT_SIZE]; InkplateModel model_; @@ -250,5 +210,5 @@ class Inkplate6 : public display::DisplayBuffer, public i2c::I2CDevice { GPIOPin *wakeup_pin_; }; -} // namespace inkplate6 +} // namespace inkplate } // namespace esphome diff --git a/esphome/components/inkplate6/__init__.py b/esphome/components/inkplate6/__init__.py index b1de57df8f..e69de29bb2 100644 --- a/esphome/components/inkplate6/__init__.py +++ b/esphome/components/inkplate6/__init__.py @@ -1 +0,0 @@ -CODEOWNERS = ["@jesserockz"] diff --git a/esphome/components/inkplate6/display.py b/esphome/components/inkplate6/display.py index 063fc8b0aa..ff14be5491 100644 --- a/esphome/components/inkplate6/display.py +++ b/esphome/components/inkplate6/display.py @@ -1,214 +1,5 @@ -from esphome import pins -import esphome.codegen as cg -from esphome.components import display, i2c -from esphome.components.esp32 import CONF_CPU_FREQUENCY import esphome.config_validation as cv -from esphome.const import ( - CONF_FULL_UPDATE_EVERY, - CONF_ID, - CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_MODEL, - CONF_OE_PIN, - CONF_PAGES, - CONF_TRANSFORM, - CONF_WAKEUP_PIN, - PLATFORM_ESP32, + +CONFIG_SCHEMA = cv.invalid( + "The inkplate6 display component has been renamed to inkplate." ) -import esphome.final_validate as fv - -DEPENDENCIES = ["i2c", "esp32"] -AUTO_LOAD = ["psram"] - -CONF_DISPLAY_DATA_0_PIN = "display_data_0_pin" -CONF_DISPLAY_DATA_1_PIN = "display_data_1_pin" -CONF_DISPLAY_DATA_2_PIN = "display_data_2_pin" -CONF_DISPLAY_DATA_3_PIN = "display_data_3_pin" -CONF_DISPLAY_DATA_4_PIN = "display_data_4_pin" -CONF_DISPLAY_DATA_5_PIN = "display_data_5_pin" -CONF_DISPLAY_DATA_6_PIN = "display_data_6_pin" -CONF_DISPLAY_DATA_7_PIN = "display_data_7_pin" - -CONF_CL_PIN = "cl_pin" -CONF_CKV_PIN = "ckv_pin" -CONF_GREYSCALE = "greyscale" -CONF_GMOD_PIN = "gmod_pin" -CONF_GPIO0_ENABLE_PIN = "gpio0_enable_pin" -CONF_LE_PIN = "le_pin" -CONF_PARTIAL_UPDATING = "partial_updating" -CONF_POWERUP_PIN = "powerup_pin" -CONF_SPH_PIN = "sph_pin" -CONF_SPV_PIN = "spv_pin" -CONF_VCOM_PIN = "vcom_pin" - -inkplate6_ns = cg.esphome_ns.namespace("inkplate6") -Inkplate6 = inkplate6_ns.class_( - "Inkplate6", - cg.PollingComponent, - i2c.I2CDevice, - display.Display, - display.DisplayBuffer, -) - -InkplateModel = inkplate6_ns.enum("InkplateModel") - -MODELS = { - "inkplate_6": InkplateModel.INKPLATE_6, - "inkplate_10": InkplateModel.INKPLATE_10, - "inkplate_6_plus": InkplateModel.INKPLATE_6_PLUS, - "inkplate_6_v2": InkplateModel.INKPLATE_6_V2, - "inkplate_5": InkplateModel.INKPLATE_5, - "inkplate_5_v2": InkplateModel.INKPLATE_5_V2, -} - -CONFIG_SCHEMA = cv.All( - display.FULL_DISPLAY_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(Inkplate6), - cv.Optional(CONF_GREYSCALE, default=False): cv.boolean, - cv.Optional(CONF_TRANSFORM): cv.Schema( - { - cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, - cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean, - } - ), - cv.Optional(CONF_PARTIAL_UPDATING, default=True): cv.boolean, - cv.Optional(CONF_FULL_UPDATE_EVERY, default=10): cv.uint32_t, - cv.Optional(CONF_MODEL, default="inkplate_6"): cv.enum( - MODELS, lower=True, space="_" - ), - # Control pins - cv.Required(CONF_CKV_PIN): pins.gpio_output_pin_schema, - cv.Required(CONF_GMOD_PIN): pins.gpio_output_pin_schema, - cv.Required(CONF_GPIO0_ENABLE_PIN): pins.gpio_output_pin_schema, - cv.Required(CONF_OE_PIN): pins.gpio_output_pin_schema, - cv.Required(CONF_POWERUP_PIN): pins.gpio_output_pin_schema, - cv.Required(CONF_SPH_PIN): pins.gpio_output_pin_schema, - cv.Required(CONF_SPV_PIN): pins.gpio_output_pin_schema, - cv.Required(CONF_VCOM_PIN): pins.gpio_output_pin_schema, - cv.Required(CONF_WAKEUP_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_CL_PIN, default=0): pins.internal_gpio_output_pin_schema, - cv.Optional(CONF_LE_PIN, default=2): pins.internal_gpio_output_pin_schema, - # Data pins - cv.Optional( - CONF_DISPLAY_DATA_0_PIN, default=4 - ): pins.internal_gpio_output_pin_schema, - cv.Optional( - CONF_DISPLAY_DATA_1_PIN, default=5 - ): pins.internal_gpio_output_pin_schema, - cv.Optional( - CONF_DISPLAY_DATA_2_PIN, default=18 - ): pins.internal_gpio_output_pin_schema, - cv.Optional( - CONF_DISPLAY_DATA_3_PIN, default=19 - ): pins.internal_gpio_output_pin_schema, - cv.Optional( - CONF_DISPLAY_DATA_4_PIN, default=23 - ): pins.internal_gpio_output_pin_schema, - cv.Optional( - CONF_DISPLAY_DATA_5_PIN, default=25 - ): pins.internal_gpio_output_pin_schema, - cv.Optional( - CONF_DISPLAY_DATA_6_PIN, default=26 - ): pins.internal_gpio_output_pin_schema, - cv.Optional( - CONF_DISPLAY_DATA_7_PIN, default=27 - ): pins.internal_gpio_output_pin_schema, - } - ) - .extend(cv.polling_component_schema("5s")) - .extend(i2c.i2c_device_schema(0x48)), - cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA), -) - - -def _validate_cpu_frequency(config): - esp32_config = fv.full_config.get()[PLATFORM_ESP32] - if esp32_config[CONF_CPU_FREQUENCY] != "240MHZ": - raise cv.Invalid( - "Inkplate requires 240MHz CPU frequency (set in esp32 component)" - ) - return config - - -FINAL_VALIDATE_SCHEMA = _validate_cpu_frequency - - -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - - await display.register_display(var, config) - await i2c.register_i2c_device(var, config) - - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) - - cg.add(var.set_greyscale(config[CONF_GREYSCALE])) - if transform := config.get(CONF_TRANSFORM): - cg.add(var.set_mirror_x(transform[CONF_MIRROR_X])) - cg.add(var.set_mirror_y(transform[CONF_MIRROR_Y])) - cg.add(var.set_partial_updating(config[CONF_PARTIAL_UPDATING])) - cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) - - cg.add(var.set_model(config[CONF_MODEL])) - - ckv = await cg.gpio_pin_expression(config[CONF_CKV_PIN]) - cg.add(var.set_ckv_pin(ckv)) - - gmod = await cg.gpio_pin_expression(config[CONF_GMOD_PIN]) - cg.add(var.set_gmod_pin(gmod)) - - gpio0_enable = await cg.gpio_pin_expression(config[CONF_GPIO0_ENABLE_PIN]) - cg.add(var.set_gpio0_enable_pin(gpio0_enable)) - - oe = await cg.gpio_pin_expression(config[CONF_OE_PIN]) - cg.add(var.set_oe_pin(oe)) - - powerup = await cg.gpio_pin_expression(config[CONF_POWERUP_PIN]) - cg.add(var.set_powerup_pin(powerup)) - - sph = await cg.gpio_pin_expression(config[CONF_SPH_PIN]) - cg.add(var.set_sph_pin(sph)) - - spv = await cg.gpio_pin_expression(config[CONF_SPV_PIN]) - cg.add(var.set_spv_pin(spv)) - - vcom = await cg.gpio_pin_expression(config[CONF_VCOM_PIN]) - cg.add(var.set_vcom_pin(vcom)) - - wakeup = await cg.gpio_pin_expression(config[CONF_WAKEUP_PIN]) - cg.add(var.set_wakeup_pin(wakeup)) - - cl = await cg.gpio_pin_expression(config[CONF_CL_PIN]) - cg.add(var.set_cl_pin(cl)) - - le = await cg.gpio_pin_expression(config[CONF_LE_PIN]) - cg.add(var.set_le_pin(le)) - - display_data_0 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_0_PIN]) - cg.add(var.set_display_data_0_pin(display_data_0)) - - display_data_1 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_1_PIN]) - cg.add(var.set_display_data_1_pin(display_data_1)) - - display_data_2 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_2_PIN]) - cg.add(var.set_display_data_2_pin(display_data_2)) - - display_data_3 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_3_PIN]) - cg.add(var.set_display_data_3_pin(display_data_3)) - - display_data_4 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_4_PIN]) - cg.add(var.set_display_data_4_pin(display_data_4)) - - display_data_5 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_5_PIN]) - cg.add(var.set_display_data_5_pin(display_data_5)) - - display_data_6 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_6_PIN]) - cg.add(var.set_display_data_6_pin(display_data_6)) - - display_data_7 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_7_PIN]) - cg.add(var.set_display_data_7_pin(display_data_7)) diff --git a/tests/components/inkplate6/common.yaml b/tests/components/inkplate/common.yaml similarity index 96% rename from tests/components/inkplate6/common.yaml rename to tests/components/inkplate/common.yaml index 6cb5d055b6..7050b1739f 100644 --- a/tests/components/inkplate6/common.yaml +++ b/tests/components/inkplate/common.yaml @@ -1,5 +1,5 @@ i2c: - - id: i2c_inkplate6 + - id: i2c_inkplate scl: 16 sda: 17 @@ -7,7 +7,7 @@ esp32: cpu_frequency: 240MHz display: - - platform: inkplate6 + - platform: inkplate id: inkplate_display greyscale: false partial_updating: false diff --git a/tests/components/inkplate6/test.esp32-ard.yaml b/tests/components/inkplate/test.esp32-ard.yaml similarity index 100% rename from tests/components/inkplate6/test.esp32-ard.yaml rename to tests/components/inkplate/test.esp32-ard.yaml diff --git a/tests/components/inkplate6/test.esp32-idf.yaml b/tests/components/inkplate/test.esp32-idf.yaml similarity index 100% rename from tests/components/inkplate6/test.esp32-idf.yaml rename to tests/components/inkplate/test.esp32-idf.yaml From 2401f81be3fab1e87b675b6ed8dc5e2e34a2b4cd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 10 Sep 2025 19:48:01 +1200 Subject: [PATCH 1020/1626] Bump version to 2025.9.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index f312ca45e2..a7f591cbf5 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.9.0-dev +PROJECT_NUMBER = 2025.9.0b1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index fae6020b88..a77f98c292 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.9.0-dev" +__version__ = "2025.9.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 9dd17b464df2ff08388f8859ed1b272108d84e9c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 10 Sep 2025 19:48:02 +1200 Subject: [PATCH 1021/1626] Bump version to 2025.10.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index f312ca45e2..cad97e645a 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.9.0-dev +PROJECT_NUMBER = 2025.10.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index fae6020b88..308abe7706 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.9.0-dev" +__version__ = "2025.10.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 55dd12c66bfbf592465cee10ca2105fcda336075 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 10 Sep 2025 05:58:07 -0500 Subject: [PATCH 1022/1626] [thermostat] Rename timer enums to mitigate naming conflict (#10666) --- .../thermostat/thermostat_climate.cpp | 144 ++++++++++-------- .../thermostat/thermostat_climate.h | 24 +-- 2 files changed, 90 insertions(+), 78 deletions(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index f05730b369..e2db3ca5e1 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -11,11 +11,11 @@ static const char *const TAG = "thermostat.climate"; void ThermostatClimate::setup() { if (this->use_startup_delay_) { // start timers so that no actions are called for a moment - this->start_timer_(thermostat::TIMER_COOLING_OFF); - this->start_timer_(thermostat::TIMER_FANNING_OFF); - this->start_timer_(thermostat::TIMER_HEATING_OFF); + this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_OFF); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_OFF); + this->start_timer_(thermostat::THERMOSTAT_TIMER_HEATING_OFF); if (this->supports_fan_only_action_uses_fan_mode_timer_) - this->start_timer_(thermostat::TIMER_FAN_MODE); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FAN_MODE); } // add a callback so that whenever the sensor state changes we can take action this->sensor_->add_on_state_callback([this](float state) { @@ -307,9 +307,10 @@ climate::ClimateAction ThermostatClimate::compute_action_(const bool ignore_time return climate::CLIMATE_ACTION_OFF; } // do not change the action if an "ON" timer is running - if ((!ignore_timers) && - (this->timer_active_(thermostat::TIMER_IDLE_ON) || this->timer_active_(thermostat::TIMER_COOLING_ON) || - this->timer_active_(thermostat::TIMER_FANNING_ON) || this->timer_active_(thermostat::TIMER_HEATING_ON))) { + if ((!ignore_timers) && (this->timer_active_(thermostat::THERMOSTAT_TIMER_IDLE_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FANNING_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_ON))) { return this->action; } @@ -444,18 +445,18 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu case climate::CLIMATE_ACTION_OFF: case climate::CLIMATE_ACTION_IDLE: if (this->idle_action_ready_()) { - this->start_timer_(thermostat::TIMER_IDLE_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_IDLE_ON); if (this->action == climate::CLIMATE_ACTION_COOLING) - this->start_timer_(thermostat::TIMER_COOLING_OFF); + this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_OFF); if (this->action == climate::CLIMATE_ACTION_FAN) { if (this->supports_fan_only_action_uses_fan_mode_timer_) { - this->start_timer_(thermostat::TIMER_FAN_MODE); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FAN_MODE); } else { - this->start_timer_(thermostat::TIMER_FANNING_OFF); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_OFF); } } if (this->action == climate::CLIMATE_ACTION_HEATING) - this->start_timer_(thermostat::TIMER_HEATING_OFF); + this->start_timer_(thermostat::THERMOSTAT_TIMER_HEATING_OFF); // trig = this->idle_action_trigger_; ESP_LOGVV(TAG, "Switching to IDLE/OFF action"); this->cooling_max_runtime_exceeded_ = false; @@ -465,10 +466,10 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu break; case climate::CLIMATE_ACTION_COOLING: if (this->cooling_action_ready_()) { - this->start_timer_(thermostat::TIMER_COOLING_ON); - this->start_timer_(thermostat::TIMER_COOLING_MAX_RUN_TIME); + this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); if (this->supports_fan_with_cooling_) { - this->start_timer_(thermostat::TIMER_FANNING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_ON); trig_fan = this->fan_only_action_trigger_; } this->cooling_max_runtime_exceeded_ = false; @@ -479,10 +480,10 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu break; case climate::CLIMATE_ACTION_HEATING: if (this->heating_action_ready_()) { - this->start_timer_(thermostat::TIMER_HEATING_ON); - this->start_timer_(thermostat::TIMER_HEATING_MAX_RUN_TIME); + this->start_timer_(thermostat::THERMOSTAT_TIMER_HEATING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); if (this->supports_fan_with_heating_) { - this->start_timer_(thermostat::TIMER_FANNING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_ON); trig_fan = this->fan_only_action_trigger_; } this->heating_max_runtime_exceeded_ = false; @@ -494,9 +495,9 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu case climate::CLIMATE_ACTION_FAN: if (this->fanning_action_ready_()) { if (this->supports_fan_only_action_uses_fan_mode_timer_) { - this->start_timer_(thermostat::TIMER_FAN_MODE); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FAN_MODE); } else { - this->start_timer_(thermostat::TIMER_FANNING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_ON); } trig = this->fan_only_action_trigger_; ESP_LOGVV(TAG, "Switching to FAN_ONLY action"); @@ -505,8 +506,8 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu break; case climate::CLIMATE_ACTION_DRYING: if (this->drying_action_ready_()) { - this->start_timer_(thermostat::TIMER_COOLING_ON); - this->start_timer_(thermostat::TIMER_FANNING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_ON); trig = this->dry_action_trigger_; ESP_LOGVV(TAG, "Switching to DRYING action"); action_ready = true; @@ -549,14 +550,14 @@ void ThermostatClimate::switch_to_supplemental_action_(climate::ClimateAction ac switch (action) { case climate::CLIMATE_ACTION_OFF: case climate::CLIMATE_ACTION_IDLE: - this->cancel_timer_(thermostat::TIMER_COOLING_MAX_RUN_TIME); - this->cancel_timer_(thermostat::TIMER_HEATING_MAX_RUN_TIME); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); break; case climate::CLIMATE_ACTION_COOLING: - this->cancel_timer_(thermostat::TIMER_COOLING_MAX_RUN_TIME); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); break; case climate::CLIMATE_ACTION_HEATING: - this->cancel_timer_(thermostat::TIMER_HEATING_MAX_RUN_TIME); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); break; default: return; @@ -571,15 +572,15 @@ void ThermostatClimate::trigger_supplemental_action_() { switch (this->supplemental_action_) { case climate::CLIMATE_ACTION_COOLING: - if (!this->timer_active_(thermostat::TIMER_COOLING_MAX_RUN_TIME)) { - this->start_timer_(thermostat::TIMER_COOLING_MAX_RUN_TIME); + if (!this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME)) { + this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); } trig = this->supplemental_cool_action_trigger_; ESP_LOGVV(TAG, "Calling supplemental COOLING action"); break; case climate::CLIMATE_ACTION_HEATING: - if (!this->timer_active_(thermostat::TIMER_HEATING_MAX_RUN_TIME)) { - this->start_timer_(thermostat::TIMER_HEATING_MAX_RUN_TIME); + if (!this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME)) { + this->start_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); } trig = this->supplemental_heat_action_trigger_; ESP_LOGVV(TAG, "Calling supplemental HEATING action"); @@ -657,7 +658,7 @@ void ThermostatClimate::switch_to_fan_mode_(climate::ClimateFanMode fan_mode, bo this->prev_fan_mode_trigger_->stop_action(); this->prev_fan_mode_trigger_ = nullptr; } - this->start_timer_(thermostat::TIMER_FAN_MODE); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FAN_MODE); if (trig != nullptr) { trig->trigger(); } @@ -758,35 +759,44 @@ void ThermostatClimate::switch_to_swing_mode_(climate::ClimateSwingMode swing_mo bool ThermostatClimate::idle_action_ready_() { if (this->supports_fan_only_action_uses_fan_mode_timer_) { - return !(this->timer_active_(thermostat::TIMER_COOLING_ON) || this->timer_active_(thermostat::TIMER_FAN_MODE) || - this->timer_active_(thermostat::TIMER_HEATING_ON)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FAN_MODE) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_ON)); } - return !(this->timer_active_(thermostat::TIMER_COOLING_ON) || this->timer_active_(thermostat::TIMER_FANNING_ON) || - this->timer_active_(thermostat::TIMER_HEATING_ON)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FANNING_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_ON)); } bool ThermostatClimate::cooling_action_ready_() { - return !(this->timer_active_(thermostat::TIMER_IDLE_ON) || this->timer_active_(thermostat::TIMER_FANNING_OFF) || - this->timer_active_(thermostat::TIMER_COOLING_OFF) || this->timer_active_(thermostat::TIMER_HEATING_ON)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_IDLE_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FANNING_OFF) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_OFF) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_ON)); } bool ThermostatClimate::drying_action_ready_() { - return !(this->timer_active_(thermostat::TIMER_IDLE_ON) || this->timer_active_(thermostat::TIMER_FANNING_OFF) || - this->timer_active_(thermostat::TIMER_COOLING_OFF) || this->timer_active_(thermostat::TIMER_HEATING_ON)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_IDLE_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FANNING_OFF) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_OFF) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_ON)); } -bool ThermostatClimate::fan_mode_ready_() { return !(this->timer_active_(thermostat::TIMER_FAN_MODE)); } +bool ThermostatClimate::fan_mode_ready_() { return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_FAN_MODE)); } bool ThermostatClimate::fanning_action_ready_() { if (this->supports_fan_only_action_uses_fan_mode_timer_) { - return !(this->timer_active_(thermostat::TIMER_FAN_MODE)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_FAN_MODE)); } - return !(this->timer_active_(thermostat::TIMER_IDLE_ON) || this->timer_active_(thermostat::TIMER_FANNING_OFF)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_IDLE_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FANNING_OFF)); } bool ThermostatClimate::heating_action_ready_() { - return !(this->timer_active_(thermostat::TIMER_IDLE_ON) || this->timer_active_(thermostat::TIMER_COOLING_ON) || - this->timer_active_(thermostat::TIMER_FANNING_OFF) || this->timer_active_(thermostat::TIMER_HEATING_OFF)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_IDLE_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FANNING_OFF) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_OFF)); } void ThermostatClimate::start_timer_(const ThermostatClimateTimerIndex timer_index) { @@ -1162,43 +1172,43 @@ void ThermostatClimate::set_heat_overrun(float overrun) { this->heating_overrun_ void ThermostatClimate::set_supplemental_cool_delta(float delta) { this->supplemental_cool_delta_ = delta; } void ThermostatClimate::set_supplemental_heat_delta(float delta) { this->supplemental_heat_delta_ = delta; } void ThermostatClimate::set_cooling_maximum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_COOLING_MAX_RUN_TIME].time = + this->timer_[thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_cooling_minimum_off_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_COOLING_OFF].time = + this->timer_[thermostat::THERMOSTAT_TIMER_COOLING_OFF].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_cooling_minimum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_COOLING_ON].time = + this->timer_[thermostat::THERMOSTAT_TIMER_COOLING_ON].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_fan_mode_minimum_switching_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_FAN_MODE].time = + this->timer_[thermostat::THERMOSTAT_TIMER_FAN_MODE].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_fanning_minimum_off_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_FANNING_OFF].time = + this->timer_[thermostat::THERMOSTAT_TIMER_FANNING_OFF].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_fanning_minimum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_FANNING_ON].time = + this->timer_[thermostat::THERMOSTAT_TIMER_FANNING_ON].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_heating_maximum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_HEATING_MAX_RUN_TIME].time = + this->timer_[thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_heating_minimum_off_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_HEATING_OFF].time = + this->timer_[thermostat::THERMOSTAT_TIMER_HEATING_OFF].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_heating_minimum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_HEATING_ON].time = + this->timer_[thermostat::THERMOSTAT_TIMER_HEATING_ON].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_idle_minimum_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_IDLE_ON].time = + this->timer_[thermostat::THERMOSTAT_TIMER_IDLE_ON].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } @@ -1327,13 +1337,14 @@ void ThermostatClimate::dump_config() { " Minimum Off Time: %" PRIu32 "s\n" " Minimum Run Time: %" PRIu32 "s", this->cooling_deadband_, this->cooling_overrun_, - this->timer_duration_(thermostat::TIMER_COOLING_OFF) / 1000, - this->timer_duration_(thermostat::TIMER_COOLING_ON) / 1000); - if ((this->supplemental_cool_delta_ > 0) || (this->timer_duration_(thermostat::TIMER_COOLING_MAX_RUN_TIME) > 0)) { + this->timer_duration_(thermostat::THERMOSTAT_TIMER_COOLING_OFF) / 1000, + this->timer_duration_(thermostat::THERMOSTAT_TIMER_COOLING_ON) / 1000); + if ((this->supplemental_cool_delta_ > 0) || + (this->timer_duration_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME) > 0)) { ESP_LOGCONFIG(TAG, " Maximum Run Time: %" PRIu32 "s\n" " Supplemental Delta: %.1f°C", - this->timer_duration_(thermostat::TIMER_COOLING_MAX_RUN_TIME) / 1000, + this->timer_duration_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME) / 1000, this->supplemental_cool_delta_); } } @@ -1345,13 +1356,14 @@ void ThermostatClimate::dump_config() { " Minimum Off Time: %" PRIu32 "s\n" " Minimum Run Time: %" PRIu32 "s", this->heating_deadband_, this->heating_overrun_, - this->timer_duration_(thermostat::TIMER_HEATING_OFF) / 1000, - this->timer_duration_(thermostat::TIMER_HEATING_ON) / 1000); - if ((this->supplemental_heat_delta_ > 0) || (this->timer_duration_(thermostat::TIMER_HEATING_MAX_RUN_TIME) > 0)) { + this->timer_duration_(thermostat::THERMOSTAT_TIMER_HEATING_OFF) / 1000, + this->timer_duration_(thermostat::THERMOSTAT_TIMER_HEATING_ON) / 1000); + if ((this->supplemental_heat_delta_ > 0) || + (this->timer_duration_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME) > 0)) { ESP_LOGCONFIG(TAG, " Maximum Run Time: %" PRIu32 "s\n" " Supplemental Delta: %.1f°C", - this->timer_duration_(thermostat::TIMER_HEATING_MAX_RUN_TIME) / 1000, + this->timer_duration_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME) / 1000, this->supplemental_heat_delta_); } } @@ -1360,15 +1372,15 @@ void ThermostatClimate::dump_config() { " Fan Parameters:\n" " Minimum Off Time: %" PRIu32 "s\n" " Minimum Run Time: %" PRIu32 "s", - this->timer_duration_(thermostat::TIMER_FANNING_OFF) / 1000, - this->timer_duration_(thermostat::TIMER_FANNING_ON) / 1000); + this->timer_duration_(thermostat::THERMOSTAT_TIMER_FANNING_OFF) / 1000, + this->timer_duration_(thermostat::THERMOSTAT_TIMER_FANNING_ON) / 1000); } if (this->supports_fan_mode_on_ || this->supports_fan_mode_off_ || this->supports_fan_mode_auto_ || this->supports_fan_mode_low_ || this->supports_fan_mode_medium_ || this->supports_fan_mode_high_ || this->supports_fan_mode_middle_ || this->supports_fan_mode_focus_ || this->supports_fan_mode_diffuse_ || this->supports_fan_mode_quiet_) { ESP_LOGCONFIG(TAG, " Minimum Fan Mode Switching Time: %" PRIu32 "s", - this->timer_duration_(thermostat::TIMER_FAN_MODE) / 1000); + this->timer_duration_(thermostat::THERMOSTAT_TIMER_FAN_MODE) / 1000); } ESP_LOGCONFIG(TAG, " Minimum Idle Time: %" PRIu32 "s\n" @@ -1381,7 +1393,7 @@ void ThermostatClimate::dump_config() { " FAN_ONLY: %s\n" " FAN_ONLY_ACTION_USES_FAN_MODE_TIMER: %s\n" " FAN_ONLY_COOLING: %s", - this->timer_[thermostat::TIMER_IDLE_ON].time / 1000, YESNO(this->supports_auto_), + this->timer_[thermostat::THERMOSTAT_TIMER_IDLE_ON].time / 1000, YESNO(this->supports_auto_), YESNO(this->supports_heat_cool_), YESNO(this->supports_heat_), YESNO(this->supports_cool_), YESNO(this->supports_dry_), YESNO(this->supports_fan_only_), YESNO(this->supports_fan_only_action_uses_fan_mode_timer_), YESNO(this->supports_fan_only_cooling_)); diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index e2c7b00266..526f07116e 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -14,17 +14,17 @@ namespace esphome { namespace thermostat { enum ThermostatClimateTimerIndex : uint8_t { - TIMER_COOLING_MAX_RUN_TIME = 0, - TIMER_COOLING_OFF = 1, - TIMER_COOLING_ON = 2, - TIMER_FAN_MODE = 3, - TIMER_FANNING_OFF = 4, - TIMER_FANNING_ON = 5, - TIMER_HEATING_MAX_RUN_TIME = 6, - TIMER_HEATING_OFF = 7, - TIMER_HEATING_ON = 8, - TIMER_IDLE_ON = 9, - TIMER_COUNT = 10, + THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME = 0, + THERMOSTAT_TIMER_COOLING_OFF = 1, + THERMOSTAT_TIMER_COOLING_ON = 2, + THERMOSTAT_TIMER_FAN_MODE = 3, + THERMOSTAT_TIMER_FANNING_OFF = 4, + THERMOSTAT_TIMER_FANNING_ON = 5, + THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME = 6, + THERMOSTAT_TIMER_HEATING_OFF = 7, + THERMOSTAT_TIMER_HEATING_ON = 8, + THERMOSTAT_TIMER_IDLE_ON = 9, + THERMOSTAT_TIMER_COUNT = 10, }; enum OnBootRestoreFrom : uint8_t { @@ -467,7 +467,7 @@ class ThermostatClimate : public climate::Climate, public Component { std::string default_custom_preset_{}; /// Climate action timers - std::array timer_{ + std::array timer_{ ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::cooling_max_run_time_timer_callback_, this)), ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::cooling_off_timer_callback_, this)), ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::cooling_on_timer_callback_, this)), From 56e85b3ef96277c0a97a29d8d87c9a25cade385b Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 10 Sep 2025 05:58:07 -0500 Subject: [PATCH 1023/1626] [thermostat] Rename timer enums to mitigate naming conflict (#10666) --- .../thermostat/thermostat_climate.cpp | 144 ++++++++++-------- .../thermostat/thermostat_climate.h | 24 +-- 2 files changed, 90 insertions(+), 78 deletions(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index f05730b369..e2db3ca5e1 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -11,11 +11,11 @@ static const char *const TAG = "thermostat.climate"; void ThermostatClimate::setup() { if (this->use_startup_delay_) { // start timers so that no actions are called for a moment - this->start_timer_(thermostat::TIMER_COOLING_OFF); - this->start_timer_(thermostat::TIMER_FANNING_OFF); - this->start_timer_(thermostat::TIMER_HEATING_OFF); + this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_OFF); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_OFF); + this->start_timer_(thermostat::THERMOSTAT_TIMER_HEATING_OFF); if (this->supports_fan_only_action_uses_fan_mode_timer_) - this->start_timer_(thermostat::TIMER_FAN_MODE); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FAN_MODE); } // add a callback so that whenever the sensor state changes we can take action this->sensor_->add_on_state_callback([this](float state) { @@ -307,9 +307,10 @@ climate::ClimateAction ThermostatClimate::compute_action_(const bool ignore_time return climate::CLIMATE_ACTION_OFF; } // do not change the action if an "ON" timer is running - if ((!ignore_timers) && - (this->timer_active_(thermostat::TIMER_IDLE_ON) || this->timer_active_(thermostat::TIMER_COOLING_ON) || - this->timer_active_(thermostat::TIMER_FANNING_ON) || this->timer_active_(thermostat::TIMER_HEATING_ON))) { + if ((!ignore_timers) && (this->timer_active_(thermostat::THERMOSTAT_TIMER_IDLE_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FANNING_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_ON))) { return this->action; } @@ -444,18 +445,18 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu case climate::CLIMATE_ACTION_OFF: case climate::CLIMATE_ACTION_IDLE: if (this->idle_action_ready_()) { - this->start_timer_(thermostat::TIMER_IDLE_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_IDLE_ON); if (this->action == climate::CLIMATE_ACTION_COOLING) - this->start_timer_(thermostat::TIMER_COOLING_OFF); + this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_OFF); if (this->action == climate::CLIMATE_ACTION_FAN) { if (this->supports_fan_only_action_uses_fan_mode_timer_) { - this->start_timer_(thermostat::TIMER_FAN_MODE); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FAN_MODE); } else { - this->start_timer_(thermostat::TIMER_FANNING_OFF); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_OFF); } } if (this->action == climate::CLIMATE_ACTION_HEATING) - this->start_timer_(thermostat::TIMER_HEATING_OFF); + this->start_timer_(thermostat::THERMOSTAT_TIMER_HEATING_OFF); // trig = this->idle_action_trigger_; ESP_LOGVV(TAG, "Switching to IDLE/OFF action"); this->cooling_max_runtime_exceeded_ = false; @@ -465,10 +466,10 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu break; case climate::CLIMATE_ACTION_COOLING: if (this->cooling_action_ready_()) { - this->start_timer_(thermostat::TIMER_COOLING_ON); - this->start_timer_(thermostat::TIMER_COOLING_MAX_RUN_TIME); + this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); if (this->supports_fan_with_cooling_) { - this->start_timer_(thermostat::TIMER_FANNING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_ON); trig_fan = this->fan_only_action_trigger_; } this->cooling_max_runtime_exceeded_ = false; @@ -479,10 +480,10 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu break; case climate::CLIMATE_ACTION_HEATING: if (this->heating_action_ready_()) { - this->start_timer_(thermostat::TIMER_HEATING_ON); - this->start_timer_(thermostat::TIMER_HEATING_MAX_RUN_TIME); + this->start_timer_(thermostat::THERMOSTAT_TIMER_HEATING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); if (this->supports_fan_with_heating_) { - this->start_timer_(thermostat::TIMER_FANNING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_ON); trig_fan = this->fan_only_action_trigger_; } this->heating_max_runtime_exceeded_ = false; @@ -494,9 +495,9 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu case climate::CLIMATE_ACTION_FAN: if (this->fanning_action_ready_()) { if (this->supports_fan_only_action_uses_fan_mode_timer_) { - this->start_timer_(thermostat::TIMER_FAN_MODE); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FAN_MODE); } else { - this->start_timer_(thermostat::TIMER_FANNING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_ON); } trig = this->fan_only_action_trigger_; ESP_LOGVV(TAG, "Switching to FAN_ONLY action"); @@ -505,8 +506,8 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu break; case climate::CLIMATE_ACTION_DRYING: if (this->drying_action_ready_()) { - this->start_timer_(thermostat::TIMER_COOLING_ON); - this->start_timer_(thermostat::TIMER_FANNING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_ON); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FANNING_ON); trig = this->dry_action_trigger_; ESP_LOGVV(TAG, "Switching to DRYING action"); action_ready = true; @@ -549,14 +550,14 @@ void ThermostatClimate::switch_to_supplemental_action_(climate::ClimateAction ac switch (action) { case climate::CLIMATE_ACTION_OFF: case climate::CLIMATE_ACTION_IDLE: - this->cancel_timer_(thermostat::TIMER_COOLING_MAX_RUN_TIME); - this->cancel_timer_(thermostat::TIMER_HEATING_MAX_RUN_TIME); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); break; case climate::CLIMATE_ACTION_COOLING: - this->cancel_timer_(thermostat::TIMER_COOLING_MAX_RUN_TIME); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); break; case climate::CLIMATE_ACTION_HEATING: - this->cancel_timer_(thermostat::TIMER_HEATING_MAX_RUN_TIME); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); break; default: return; @@ -571,15 +572,15 @@ void ThermostatClimate::trigger_supplemental_action_() { switch (this->supplemental_action_) { case climate::CLIMATE_ACTION_COOLING: - if (!this->timer_active_(thermostat::TIMER_COOLING_MAX_RUN_TIME)) { - this->start_timer_(thermostat::TIMER_COOLING_MAX_RUN_TIME); + if (!this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME)) { + this->start_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); } trig = this->supplemental_cool_action_trigger_; ESP_LOGVV(TAG, "Calling supplemental COOLING action"); break; case climate::CLIMATE_ACTION_HEATING: - if (!this->timer_active_(thermostat::TIMER_HEATING_MAX_RUN_TIME)) { - this->start_timer_(thermostat::TIMER_HEATING_MAX_RUN_TIME); + if (!this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME)) { + this->start_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); } trig = this->supplemental_heat_action_trigger_; ESP_LOGVV(TAG, "Calling supplemental HEATING action"); @@ -657,7 +658,7 @@ void ThermostatClimate::switch_to_fan_mode_(climate::ClimateFanMode fan_mode, bo this->prev_fan_mode_trigger_->stop_action(); this->prev_fan_mode_trigger_ = nullptr; } - this->start_timer_(thermostat::TIMER_FAN_MODE); + this->start_timer_(thermostat::THERMOSTAT_TIMER_FAN_MODE); if (trig != nullptr) { trig->trigger(); } @@ -758,35 +759,44 @@ void ThermostatClimate::switch_to_swing_mode_(climate::ClimateSwingMode swing_mo bool ThermostatClimate::idle_action_ready_() { if (this->supports_fan_only_action_uses_fan_mode_timer_) { - return !(this->timer_active_(thermostat::TIMER_COOLING_ON) || this->timer_active_(thermostat::TIMER_FAN_MODE) || - this->timer_active_(thermostat::TIMER_HEATING_ON)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FAN_MODE) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_ON)); } - return !(this->timer_active_(thermostat::TIMER_COOLING_ON) || this->timer_active_(thermostat::TIMER_FANNING_ON) || - this->timer_active_(thermostat::TIMER_HEATING_ON)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FANNING_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_ON)); } bool ThermostatClimate::cooling_action_ready_() { - return !(this->timer_active_(thermostat::TIMER_IDLE_ON) || this->timer_active_(thermostat::TIMER_FANNING_OFF) || - this->timer_active_(thermostat::TIMER_COOLING_OFF) || this->timer_active_(thermostat::TIMER_HEATING_ON)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_IDLE_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FANNING_OFF) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_OFF) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_ON)); } bool ThermostatClimate::drying_action_ready_() { - return !(this->timer_active_(thermostat::TIMER_IDLE_ON) || this->timer_active_(thermostat::TIMER_FANNING_OFF) || - this->timer_active_(thermostat::TIMER_COOLING_OFF) || this->timer_active_(thermostat::TIMER_HEATING_ON)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_IDLE_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FANNING_OFF) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_OFF) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_ON)); } -bool ThermostatClimate::fan_mode_ready_() { return !(this->timer_active_(thermostat::TIMER_FAN_MODE)); } +bool ThermostatClimate::fan_mode_ready_() { return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_FAN_MODE)); } bool ThermostatClimate::fanning_action_ready_() { if (this->supports_fan_only_action_uses_fan_mode_timer_) { - return !(this->timer_active_(thermostat::TIMER_FAN_MODE)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_FAN_MODE)); } - return !(this->timer_active_(thermostat::TIMER_IDLE_ON) || this->timer_active_(thermostat::TIMER_FANNING_OFF)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_IDLE_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FANNING_OFF)); } bool ThermostatClimate::heating_action_ready_() { - return !(this->timer_active_(thermostat::TIMER_IDLE_ON) || this->timer_active_(thermostat::TIMER_COOLING_ON) || - this->timer_active_(thermostat::TIMER_FANNING_OFF) || this->timer_active_(thermostat::TIMER_HEATING_OFF)); + return !(this->timer_active_(thermostat::THERMOSTAT_TIMER_IDLE_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_COOLING_ON) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_FANNING_OFF) || + this->timer_active_(thermostat::THERMOSTAT_TIMER_HEATING_OFF)); } void ThermostatClimate::start_timer_(const ThermostatClimateTimerIndex timer_index) { @@ -1162,43 +1172,43 @@ void ThermostatClimate::set_heat_overrun(float overrun) { this->heating_overrun_ void ThermostatClimate::set_supplemental_cool_delta(float delta) { this->supplemental_cool_delta_ = delta; } void ThermostatClimate::set_supplemental_heat_delta(float delta) { this->supplemental_heat_delta_ = delta; } void ThermostatClimate::set_cooling_maximum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_COOLING_MAX_RUN_TIME].time = + this->timer_[thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_cooling_minimum_off_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_COOLING_OFF].time = + this->timer_[thermostat::THERMOSTAT_TIMER_COOLING_OFF].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_cooling_minimum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_COOLING_ON].time = + this->timer_[thermostat::THERMOSTAT_TIMER_COOLING_ON].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_fan_mode_minimum_switching_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_FAN_MODE].time = + this->timer_[thermostat::THERMOSTAT_TIMER_FAN_MODE].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_fanning_minimum_off_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_FANNING_OFF].time = + this->timer_[thermostat::THERMOSTAT_TIMER_FANNING_OFF].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_fanning_minimum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_FANNING_ON].time = + this->timer_[thermostat::THERMOSTAT_TIMER_FANNING_ON].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_heating_maximum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_HEATING_MAX_RUN_TIME].time = + this->timer_[thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_heating_minimum_off_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_HEATING_OFF].time = + this->timer_[thermostat::THERMOSTAT_TIMER_HEATING_OFF].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_heating_minimum_run_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_HEATING_ON].time = + this->timer_[thermostat::THERMOSTAT_TIMER_HEATING_ON].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_idle_minimum_time_in_sec(uint32_t time) { - this->timer_[thermostat::TIMER_IDLE_ON].time = + this->timer_[thermostat::THERMOSTAT_TIMER_IDLE_ON].time = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); } void ThermostatClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } @@ -1327,13 +1337,14 @@ void ThermostatClimate::dump_config() { " Minimum Off Time: %" PRIu32 "s\n" " Minimum Run Time: %" PRIu32 "s", this->cooling_deadband_, this->cooling_overrun_, - this->timer_duration_(thermostat::TIMER_COOLING_OFF) / 1000, - this->timer_duration_(thermostat::TIMER_COOLING_ON) / 1000); - if ((this->supplemental_cool_delta_ > 0) || (this->timer_duration_(thermostat::TIMER_COOLING_MAX_RUN_TIME) > 0)) { + this->timer_duration_(thermostat::THERMOSTAT_TIMER_COOLING_OFF) / 1000, + this->timer_duration_(thermostat::THERMOSTAT_TIMER_COOLING_ON) / 1000); + if ((this->supplemental_cool_delta_ > 0) || + (this->timer_duration_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME) > 0)) { ESP_LOGCONFIG(TAG, " Maximum Run Time: %" PRIu32 "s\n" " Supplemental Delta: %.1f°C", - this->timer_duration_(thermostat::TIMER_COOLING_MAX_RUN_TIME) / 1000, + this->timer_duration_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME) / 1000, this->supplemental_cool_delta_); } } @@ -1345,13 +1356,14 @@ void ThermostatClimate::dump_config() { " Minimum Off Time: %" PRIu32 "s\n" " Minimum Run Time: %" PRIu32 "s", this->heating_deadband_, this->heating_overrun_, - this->timer_duration_(thermostat::TIMER_HEATING_OFF) / 1000, - this->timer_duration_(thermostat::TIMER_HEATING_ON) / 1000); - if ((this->supplemental_heat_delta_ > 0) || (this->timer_duration_(thermostat::TIMER_HEATING_MAX_RUN_TIME) > 0)) { + this->timer_duration_(thermostat::THERMOSTAT_TIMER_HEATING_OFF) / 1000, + this->timer_duration_(thermostat::THERMOSTAT_TIMER_HEATING_ON) / 1000); + if ((this->supplemental_heat_delta_ > 0) || + (this->timer_duration_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME) > 0)) { ESP_LOGCONFIG(TAG, " Maximum Run Time: %" PRIu32 "s\n" " Supplemental Delta: %.1f°C", - this->timer_duration_(thermostat::TIMER_HEATING_MAX_RUN_TIME) / 1000, + this->timer_duration_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME) / 1000, this->supplemental_heat_delta_); } } @@ -1360,15 +1372,15 @@ void ThermostatClimate::dump_config() { " Fan Parameters:\n" " Minimum Off Time: %" PRIu32 "s\n" " Minimum Run Time: %" PRIu32 "s", - this->timer_duration_(thermostat::TIMER_FANNING_OFF) / 1000, - this->timer_duration_(thermostat::TIMER_FANNING_ON) / 1000); + this->timer_duration_(thermostat::THERMOSTAT_TIMER_FANNING_OFF) / 1000, + this->timer_duration_(thermostat::THERMOSTAT_TIMER_FANNING_ON) / 1000); } if (this->supports_fan_mode_on_ || this->supports_fan_mode_off_ || this->supports_fan_mode_auto_ || this->supports_fan_mode_low_ || this->supports_fan_mode_medium_ || this->supports_fan_mode_high_ || this->supports_fan_mode_middle_ || this->supports_fan_mode_focus_ || this->supports_fan_mode_diffuse_ || this->supports_fan_mode_quiet_) { ESP_LOGCONFIG(TAG, " Minimum Fan Mode Switching Time: %" PRIu32 "s", - this->timer_duration_(thermostat::TIMER_FAN_MODE) / 1000); + this->timer_duration_(thermostat::THERMOSTAT_TIMER_FAN_MODE) / 1000); } ESP_LOGCONFIG(TAG, " Minimum Idle Time: %" PRIu32 "s\n" @@ -1381,7 +1393,7 @@ void ThermostatClimate::dump_config() { " FAN_ONLY: %s\n" " FAN_ONLY_ACTION_USES_FAN_MODE_TIMER: %s\n" " FAN_ONLY_COOLING: %s", - this->timer_[thermostat::TIMER_IDLE_ON].time / 1000, YESNO(this->supports_auto_), + this->timer_[thermostat::THERMOSTAT_TIMER_IDLE_ON].time / 1000, YESNO(this->supports_auto_), YESNO(this->supports_heat_cool_), YESNO(this->supports_heat_), YESNO(this->supports_cool_), YESNO(this->supports_dry_), YESNO(this->supports_fan_only_), YESNO(this->supports_fan_only_action_uses_fan_mode_timer_), YESNO(this->supports_fan_only_cooling_)); diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index e2c7b00266..526f07116e 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -14,17 +14,17 @@ namespace esphome { namespace thermostat { enum ThermostatClimateTimerIndex : uint8_t { - TIMER_COOLING_MAX_RUN_TIME = 0, - TIMER_COOLING_OFF = 1, - TIMER_COOLING_ON = 2, - TIMER_FAN_MODE = 3, - TIMER_FANNING_OFF = 4, - TIMER_FANNING_ON = 5, - TIMER_HEATING_MAX_RUN_TIME = 6, - TIMER_HEATING_OFF = 7, - TIMER_HEATING_ON = 8, - TIMER_IDLE_ON = 9, - TIMER_COUNT = 10, + THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME = 0, + THERMOSTAT_TIMER_COOLING_OFF = 1, + THERMOSTAT_TIMER_COOLING_ON = 2, + THERMOSTAT_TIMER_FAN_MODE = 3, + THERMOSTAT_TIMER_FANNING_OFF = 4, + THERMOSTAT_TIMER_FANNING_ON = 5, + THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME = 6, + THERMOSTAT_TIMER_HEATING_OFF = 7, + THERMOSTAT_TIMER_HEATING_ON = 8, + THERMOSTAT_TIMER_IDLE_ON = 9, + THERMOSTAT_TIMER_COUNT = 10, }; enum OnBootRestoreFrom : uint8_t { @@ -467,7 +467,7 @@ class ThermostatClimate : public climate::Climate, public Component { std::string default_custom_preset_{}; /// Climate action timers - std::array timer_{ + std::array timer_{ ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::cooling_max_run_time_timer_callback_, this)), ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::cooling_off_timer_callback_, this)), ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::cooling_on_timer_callback_, this)), From 10aae33979641652afaa10f5fc975e9210965d7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Sep 2025 08:17:34 -0500 Subject: [PATCH 1024/1626] Improve coverage for various core modules (#10663) --- tests/unit_tests/conftest.py | 7 + .../test_config_validation_paths.py | 187 +++++++++++++++++ tests/unit_tests/test_external_files.py | 196 ++++++++++++++++++ tests/unit_tests/test_platformio_api.py | 129 ++++++++++++ tests/unit_tests/test_storage_json.py | 182 ++++++++++++++++ 5 files changed, 701 insertions(+) create mode 100644 tests/unit_tests/test_config_validation_paths.py create mode 100644 tests/unit_tests/test_external_files.py create mode 100644 tests/unit_tests/test_platformio_api.py create mode 100644 tests/unit_tests/test_storage_json.py diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index aac5a642f6..a1e438b577 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -36,3 +36,10 @@ def fixture_path() -> Path: Location of all fixture files. """ return here / "fixtures" + + +@pytest.fixture +def setup_core(tmp_path: Path) -> Path: + """Set up CORE with test paths.""" + CORE.config_path = str(tmp_path / "test.yaml") + return tmp_path diff --git a/tests/unit_tests/test_config_validation_paths.py b/tests/unit_tests/test_config_validation_paths.py new file mode 100644 index 0000000000..f8f038390e --- /dev/null +++ b/tests/unit_tests/test_config_validation_paths.py @@ -0,0 +1,187 @@ +"""Tests for config_validation.py path-related functions.""" + +from pathlib import Path + +import pytest +import voluptuous as vol + +from esphome import config_validation as cv + + +def test_directory_valid_path(setup_core: Path) -> None: + """Test directory validator with valid directory.""" + test_dir = setup_core / "test_directory" + test_dir.mkdir() + + result = cv.directory("test_directory") + + assert result == "test_directory" + + +def test_directory_absolute_path(setup_core: Path) -> None: + """Test directory validator with absolute path.""" + test_dir = setup_core / "test_directory" + test_dir.mkdir() + + result = cv.directory(str(test_dir)) + + assert result == str(test_dir) + + +def test_directory_nonexistent_path(setup_core: Path) -> None: + """Test directory validator raises error for non-existent directory.""" + with pytest.raises( + vol.Invalid, match="Could not find directory.*nonexistent_directory" + ): + cv.directory("nonexistent_directory") + + +def test_directory_file_instead_of_directory(setup_core: Path) -> None: + """Test directory validator raises error when path is a file.""" + test_file = setup_core / "test_file.txt" + test_file.write_text("content") + + with pytest.raises(vol.Invalid, match="is not a directory"): + cv.directory("test_file.txt") + + +def test_directory_with_parent_directory(setup_core: Path) -> None: + """Test directory validator with nested directory structure.""" + nested_dir = setup_core / "parent" / "child" / "grandchild" + nested_dir.mkdir(parents=True) + + result = cv.directory("parent/child/grandchild") + + assert result == "parent/child/grandchild" + + +def test_file_valid_path(setup_core: Path) -> None: + """Test file_ validator with valid file.""" + test_file = setup_core / "test_file.yaml" + test_file.write_text("test content") + + result = cv.file_("test_file.yaml") + + assert result == "test_file.yaml" + + +def test_file_absolute_path(setup_core: Path) -> None: + """Test file_ validator with absolute path.""" + test_file = setup_core / "test_file.yaml" + test_file.write_text("test content") + + result = cv.file_(str(test_file)) + + assert result == str(test_file) + + +def test_file_nonexistent_path(setup_core: Path) -> None: + """Test file_ validator raises error for non-existent file.""" + with pytest.raises(vol.Invalid, match="Could not find file.*nonexistent_file.yaml"): + cv.file_("nonexistent_file.yaml") + + +def test_file_directory_instead_of_file(setup_core: Path) -> None: + """Test file_ validator raises error when path is a directory.""" + test_dir = setup_core / "test_directory" + test_dir.mkdir() + + with pytest.raises(vol.Invalid, match="is not a file"): + cv.file_("test_directory") + + +def test_file_with_parent_directory(setup_core: Path) -> None: + """Test file_ validator with file in nested directory.""" + nested_dir = setup_core / "configs" / "sensors" + nested_dir.mkdir(parents=True) + test_file = nested_dir / "temperature.yaml" + test_file.write_text("sensor config") + + result = cv.file_("configs/sensors/temperature.yaml") + + assert result == "configs/sensors/temperature.yaml" + + +def test_directory_handles_trailing_slash(setup_core: Path) -> None: + """Test directory validator handles trailing slashes correctly.""" + test_dir = setup_core / "test_dir" + test_dir.mkdir() + + result = cv.directory("test_dir/") + assert result == "test_dir/" + + result = cv.directory("test_dir") + assert result == "test_dir" + + +def test_file_handles_various_extensions(setup_core: Path) -> None: + """Test file_ validator works with different file extensions.""" + yaml_file = setup_core / "config.yaml" + yaml_file.write_text("yaml content") + assert cv.file_("config.yaml") == "config.yaml" + + yml_file = setup_core / "config.yml" + yml_file.write_text("yml content") + assert cv.file_("config.yml") == "config.yml" + + txt_file = setup_core / "readme.txt" + txt_file.write_text("text content") + assert cv.file_("readme.txt") == "readme.txt" + + no_ext_file = setup_core / "LICENSE" + no_ext_file.write_text("license content") + assert cv.file_("LICENSE") == "LICENSE" + + +def test_directory_with_symlink(setup_core: Path) -> None: + """Test directory validator follows symlinks.""" + actual_dir = setup_core / "actual_directory" + actual_dir.mkdir() + + symlink_dir = setup_core / "symlink_directory" + symlink_dir.symlink_to(actual_dir) + + result = cv.directory("symlink_directory") + assert result == "symlink_directory" + + +def test_file_with_symlink(setup_core: Path) -> None: + """Test file_ validator follows symlinks.""" + actual_file = setup_core / "actual_file.txt" + actual_file.write_text("content") + + symlink_file = setup_core / "symlink_file.txt" + symlink_file.symlink_to(actual_file) + + result = cv.file_("symlink_file.txt") + assert result == "symlink_file.txt" + + +def test_directory_error_shows_full_path(setup_core: Path) -> None: + """Test directory validator error message includes full path.""" + with pytest.raises(vol.Invalid, match=".*missing_dir.*full path:.*"): + cv.directory("missing_dir") + + +def test_file_error_shows_full_path(setup_core: Path) -> None: + """Test file_ validator error message includes full path.""" + with pytest.raises(vol.Invalid, match=".*missing_file.yaml.*full path:.*"): + cv.file_("missing_file.yaml") + + +def test_directory_with_spaces_in_name(setup_core: Path) -> None: + """Test directory validator handles spaces in directory names.""" + dir_with_spaces = setup_core / "my test directory" + dir_with_spaces.mkdir() + + result = cv.directory("my test directory") + assert result == "my test directory" + + +def test_file_with_spaces_in_name(setup_core: Path) -> None: + """Test file_ validator handles spaces in file names.""" + file_with_spaces = setup_core / "my test file.yaml" + file_with_spaces.write_text("content") + + result = cv.file_("my test file.yaml") + assert result == "my test file.yaml" diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py new file mode 100644 index 0000000000..3fa7de2f64 --- /dev/null +++ b/tests/unit_tests/test_external_files.py @@ -0,0 +1,196 @@ +"""Tests for external_files.py functions.""" + +from pathlib import Path +import time +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from esphome import external_files +from esphome.config_validation import Invalid +from esphome.core import CORE, TimePeriod + + +def test_compute_local_file_dir(setup_core: Path) -> None: + """Test compute_local_file_dir creates and returns correct path.""" + domain = "font" + + result = external_files.compute_local_file_dir(domain) + + assert isinstance(result, Path) + assert result == Path(CORE.data_dir) / domain + assert result.exists() + assert result.is_dir() + + +def test_compute_local_file_dir_nested(setup_core: Path) -> None: + """Test compute_local_file_dir works with nested domains.""" + domain = "images/icons" + + result = external_files.compute_local_file_dir(domain) + + assert result == Path(CORE.data_dir) / "images" / "icons" + assert result.exists() + assert result.is_dir() + + +def test_is_file_recent_with_recent_file(setup_core: Path) -> None: + """Test is_file_recent returns True for recently created file.""" + test_file = setup_core / "recent.txt" + test_file.write_text("content") + + refresh = TimePeriod(seconds=3600) + + result = external_files.is_file_recent(str(test_file), refresh) + + assert result is True + + +def test_is_file_recent_with_old_file(setup_core: Path) -> None: + """Test is_file_recent returns False for old file.""" + test_file = setup_core / "old.txt" + test_file.write_text("content") + + old_time = time.time() - 7200 + + with patch("os.path.getctime", return_value=old_time): + refresh = TimePeriod(seconds=3600) + + result = external_files.is_file_recent(str(test_file), refresh) + + assert result is False + + +def test_is_file_recent_nonexistent_file(setup_core: Path) -> None: + """Test is_file_recent returns False for non-existent file.""" + test_file = setup_core / "nonexistent.txt" + refresh = TimePeriod(seconds=3600) + + result = external_files.is_file_recent(str(test_file), refresh) + + assert result is False + + +def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None: + """Test is_file_recent with zero refresh period returns False.""" + test_file = setup_core / "test.txt" + test_file.write_text("content") + + # Mock getctime to return a time 10 seconds ago + with patch("os.path.getctime", return_value=time.time() - 10): + refresh = TimePeriod(seconds=0) + result = external_files.is_file_recent(str(test_file), refresh) + assert result is False + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_not_modified( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed returns False when file not modified.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_head.return_value = mock_response + + url = "https://example.com/file.txt" + result = external_files.has_remote_file_changed(url, str(test_file)) + + assert result is False + mock_head.assert_called_once() + + call_args = mock_head.call_args + headers = call_args[1]["headers"] + assert external_files.IF_MODIFIED_SINCE in headers + assert external_files.CACHE_CONTROL in headers + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_modified( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed returns True when file modified.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_head.return_value = mock_response + + url = "https://example.com/file.txt" + result = external_files.has_remote_file_changed(url, str(test_file)) + + assert result is True + + +def test_has_remote_file_changed_no_local_file(setup_core: Path) -> None: + """Test has_remote_file_changed returns True when local file doesn't exist.""" + test_file = setup_core / "nonexistent.txt" + + url = "https://example.com/file.txt" + result = external_files.has_remote_file_changed(url, str(test_file)) + + assert result is True + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_network_error( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed handles network errors gracefully.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_head.side_effect = requests.exceptions.RequestException("Network error") + + url = "https://example.com/file.txt" + + with pytest.raises(Invalid, match="Could not check if.*Network error"): + external_files.has_remote_file_changed(url, str(test_file)) + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_timeout( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed respects timeout.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_head.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.has_remote_file_changed(url, str(test_file)) + + call_args = mock_head.call_args + assert call_args[1]["timeout"] == external_files.NETWORK_TIMEOUT + + +def test_compute_local_file_dir_creates_parent_dirs(setup_core: Path) -> None: + """Test compute_local_file_dir creates parent directories.""" + domain = "level1/level2/level3/level4" + + result = external_files.compute_local_file_dir(domain) + + assert result.exists() + assert result.is_dir() + assert result.parent.name == "level3" + assert result.parent.parent.name == "level2" + assert result.parent.parent.parent.name == "level1" + + +def test_is_file_recent_handles_float_seconds(setup_core: Path) -> None: + """Test is_file_recent works with float seconds in TimePeriod.""" + test_file = setup_core / "test.txt" + test_file.write_text("content") + + refresh = TimePeriod(seconds=3600.5) + + result = external_files.is_file_recent(str(test_file), refresh) + + assert result is True diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py new file mode 100644 index 0000000000..a1fa963e51 --- /dev/null +++ b/tests/unit_tests/test_platformio_api.py @@ -0,0 +1,129 @@ +"""Tests for platformio_api.py path functions.""" + +from pathlib import Path +from unittest.mock import patch + +from esphome import platformio_api +from esphome.core import CORE + + +def test_idedata_firmware_elf_path(setup_core: Path) -> None: + """Test IDEData.firmware_elf_path returns correct path.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + raw_data = {"prog_path": "/path/to/firmware.elf"} + idedata = platformio_api.IDEData(raw_data) + + assert idedata.firmware_elf_path == "/path/to/firmware.elf" + + +def test_idedata_firmware_bin_path(setup_core: Path) -> None: + """Test IDEData.firmware_bin_path returns Path with .bin extension.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + prog_path = str(Path("/path/to/firmware.elf")) + raw_data = {"prog_path": prog_path} + idedata = platformio_api.IDEData(raw_data) + + result = idedata.firmware_bin_path + assert isinstance(result, str) + expected = str(Path("/path/to/firmware.bin")) + assert result == expected + assert result.endswith(".bin") + + +def test_idedata_firmware_bin_path_preserves_directory(setup_core: Path) -> None: + """Test firmware_bin_path preserves the directory structure.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + prog_path = str(Path("/complex/path/to/build/firmware.elf")) + raw_data = {"prog_path": prog_path} + idedata = platformio_api.IDEData(raw_data) + + result = idedata.firmware_bin_path + expected = str(Path("/complex/path/to/build/firmware.bin")) + assert result == expected + + +def test_idedata_extra_flash_images(setup_core: Path) -> None: + """Test IDEData.extra_flash_images returns list of FlashImage objects.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + raw_data = { + "prog_path": "/path/to/firmware.elf", + "extra": { + "flash_images": [ + {"path": "/path/to/bootloader.bin", "offset": "0x1000"}, + {"path": "/path/to/partition.bin", "offset": "0x8000"}, + ] + }, + } + idedata = platformio_api.IDEData(raw_data) + + images = idedata.extra_flash_images + assert len(images) == 2 + assert all(isinstance(img, platformio_api.FlashImage) for img in images) + assert images[0].path == "/path/to/bootloader.bin" + assert images[0].offset == "0x1000" + assert images[1].path == "/path/to/partition.bin" + assert images[1].offset == "0x8000" + + +def test_idedata_extra_flash_images_empty(setup_core: Path) -> None: + """Test extra_flash_images returns empty list when no extra images.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + raw_data = {"prog_path": "/path/to/firmware.elf", "extra": {"flash_images": []}} + idedata = platformio_api.IDEData(raw_data) + + images = idedata.extra_flash_images + assert images == [] + + +def test_idedata_cc_path(setup_core: Path) -> None: + """Test IDEData.cc_path returns compiler path.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + raw_data = { + "prog_path": "/path/to/firmware.elf", + "cc_path": "/Users/test/.platformio/packages/toolchain-xtensa32/bin/xtensa-esp32-elf-gcc", + } + idedata = platformio_api.IDEData(raw_data) + + assert ( + idedata.cc_path + == "/Users/test/.platformio/packages/toolchain-xtensa32/bin/xtensa-esp32-elf-gcc" + ) + + +def test_flash_image_dataclass() -> None: + """Test FlashImage dataclass stores path and offset correctly.""" + image = platformio_api.FlashImage(path="/path/to/image.bin", offset="0x10000") + + assert image.path == "/path/to/image.bin" + assert image.offset == "0x10000" + + +def test_load_idedata_returns_dict(setup_core: Path) -> None: + """Test _load_idedata returns parsed idedata dict when successful.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + + # Create required files + platformio_ini = setup_core / "build" / "test" / "platformio.ini" + platformio_ini.parent.mkdir(parents=True, exist_ok=True) + platformio_ini.touch() + + idedata_path = setup_core / ".esphome" / "idedata" / "test.json" + idedata_path.parent.mkdir(parents=True, exist_ok=True) + idedata_path.write_text('{"prog_path": "/test/firmware.elf"}') + + with patch("esphome.platformio_api.run_platformio_cli_run") as mock_run: + mock_run.return_value = '{"prog_path": "/test/firmware.elf"}' + + config = {"name": "test"} + result = platformio_api._load_idedata(config) + + assert result is not None + assert isinstance(result, dict) + assert result["prog_path"] == "/test/firmware.elf" diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py new file mode 100644 index 0000000000..52de327bbc --- /dev/null +++ b/tests/unit_tests/test_storage_json.py @@ -0,0 +1,182 @@ +"""Tests for storage_json.py path functions.""" + +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + +from esphome import storage_json +from esphome.core import CORE + + +def test_storage_path(setup_core: Path) -> None: + """Test storage_path returns correct path for current config.""" + CORE.config_path = str(setup_core / "my_device.yaml") + + result = storage_json.storage_path() + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "storage" / "my_device.yaml.json") + assert result == expected + + +def test_ext_storage_path(setup_core: Path) -> None: + """Test ext_storage_path returns correct path for given filename.""" + result = storage_json.ext_storage_path("other_device.yaml") + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "storage" / "other_device.yaml.json") + assert result == expected + + +def test_ext_storage_path_handles_various_extensions(setup_core: Path) -> None: + """Test ext_storage_path works with different file extensions.""" + result_yml = storage_json.ext_storage_path("device.yml") + assert result_yml.endswith("device.yml.json") + + result_no_ext = storage_json.ext_storage_path("device") + assert result_no_ext.endswith("device.json") + + result_path = storage_json.ext_storage_path("my/device.yaml") + assert result_path.endswith("device.yaml.json") + + +def test_esphome_storage_path(setup_core: Path) -> None: + """Test esphome_storage_path returns correct path.""" + result = storage_json.esphome_storage_path() + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "esphome.json") + assert result == expected + + +def test_ignored_devices_storage_path(setup_core: Path) -> None: + """Test ignored_devices_storage_path returns correct path.""" + result = storage_json.ignored_devices_storage_path() + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "ignored-devices.json") + assert result == expected + + +def test_trash_storage_path(setup_core: Path) -> None: + """Test trash_storage_path returns correct path.""" + CORE.config_path = str(setup_core / "configs" / "device.yaml") + + result = storage_json.trash_storage_path() + + expected = str(setup_core / "configs" / "trash") + assert result == expected + + +def test_archive_storage_path(setup_core: Path) -> None: + """Test archive_storage_path returns correct path.""" + CORE.config_path = str(setup_core / "configs" / "device.yaml") + + result = storage_json.archive_storage_path() + + expected = str(setup_core / "configs" / "archive") + assert result == expected + + +def test_storage_path_with_subdirectory(setup_core: Path) -> None: + """Test storage paths work correctly when config is in subdirectory.""" + subdir = setup_core / "configs" / "basement" + subdir.mkdir(parents=True, exist_ok=True) + CORE.config_path = str(subdir / "sensor.yaml") + + result = storage_json.storage_path() + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "storage" / "sensor.yaml.json") + assert result == expected + + +def test_storage_json_firmware_bin_path_property(setup_core: Path) -> None: + """Test StorageJSON firmware_bin_path property.""" + storage = storage_json.StorageJSON( + storage_version=1, + name="test_device", + friendly_name="Test Device", + comment=None, + esphome_version="2024.1.0", + src_version=None, + address="192.168.1.100", + web_port=80, + target_platform="ESP32", + build_path="build/test_device", + firmware_bin_path="/path/to/firmware.bin", + loaded_integrations={"wifi", "api"}, + loaded_platforms=set(), + no_mdns=False, + ) + + assert storage.firmware_bin_path == "/path/to/firmware.bin" + + +def test_storage_json_save_creates_directory(setup_core: Path, tmp_path: Path) -> None: + """Test StorageJSON.save creates storage directory if it doesn't exist.""" + storage_dir = tmp_path / "new_data" / "storage" + storage_file = storage_dir / "test.json" + + assert not storage_dir.exists() + + storage = storage_json.StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=None, + address="test.local", + web_port=None, + target_platform="ESP8266", + build_path=None, + firmware_bin_path=None, + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + ) + + with patch("esphome.storage_json.write_file_if_changed") as mock_write: + storage.save(str(storage_file)) + mock_write.assert_called_once() + call_args = mock_write.call_args[0] + assert call_args[0] == str(storage_file) + + +def test_storage_json_from_wizard(setup_core: Path) -> None: + """Test StorageJSON.from_wizard creates correct storage object.""" + storage = storage_json.StorageJSON.from_wizard( + name="my_device", + friendly_name="My Device", + address="my_device.local", + platform="ESP32", + ) + + assert storage.name == "my_device" + assert storage.friendly_name == "My Device" + assert storage.address == "my_device.local" + assert storage.target_platform == "ESP32" + assert storage.build_path is None + assert storage.firmware_bin_path is None + + +@pytest.mark.skipif(sys.platform == "win32", reason="HA addons don't run on Windows") +@patch("esphome.core.is_ha_addon") +def test_storage_paths_with_ha_addon(mock_is_ha_addon: bool, tmp_path: Path) -> None: + """Test storage paths when running as Home Assistant addon.""" + mock_is_ha_addon.return_value = True + + CORE.config_path = str(tmp_path / "test.yaml") + + result = storage_json.storage_path() + # When is_ha_addon is True, CORE.data_dir returns "/data" + # This is the standard mount point for HA addon containers + expected = str(Path("/data") / "storage" / "test.yaml.json") + assert result == expected + + result = storage_json.esphome_storage_path() + expected = str(Path("/data") / "esphome.json") + assert result == expected From 32e4eb26ad54bace3a6f16db11e3a5881f0f9b38 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:46:30 -0400 Subject: [PATCH 1025/1626] [remote] Remove duplicate implementations of remote code (#10548) --- .../components/remote_receiver/__init__.py | 4 +- ...ceiver_esp8266.cpp => remote_receiver.cpp} | 4 +- .../remote_receiver_libretiny.cpp | 125 ------------------ .../components/remote_transmitter/__init__.py | 4 +- .../remote_transmitter/remote_transmitter.cpp | 97 ++++++++++++++ .../remote_transmitter_esp8266.cpp | 107 --------------- .../remote_transmitter_libretiny.cpp | 110 --------------- 7 files changed, 103 insertions(+), 348 deletions(-) rename esphome/components/remote_receiver/{remote_receiver_esp8266.cpp => remote_receiver.cpp} (97%) delete mode 100644 esphome/components/remote_receiver/remote_receiver_libretiny.cpp delete mode 100644 esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp delete mode 100644 esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 9095016b55..956f240b14 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -196,8 +196,8 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, - "remote_receiver_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "remote_receiver_libretiny.cpp": { + "remote_receiver.cpp": { + PlatformFramework.ESP8266_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/remote_receiver/remote_receiver_esp8266.cpp b/esphome/components/remote_receiver/remote_receiver.cpp similarity index 97% rename from esphome/components/remote_receiver/remote_receiver_esp8266.cpp rename to esphome/components/remote_receiver/remote_receiver.cpp index b8ac29a543..a8438e20d7 100644 --- a/esphome/components/remote_receiver/remote_receiver_esp8266.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -3,12 +3,12 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP8266 +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) namespace esphome { namespace remote_receiver { -static const char *const TAG = "remote_receiver.esp8266"; +static const char *const TAG = "remote_receiver"; void IRAM_ATTR HOT RemoteReceiverComponentStore::gpio_intr(RemoteReceiverComponentStore *arg) { const uint32_t now = micros(); diff --git a/esphome/components/remote_receiver/remote_receiver_libretiny.cpp b/esphome/components/remote_receiver/remote_receiver_libretiny.cpp deleted file mode 100644 index 8d801b37d2..0000000000 --- a/esphome/components/remote_receiver/remote_receiver_libretiny.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include "remote_receiver.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" - -#ifdef USE_LIBRETINY - -namespace esphome { -namespace remote_receiver { - -static const char *const TAG = "remote_receiver.libretiny"; - -void IRAM_ATTR HOT RemoteReceiverComponentStore::gpio_intr(RemoteReceiverComponentStore *arg) { - const uint32_t now = micros(); - // If the lhs is 1 (rising edge) we should write to an uneven index and vice versa - const uint32_t next = (arg->buffer_write_at + 1) % arg->buffer_size; - const bool level = arg->pin.digital_read(); - if (level != next % 2) - return; - - // If next is buffer_read, we have hit an overflow - if (next == arg->buffer_read_at) - return; - - const uint32_t last_change = arg->buffer[arg->buffer_write_at]; - const uint32_t time_since_change = now - last_change; - if (time_since_change <= arg->filter_us) - return; - - arg->buffer[arg->buffer_write_at = next] = now; -} - -void RemoteReceiverComponent::setup() { - this->pin_->setup(); - auto &s = this->store_; - s.filter_us = this->filter_us_; - s.pin = this->pin_->to_isr(); - s.buffer_size = this->buffer_size_; - - this->high_freq_.start(); - if (s.buffer_size % 2 != 0) { - // Make sure divisible by two. This way, we know that every 0bxxx0 index is a space and every 0bxxx1 index is a mark - s.buffer_size++; - } - - s.buffer = new uint32_t[s.buffer_size]; - void *buf = (void *) s.buffer; - memset(buf, 0, s.buffer_size * sizeof(uint32_t)); - - // First index is a space. - if (this->pin_->digital_read()) { - s.buffer_write_at = s.buffer_read_at = 1; - } else { - s.buffer_write_at = s.buffer_read_at = 0; - } - this->pin_->attach_interrupt(RemoteReceiverComponentStore::gpio_intr, &this->store_, gpio::INTERRUPT_ANY_EDGE); -} -void RemoteReceiverComponent::dump_config() { - ESP_LOGCONFIG(TAG, "Remote Receiver:"); - LOG_PIN(" Pin: ", this->pin_); - if (this->pin_->digital_read()) { - ESP_LOGW(TAG, "Remote Receiver Signal starts with a HIGH value. Usually this means you have to " - "invert the signal using 'inverted: True' in the pin schema!"); - } - ESP_LOGCONFIG(TAG, - " Buffer Size: %u\n" - " Tolerance: %u%s\n" - " Filter out pulses shorter than: %u us\n" - " Signal is done after %u us of no changes", - this->buffer_size_, this->tolerance_, - (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", this->filter_us_, - this->idle_us_); -} - -void RemoteReceiverComponent::loop() { - auto &s = this->store_; - - // copy write at to local variables, as it's volatile - const uint32_t write_at = s.buffer_write_at; - const uint32_t dist = (s.buffer_size + write_at - s.buffer_read_at) % s.buffer_size; - // signals must at least one rising and one leading edge - if (dist <= 1) - return; - const uint32_t now = micros(); - if (now - s.buffer[write_at] < this->idle_us_) { - // The last change was fewer than the configured idle time ago. - return; - } - - ESP_LOGVV(TAG, "read_at=%u write_at=%u dist=%u now=%u end=%u", s.buffer_read_at, write_at, dist, now, - s.buffer[write_at]); - - // Skip first value, it's from the previous idle level - s.buffer_read_at = (s.buffer_read_at + 1) % s.buffer_size; - uint32_t prev = s.buffer_read_at; - s.buffer_read_at = (s.buffer_read_at + 1) % s.buffer_size; - const uint32_t reserve_size = 1 + (s.buffer_size + write_at - s.buffer_read_at) % s.buffer_size; - this->temp_.clear(); - this->temp_.reserve(reserve_size); - int32_t multiplier = s.buffer_read_at % 2 == 0 ? 1 : -1; - - for (uint32_t i = 0; prev != write_at; i++) { - int32_t delta = s.buffer[s.buffer_read_at] - s.buffer[prev]; - if (uint32_t(delta) >= this->idle_us_) { - // already found a space longer than idle. There must have been two pulses - break; - } - - ESP_LOGVV(TAG, " i=%u buffer[%u]=%u - buffer[%u]=%u -> %d", i, s.buffer_read_at, s.buffer[s.buffer_read_at], prev, - s.buffer[prev], multiplier * delta); - this->temp_.push_back(multiplier * delta); - prev = s.buffer_read_at; - s.buffer_read_at = (s.buffer_read_at + 1) % s.buffer_size; - multiplier *= -1; - } - s.buffer_read_at = (s.buffer_size + s.buffer_read_at - 1) % s.buffer_size; - this->temp_.push_back(this->idle_us_ * multiplier); - - this->call_listeners_dumpers_(); -} - -} // namespace remote_receiver -} // namespace esphome - -#endif diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index e79437013f..cb98c017f1 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -131,8 +131,8 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, - "remote_transmitter_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "remote_transmitter_libretiny.cpp": { + "remote_transmitter.cpp": { + PlatformFramework.ESP8266_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 425418ff39..79d9cda93b 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -2,10 +2,107 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) + namespace esphome { namespace remote_transmitter { static const char *const TAG = "remote_transmitter"; +void RemoteTransmitterComponent::setup() { + this->pin_->setup(); + this->pin_->digital_write(false); +} + +void RemoteTransmitterComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Remote Transmitter:\n" + " Carrier Duty: %u%%", + this->carrier_duty_percent_); + LOG_PIN(" Pin: ", this->pin_); +} + +void RemoteTransmitterComponent::calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, + uint32_t *off_time_period) { + if (carrier_frequency == 0) { + *on_time_period = 0; + *off_time_period = 0; + return; + } + uint32_t period = (1000000UL + carrier_frequency / 2) / carrier_frequency; // round(1000000/freq) + period = std::max(uint32_t(1), period); + *on_time_period = (period * this->carrier_duty_percent_) / 100; + *off_time_period = period - *on_time_period; +} + +void RemoteTransmitterComponent::await_target_time_() { + const uint32_t current_time = micros(); + if (this->target_time_ == 0) { + this->target_time_ = current_time; + } else if ((int32_t) (this->target_time_ - current_time) > 0) { + delayMicroseconds(this->target_time_ - current_time); + } +} + +void RemoteTransmitterComponent::mark_(uint32_t on_time, uint32_t off_time, uint32_t usec) { + this->await_target_time_(); + this->pin_->digital_write(true); + + const uint32_t target = this->target_time_ + usec; + if (this->carrier_duty_percent_ < 100 && (on_time > 0 || off_time > 0)) { + while (true) { // Modulate with carrier frequency + this->target_time_ += on_time; + if ((int32_t) (this->target_time_ - target) >= 0) + break; + this->await_target_time_(); + this->pin_->digital_write(false); + + this->target_time_ += off_time; + if ((int32_t) (this->target_time_ - target) >= 0) + break; + this->await_target_time_(); + this->pin_->digital_write(true); + } + } + this->target_time_ = target; +} + +void RemoteTransmitterComponent::space_(uint32_t usec) { + this->await_target_time_(); + this->pin_->digital_write(false); + this->target_time_ += usec; +} + +void RemoteTransmitterComponent::digital_write(bool value) { this->pin_->digital_write(value); } + +void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { + ESP_LOGD(TAG, "Sending remote code"); + uint32_t on_time, off_time; + this->calculate_on_off_time_(this->temp_.get_carrier_frequency(), &on_time, &off_time); + this->target_time_ = 0; + this->transmit_trigger_->trigger(); + for (uint32_t i = 0; i < send_times; i++) { + InterruptLock lock; + for (int32_t item : this->temp_.get_data()) { + if (item > 0) { + const auto length = uint32_t(item); + this->mark_(on_time, off_time, length); + } else { + const auto length = uint32_t(-item); + this->space_(length); + } + App.feed_wdt(); + } + this->await_target_time_(); // wait for duration of last pulse + this->pin_->digital_write(false); + + if (i + 1 < send_times) + this->target_time_ += send_wait; + } + this->complete_trigger_->trigger(); +} + } // namespace remote_transmitter } // namespace esphome + +#endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp b/esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp deleted file mode 100644 index fdd4198773..0000000000 --- a/esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp +++ /dev/null @@ -1,107 +0,0 @@ -#include "remote_transmitter.h" -#include "esphome/core/log.h" -#include "esphome/core/application.h" - -#ifdef USE_ESP8266 - -namespace esphome { -namespace remote_transmitter { - -static const char *const TAG = "remote_transmitter"; - -void RemoteTransmitterComponent::setup() { - this->pin_->setup(); - this->pin_->digital_write(false); -} - -void RemoteTransmitterComponent::dump_config() { - ESP_LOGCONFIG(TAG, - "Remote Transmitter:\n" - " Carrier Duty: %u%%", - this->carrier_duty_percent_); - LOG_PIN(" Pin: ", this->pin_); -} - -void RemoteTransmitterComponent::calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, - uint32_t *off_time_period) { - if (carrier_frequency == 0) { - *on_time_period = 0; - *off_time_period = 0; - return; - } - uint32_t period = (1000000UL + carrier_frequency / 2) / carrier_frequency; // round(1000000/freq) - period = std::max(uint32_t(1), period); - *on_time_period = (period * this->carrier_duty_percent_) / 100; - *off_time_period = period - *on_time_period; -} - -void RemoteTransmitterComponent::await_target_time_() { - const uint32_t current_time = micros(); - if (this->target_time_ == 0) { - this->target_time_ = current_time; - } else if ((int32_t) (this->target_time_ - current_time) > 0) { - delayMicroseconds(this->target_time_ - current_time); - } -} - -void RemoteTransmitterComponent::mark_(uint32_t on_time, uint32_t off_time, uint32_t usec) { - this->await_target_time_(); - this->pin_->digital_write(true); - - const uint32_t target = this->target_time_ + usec; - if (this->carrier_duty_percent_ < 100 && (on_time > 0 || off_time > 0)) { - while (true) { // Modulate with carrier frequency - this->target_time_ += on_time; - if ((int32_t) (this->target_time_ - target) >= 0) - break; - this->await_target_time_(); - this->pin_->digital_write(false); - - this->target_time_ += off_time; - if ((int32_t) (this->target_time_ - target) >= 0) - break; - this->await_target_time_(); - this->pin_->digital_write(true); - } - } - this->target_time_ = target; -} - -void RemoteTransmitterComponent::space_(uint32_t usec) { - this->await_target_time_(); - this->pin_->digital_write(false); - this->target_time_ += usec; -} - -void RemoteTransmitterComponent::digital_write(bool value) { this->pin_->digital_write(value); } - -void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { - ESP_LOGD(TAG, "Sending remote code"); - uint32_t on_time, off_time; - this->calculate_on_off_time_(this->temp_.get_carrier_frequency(), &on_time, &off_time); - this->target_time_ = 0; - this->transmit_trigger_->trigger(); - for (uint32_t i = 0; i < send_times; i++) { - for (int32_t item : this->temp_.get_data()) { - if (item > 0) { - const auto length = uint32_t(item); - this->mark_(on_time, off_time, length); - } else { - const auto length = uint32_t(-item); - this->space_(length); - } - App.feed_wdt(); - } - this->await_target_time_(); // wait for duration of last pulse - this->pin_->digital_write(false); - - if (i + 1 < send_times) - this->target_time_ += send_wait; - } - this->complete_trigger_->trigger(); -} - -} // namespace remote_transmitter -} // namespace esphome - -#endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp deleted file mode 100644 index 9ba850090d..0000000000 --- a/esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp +++ /dev/null @@ -1,110 +0,0 @@ -#include "remote_transmitter.h" -#include "esphome/core/log.h" -#include "esphome/core/application.h" - -#ifdef USE_LIBRETINY - -namespace esphome { -namespace remote_transmitter { - -static const char *const TAG = "remote_transmitter"; - -void RemoteTransmitterComponent::setup() { - this->pin_->setup(); - this->pin_->digital_write(false); -} - -void RemoteTransmitterComponent::dump_config() { - ESP_LOGCONFIG(TAG, - "Remote Transmitter:\n" - " Carrier Duty: %u%%", - this->carrier_duty_percent_); - LOG_PIN(" Pin: ", this->pin_); -} - -void RemoteTransmitterComponent::calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, - uint32_t *off_time_period) { - if (carrier_frequency == 0) { - *on_time_period = 0; - *off_time_period = 0; - return; - } - uint32_t period = (1000000UL + carrier_frequency / 2) / carrier_frequency; // round(1000000/freq) - period = std::max(uint32_t(1), period); - *on_time_period = (period * this->carrier_duty_percent_) / 100; - *off_time_period = period - *on_time_period; -} - -void RemoteTransmitterComponent::await_target_time_() { - const uint32_t current_time = micros(); - if (this->target_time_ == 0) { - this->target_time_ = current_time; - } else { - while ((int32_t) (this->target_time_ - micros()) > 0) { - // busy loop that ensures micros is constantly called - } - } -} - -void RemoteTransmitterComponent::mark_(uint32_t on_time, uint32_t off_time, uint32_t usec) { - this->await_target_time_(); - this->pin_->digital_write(true); - - const uint32_t target = this->target_time_ + usec; - if (this->carrier_duty_percent_ < 100 && (on_time > 0 || off_time > 0)) { - while (true) { // Modulate with carrier frequency - this->target_time_ += on_time; - if ((int32_t) (this->target_time_ - target) >= 0) - break; - this->await_target_time_(); - this->pin_->digital_write(false); - - this->target_time_ += off_time; - if ((int32_t) (this->target_time_ - target) >= 0) - break; - this->await_target_time_(); - this->pin_->digital_write(true); - } - } - this->target_time_ = target; -} - -void RemoteTransmitterComponent::space_(uint32_t usec) { - this->await_target_time_(); - this->pin_->digital_write(false); - this->target_time_ += usec; -} - -void RemoteTransmitterComponent::digital_write(bool value) { this->pin_->digital_write(value); } - -void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { - ESP_LOGD(TAG, "Sending remote code"); - uint32_t on_time, off_time; - this->calculate_on_off_time_(this->temp_.get_carrier_frequency(), &on_time, &off_time); - this->target_time_ = 0; - this->transmit_trigger_->trigger(); - for (uint32_t i = 0; i < send_times; i++) { - InterruptLock lock; - for (int32_t item : this->temp_.get_data()) { - if (item > 0) { - const auto length = uint32_t(item); - this->mark_(on_time, off_time, length); - } else { - const auto length = uint32_t(-item); - this->space_(length); - } - App.feed_wdt(); - } - this->await_target_time_(); // wait for duration of last pulse - this->pin_->digital_write(false); - - if (i + 1 < send_times) - this->target_time_ += send_wait; - } - this->complete_trigger_->trigger(); -} - -} // namespace remote_transmitter -} // namespace esphome - -#endif From af0da3f89721566d3b94cf2fe5ada1868e269051 Mon Sep 17 00:00:00 2001 From: esphomebot Date: Thu, 11 Sep 2025 03:41:18 +1200 Subject: [PATCH 1026/1626] Update webserver local assets to 20250910-110003 (#10668) --- .../components/captive_portal/captive_index.h | 174 +++++----- .../components/web_server/server_index_v2.h | 298 +++++++++--------- 2 files changed, 226 insertions(+), 246 deletions(-) diff --git a/esphome/components/captive_portal/captive_index.h b/esphome/components/captive_portal/captive_index.h index 8835762fb3..3122f27558 100644 --- a/esphome/components/captive_portal/captive_index.h +++ b/esphome/components/captive_portal/captive_index.h @@ -7,103 +7,83 @@ namespace esphome { namespace captive_portal { const uint8_t INDEX_GZ[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xdd, 0x58, 0x6d, 0x6f, 0xdb, 0x38, 0x12, 0xfe, 0xde, - 0x5f, 0x31, 0xa7, 0x36, 0x6b, 0x6b, 0x1b, 0x51, 0x22, 0xe5, 0xb7, 0xd8, 0x92, 0x16, 0x69, 0xae, 0x8b, 0x5d, 0xa0, - 0xdd, 0x2d, 0x90, 0x6c, 0xef, 0x43, 0x51, 0x20, 0xb4, 0x34, 0xb2, 0xd8, 0x48, 0xa4, 0x4e, 0xa4, 0x5f, 0x52, 0xc3, - 0xf7, 0xdb, 0x0f, 0x94, 0x6c, 0xc7, 0xe9, 0x35, 0x87, 0xeb, 0xe2, 0x0e, 0x87, 0xdd, 0x18, 0x21, 0x86, 0xe4, 0xcc, - 0x70, 0xe6, 0xf1, 0x0c, 0x67, 0xcc, 0xe8, 0x2f, 0x99, 0x4a, 0xcd, 0x7d, 0x8d, 0x50, 0x98, 0xaa, 0x4c, 0x22, 0x3b, - 0x42, 0xc9, 0xe5, 0x22, 0x46, 0x99, 0x44, 0x05, 0xf2, 0x2c, 0x89, 0x2a, 0x34, 0x1c, 0xd2, 0x82, 0x37, 0x1a, 0x4d, - 0xfc, 0xdb, 0xcd, 0x8f, 0xde, 0x04, 0xfc, 0x24, 0x2a, 0x85, 0xbc, 0x83, 0x06, 0xcb, 0x58, 0xa4, 0x4a, 0x42, 0xd1, - 0x60, 0x1e, 0x67, 0xdc, 0xf0, 0xa9, 0xa8, 0xf8, 0x02, 0x2d, 0x43, 0x2b, 0x26, 0x79, 0x85, 0xf1, 0x4a, 0xe0, 0xba, - 0x56, 0x8d, 0x81, 0x54, 0x49, 0x83, 0xd2, 0xc4, 0xce, 0x5a, 0x64, 0xa6, 0x88, 0x33, 0x5c, 0x89, 0x14, 0xbd, 0x76, - 0x72, 0x2e, 0xa4, 0x30, 0x82, 0x97, 0x9e, 0x4e, 0x79, 0x89, 0x31, 0x3d, 0x5f, 0x6a, 0x6c, 0xda, 0x09, 0x9f, 0x97, - 0x18, 0x4b, 0xe5, 0xf8, 0x49, 0xa4, 0xd3, 0x46, 0xd4, 0x06, 0xac, 0xbd, 0x71, 0xa5, 0xb2, 0x65, 0x89, 0x89, 0xef, - 0x73, 0xad, 0xd1, 0x68, 0x5f, 0xc8, 0x0c, 0x37, 0x64, 0x14, 0x86, 0x29, 0xe3, 0xe3, 0x9c, 0x7c, 0xd2, 0xcf, 0x32, - 0x95, 0x2e, 0x2b, 0x94, 0x86, 0x94, 0x2a, 0xe5, 0x46, 0x28, 0x49, 0x34, 0xf2, 0x26, 0x2d, 0xe2, 0x38, 0x76, 0x7e, - 0xd0, 0x7c, 0x85, 0xce, 0x77, 0xdf, 0xf5, 0x8f, 0x4c, 0x0b, 0x34, 0xaf, 0x4b, 0xb4, 0xa4, 0x7e, 0x75, 0x7f, 0xc3, - 0x17, 0xbf, 0xf0, 0x0a, 0xfb, 0x0e, 0xd7, 0x22, 0x43, 0xc7, 0xfd, 0x10, 0x7c, 0x24, 0xda, 0xdc, 0x97, 0x48, 0x32, - 0xa1, 0xeb, 0x92, 0xdf, 0xc7, 0xce, 0xbc, 0x54, 0xe9, 0x9d, 0xe3, 0xce, 0xf2, 0xa5, 0x4c, 0xad, 0x72, 0xd0, 0x7d, - 0x74, 0xb7, 0x25, 0x1a, 0x30, 0xf1, 0x5b, 0x6e, 0x0a, 0x52, 0xf1, 0x4d, 0xbf, 0x23, 0x84, 0xec, 0xb3, 0xef, 0xfb, - 0xf8, 0x92, 0x06, 0x81, 0x7b, 0xde, 0x0e, 0x81, 0xeb, 0xd3, 0x20, 0x98, 0x35, 0x68, 0x96, 0x8d, 0x04, 0xde, 0xbf, - 0x8d, 0x6a, 0x6e, 0x0a, 0xc8, 0x62, 0xa7, 0xa2, 0x8c, 0x04, 0xc1, 0x04, 0xe8, 0x05, 0x61, 0x43, 0x8f, 0x52, 0x12, - 0x7a, 0x74, 0x98, 0x8e, 0xbd, 0x21, 0xd0, 0x81, 0x37, 0x04, 0xc6, 0xc8, 0x10, 0x82, 0xcf, 0x0e, 0xe4, 0xa2, 0x2c, - 0x63, 0x47, 0x2a, 0x89, 0x0e, 0x68, 0xd3, 0xa8, 0x3b, 0x8c, 0x9d, 0x74, 0xd9, 0x34, 0x28, 0xcd, 0x95, 0x2a, 0x55, - 0xe3, 0xf8, 0xc9, 0x33, 0x78, 0xf4, 0xf7, 0xcd, 0x47, 0x98, 0x86, 0x4b, 0x9d, 0xab, 0xa6, 0x8a, 0x9d, 0xf6, 0x4b, - 0xe9, 0xbf, 0xd8, 0x9a, 0x1d, 0xd8, 0xc1, 0x3d, 0xd9, 0xf4, 0x54, 0x23, 0x16, 0x42, 0xc6, 0x0e, 0x65, 0x40, 0x27, - 0x8e, 0x9f, 0xdc, 0xba, 0xbb, 0x23, 0x26, 0xdc, 0x62, 0xb2, 0xf7, 0x52, 0xf5, 0x3f, 0xdc, 0x46, 0x7a, 0xb5, 0x80, - 0x4d, 0x55, 0x4a, 0x1d, 0x3b, 0x85, 0x31, 0xf5, 0xd4, 0xf7, 0xd7, 0xeb, 0x35, 0x59, 0x87, 0x44, 0x35, 0x0b, 0x9f, - 0x05, 0x41, 0xe0, 0xeb, 0xd5, 0xc2, 0x81, 0x2e, 0x3e, 0x1c, 0x36, 0x70, 0xa0, 0x40, 0xb1, 0x28, 0x4c, 0x4b, 0x27, - 0x2f, 0xb6, 0xb8, 0x8b, 0x2c, 0x47, 0x72, 0xfb, 0xf1, 0xe4, 0x14, 0x71, 0x72, 0x0a, 0xfe, 0x70, 0x82, 0x66, 0xef, - 0xad, 0x35, 0x6a, 0xcc, 0x19, 0x30, 0x08, 0xda, 0x0f, 0xf3, 0x2c, 0xbd, 0x9f, 0x79, 0x5f, 0xcc, 0xe0, 0x64, 0x06, - 0x0c, 0x9e, 0x01, 0xb0, 0x6a, 0xe4, 0x5d, 0x1c, 0xc5, 0xa9, 0xdd, 0x5e, 0xd1, 0xe0, 0x61, 0xc1, 0xca, 0xfc, 0x34, - 0x3a, 0x9d, 0x7b, 0xec, 0xbd, 0x65, 0xb0, 0xd8, 0x1f, 0x85, 0x3c, 0x56, 0xd0, 0xf7, 0x23, 0x3e, 0x84, 0xe1, 0x7e, - 0x65, 0xe8, 0x59, 0xfa, 0x38, 0xb3, 0x27, 0xc1, 0x70, 0xc5, 0x0a, 0x5a, 0x79, 0x23, 0x6f, 0xc8, 0x43, 0x08, 0xf7, - 0x26, 0x85, 0x10, 0xae, 0x58, 0x31, 0x7a, 0x3f, 0x3a, 0x5d, 0xf3, 0xc2, 0xcf, 0x3d, 0x0b, 0xf3, 0xd4, 0x71, 0x1e, - 0x30, 0x50, 0xa7, 0x18, 0x90, 0x4f, 0x4a, 0xc8, 0xbe, 0xe3, 0xb8, 0xbb, 0x1c, 0x4d, 0x5a, 0xf4, 0x1d, 0x3f, 0x55, - 0x32, 0x17, 0x0b, 0xf2, 0x49, 0x2b, 0xe9, 0xb8, 0xc4, 0x14, 0x28, 0xfb, 0x07, 0x51, 0x2b, 0x88, 0xed, 0x4e, 0xff, - 0xcb, 0x1d, 0xe3, 0x6e, 0x8f, 0xf9, 0x61, 0x84, 0x29, 0x31, 0x36, 0xc4, 0x66, 0xf4, 0xf9, 0x71, 0x75, 0xae, 0xb2, - 0xfb, 0x27, 0x52, 0xa7, 0xa0, 0x5d, 0xde, 0x08, 0x29, 0xb1, 0xb9, 0xc1, 0x8d, 0x89, 0x9d, 0xb7, 0x97, 0x57, 0x70, - 0x99, 0x65, 0x0d, 0x6a, 0x3d, 0x05, 0xe7, 0xa5, 0x21, 0x15, 0x4f, 0xff, 0x73, 0x5d, 0xf4, 0x91, 0xae, 0xbf, 0x89, - 0x1f, 0x05, 0xfc, 0x82, 0x66, 0xad, 0x9a, 0xbb, 0xbd, 0x36, 0x6b, 0xda, 0xcc, 0x66, 0x60, 0x13, 0x1b, 0xc2, 0x6b, - 0x4d, 0x74, 0x29, 0x52, 0xec, 0x53, 0x97, 0x54, 0xbc, 0x7e, 0xf0, 0x4a, 0x1e, 0x80, 0xba, 0x8d, 0x32, 0xb1, 0x82, - 0xb4, 0xe4, 0x5a, 0xc7, 0x8e, 0xec, 0x54, 0x39, 0xb0, 0x4f, 0x1b, 0x25, 0xd3, 0x52, 0xa4, 0x77, 0xb1, 0xf3, 0x95, - 0x1b, 0xe2, 0xd5, 0xfd, 0xcf, 0x59, 0xbf, 0xa7, 0xb5, 0xc8, 0x7a, 0x2e, 0x59, 0xf1, 0x72, 0x89, 0x10, 0x83, 0x29, - 0x84, 0x7e, 0x30, 0x70, 0xf6, 0xa4, 0x58, 0xad, 0xef, 0x7a, 0x2e, 0xc9, 0x55, 0xba, 0xd4, 0x7d, 0xd7, 0x39, 0x64, - 0x69, 0xc4, 0xbb, 0x3b, 0xd4, 0x79, 0xee, 0x7c, 0x61, 0x91, 0x57, 0x62, 0x6e, 0x9c, 0x87, 0x6c, 0x7e, 0xb1, 0xd5, - 0x7d, 0x49, 0x1a, 0xad, 0x85, 0xbb, 0x3b, 0x2e, 0x46, 0xba, 0xe6, 0xf2, 0x4b, 0x41, 0x6b, 0xa0, 0x4d, 0x1a, 0x49, - 0x2c, 0x65, 0x33, 0xa7, 0xe6, 0xf2, 0x78, 0xa0, 0xcf, 0x0f, 0xe4, 0x8b, 0xad, 0xe8, 0x4b, 0x7b, 0x4b, 0xde, 0x1d, - 0x35, 0x46, 0x7e, 0x26, 0x56, 0xc9, 0xed, 0xce, 0x7d, 0xf0, 0xe3, 0xef, 0x4b, 0x6c, 0xee, 0xaf, 0xb1, 0xc4, 0xd4, - 0xa8, 0xa6, 0xef, 0x3c, 0x97, 0x68, 0x1c, 0xb7, 0x73, 0xf8, 0xa7, 0x9b, 0xb7, 0x6f, 0x62, 0xd5, 0x6f, 0xdc, 0xf3, - 0xa7, 0xb8, 0x6d, 0xb5, 0xf8, 0xd0, 0x60, 0xf9, 0x8f, 0xb8, 0x67, 0xeb, 0x45, 0xef, 0xa3, 0xe3, 0x92, 0xd6, 0xdf, - 0xdb, 0x87, 0xa2, 0x61, 0x13, 0xfb, 0xe5, 0xa6, 0x2a, 0xcf, 0xad, 0x87, 0xde, 0x68, 0xe8, 0xee, 0x6e, 0x77, 0xee, - 0xce, 0x9d, 0x45, 0x7e, 0x77, 0xef, 0x27, 0x51, 0x7b, 0x05, 0x27, 0xdf, 0x6f, 0xe7, 0x6a, 0xe3, 0x69, 0xf1, 0x59, - 0xc8, 0xc5, 0x54, 0xc8, 0x02, 0x1b, 0x61, 0x76, 0x99, 0x58, 0x9d, 0x0b, 0x59, 0x2f, 0xcd, 0xb6, 0xe6, 0x59, 0x66, - 0x77, 0x86, 0xf5, 0x66, 0x96, 0x2b, 0x69, 0x2c, 0x27, 0x4e, 0x29, 0x56, 0xbb, 0x6e, 0xbf, 0xbd, 0x5b, 0xa6, 0x17, - 0xc3, 0xb3, 0x9d, 0x0d, 0xb8, 0xad, 0xc1, 0x8d, 0xf1, 0x78, 0x29, 0x16, 0x72, 0x9a, 0xa2, 0x34, 0xd8, 0x74, 0x42, - 0x39, 0xaf, 0x44, 0x79, 0x3f, 0xd5, 0x5c, 0x6a, 0x4f, 0x63, 0x23, 0xf2, 0xdd, 0x7c, 0x69, 0x8c, 0x92, 0xdb, 0xb9, - 0x6a, 0x32, 0x6c, 0xa6, 0xc1, 0xac, 0x23, 0xbc, 0x86, 0x67, 0x62, 0xa9, 0xa7, 0x24, 0x6c, 0xb0, 0x9a, 0xcd, 0x79, - 0x7a, 0xb7, 0x68, 0xd4, 0x52, 0x66, 0x5e, 0x6a, 0x6f, 0xe1, 0xe9, 0x73, 0x9a, 0xf3, 0x10, 0xd3, 0xd9, 0x7e, 0x96, - 0xe7, 0xf9, 0xac, 0x14, 0x12, 0xbd, 0xee, 0x56, 0x9b, 0x32, 0x32, 0xb0, 0x62, 0x27, 0x66, 0x12, 0x66, 0x17, 0x3a, - 0x1b, 0x69, 0x10, 0x9c, 0xcd, 0x0e, 0xee, 0x04, 0xb3, 0x74, 0xd9, 0x68, 0xd5, 0x4c, 0x6b, 0x25, 0xac, 0x99, 0xbb, - 0x8a, 0x0b, 0x79, 0x6a, 0xbd, 0x0d, 0x93, 0xd9, 0xbe, 0x3c, 0x4d, 0x85, 0x6c, 0x8f, 0x69, 0x8b, 0xd4, 0xac, 0x12, - 0xb2, 0x2b, 0xb2, 0x53, 0x36, 0x0a, 0xea, 0xcd, 0x8e, 0xec, 0x03, 0x64, 0x7b, 0xe0, 0xce, 0x4b, 0xdc, 0xcc, 0x3e, - 0x2d, 0xb5, 0x11, 0xf9, 0xbd, 0xb7, 0x2f, 0xd2, 0x53, 0x5d, 0xf3, 0x14, 0xbd, 0x39, 0x9a, 0x35, 0xa2, 0x9c, 0xb5, - 0x67, 0x78, 0xc2, 0x60, 0xa5, 0xf7, 0x38, 0x1d, 0xd5, 0xb4, 0x01, 0xfa, 0x58, 0xd7, 0xbf, 0xe3, 0xb6, 0xb1, 0xb8, - 0xad, 0x78, 0xb3, 0x10, 0xd2, 0x9b, 0x2b, 0x63, 0x54, 0x35, 0xf5, 0xc6, 0xf5, 0x66, 0xb6, 0x5f, 0xb2, 0xca, 0xa6, - 0xd4, 0x9a, 0xd9, 0xd6, 0xde, 0x03, 0xde, 0xb4, 0xde, 0x80, 0x56, 0xa5, 0xc8, 0xf6, 0x7c, 0x2d, 0x0b, 0x04, 0x47, - 0x78, 0xe8, 0xb0, 0xde, 0x80, 0x5d, 0x3b, 0x40, 0x3d, 0xc8, 0x27, 0x9c, 0x06, 0x5f, 0xf9, 0x46, 0xb2, 0x3c, 0x67, - 0xf3, 0xfc, 0x88, 0x94, 0x2d, 0xa1, 0x3b, 0xb1, 0x8f, 0x0a, 0x36, 0xa8, 0x37, 0xb3, 0xc3, 0x77, 0x33, 0xa8, 0x37, - 0x3b, 0xd1, 0xa6, 0xc5, 0xf6, 0x44, 0x4b, 0x1b, 0xaa, 0xd3, 0x65, 0x53, 0xf6, 0x9d, 0xaf, 0x84, 0xee, 0x59, 0x78, - 0xf5, 0x50, 0xe2, 0x7a, 0x4f, 0x97, 0xb8, 0x1e, 0xd8, 0xa6, 0xe8, 0x95, 0xda, 0xc4, 0xbd, 0xb6, 0xd8, 0x0c, 0x80, - 0x0d, 0x7a, 0x67, 0xe1, 0xeb, 0xb3, 0xf0, 0xea, 0xbf, 0x52, 0xbb, 0x7e, 0x77, 0xe1, 0xfa, 0x86, 0xaa, 0xf5, 0x8d, - 0x15, 0xab, 0xf3, 0xce, 0x3a, 0x7f, 0x16, 0xbe, 0x76, 0xdc, 0x9d, 0x20, 0x5a, 0x2c, 0xe8, 0xff, 0x02, 0xda, 0x7f, - 0xc5, 0x31, 0xbc, 0xa4, 0x13, 0x72, 0x01, 0xed, 0xd0, 0x41, 0x44, 0xc2, 0x09, 0x8c, 0xaf, 0x06, 0x64, 0x40, 0xc1, - 0xb6, 0x43, 0x23, 0x18, 0x93, 0xc9, 0x05, 0xd0, 0x11, 0x09, 0xc7, 0x40, 0x19, 0x30, 0x4a, 0x86, 0x6f, 0x58, 0x48, - 0x46, 0x43, 0x18, 0x5f, 0xb1, 0x80, 0x84, 0x0c, 0x3a, 0xde, 0x11, 0x61, 0x0c, 0x42, 0xcb, 0x12, 0x56, 0x01, 0xb0, - 0x34, 0x24, 0xc1, 0x18, 0x02, 0x18, 0x91, 0xe0, 0x82, 0x4c, 0x46, 0x30, 0x21, 0x63, 0x0a, 0x8c, 0x0c, 0x86, 0xa5, - 0x37, 0x24, 0x14, 0x46, 0x24, 0x1c, 0xf1, 0x09, 0x19, 0x84, 0xd0, 0x0e, 0x1d, 0x1c, 0x63, 0xc2, 0x98, 0x47, 0x02, - 0xfa, 0x26, 0x24, 0x6c, 0x0c, 0x63, 0x32, 0x18, 0x5c, 0xd2, 0x11, 0xb9, 0x18, 0x40, 0x37, 0x76, 0xf0, 0x52, 0x06, - 0xc3, 0xa7, 0x40, 0x63, 0x7f, 0x5e, 0xd0, 0x42, 0xc2, 0x28, 0x84, 0xe4, 0x62, 0xc2, 0x6d, 0x5f, 0xca, 0xa0, 0x1b, - 0x3b, 0xdc, 0x28, 0x85, 0xe0, 0x77, 0x63, 0x16, 0xfe, 0x79, 0x31, 0xa3, 0x16, 0x01, 0x46, 0x06, 0xe1, 0x25, 0x0d, - 0xc9, 0x08, 0xda, 0xa1, 0x3b, 0x9b, 0x32, 0x98, 0x5c, 0x5d, 0xc0, 0x04, 0x46, 0x64, 0x34, 0x81, 0x0b, 0x18, 0x5a, - 0x74, 0x2f, 0xc8, 0x64, 0xd0, 0x09, 0x79, 0x8c, 0x7c, 0x2b, 0x8c, 0x83, 0x3f, 0x30, 0x8c, 0x4f, 0xf9, 0xf4, 0x07, - 0x76, 0xe9, 0xff, 0x71, 0x05, 0x45, 0x7e, 0xd7, 0x86, 0x45, 0x7e, 0xf7, 0x3c, 0x60, 0xbb, 0xa8, 0x24, 0xb2, 0xdd, - 0x48, 0x12, 0x15, 0x14, 0x44, 0x16, 0x57, 0x3c, 0x4d, 0x4e, 0x5a, 0xfd, 0xc8, 0x2f, 0xe8, 0x61, 0xab, 0xa0, 0xc9, - 0xa3, 0xc6, 0xbd, 0xdb, 0x6b, 0x2b, 0x7d, 0x72, 0x53, 0x20, 0xbc, 0xbe, 0x7e, 0x07, 0x6b, 0x51, 0x96, 0x20, 0xd5, - 0x1a, 0x4c, 0x73, 0x0f, 0x46, 0xd9, 0x57, 0x03, 0x89, 0xa9, 0xb1, 0xa4, 0x29, 0x10, 0xf6, 0x7d, 0x04, 0x21, 0x24, - 0x9a, 0x37, 0xc9, 0xbb, 0x12, 0xb9, 0x46, 0x58, 0x88, 0x15, 0x82, 0x30, 0xa0, 0x55, 0x85, 0x60, 0x84, 0x1d, 0x8e, - 0x82, 0x2d, 0x5f, 0xe4, 0x77, 0x87, 0x74, 0x8d, 0xb2, 0xc8, 0x62, 0x89, 0x26, 0xd9, 0x77, 0xc4, 0x51, 0x11, 0x76, - 0x56, 0x5d, 0xa3, 0x31, 0x42, 0x2e, 0xac, 0x55, 0x61, 0x12, 0xd9, 0x5f, 0xb7, 0xc0, 0xdb, 0xdf, 0x0c, 0xb1, 0xbf, - 0x16, 0xb9, 0xb0, 0x6f, 0x06, 0x49, 0xd4, 0x76, 0x91, 0x56, 0x83, 0x6d, 0x64, 0xba, 0x07, 0x8e, 0x96, 0x2a, 0x51, - 0x2e, 0x4c, 0x11, 0x87, 0x0c, 0xea, 0x92, 0xa7, 0x58, 0xa8, 0x32, 0xc3, 0x26, 0xbe, 0xbe, 0xfe, 0xf9, 0xaf, 0xf6, - 0x35, 0xc4, 0x9a, 0x70, 0x94, 0xac, 0xf5, 0x5d, 0x27, 0x68, 0x89, 0xbd, 0xdc, 0x68, 0xd0, 0xbd, 0x6b, 0xd4, 0x5c, - 0xeb, 0xb5, 0x6a, 0xb2, 0x47, 0x5a, 0xde, 0x1d, 0x16, 0xf7, 0x9a, 0xda, 0xff, 0xb6, 0x1f, 0xed, 0x84, 0xf4, 0x72, - 0x5e, 0x09, 0x93, 0x5c, 0xf3, 0x15, 0x46, 0x7e, 0xb7, 0x91, 0x44, 0xbe, 0x75, 0xa0, 0xe3, 0x2d, 0xf6, 0x32, 0x05, - 0x4d, 0x7e, 0xbd, 0xb9, 0x84, 0xdf, 0xea, 0x8c, 0x1b, 0xec, 0xb0, 0x6f, 0xbd, 0xac, 0xd0, 0x14, 0x2a, 0x8b, 0xdf, - 0xfd, 0x7a, 0x7d, 0x73, 0xf4, 0x78, 0xd9, 0x32, 0x01, 0xca, 0xb4, 0x7b, 0x6f, 0x59, 0x96, 0x46, 0xd4, 0xbc, 0x31, - 0xad, 0x5a, 0xcf, 0x66, 0xc7, 0xc1, 0xa3, 0x76, 0x3f, 0x17, 0x25, 0x76, 0x4e, 0xed, 0x05, 0xfd, 0x04, 0xbe, 0x66, - 0xe3, 0xe1, 0xec, 0x2f, 0xac, 0xf4, 0xbb, 0x00, 0xf2, 0xbb, 0x68, 0xf2, 0xdb, 0xd7, 0xa8, 0x7f, 0x02, 0x14, 0xee, - 0xbc, 0x64, 0x9d, 0x12, 0x00, 0x00}; + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7e, + 0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36, + 0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf, + 0x7e, 0xa0, 0x24, 0x7b, 0x9d, 0x45, 0x73, 0xb8, 0xb3, 0x60, 0x61, 0x38, 0xef, 0x19, 0xcd, 0x83, 0xc5, 0xdf, 0x2a, + 0xc5, 0xec, 0xbe, 0x03, 0xd4, 0xd8, 0x56, 0x94, 0x85, 0x7b, 0x23, 0x41, 0xe5, 0x8a, 0x80, 0x2c, 0x8b, 0x06, 0x68, + 0x55, 0x16, 0x2d, 0x58, 0x8a, 0x58, 0x43, 0xb5, 0x01, 0x4b, 0xfe, 0xbc, 0xff, 0x39, 0xb8, 0x2d, 0x0b, 0xc1, 0xe5, + 0x1a, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0x24, 0x22, + 0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0x04, 0x6f, 0x79, 0x65, 0x1b, 0x52, + 0xc1, 0x86, 0x33, 0x08, 0x86, 0xc3, 0x35, 0x97, 0xdc, 0x72, 0x2a, 0x02, 0xc3, 0xa8, 0x00, 0x92, 0x5c, 0xf7, 0x06, + 0xf4, 0x70, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0x5c, 0x16, 0x86, 0x69, 0xde, 0x59, 0xe4, 0x5c, 0x25, 0xad, 0xaa, 0x7a, + 0x01, 0x65, 0x14, 0x51, 0x63, 0xc0, 0x9a, 0x88, 0xcb, 0x0a, 0x76, 0xe1, 0x32, 0x66, 0x2c, 0x86, 0xdb, 0xdb, 0xf0, + 0xb3, 0x79, 0x56, 0x29, 0xd6, 0xb7, 0x20, 0x6d, 0x28, 0x14, 0xa3, 0x96, 0x2b, 0x19, 0x1a, 0xa0, 0x9a, 0x35, 0x84, + 0x10, 0xfc, 0xa3, 0xa1, 0x1b, 0xc0, 0xdf, 0x7f, 0xef, 0x9d, 0x99, 0x56, 0x60, 0xff, 0x21, 0xc0, 0x81, 0xe6, 0xa7, + 0xfd, 0x3d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x30, 0x35, 0xbc, 0x02, 0xec, 0x7f, 0x8c, 0x3f, 0x85, 0xc6, 0xee, 0x05, + 0x84, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x82, 0x97, 0x42, 0xb1, 0x35, 0xf6, 0xf3, 0xba, 0x97, 0xcc, 0x29, 0x47, 0xc6, + 0x03, 0xff, 0x20, 0xc0, 0x22, 0x4b, 0xde, 0x51, 0xdb, 0x84, 0x2d, 0xdd, 0x79, 0x23, 0xc0, 0xa5, 0x97, 0xfe, 0xe0, + 0xc1, 0xcb, 0x24, 0x8e, 0xfd, 0xeb, 0xe1, 0x15, 0xfb, 0x51, 0x12, 0xc7, 0xb9, 0x06, 0xdb, 0x6b, 0x89, 0xa8, 0xf7, + 0x50, 0x74, 0xd4, 0x36, 0xa8, 0x22, 0xf8, 0x5d, 0x92, 0xa2, 0xe4, 0x75, 0x98, 0xce, 0x7f, 0x0b, 0x5f, 0xa1, 0x9b, + 0x30, 0x9d, 0xb3, 0x57, 0xc1, 0x1c, 0x25, 0x37, 0xc1, 0x1c, 0xa5, 0x69, 0x38, 0x47, 0xf1, 0x17, 0x8c, 0x6a, 0x2e, + 0x04, 0xc1, 0x52, 0x49, 0xc0, 0xc8, 0x58, 0xad, 0xd6, 0x40, 0x30, 0xeb, 0xb5, 0x06, 0x69, 0xdf, 0x2a, 0xa1, 0x34, + 0x8e, 0xca, 0x67, 0xff, 0x97, 0x42, 0xab, 0xa9, 0x34, 0xb5, 0xd2, 0x2d, 0xc1, 0x43, 0xf6, 0xbd, 0x17, 0x07, 0x7b, + 0x44, 0xee, 0xe5, 0x5f, 0x10, 0x03, 0xa5, 0xf9, 0x8a, 0x4b, 0x82, 0x9d, 0xc6, 0x5b, 0x1c, 0x95, 0x0f, 0xfe, 0xf1, + 0x1c, 0x3d, 0x75, 0xd1, 0x4f, 0xf1, 0x28, 0xef, 0xe3, 0x43, 0x61, 0x36, 0x2b, 0xb4, 0x6b, 0x85, 0x34, 0x04, 0x37, + 0xd6, 0x76, 0x59, 0x14, 0x6d, 0xb7, 0xdb, 0x70, 0x3b, 0x0b, 0x95, 0x5e, 0x45, 0x69, 0x1c, 0xc7, 0x91, 0xd9, 0xac, + 0x30, 0x1a, 0x0b, 0x01, 0xa7, 0x37, 0x18, 0x35, 0xc0, 0x57, 0x8d, 0x1d, 0xe0, 0xf2, 0xc5, 0x01, 0x8e, 0x85, 0xe3, + 0x28, 0x1f, 0x3e, 0x5d, 0x58, 0xe1, 0x17, 0x56, 0xe0, 0x47, 0xea, 0xe1, 0x53, 0x98, 0x57, 0x43, 0x98, 0xaf, 0x68, + 0x8a, 0x52, 0x14, 0x0f, 0x4f, 0x1a, 0x38, 0x78, 0x3a, 0x05, 0x4f, 0x4e, 0xe8, 0xe2, 0xe4, 0xa0, 0x76, 0x11, 0xbc, + 0x3e, 0xcb, 0x26, 0x0e, 0xb3, 0x49, 0xe2, 0x47, 0x84, 0x13, 0xf8, 0x65, 0x71, 0x79, 0x0e, 0xd2, 0x0f, 0x97, 0x0c, + 0xce, 0x5a, 0x93, 0x7c, 0x58, 0xd0, 0x39, 0x9a, 0x4f, 0x98, 0x79, 0xe0, 0xe0, 0xf3, 0x09, 0xcd, 0x37, 0x69, 0x93, + 0xb4, 0xc1, 0x22, 0x98, 0xd3, 0x19, 0x9a, 0x4d, 0x8e, 0xcc, 0xd0, 0x6c, 0x93, 0x36, 0x8b, 0x0f, 0x8b, 0x4b, 0x5c, + 0x30, 0xfb, 0x72, 0x15, 0x95, 0xd8, 0xcf, 0x30, 0x7e, 0x8c, 0x5c, 0x5d, 0x46, 0x1e, 0x7e, 0x56, 0x5c, 0x7a, 0x18, + 0xfb, 0xc7, 0x1a, 0x2c, 0x6b, 0x3c, 0x1c, 0x31, 0x25, 0x6b, 0xbe, 0x0a, 0x3f, 0x1b, 0x25, 0xb1, 0x1f, 0xda, 0x06, + 0xa4, 0x77, 0x12, 0x75, 0x82, 0x30, 0x50, 0xbc, 0xa7, 0x14, 0xeb, 0x1f, 0xce, 0xf5, 0x6f, 0xb9, 0x15, 0x40, 0x6c, + 0xe8, 0x1a, 0xf6, 0xfa, 0x8c, 0x5d, 0xaa, 0x6a, 0xff, 0x8d, 0xd6, 0x68, 0x92, 0xb1, 0x2f, 0xb8, 0x94, 0xa0, 0xef, + 0x61, 0x67, 0x09, 0x7e, 0xf7, 0xe6, 0x2d, 0x7a, 0x53, 0x55, 0x1a, 0x8c, 0xc9, 0x10, 0x7e, 0x69, 0xc3, 0x96, 0xb2, + 0xff, 0x5d, 0x57, 0xf2, 0x95, 0xae, 0x7f, 0xf2, 0x9f, 0x39, 0xfa, 0x1d, 0xec, 0x56, 0xe9, 0xf5, 0xa4, 0xcd, 0xb9, + 0x96, 0xbb, 0x0e, 0xd3, 0xc4, 0x86, 0xb4, 0x33, 0xa1, 0x11, 0x9c, 0x81, 0x97, 0xf8, 0x61, 0x4b, 0xbb, 0xc7, 0xa8, + 0xe4, 0x29, 0x51, 0x0f, 0x45, 0xc5, 0x37, 0x88, 0x09, 0x6a, 0x0c, 0xc1, 0x72, 0x54, 0x85, 0xd1, 0x33, 0x34, 0xfc, + 0x94, 0x64, 0x82, 0xb3, 0x35, 0xc1, 0x7f, 0x31, 0x01, 0x7e, 0xda, 0xff, 0x5a, 0x79, 0x57, 0xc6, 0xf0, 0xea, 0xca, + 0x0f, 0x37, 0x54, 0xf4, 0x80, 0x08, 0xb2, 0x0d, 0x37, 0x8f, 0x0e, 0xe6, 0xdf, 0x14, 0xeb, 0xcc, 0xfa, 0xca, 0x0f, + 0x6b, 0xc5, 0x7a, 0xe3, 0xf9, 0xb8, 0x9c, 0xcc, 0x15, 0x74, 0x1c, 0x90, 0xf8, 0x39, 0x7e, 0xe2, 0x51, 0x20, 0xa0, + 0xb6, 0x67, 0x3e, 0x84, 0x5e, 0x1c, 0x8c, 0x27, 0x43, 0x6d, 0x0c, 0xf7, 0x8f, 0x67, 0x64, 0x61, 0x3a, 0x2a, 0x9f, + 0x0a, 0x3a, 0x07, 0x5d, 0xab, 0xc8, 0xd0, 0x41, 0xae, 0x5f, 0x3a, 0x2a, 0xcf, 0x06, 0x23, 0x7a, 0x02, 0x5f, 0x1c, + 0xb8, 0x27, 0xdd, 0x14, 0x5c, 0x9f, 0x35, 0x16, 0x51, 0xc5, 0x37, 0xe5, 0xc3, 0xd1, 0x7f, 0x8c, 0xe3, 0x5f, 0x3d, + 0xe8, 0xfd, 0x1d, 0x08, 0x60, 0x56, 0x69, 0x0f, 0x3f, 0x97, 0x60, 0xb1, 0x3f, 0x06, 0xfc, 0xcb, 0xfd, 0xbb, 0xdf, + 0x88, 0xf2, 0xb4, 0x7f, 0xfd, 0x2d, 0x6e, 0xb7, 0x0a, 0x3e, 0x6a, 0x10, 0xff, 0x26, 0x57, 0x6e, 0x19, 0x5c, 0x7d, + 0xc2, 0x7e, 0x38, 0xc4, 0xfb, 0xf0, 0xb8, 0x11, 0x5c, 0x3b, 0xbf, 0xdc, 0xb5, 0xe2, 0xda, 0x45, 0x18, 0x2c, 0xe6, + 0xfe, 0xf1, 0xe1, 0xe8, 0x1f, 0xfd, 0xbc, 0x88, 0xc6, 0xb9, 0x5e, 0x16, 0xc3, 0x88, 0x2d, 0x7f, 0x38, 0x2c, 0xd5, + 0x2e, 0x30, 0xfc, 0x0b, 0x97, 0xab, 0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0xae, 0xb9, 0xec, 0x7a, 0x7b, + 0xe8, 0x68, 0x55, 0x39, 0xca, 0xbc, 0xdb, 0xe5, 0xb5, 0x92, 0xd6, 0x71, 0x42, 0x96, 0x40, 0x7b, 0x1c, 0xe9, 0xc3, + 0x44, 0xc9, 0x5e, 0xcf, 0xbf, 0x3b, 0xba, 0x82, 0x3b, 0x58, 0xd8, 0xd9, 0x80, 0x0a, 0xbe, 0x92, 0x19, 0x03, 0x69, + 0x41, 0x8f, 0x42, 0x35, 0x6d, 0xb9, 0xd8, 0x67, 0x86, 0x4a, 0x13, 0x18, 0xd0, 0xbc, 0x3e, 0x2e, 0x7b, 0x6b, 0x95, + 0x3c, 0x2c, 0x95, 0xae, 0x40, 0x67, 0x71, 0x3e, 0x02, 0x81, 0xa6, 0x15, 0xef, 0x4d, 0x16, 0xce, 0x34, 0xb4, 0xf9, + 0x92, 0xb2, 0xf5, 0x4a, 0xab, 0x5e, 0x56, 0x01, 0x73, 0x93, 0x36, 0x7b, 0x9e, 0xd4, 0x74, 0x06, 0x2c, 0x9f, 0x4e, + 0x75, 0x5d, 0xe7, 0x82, 0x4b, 0x08, 0xc6, 0x59, 0x96, 0xa5, 0xe1, 0x8d, 0x13, 0xbb, 0x70, 0x33, 0x4c, 0x1d, 0x62, + 0xf4, 0x31, 0x89, 0xe3, 0xef, 0xf2, 0x53, 0x38, 0x71, 0xce, 0x7a, 0x6d, 0x94, 0xce, 0x3a, 0xc5, 0x9d, 0x9b, 0xc7, + 0x96, 0x72, 0x79, 0xe9, 0xbd, 0x2b, 0x93, 0x7c, 0x5a, 0x3f, 0x19, 0x97, 0x83, 0x99, 0x61, 0x09, 0xe5, 0x2d, 0x97, + 0xe3, 0x0e, 0xcd, 0xd2, 0x45, 0xdc, 0xed, 0x8e, 0xe1, 0x54, 0x20, 0x87, 0x13, 0x77, 0x2d, 0x60, 0x97, 0x7f, 0xee, + 0x8d, 0xe5, 0xf5, 0x3e, 0x98, 0x76, 0x70, 0x66, 0x3a, 0xca, 0x20, 0x58, 0x82, 0xdd, 0x02, 0xc8, 0x7c, 0xb0, 0x11, + 0x70, 0x0b, 0xad, 0x99, 0xf2, 0x74, 0x56, 0x33, 0x14, 0xe8, 0xd7, 0xba, 0xfe, 0x1b, 0xb7, 0xab, 0xc5, 0x43, 0x4b, + 0xf5, 0x8a, 0xcb, 0x60, 0xa9, 0xac, 0x55, 0x6d, 0x16, 0xbc, 0xea, 0x76, 0xf9, 0x84, 0x72, 0xca, 0xb2, 0xc4, 0xb9, + 0x39, 0xec, 0xd6, 0x53, 0xbe, 0x93, 0x6e, 0x87, 0x8c, 0x12, 0xbc, 0x9a, 0xf8, 0x06, 0x16, 0x14, 0x9f, 0xd3, 0x93, + 0xcc, 0xbb, 0x1d, 0x72, 0xb8, 0x53, 0xaa, 0x6f, 0xea, 0x5b, 0x9a, 0xc4, 0x7f, 0xf1, 0x45, 0xaa, 0xba, 0x4e, 0x97, + 0xf5, 0x39, 0x53, 0x6e, 0x4d, 0xba, 0xd6, 0x18, 0x4a, 0xab, 0x88, 0xc6, 0xdb, 0x8c, 0xab, 0x8c, 0xb2, 0x70, 0x19, + 0x2e, 0x8b, 0x26, 0x41, 0xbc, 0x22, 0x2d, 0x65, 0xe5, 0xc5, 0xf8, 0x2a, 0xa2, 0x26, 0x39, 0x91, 0x9a, 0xa4, 0xfc, + 0x6a, 0x18, 0x8d, 0xb4, 0xc1, 0xfb, 0xf2, 0xad, 0x92, 0x12, 0x98, 0xe5, 0x72, 0x85, 0xac, 0x42, 0x53, 0x0a, 0xc2, + 0x30, 0x2c, 0x96, 0xba, 0x7c, 0x2f, 0x80, 0x1a, 0x40, 0x5b, 0xca, 0x6d, 0x58, 0x44, 0x23, 0xff, 0xd8, 0xc7, 0xbc, + 0x22, 0x12, 0x6c, 0x39, 0x35, 0x6c, 0xd1, 0xcc, 0x46, 0x03, 0x77, 0x60, 0x9d, 0x26, 0x67, 0x60, 0x56, 0x16, 0x6e, + 0xe5, 0x22, 0x3a, 0x8c, 0x34, 0x12, 0x6d, 0x79, 0xcd, 0xdd, 0x95, 0xa5, 0x2c, 0x86, 0x22, 0x77, 0x1a, 0x5c, 0x9e, + 0xc7, 0xeb, 0xd5, 0x00, 0x09, 0x90, 0x2b, 0xdb, 0x90, 0x59, 0x8a, 0x3a, 0x41, 0x19, 0x34, 0x4a, 0x54, 0xa0, 0xc9, + 0xdd, 0xdd, 0xaf, 0x7f, 0x2f, 0x9d, 0x33, 0x8f, 0x72, 0x9d, 0x59, 0x8f, 0x62, 0x0e, 0x98, 0xa4, 0x16, 0x37, 0xe3, + 0xa5, 0xaa, 0xa3, 0xc6, 0x6c, 0x95, 0xae, 0xbe, 0xd2, 0xf1, 0x7e, 0x42, 0x8e, 0x7a, 0x86, 0xff, 0xd0, 0x2a, 0xe5, + 0x1d, 0xdd, 0x40, 0x11, 0x4d, 0x87, 0x22, 0x72, 0x0e, 0x8f, 0xf4, 0x66, 0xe2, 0x6b, 0x92, 0xf2, 0x8f, 0xfb, 0x37, + 0xe8, 0xcf, 0xae, 0xa2, 0x16, 0xc6, 0xb4, 0x0d, 0x51, 0xb5, 0x60, 0x1b, 0x55, 0x91, 0xf7, 0x7f, 0xdc, 0xdd, 0x9f, + 0x23, 0xec, 0x07, 0x26, 0x04, 0x92, 0x8d, 0xd7, 0xbb, 0x5e, 0x58, 0xde, 0x51, 0x6d, 0x07, 0xb5, 0x81, 0x9b, 0x22, + 0xa7, 0x18, 0x06, 0x7a, 0xcd, 0x05, 0x8c, 0x61, 0x8c, 0x82, 0x25, 0x3a, 0x79, 0x75, 0xb2, 0xf6, 0xc4, 0xaf, 0x68, + 0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00}; } // namespace captive_portal } // namespace esphome diff --git a/esphome/components/web_server/server_index_v2.h b/esphome/components/web_server/server_index_v2.h index ec093d3186..e675d81552 100644 --- a/esphome/components/web_server/server_index_v2.h +++ b/esphome/components/web_server/server_index_v2.h @@ -494,155 +494,155 @@ const uint8_t INDEX_GZ[] PROGMEM = { 0x1c, 0x40, 0xc8, 0x12, 0x7c, 0xa6, 0xc1, 0x29, 0x21, 0xa4, 0xd5, 0x9f, 0x05, 0x5f, 0xe2, 0x9b, 0x98, 0xa6, 0xc1, 0xbc, 0xe8, 0x96, 0x04, 0xa0, 0x22, 0xa6, 0x6f, 0x45, 0x79, 0x6f, 0x9c, 0xa4, 0x8a, 0xea, 0xb5, 0x82, 0xb3, 0x59, 0x52, 0xcf, 0x96, 0x58, 0x9a, 0xe5, 0x93, 0x19, 0x25, 0xfc, 0xa6, 0x79, 0xeb, 0xf6, 0x36, 0xc7, 0xd7, 0x60, 0x76, - 0x65, 0x7c, 0x4d, 0x02, 0x5b, 0x3e, 0xbd, 0x0f, 0xc7, 0xe5, 0xef, 0x57, 0x34, 0xcf, 0xc3, 0xb1, 0xae, 0xb9, 0x3d, - 0x9e, 0x26, 0x41, 0xb4, 0x63, 0x69, 0x06, 0x08, 0x88, 0x89, 0x01, 0x46, 0xc0, 0xa7, 0xa1, 0x43, 0x64, 0x30, 0xf5, - 0x7a, 0x74, 0x4d, 0x0e, 0x5f, 0x2f, 0x12, 0xe1, 0xb8, 0x2a, 0x38, 0x99, 0x66, 0x54, 0x96, 0x2a, 0x34, 0x16, 0x27, - 0xfb, 0x50, 0xa0, 0x5e, 0x6f, 0x89, 0xa2, 0x19, 0x07, 0xca, 0xf6, 0x58, 0x9a, 0x63, 0xa2, 0x68, 0x76, 0xa2, 0x52, - 0x99, 0xa5, 0xb4, 0x1e, 0xbb, 0xf9, 0xbc, 0x3d, 0x84, 0x3f, 0x3a, 0x32, 0xf4, 0xf9, 0x68, 0x34, 0xba, 0x37, 0xaa, - 0xf6, 0x79, 0x34, 0xa2, 0x1d, 0x7a, 0xd4, 0x85, 0x24, 0x96, 0xa6, 0x8e, 0xc5, 0xb4, 0x0b, 0x89, 0xbb, 0xc5, 0xc3, - 0x2a, 0x43, 0xd8, 0x46, 0xc4, 0x8b, 0x87, 0x47, 0xd8, 0x8a, 0x69, 0x46, 0x17, 0x93, 0x30, 0x1b, 0xb3, 0x34, 0x68, - 0x15, 0xfe, 0x5c, 0x87, 0xa4, 0x3e, 0x3f, 0x3e, 0x3e, 0x2e, 0xfc, 0xc8, 0x3c, 0xb5, 0xa2, 0xa8, 0xf0, 0x87, 0x8b, - 0x72, 0x1a, 0xad, 0xd6, 0x68, 0x54, 0xf8, 0xcc, 0x14, 0x1c, 0x74, 0x86, 0xd1, 0x41, 0xa7, 0xf0, 0x6f, 0xac, 0x1a, - 0x85, 0x4f, 0xf5, 0x53, 0x46, 0xa3, 0x5a, 0x26, 0xcc, 0xe3, 0x56, 0xab, 0xf0, 0x15, 0xa1, 0x2d, 0xc0, 0x2c, 0x55, - 0x3f, 0x83, 0x70, 0x26, 0x38, 0x30, 0xf7, 0x6e, 0x22, 0xbc, 0xc1, 0xa5, 0xbe, 0x65, 0x44, 0x7d, 0x93, 0xa3, 0x40, - 0x17, 0xf8, 0x67, 0x3b, 0x78, 0x04, 0xc4, 0x2c, 0x83, 0x46, 0x89, 0x89, 0x2d, 0xd5, 0x5e, 0x03, 0x65, 0xc9, 0xd7, - 0x3f, 0x93, 0xa4, 0x8a, 0x29, 0x01, 0x27, 0x83, 0x9a, 0xea, 0x32, 0x3c, 0x4a, 0xb7, 0xc8, 0x0f, 0xf6, 0x69, 0xf9, - 0x71, 0xf7, 0x10, 0xf1, 0xc1, 0xfe, 0x70, 0xf1, 0x41, 0xa9, 0x25, 0x3e, 0x14, 0xf3, 0xb8, 0x13, 0xc4, 0x1d, 0xc6, - 0x74, 0xf8, 0xf1, 0x9a, 0xdf, 0x36, 0x61, 0x4b, 0x64, 0xae, 0x14, 0x2c, 0xbb, 0xbf, 0x35, 0x6b, 0xc6, 0x74, 0x66, - 0x7d, 0xd1, 0x43, 0xaa, 0x0f, 0x6f, 0x52, 0xe2, 0xbe, 0x31, 0xb6, 0xad, 0x2a, 0x19, 0x8d, 0x88, 0xfb, 0x66, 0x34, - 0x72, 0xcd, 0x59, 0xc9, 0x50, 0x50, 0x59, 0xeb, 0x75, 0xad, 0x44, 0xd6, 0xfa, 0xf2, 0x4b, 0xbb, 0xcc, 0x2e, 0xd0, - 0xa1, 0x27, 0x3b, 0xcc, 0xa4, 0xdf, 0x44, 0x2c, 0x87, 0xad, 0x06, 0x1f, 0x1a, 0xa9, 0xdf, 0xd5, 0x98, 0xd6, 0xae, - 0xd5, 0x2e, 0x01, 0xde, 0x70, 0x17, 0xf8, 0xea, 0x45, 0x01, 0x63, 0x6a, 0xf2, 0x16, 0x9f, 0xde, 0x7d, 0x15, 0x79, - 0x77, 0x02, 0x15, 0x2c, 0x7f, 0x93, 0xae, 0x1c, 0x02, 0x52, 0x30, 0x12, 0x62, 0x4f, 0xab, 0x10, 0x7c, 0x3c, 0x4e, - 0xe0, 0x5b, 0x2f, 0x8b, 0xda, 0xfd, 0xb1, 0xaa, 0x79, 0xbf, 0x36, 0xdf, 0xc0, 0x6e, 0xa8, 0x6f, 0x5b, 0x95, 0x9f, - 0x9e, 0x52, 0xc9, 0xe3, 0x73, 0xfd, 0x0d, 0x22, 0x69, 0x16, 0x2f, 0x34, 0x93, 0x5f, 0xa8, 0x94, 0x63, 0x01, 0xe9, - 0x36, 0xaa, 0xe3, 0xa8, 0x28, 0xf4, 0x61, 0x8d, 0x88, 0xe5, 0x53, 0xb8, 0xd7, 0x54, 0xb5, 0xa4, 0x9f, 0x62, 0xe1, - 0xf9, 0x8d, 0x15, 0xdf, 0xa9, 0x2d, 0x57, 0x61, 0x02, 0x3c, 0xca, 0x61, 0x7e, 0x27, 0x0a, 0x57, 0xfb, 0xdd, 0x0d, - 0x12, 0x5d, 0x47, 0xe1, 0x53, 0x45, 0x9e, 0xac, 0x19, 0x82, 0xf3, 0xbb, 0x5c, 0x10, 0xf3, 0xca, 0x14, 0x14, 0x76, - 0xfc, 0x52, 0xbe, 0x51, 0xd8, 0x92, 0xd1, 0x92, 0x7c, 0x1a, 0xa6, 0x8a, 0x8d, 0x12, 0x57, 0xf1, 0x83, 0xdd, 0x45, - 0xb5, 0xf2, 0x85, 0x6b, 0xc0, 0x56, 0xc4, 0xdb, 0x3b, 0xd9, 0x87, 0x06, 0x3d, 0xa7, 0x06, 0x7a, 0xba, 0x16, 0x64, - 0xf9, 0x44, 0xba, 0xc3, 0x95, 0x9f, 0xdf, 0x60, 0x3f, 0xbf, 0x71, 0xfe, 0xbc, 0x68, 0xde, 0xd0, 0xeb, 0x8f, 0x4c, - 0x34, 0x45, 0x38, 0x6d, 0x82, 0xe1, 0x23, 0x9d, 0xa3, 0x9a, 0x3d, 0xcb, 0x2c, 0x3f, 0x75, 0xd5, 0x41, 0x77, 0x96, - 0x43, 0x56, 0x84, 0x54, 0xdf, 0x83, 0x94, 0xa7, 0xb4, 0x5b, 0xcf, 0xe6, 0xb4, 0x83, 0xec, 0x06, 0x5b, 0x17, 0x0b, - 0x0e, 0x59, 0x14, 0xe2, 0x2e, 0x68, 0x69, 0xb6, 0xde, 0x32, 0x11, 0xf4, 0xd6, 0xc6, 0xfa, 0x81, 0x46, 0x6e, 0x43, - 0x4a, 0xaf, 0x6c, 0x3d, 0x93, 0x60, 0x5b, 0x26, 0xc0, 0xa7, 0x72, 0x1b, 0xc1, 0xa5, 0x6a, 0xfe, 0x5a, 0x49, 0xa1, - 0xab, 0xc5, 0x32, 0xb7, 0xf1, 0x21, 0x90, 0x05, 0xe1, 0x48, 0xd0, 0x0c, 0x3f, 0xa4, 0xe6, 0xb5, 0x3c, 0x86, 0xb4, - 0x00, 0x31, 0x13, 0xb4, 0x8f, 0xa7, 0xb7, 0x0f, 0xef, 0xfe, 0xfe, 0xe9, 0x17, 0x1a, 0x47, 0xe6, 0x5a, 0x1e, 0xd7, - 0xed, 0xc2, 0x46, 0x48, 0xc2, 0xbb, 0x80, 0xa5, 0x52, 0xe6, 0x5d, 0x83, 0x5f, 0xb4, 0x3b, 0xe5, 0x3a, 0x49, 0x37, - 0xa3, 0x89, 0xfc, 0x0a, 0x9f, 0x5e, 0x8a, 0x83, 0x47, 0xd3, 0x5b, 0xb3, 0x1a, 0xed, 0x95, 0xe4, 0xdb, 0x3f, 0x34, - 0xc7, 0x76, 0x7b, 0x52, 0x6f, 0x3d, 0x4f, 0xf4, 0x68, 0x7a, 0xdb, 0x55, 0x82, 0xb6, 0x99, 0x29, 0xa8, 0x5a, 0xd3, - 0x5b, 0x3b, 0xcb, 0xb8, 0xea, 0xc8, 0xf1, 0x0f, 0x72, 0x87, 0x86, 0x39, 0xed, 0xc2, 0xbd, 0xe3, 0x6c, 0x18, 0x26, - 0x5a, 0x98, 0x4f, 0x58, 0x14, 0x25, 0xb4, 0x6b, 0xe4, 0xb5, 0xd3, 0x7e, 0x04, 0x49, 0xba, 0xf6, 0x92, 0xd5, 0x57, - 0xc5, 0x42, 0x5e, 0x89, 0xa7, 0xf0, 0x3a, 0xe7, 0x09, 0x7c, 0xf4, 0x63, 0x23, 0x3a, 0x75, 0xf6, 0x6a, 0xab, 0x42, - 0x9e, 0xfc, 0x5d, 0x9f, 0xcb, 0x51, 0xeb, 0x4f, 0x5d, 0xb9, 0xe0, 0xad, 0xae, 0xe0, 0xd3, 0xa0, 0x79, 0x50, 0x9f, - 0x08, 0xbc, 0x2a, 0xa7, 0x80, 0x37, 0x4c, 0x0b, 0x83, 0xb4, 0x52, 0x7c, 0xda, 0xf1, 0xdb, 0xba, 0x4c, 0x76, 0x00, - 0x79, 0x61, 0x65, 0x51, 0x51, 0x9f, 0xcc, 0xbf, 0xcd, 0x6e, 0x79, 0xb2, 0x79, 0xb7, 0x3c, 0x31, 0xbb, 0xe5, 0x7e, - 0x8a, 0xfd, 0x7c, 0xd4, 0x86, 0x3f, 0xdd, 0x6a, 0x42, 0x41, 0xcb, 0x39, 0x98, 0xde, 0x3a, 0xa0, 0xa7, 0x35, 0x3b, - 0xd3, 0x5b, 0x95, 0x63, 0x0d, 0xb1, 0x9b, 0x16, 0x64, 0x1d, 0xe3, 0x96, 0x03, 0x85, 0xf0, 0xb7, 0x55, 0x7b, 0xd5, - 0x3e, 0x84, 0x77, 0xd0, 0xea, 0x68, 0xfd, 0x5d, 0xe7, 0xfe, 0x4d, 0x1b, 0xa4, 0x5c, 0x78, 0x81, 0xe1, 0xc6, 0xc8, - 0x17, 0xe1, 0xf5, 0x35, 0x8d, 0x82, 0x11, 0x1f, 0xce, 0xf2, 0x7f, 0xd2, 0xf0, 0x6b, 0x24, 0xde, 0xbb, 0xa5, 0x57, - 0xfa, 0x31, 0x4d, 0x55, 0xc6, 0xb7, 0xe9, 0x61, 0x51, 0xae, 0x53, 0x90, 0x0f, 0xc3, 0x84, 0x7a, 0x1d, 0xff, 0x70, - 0xc3, 0x26, 0xf8, 0x77, 0x59, 0x9b, 0x8d, 0x93, 0xf9, 0xbd, 0xc8, 0xb8, 0x17, 0x09, 0xbf, 0x0a, 0x07, 0xf6, 0x1a, - 0xb6, 0x8e, 0x37, 0x83, 0x3b, 0x30, 0x23, 0x5d, 0x18, 0xa1, 0xa0, 0xe5, 0x4e, 0x44, 0x47, 0xe1, 0x2c, 0x11, 0xf7, - 0xf7, 0xba, 0x8d, 0x32, 0xd6, 0x7a, 0xbd, 0x87, 0xa1, 0x57, 0x75, 0x1f, 0xc8, 0xa5, 0x3f, 0x7f, 0x72, 0x08, 0x7f, - 0x54, 0xfe, 0xd7, 0x5d, 0xa5, 0xab, 0x2b, 0xbb, 0x17, 0x74, 0xf5, 0xdd, 0x9a, 0x32, 0xae, 0x44, 0xb8, 0xd4, 0xc7, - 0x1f, 0x5a, 0x1b, 0xb4, 0xca, 0x07, 0x55, 0xd7, 0x5a, 0xd6, 0xaf, 0xaa, 0xfd, 0xeb, 0x3a, 0x7f, 0x60, 0xdd, 0xa1, - 0xd2, 0x5c, 0xeb, 0x75, 0xf5, 0x67, 0x08, 0xd7, 0x2a, 0x1b, 0x8c, 0xcb, 0xfa, 0xbb, 0xe4, 0xae, 0x34, 0x51, 0x54, - 0x34, 0x16, 0xac, 0x94, 0x5d, 0x65, 0xa5, 0xe4, 0x94, 0x5c, 0x9d, 0xf4, 0x6f, 0x27, 0x89, 0x33, 0x57, 0xc7, 0x25, - 0x89, 0xdb, 0xf6, 0x5b, 0xae, 0x23, 0xf3, 0x00, 0xe0, 0xd6, 0x76, 0x57, 0x7e, 0xde, 0xd6, 0xed, 0x83, 0xa6, 0x35, - 0x1f, 0x4b, 0xcd, 0xee, 0x65, 0x78, 0x47, 0xb3, 0xcb, 0x8e, 0xeb, 0x80, 0x9f, 0xa6, 0xa9, 0x52, 0x26, 0x64, 0x99, - 0xd3, 0x71, 0x9d, 0xdb, 0x49, 0x92, 0xe6, 0xc4, 0x8d, 0x85, 0x98, 0x06, 0xea, 0xfb, 0xb7, 0x37, 0x07, 0x3e, 0xcf, - 0xc6, 0xfb, 0x9d, 0x56, 0xab, 0x05, 0x17, 0xc0, 0xba, 0xce, 0x9c, 0xd1, 0x9b, 0xa7, 0xfc, 0x96, 0xb8, 0x2d, 0xa7, - 0xe5, 0xb4, 0x3b, 0xc7, 0x4e, 0xbb, 0x73, 0xe8, 0x3f, 0x3a, 0x76, 0x7b, 0x9f, 0x39, 0xce, 0x49, 0x44, 0x47, 0x39, - 0xfc, 0x70, 0x9c, 0x13, 0xa9, 0x78, 0xa9, 0xdf, 0x8e, 0xe3, 0x0f, 0x93, 0xbc, 0xd9, 0x76, 0x16, 0xfa, 0xd1, 0x71, - 0xe0, 0x50, 0x69, 0xe0, 0x7c, 0x3e, 0xea, 0x8c, 0x0e, 0x47, 0x4f, 0xba, 0xba, 0xb8, 0xf8, 0xac, 0x56, 0x1d, 0xab, - 0xff, 0x3b, 0x56, 0xb3, 0x5c, 0x64, 0xfc, 0x23, 0xd5, 0x39, 0x89, 0x0e, 0x88, 0x9e, 0x8d, 0x4d, 0x3b, 0xeb, 0x23, - 0xb5, 0x8f, 0xaf, 0x87, 0xa3, 0x4e, 0x55, 0x5d, 0xc2, 0xb8, 0x5f, 0x02, 0x79, 0xb2, 0x6f, 0x40, 0x3f, 0xb1, 0xd1, - 0xd4, 0x6e, 0x6e, 0x42, 0x54, 0xdb, 0xd5, 0x73, 0x1c, 0x9b, 0xf9, 0x9d, 0xc0, 0x19, 0x06, 0xa3, 0xab, 0x4a, 0x08, - 0x5c, 0x27, 0x22, 0xee, 0xab, 0x76, 0xe7, 0x18, 0xb7, 0xdb, 0x8f, 0xfc, 0x47, 0xc7, 0xc3, 0x16, 0x3e, 0xf4, 0x0f, - 0x9b, 0x07, 0xfe, 0x23, 0x7c, 0xdc, 0x3c, 0xc6, 0xc7, 0x2f, 0x8e, 0x87, 0xcd, 0x43, 0xff, 0x10, 0xb7, 0x9a, 0xc7, - 0x50, 0xd8, 0x3c, 0x6e, 0x1e, 0xcf, 0x9b, 0x87, 0xc7, 0xc3, 0x96, 0x2c, 0xed, 0xf8, 0x47, 0x47, 0xcd, 0x76, 0xcb, - 0x3f, 0x3a, 0xc2, 0x47, 0xfe, 0xa3, 0x47, 0xcd, 0xf6, 0x81, 0xff, 0xe8, 0xd1, 0xcb, 0xa3, 0x63, 0xff, 0x00, 0xde, - 0x1d, 0x1c, 0x0c, 0x0f, 0xfc, 0x76, 0xbb, 0x09, 0xff, 0xe0, 0x63, 0xbf, 0xa3, 0x7e, 0xb4, 0xdb, 0xfe, 0x41, 0x1b, - 0xb7, 0x92, 0xa3, 0x8e, 0xff, 0xe8, 0x09, 0x96, 0xff, 0xca, 0x6a, 0x58, 0xfe, 0x03, 0xdd, 0xe0, 0x27, 0x7e, 0xe7, - 0x91, 0xfa, 0x25, 0x3b, 0x9c, 0x1f, 0x1e, 0xff, 0xe0, 0xee, 0x6f, 0x9d, 0x43, 0x5b, 0xcd, 0xe1, 0xf8, 0xc8, 0x3f, - 0x38, 0xc0, 0x87, 0x6d, 0xff, 0xf8, 0x20, 0x6e, 0x1e, 0x76, 0xfc, 0x47, 0x8f, 0x87, 0xcd, 0xb6, 0xff, 0xf8, 0x31, - 0x6e, 0x35, 0x0f, 0xfc, 0x0e, 0x6e, 0xfb, 0x87, 0x07, 0xf2, 0xc7, 0x81, 0xdf, 0x99, 0x3f, 0x7e, 0xe2, 0x3f, 0x3a, - 0x8a, 0x1f, 0xf9, 0x87, 0xdf, 0x1e, 0x1e, 0xfb, 0x9d, 0x83, 0xf8, 0xe0, 0x91, 0xdf, 0x79, 0x3c, 0x7f, 0xe4, 0x1f, - 0xc6, 0xcd, 0xce, 0xa3, 0x7b, 0x5b, 0xb6, 0x3b, 0x3e, 0xe0, 0x48, 0xbe, 0x86, 0x17, 0x58, 0xbf, 0x80, 0xbf, 0xb1, - 0x6c, 0xfb, 0xef, 0xd8, 0x4d, 0xbe, 0xde, 0xf4, 0x89, 0x7f, 0xfc, 0x78, 0xa8, 0xaa, 0x43, 0x41, 0xd3, 0xd4, 0x80, - 0x26, 0xf3, 0xa6, 0x1a, 0x56, 0x76, 0xd7, 0x34, 0x1d, 0x99, 0xbf, 0x7a, 0xb0, 0x79, 0x13, 0x06, 0x56, 0xe3, 0xfe, - 0x87, 0xf6, 0x53, 0x2e, 0xf9, 0xc9, 0xfe, 0x58, 0x91, 0xfe, 0xb8, 0xf7, 0x99, 0xba, 0xdd, 0xf9, 0xb3, 0x2b, 0x9c, - 0x6e, 0x73, 0x7c, 0x64, 0x9f, 0x76, 0x7c, 0x70, 0xfa, 0x10, 0xcf, 0x47, 0xf6, 0x87, 0x7b, 0x3e, 0x52, 0xba, 0xe2, - 0x38, 0xbf, 0x16, 0x6b, 0x0e, 0x8e, 0x55, 0xab, 0xf8, 0xa9, 0xf0, 0x06, 0x39, 0x7c, 0x47, 0xac, 0xe8, 0x5e, 0x0b, - 0xc2, 0xa9, 0xed, 0x07, 0xe2, 0xc0, 0x62, 0xaf, 0x85, 0xe2, 0xb1, 0xc9, 0x36, 0x84, 0x84, 0x9f, 0x46, 0xc8, 0xb7, - 0x0f, 0xc1, 0x47, 0xf8, 0x87, 0xe3, 0x23, 0xb1, 0xf1, 0x51, 0xf3, 0xe5, 0x4b, 0x4f, 0x83, 0xf4, 0x14, 0x9c, 0xcb, - 0x67, 0x0f, 0x0e, 0x51, 0x35, 0xdc, 0x7d, 0x0a, 0x45, 0xb9, 0xab, 0x22, 0x5f, 0xef, 0x7e, 0x4d, 0xd8, 0x41, 0x9d, - 0x98, 0x24, 0xae, 0x76, 0xcb, 0x4c, 0xa5, 0xd4, 0xd1, 0x0f, 0xa5, 0x50, 0xea, 0xf8, 0x2d, 0xbf, 0x55, 0xba, 0x74, - 0xe0, 0x94, 0x2c, 0x59, 0x70, 0x11, 0xc2, 0x17, 0x6b, 0x13, 0x3e, 0x96, 0xdf, 0xb6, 0x85, 0xaf, 0x09, 0x40, 0xd2, - 0xcf, 0x50, 0x7d, 0xc8, 0x21, 0x70, 0x5d, 0x7d, 0xb7, 0x06, 0x9c, 0xc2, 0xfc, 0x06, 0x4e, 0xaa, 0x9a, 0xa8, 0xc4, - 0x04, 0xbc, 0x1d, 0xaf, 0x68, 0xc4, 0x42, 0xcf, 0xf5, 0xa6, 0x19, 0x1d, 0xd1, 0x2c, 0x6f, 0xd6, 0x8e, 0x6f, 0xca, - 0x93, 0x9b, 0xc8, 0x35, 0x9f, 0x46, 0xcd, 0xe0, 0x76, 0x6c, 0x32, 0xd0, 0xfe, 0x46, 0x57, 0x1b, 0x60, 0x6e, 0x81, - 0x4d, 0x49, 0x06, 0xb2, 0xb6, 0x52, 0xda, 0x5c, 0xa5, 0xb5, 0xb5, 0xfd, 0xce, 0x11, 0x72, 0x64, 0x31, 0xdc, 0x3b, - 0xfc, 0xbd, 0xd7, 0x3c, 0x68, 0xfd, 0x09, 0x59, 0xcd, 0xca, 0x8e, 0x2e, 0xb4, 0xbb, 0x2d, 0xad, 0xbe, 0x29, 0x5d, - 0x3f, 0x5b, 0xeb, 0x2a, 0x8a, 0xf8, 0x5c, 0xcd, 0xdd, 0x45, 0xdd, 0x54, 0x47, 0xb8, 0xd5, 0x0d, 0x11, 0x23, 0x36, - 0xf6, 0xec, 0x2f, 0x06, 0xab, 0x7b, 0x8d, 0xe5, 0x87, 0xc6, 0x51, 0x51, 0x55, 0x49, 0xd1, 0x42, 0xc6, 0x5b, 0x58, - 0xea, 0xa4, 0xcb, 0xa5, 0x97, 0x82, 0x8b, 0x9c, 0x58, 0x38, 0x85, 0x67, 0x54, 0x43, 0x72, 0x8a, 0x4b, 0x80, 0x24, - 0x82, 0x49, 0xaa, 0xfe, 0xaf, 0x8a, 0xcd, 0x0f, 0xed, 0xf8, 0xf2, 0x93, 0x30, 0x1d, 0x03, 0x15, 0x86, 0xe9, 0x78, - 0xcd, 0xad, 0xa6, 0x42, 0x46, 0x2b, 0xa5, 0x55, 0x57, 0x95, 0xfb, 0x2c, 0x7f, 0x7a, 0xf7, 0x5e, 0x5f, 0x80, 0xe6, - 0x82, 0x77, 0x5a, 0x46, 0x38, 0xaa, 0xcb, 0x9a, 0x1b, 0xe4, 0x8b, 0x93, 0x09, 0x15, 0xa1, 0xca, 0xd7, 0x04, 0x7d, - 0x02, 0x4e, 0xcd, 0x3a, 0xda, 0x1a, 0x25, 0xae, 0x94, 0xee, 0x24, 0xa2, 0x73, 0x36, 0xd4, 0xa2, 0x1e, 0x3b, 0xfa, - 0xe6, 0x80, 0xa6, 0x5c, 0x1a, 0xd2, 0xc6, 0xca, 0x1f, 0x33, 0x0c, 0x65, 0x46, 0x3e, 0x49, 0xb9, 0xdb, 0xfb, 0xa2, - 0xfc, 0xfa, 0xe9, 0xb6, 0x45, 0x48, 0x58, 0xfa, 0x71, 0x90, 0xd1, 0xe4, 0x9f, 0xc8, 0x17, 0x6c, 0xc8, 0xd3, 0x2f, - 0x2e, 0xe0, 0xab, 0xf4, 0x7e, 0x9c, 0xd1, 0x11, 0xf9, 0x02, 0x64, 0x7c, 0x20, 0xad, 0x0f, 0x60, 0x84, 0x8d, 0xdb, - 0x49, 0x82, 0xa5, 0xc6, 0xf4, 0x00, 0x85, 0x48, 0x81, 0xeb, 0x76, 0x8e, 0x5c, 0x47, 0xd9, 0xc4, 0xf2, 0x77, 0x4f, - 0x89, 0x53, 0xa9, 0x04, 0x38, 0xed, 0x8e, 0x7f, 0x14, 0x77, 0xfc, 0x27, 0xf3, 0xc7, 0xfe, 0x71, 0xdc, 0x7e, 0x3c, - 0x6f, 0xc2, 0xff, 0x1d, 0xff, 0x49, 0xd2, 0xec, 0xf8, 0x4f, 0xe0, 0xef, 0xb7, 0x87, 0xfe, 0x51, 0xdc, 0x6c, 0xfb, - 0xc7, 0xf3, 0x03, 0xff, 0xe0, 0x65, 0xbb, 0xe3, 0x1f, 0x38, 0x6d, 0x47, 0xb5, 0x03, 0x76, 0xad, 0xb8, 0xf3, 0x17, - 0x2b, 0x1b, 0x62, 0x43, 0x38, 0x4e, 0xe5, 0x9c, 0xba, 0xd8, 0x2b, 0xbf, 0xb1, 0xa8, 0xf7, 0xa7, 0x76, 0xd6, 0x3d, - 0x0b, 0x33, 0xf8, 0xd0, 0x4d, 0x7d, 0xef, 0xd6, 0xde, 0xe1, 0x1a, 0xbf, 0xd8, 0x30, 0x04, 0xec, 0x70, 0x17, 0xdb, - 0x47, 0xef, 0xe1, 0xdc, 0xba, 0xbc, 0x17, 0xdc, 0x5c, 0x8f, 0xb8, 0x9d, 0xb4, 0x55, 0x45, 0x73, 0x05, 0xa3, 0x64, - 0x16, 0x4c, 0x7e, 0x81, 0x41, 0x0e, 0xf2, 0x55, 0x54, 0xac, 0x8e, 0x0f, 0xa9, 0xaf, 0x19, 0xb7, 0x6e, 0x1f, 0xa0, - 0xd5, 0x81, 0x8d, 0x88, 0xc1, 0x7d, 0x11, 0x45, 0x61, 0x40, 0xaf, 0xb9, 0x69, 0x2b, 0x2c, 0x49, 0x7e, 0x41, 0xf3, - 0xbe, 0x0b, 0x45, 0x6e, 0xe0, 0x4a, 0x17, 0x9f, 0x5b, 0x7e, 0xec, 0xa7, 0x24, 0xec, 0xaa, 0x00, 0xcb, 0x43, 0x57, - 0xb0, 0x6b, 0x01, 0x3f, 0x2e, 0xda, 0xdb, 0xdb, 0xba, 0x5f, 0xa4, 0x02, 0x09, 0x73, 0xad, 0xbe, 0x11, 0x62, 0xb3, - 0x22, 0xd7, 0x46, 0x74, 0xd9, 0xaf, 0x44, 0x21, 0xd2, 0x78, 0xba, 0xa6, 0xa1, 0xf0, 0xc3, 0x54, 0x25, 0xd1, 0x58, - 0x0c, 0x0b, 0xb7, 0xe9, 0x01, 0x2a, 0xb8, 0x08, 0xad, 0xef, 0x00, 0xeb, 0x7d, 0xce, 0x45, 0x68, 0xce, 0xd2, 0x5a, - 0xd7, 0x06, 0x81, 0xa3, 0x37, 0xee, 0xf4, 0xde, 0xbc, 0x3f, 0x75, 0xd4, 0xf6, 0x3c, 0xd9, 0x8f, 0x3b, 0xbd, 0x13, - 0xe9, 0x33, 0x51, 0x27, 0xf1, 0x88, 0x3a, 0x89, 0xe7, 0xe8, 0x53, 0x99, 0x10, 0x49, 0x2b, 0xf6, 0xd5, 0xb4, 0xa5, - 0xcd, 0xa0, 0xbc, 0xbd, 0x93, 0x59, 0x22, 0x18, 0xdc, 0x71, 0xbd, 0x2f, 0x8f, 0xe1, 0xc1, 0x82, 0x95, 0x79, 0xd8, - 0x5a, 0x3b, 0xbc, 0x16, 0xa9, 0xf1, 0x0d, 0x8f, 0x58, 0x42, 0x4d, 0xe6, 0xb5, 0xee, 0xaa, 0x3c, 0x29, 0xb0, 0x5e, - 0x3b, 0x9f, 0x5d, 0x4f, 0x98, 0x70, 0xcd, 0x79, 0x86, 0x0f, 0xba, 0xc1, 0x89, 0x1c, 0xaa, 0x77, 0x55, 0x68, 0xe7, - 0xb5, 0xf9, 0x9a, 0x4f, 0x7d, 0x49, 0xf5, 0xec, 0xb5, 0x84, 0x80, 0x13, 0x72, 0xf1, 0x41, 0xaf, 0x74, 0x17, 0xdb, - 0xef, 0x8a, 0x93, 0xfd, 0xf8, 0xa0, 0x77, 0x15, 0x4c, 0x75, 0x7f, 0x2f, 0xf9, 0x78, 0x73, 0x5f, 0x09, 0x1f, 0xf7, - 0xe5, 0x51, 0x10, 0x75, 0x48, 0xd9, 0x28, 0xbf, 0x3c, 0x71, 0x7b, 0x27, 0x5a, 0x19, 0x70, 0x64, 0x60, 0xdd, 0x3d, - 0x6a, 0x99, 0xd3, 0x25, 0x09, 0x1f, 0xc3, 0x86, 0x54, 0x4d, 0xac, 0x41, 0x6a, 0x1e, 0xf7, 0xb8, 0xdd, 0x3b, 0x09, - 0x1d, 0xc9, 0x5b, 0x24, 0xf3, 0xc8, 0x83, 0x7d, 0x68, 0x1c, 0xf3, 0x09, 0xf5, 0x19, 0xdf, 0xbf, 0xa1, 0xd7, 0xcd, - 0x70, 0xca, 0x2a, 0xf7, 0x36, 0x28, 0x1d, 0xe5, 0x90, 0xdc, 0x78, 0xc4, 0xf5, 0xd9, 0xab, 0x4e, 0xe5, 0x6e, 0x3b, - 0x04, 0x9b, 0xc7, 0xb8, 0xe6, 0xa4, 0x4f, 0xce, 0x02, 0x8b, 0xf7, 0x4e, 0xf6, 0xc3, 0x15, 0x8c, 0x48, 0x7e, 0x5f, - 0x68, 0x47, 0x3b, 0x18, 0x36, 0x40, 0x6f, 0xae, 0xa3, 0xc4, 0x81, 0x71, 0xc8, 0x6b, 0x41, 0x5d, 0xb8, 0xbd, 0x7f, - 0xfd, 0x1f, 0xff, 0x4b, 0xfb, 0xd8, 0x4f, 0xf6, 0xe3, 0xb6, 0xe9, 0x6b, 0x65, 0x55, 0x8a, 0x13, 0x38, 0xee, 0x59, - 0x05, 0x85, 0xe9, 0x6d, 0x73, 0x9c, 0xb1, 0xa8, 0x19, 0x87, 0xc9, 0xc8, 0xed, 0x6d, 0xc7, 0xa6, 0x7d, 0x6c, 0x4b, - 0x43, 0x5d, 0x2f, 0x02, 0x7a, 0xfd, 0x4d, 0x07, 0x8f, 0xcc, 0xf9, 0x15, 0xb9, 0xb5, 0xed, 0x63, 0x48, 0xd5, 0xee, - 0xab, 0x1d, 0x45, 0x4a, 0xf5, 0x27, 0xc2, 0x34, 0x07, 0x4c, 0x6b, 0x27, 0x90, 0x0a, 0xd7, 0x29, 0x83, 0x5a, 0xff, - 0xf7, 0x7f, 0xfe, 0x97, 0xff, 0x66, 0x1e, 0x21, 0x56, 0xf5, 0xaf, 0xff, 0xfd, 0x3f, 0xff, 0x9f, 0xff, 0xfd, 0x5f, - 0xe1, 0xd4, 0x8a, 0x8e, 0x67, 0x49, 0xa6, 0xe2, 0x54, 0xc1, 0x2c, 0xc5, 0x5d, 0x1c, 0x48, 0xec, 0x9c, 0xb0, 0x5c, - 0xb0, 0x61, 0xfd, 0x4c, 0xd2, 0xb9, 0x1c, 0x50, 0xee, 0x4c, 0x0d, 0x9d, 0xdc, 0xe1, 0x45, 0x45, 0x50, 0x35, 0x94, - 0x4b, 0xc2, 0x2d, 0x4e, 0xf6, 0x01, 0xdf, 0x0f, 0x3b, 0xc6, 0xe9, 0x97, 0xcb, 0xb1, 0x30, 0x64, 0x02, 0x25, 0x45, - 0x55, 0xee, 0x40, 0x6c, 0x65, 0x01, 0x8f, 0x41, 0xc7, 0x2a, 0x96, 0xab, 0x57, 0x6b, 0xd3, 0xfd, 0x69, 0x96, 0x0b, - 0x36, 0x02, 0x94, 0x2b, 0x3f, 0xb1, 0x0c, 0x63, 0x37, 0x41, 0x57, 0x4c, 0xee, 0x0a, 0xd9, 0x8b, 0x22, 0xd0, 0xc3, - 0xe3, 0x3f, 0x15, 0x7f, 0x99, 0x80, 0x46, 0xe6, 0x78, 0x93, 0xf0, 0x56, 0x9b, 0xe7, 0x8f, 0x5a, 0xad, 0xe9, 0x2d, - 0x5a, 0x54, 0x23, 0xe0, 0x6d, 0x83, 0x49, 0x3a, 0xb6, 0x3b, 0x94, 0xf1, 0xef, 0xd2, 0x8d, 0xdd, 0x72, 0xc0, 0x17, - 0xee, 0xb4, 0x8a, 0xe2, 0xcf, 0x0b, 0xe9, 0x49, 0x65, 0xbf, 0x40, 0x9c, 0x5a, 0x3b, 0x9d, 0xaf, 0xb9, 0x3d, 0xb9, - 0x85, 0xd5, 0xaa, 0xa3, 0x5a, 0xc5, 0xed, 0xf5, 0xd3, 0x89, 0x76, 0x9c, 0xdd, 0x8e, 0x90, 0x1f, 0x42, 0xcc, 0x3b, - 0x6e, 0xe3, 0xb8, 0xb3, 0x28, 0xbb, 0x17, 0x82, 0x4f, 0xec, 0xc0, 0x3a, 0x0d, 0xe9, 0x90, 0x8e, 0x8c, 0xb3, 0x5e, - 0xbf, 0x57, 0x41, 0xf3, 0x22, 0x3e, 0xd8, 0x30, 0x96, 0x06, 0x49, 0x06, 0xd4, 0x9d, 0x56, 0xf1, 0x39, 0xec, 0xc0, - 0xc5, 0x28, 0xe1, 0xa1, 0x08, 0x24, 0xc1, 0x76, 0xed, 0xf0, 0x7c, 0x08, 0x3c, 0x89, 0x2f, 0x2c, 0x78, 0xba, 0xaa, - 0x2a, 0xb8, 0xcd, 0xeb, 0x67, 0x48, 0x0b, 0x5f, 0x36, 0xb7, 0xbb, 0x52, 0x5e, 0xb7, 0x6f, 0x75, 0xd4, 0xfb, 0x5d, - 0xcd, 0x5d, 0xa5, 0x05, 0x52, 0x07, 0x6d, 0x7e, 0xaf, 0xe4, 0xba, 0x7a, 0xfb, 0xb5, 0xf0, 0x5c, 0x09, 0xa6, 0xbb, - 0x5a, 0x4b, 0x16, 0x42, 0xad, 0x77, 0xe4, 0xdb, 0xd2, 0x64, 0x0a, 0xa7, 0x53, 0x59, 0x11, 0x75, 0x4f, 0xf6, 0x95, - 0xa6, 0x0b, 0xdc, 0x43, 0xa6, 0x74, 0xa8, 0x0c, 0x0a, 0x5d, 0x49, 0x6f, 0x05, 0xf5, 0x4b, 0xe7, 0x56, 0xc0, 0xa7, - 0xe3, 0x7a, 0xff, 0x0f, 0x82, 0x7a, 0x0b, 0xa7, 0xcf, 0x89, 0x00, 0x00}; + 0x65, 0x7c, 0xed, 0x25, 0x00, 0x5b, 0x3e, 0xbd, 0x0f, 0xc7, 0xe5, 0xef, 0x57, 0x34, 0xcf, 0xc3, 0xb1, 0xae, 0xb9, + 0x3d, 0x9e, 0x26, 0x41, 0xb4, 0x63, 0x69, 0x06, 0x08, 0x88, 0x89, 0x01, 0x46, 0xc0, 0xa7, 0xa1, 0x43, 0x64, 0x30, + 0xf5, 0x7a, 0x74, 0x4d, 0xe2, 0xaa, 0x5e, 0x24, 0xc2, 0x71, 0x55, 0x70, 0x32, 0xcd, 0xa8, 0x2c, 0x55, 0x68, 0x2c, + 0x4e, 0xf6, 0xa1, 0x40, 0xbd, 0xde, 0x12, 0x45, 0x33, 0x0e, 0x94, 0xed, 0xb1, 0x34, 0xc7, 0x44, 0xd1, 0xec, 0x44, + 0xa5, 0x32, 0x4b, 0x69, 0x3d, 0x76, 0xf3, 0x79, 0x7b, 0x08, 0x7f, 0x74, 0x64, 0xe8, 0xf3, 0xd1, 0x68, 0x74, 0x6f, + 0x54, 0xed, 0xf3, 0x68, 0x44, 0x3b, 0xf4, 0xa8, 0x0b, 0x49, 0x2c, 0x4d, 0x1d, 0x8b, 0x69, 0x17, 0x12, 0x77, 0x8b, + 0x87, 0x55, 0x86, 0xb0, 0x8d, 0x88, 0x17, 0x0f, 0x8f, 0xb0, 0x15, 0xd3, 0x8c, 0x2e, 0x26, 0x61, 0x36, 0x66, 0x69, + 0xd0, 0x2a, 0xfc, 0xb9, 0x0e, 0x49, 0x7d, 0x7e, 0x7c, 0x7c, 0x5c, 0xf8, 0x91, 0x79, 0x6a, 0x45, 0x51, 0xe1, 0x0f, + 0x17, 0xe5, 0x34, 0x5a, 0xad, 0xd1, 0xa8, 0xf0, 0x99, 0x29, 0x38, 0xe8, 0x0c, 0xa3, 0x83, 0x4e, 0xe1, 0xdf, 0x58, + 0x35, 0x0a, 0x9f, 0xea, 0xa7, 0x8c, 0x46, 0xb5, 0x4c, 0x98, 0xc7, 0xad, 0x56, 0xe1, 0x2b, 0x42, 0x5b, 0x80, 0x59, + 0xaa, 0x7e, 0x06, 0xe1, 0x4c, 0x70, 0x60, 0xee, 0xdd, 0x44, 0x78, 0x83, 0x4b, 0x7d, 0xcb, 0x88, 0xfa, 0x26, 0x47, + 0x81, 0x2e, 0xf0, 0xcf, 0x76, 0xf0, 0x08, 0x88, 0x59, 0x06, 0x8d, 0x12, 0x13, 0x5b, 0xaa, 0xbd, 0x06, 0xca, 0x92, + 0xaf, 0x7f, 0x26, 0x49, 0x15, 0x53, 0x02, 0x4e, 0x06, 0x35, 0xd5, 0x65, 0x78, 0x94, 0x6e, 0x91, 0x1f, 0xec, 0xd3, + 0xf2, 0xe3, 0xee, 0x21, 0xe2, 0x83, 0xfd, 0xe1, 0xe2, 0x83, 0x52, 0x4b, 0x7c, 0x28, 0xe6, 0x71, 0x27, 0x88, 0x3b, + 0x8c, 0xe9, 0xf0, 0xe3, 0x35, 0xbf, 0x6d, 0xc2, 0x96, 0xc8, 0x5c, 0x29, 0x58, 0x76, 0x7f, 0x6b, 0xd6, 0x8c, 0xe9, + 0xcc, 0xfa, 0xa2, 0x87, 0x54, 0x1f, 0xde, 0xa4, 0xc4, 0x7d, 0x63, 0x6c, 0x5b, 0x55, 0x32, 0x1a, 0x11, 0xf7, 0xcd, + 0x68, 0xe4, 0x9a, 0xb3, 0x92, 0xa1, 0xa0, 0xb2, 0xd6, 0xeb, 0x5a, 0x89, 0xac, 0xf5, 0xe5, 0x97, 0x76, 0x99, 0x5d, + 0xa0, 0x43, 0x4f, 0x76, 0x98, 0x49, 0xbf, 0x89, 0x58, 0x0e, 0x5b, 0x0d, 0x3e, 0x34, 0x52, 0xbf, 0xab, 0x31, 0xad, + 0x5d, 0xab, 0x5d, 0x02, 0xbc, 0xe1, 0x2e, 0xf0, 0xd5, 0x8b, 0x02, 0xc6, 0xd4, 0xe4, 0x2d, 0x3e, 0xbd, 0xfb, 0x2a, + 0xf2, 0xee, 0x04, 0x2a, 0x58, 0xfe, 0x26, 0x5d, 0x39, 0x04, 0xa4, 0x60, 0x24, 0xc4, 0x9e, 0x56, 0x21, 0xf8, 0x78, + 0x9c, 0xc0, 0xb7, 0x5e, 0x16, 0xb5, 0xfb, 0x63, 0x55, 0xf3, 0x7e, 0x6d, 0xbe, 0x81, 0xdd, 0x50, 0xdf, 0xb6, 0x2a, + 0x3f, 0x3d, 0xa5, 0x92, 0xc7, 0xe7, 0xfa, 0x1b, 0x44, 0xd2, 0x2c, 0x5e, 0x68, 0x26, 0xbf, 0x50, 0x29, 0xc7, 0x02, + 0xd2, 0x6d, 0x54, 0xc7, 0x51, 0x51, 0xe8, 0xc3, 0x1a, 0x11, 0xcb, 0xa7, 0x70, 0xaf, 0xa9, 0x6a, 0x49, 0x3f, 0xc5, + 0xc2, 0xf3, 0x1b, 0x2b, 0xbe, 0x53, 0x5b, 0xae, 0xc2, 0x04, 0x78, 0x94, 0xc3, 0xfc, 0x4e, 0x14, 0xae, 0xf6, 0xbb, + 0x1b, 0x24, 0xba, 0x8e, 0xc2, 0xa7, 0x8a, 0x3c, 0x59, 0x33, 0x04, 0xe7, 0x77, 0xb9, 0x20, 0xe6, 0x95, 0x29, 0x28, + 0xec, 0xf8, 0xa5, 0x7c, 0xa3, 0xb0, 0x25, 0xa3, 0x25, 0xf9, 0x34, 0x4c, 0x15, 0x1b, 0x25, 0xae, 0xe2, 0x07, 0xbb, + 0x8b, 0x6a, 0xe5, 0x0b, 0xd7, 0x80, 0xad, 0x88, 0xb7, 0x77, 0xb2, 0x0f, 0x0d, 0x7a, 0x4e, 0x0d, 0xf4, 0x74, 0x2d, + 0xc8, 0xf2, 0x89, 0x74, 0x87, 0x2b, 0x3f, 0xbf, 0xc1, 0x7e, 0x7e, 0xe3, 0xfc, 0x79, 0xd1, 0xbc, 0xa1, 0xd7, 0x1f, + 0x99, 0x68, 0x8a, 0x70, 0xda, 0x04, 0xc3, 0x47, 0x3a, 0x47, 0x35, 0x7b, 0x96, 0x59, 0x7e, 0xea, 0xaa, 0x83, 0xee, + 0x2c, 0x87, 0xac, 0x08, 0xa9, 0xbe, 0x07, 0x29, 0x4f, 0x69, 0xb7, 0x9e, 0xcd, 0x69, 0x07, 0xd9, 0x0d, 0xb6, 0x2e, + 0x16, 0x1c, 0xb2, 0x28, 0xc4, 0x5d, 0xd0, 0xd2, 0x6c, 0xbd, 0x65, 0x22, 0xe8, 0xad, 0x8d, 0xf5, 0x03, 0x8d, 0xdc, + 0x86, 0x94, 0x5e, 0xd9, 0x7a, 0x26, 0xc1, 0xb6, 0x4c, 0x80, 0x4f, 0xe5, 0x36, 0x82, 0x4b, 0xd5, 0xfc, 0xb5, 0x92, + 0x42, 0x57, 0x8b, 0x65, 0x6e, 0xe3, 0x43, 0x20, 0x0b, 0xc2, 0x91, 0xa0, 0x19, 0x7e, 0x48, 0xcd, 0x6b, 0x79, 0x0c, + 0x69, 0x01, 0x62, 0x26, 0x68, 0x1f, 0x4f, 0x6f, 0x1f, 0xde, 0xfd, 0xfd, 0xd3, 0x2f, 0x34, 0x8e, 0xcc, 0xb5, 0x3c, + 0xae, 0xdb, 0x85, 0x8d, 0x90, 0x84, 0x77, 0x01, 0x4b, 0xa5, 0xcc, 0xbb, 0x06, 0xbf, 0x68, 0x77, 0xca, 0x75, 0x92, + 0x6e, 0x46, 0x13, 0xf9, 0x15, 0x3e, 0xbd, 0x14, 0x07, 0x8f, 0xa6, 0xb7, 0x66, 0x35, 0xda, 0x2b, 0xc9, 0xb7, 0x7f, + 0x68, 0x8e, 0xed, 0xf6, 0xa4, 0xde, 0x7a, 0x9e, 0xe8, 0xd1, 0xf4, 0xb6, 0xab, 0x04, 0x6d, 0x33, 0x53, 0x50, 0xb5, + 0xa6, 0xb7, 0x76, 0x96, 0x71, 0xd5, 0x91, 0xe3, 0x1f, 0xe4, 0x0e, 0x0d, 0x73, 0xda, 0x85, 0x7b, 0xc7, 0xd9, 0x30, + 0x4c, 0xb4, 0x30, 0x9f, 0xb0, 0x28, 0x4a, 0x68, 0xd7, 0xc8, 0x6b, 0xa7, 0xfd, 0x08, 0x92, 0x74, 0xed, 0x25, 0xab, + 0xaf, 0x8a, 0x85, 0xbc, 0x12, 0x4f, 0xe1, 0x75, 0xce, 0x13, 0xf8, 0xe8, 0xc7, 0x46, 0x74, 0xea, 0xec, 0xd5, 0x56, + 0x85, 0x3c, 0xf9, 0xbb, 0x3e, 0x97, 0xa3, 0xd6, 0x9f, 0xba, 0x72, 0xc1, 0x5b, 0x5d, 0xc1, 0xa7, 0x41, 0xf3, 0xa0, + 0x3e, 0x11, 0x78, 0x55, 0x4e, 0x01, 0x6f, 0x98, 0x16, 0x06, 0x69, 0xa5, 0xf8, 0xb4, 0xe3, 0xb7, 0x75, 0x99, 0xec, + 0x00, 0xf2, 0xc2, 0xca, 0xa2, 0xa2, 0x3e, 0x99, 0x7f, 0x9b, 0xdd, 0xf2, 0x64, 0xf3, 0x6e, 0x79, 0x62, 0x76, 0xcb, + 0xfd, 0x14, 0xfb, 0xf9, 0xa8, 0x0d, 0x7f, 0xba, 0xd5, 0x84, 0x82, 0x96, 0x73, 0x30, 0xbd, 0x75, 0x40, 0x4f, 0x6b, + 0x76, 0xa6, 0xb7, 0x2a, 0xc7, 0x1a, 0x62, 0x37, 0x2d, 0xc8, 0x3a, 0xc6, 0x2d, 0x07, 0x0a, 0xe1, 0x6f, 0xab, 0xf6, + 0xaa, 0x7d, 0x08, 0xef, 0xa0, 0xd5, 0xd1, 0xfa, 0xbb, 0xce, 0xfd, 0x9b, 0x36, 0x48, 0xb9, 0xf0, 0x02, 0xc3, 0x8d, + 0x91, 0x2f, 0xc2, 0xeb, 0x6b, 0x1a, 0x05, 0x23, 0x3e, 0x9c, 0xe5, 0xff, 0xa4, 0xe1, 0xd7, 0x48, 0xbc, 0x77, 0x4b, + 0xaf, 0xf4, 0x63, 0x9a, 0xaa, 0x8c, 0x6f, 0xd3, 0xc3, 0xa2, 0x5c, 0xa7, 0x20, 0x1f, 0x86, 0x09, 0xf5, 0x3a, 0xfe, + 0xe1, 0x86, 0x4d, 0xf0, 0xef, 0xb2, 0x36, 0x1b, 0x27, 0xf3, 0x7b, 0x91, 0x71, 0x2f, 0x12, 0x7e, 0x15, 0x0e, 0xec, + 0x35, 0x6c, 0x1d, 0x6f, 0x06, 0x77, 0x60, 0x46, 0xba, 0x30, 0x42, 0x41, 0xcb, 0x9d, 0x88, 0x8e, 0xc2, 0x59, 0x22, + 0xee, 0xef, 0x75, 0x1b, 0x65, 0xac, 0xf5, 0x7a, 0x0f, 0x43, 0xaf, 0xea, 0x3e, 0x90, 0x4b, 0x7f, 0xfe, 0xe4, 0x10, + 0xfe, 0xa8, 0xfc, 0xaf, 0xbb, 0x4a, 0x57, 0x57, 0x76, 0x2f, 0xe8, 0xea, 0xbb, 0x35, 0x65, 0x5c, 0x89, 0x70, 0xa9, + 0x8f, 0x3f, 0xb4, 0x36, 0x68, 0x95, 0x0f, 0xaa, 0xae, 0xb5, 0xac, 0x5f, 0x55, 0xfb, 0xd7, 0x75, 0xfe, 0xc0, 0xba, + 0x43, 0xa5, 0xb9, 0xd6, 0xeb, 0xea, 0xcf, 0x10, 0xae, 0x55, 0x36, 0x18, 0x97, 0xf5, 0x77, 0xc9, 0x5d, 0x69, 0xa2, + 0xa8, 0x68, 0x2c, 0x58, 0x29, 0xbb, 0xca, 0x4a, 0xc9, 0x29, 0xb9, 0x3a, 0xe9, 0xdf, 0x4e, 0x12, 0x67, 0xae, 0x8e, + 0x4b, 0x12, 0xb7, 0xed, 0xb7, 0x5c, 0x47, 0xe6, 0x01, 0xc0, 0xad, 0xed, 0xae, 0xfc, 0xbc, 0xad, 0xdb, 0x07, 0x4d, + 0x6b, 0x3e, 0x96, 0x9a, 0xdd, 0xcb, 0xf0, 0x8e, 0x66, 0x97, 0x1d, 0xd7, 0x01, 0x3f, 0x4d, 0x53, 0xa5, 0x4c, 0xc8, + 0x32, 0xa7, 0xe3, 0x3a, 0xb7, 0x93, 0x24, 0xcd, 0x89, 0x1b, 0x0b, 0x31, 0x0d, 0xd4, 0xf7, 0x6f, 0x6f, 0x0e, 0x7c, + 0x9e, 0x8d, 0xf7, 0x3b, 0xad, 0x56, 0x0b, 0x2e, 0x80, 0x75, 0x9d, 0x39, 0xa3, 0x37, 0x4f, 0xf9, 0x2d, 0x71, 0x5b, + 0x4e, 0xcb, 0x69, 0x77, 0x8e, 0x9d, 0x76, 0xe7, 0xd0, 0x7f, 0x74, 0xec, 0xf6, 0x3e, 0x73, 0x9c, 0x93, 0x88, 0x8e, + 0x72, 0xf8, 0xe1, 0x38, 0x27, 0x52, 0xf1, 0x52, 0xbf, 0x1d, 0xc7, 0x1f, 0x26, 0x79, 0xb3, 0xed, 0x2c, 0xf4, 0xa3, + 0xe3, 0xc0, 0xa1, 0xd2, 0xc0, 0xf9, 0x7c, 0xd4, 0x19, 0x1d, 0x8e, 0x9e, 0x74, 0x75, 0x71, 0xf1, 0x59, 0xad, 0x3a, + 0x56, 0xff, 0x77, 0xac, 0x66, 0xb9, 0xc8, 0xf8, 0x47, 0xaa, 0x73, 0x12, 0x1d, 0x10, 0x3d, 0x1b, 0x9b, 0x76, 0xd6, + 0x47, 0x6a, 0x1f, 0x5f, 0x0f, 0x47, 0x9d, 0xaa, 0xba, 0x84, 0x71, 0xbf, 0x04, 0xf2, 0x64, 0xdf, 0x80, 0x7e, 0x62, + 0xa3, 0xa9, 0xdd, 0xdc, 0x84, 0xa8, 0xb6, 0xab, 0xe7, 0x38, 0x36, 0xf3, 0x3b, 0x81, 0x33, 0x0c, 0x46, 0x57, 0x95, + 0x10, 0xb8, 0x4e, 0x44, 0xdc, 0x57, 0xed, 0xce, 0x31, 0x6e, 0xb7, 0x1f, 0xf9, 0x8f, 0x8e, 0x87, 0x2d, 0x7c, 0xe8, + 0x1f, 0x36, 0x0f, 0xfc, 0x47, 0xf8, 0xb8, 0x79, 0x8c, 0x8f, 0x5f, 0x1c, 0x0f, 0x9b, 0x87, 0xfe, 0x21, 0x6e, 0x35, + 0x8f, 0xa1, 0xb0, 0x79, 0xdc, 0x3c, 0x9e, 0x37, 0x0f, 0x8f, 0x87, 0x2d, 0x59, 0xda, 0xf1, 0x8f, 0x8e, 0x9a, 0xed, + 0x96, 0x7f, 0x74, 0x84, 0x8f, 0xfc, 0x47, 0x8f, 0x9a, 0xed, 0x03, 0xff, 0xd1, 0xa3, 0x97, 0x47, 0xc7, 0xfe, 0x01, + 0xbc, 0x3b, 0x38, 0x18, 0x1e, 0xf8, 0xed, 0x76, 0x13, 0xfe, 0xc1, 0xc7, 0x7e, 0x47, 0xfd, 0x68, 0xb7, 0xfd, 0x83, + 0x36, 0x6e, 0x25, 0x47, 0x1d, 0xff, 0xd1, 0x13, 0x2c, 0xff, 0x95, 0xd5, 0xb0, 0xfc, 0x07, 0xba, 0xc1, 0x4f, 0xfc, + 0xce, 0x23, 0xf5, 0x4b, 0x76, 0x38, 0x3f, 0x3c, 0xfe, 0xc1, 0xdd, 0xdf, 0x3a, 0x87, 0xb6, 0x9a, 0xc3, 0xf1, 0x91, + 0x7f, 0x70, 0x80, 0x0f, 0xdb, 0xfe, 0xf1, 0x41, 0xdc, 0x3c, 0xec, 0xf8, 0x8f, 0x1e, 0x0f, 0x9b, 0x6d, 0xff, 0xf1, + 0x63, 0xdc, 0x6a, 0x1e, 0xf8, 0x1d, 0xdc, 0xf6, 0x0f, 0x0f, 0xe4, 0x8f, 0x03, 0xbf, 0x33, 0x7f, 0xfc, 0xc4, 0x7f, + 0x74, 0x14, 0x3f, 0xf2, 0x0f, 0xbf, 0x3d, 0x3c, 0xf6, 0x3b, 0x07, 0xf1, 0xc1, 0x23, 0xbf, 0xf3, 0x78, 0xfe, 0xc8, + 0x3f, 0x8c, 0x9b, 0x9d, 0x47, 0xf7, 0xb6, 0x6c, 0x77, 0x7c, 0xc0, 0x91, 0x7c, 0x0d, 0x2f, 0xb0, 0x7e, 0x01, 0x7f, + 0x63, 0xd9, 0xf6, 0xdf, 0xb1, 0x9b, 0x7c, 0xbd, 0xe9, 0x13, 0xff, 0xf8, 0xf1, 0x50, 0x55, 0x87, 0x82, 0xa6, 0xa9, + 0x01, 0x4d, 0xe6, 0x4d, 0x35, 0xac, 0xec, 0xae, 0x69, 0x3a, 0x32, 0x7f, 0xf5, 0x60, 0xf3, 0x26, 0x0c, 0xac, 0xc6, + 0xfd, 0x0f, 0xed, 0xa7, 0x5c, 0xf2, 0x93, 0xfd, 0xb1, 0x22, 0xfd, 0x71, 0xef, 0x33, 0x75, 0xbb, 0xf3, 0x67, 0x57, + 0x38, 0xdd, 0xe6, 0xf8, 0xc8, 0x3e, 0xed, 0xf8, 0xe0, 0xf4, 0x21, 0x9e, 0x8f, 0xec, 0x0f, 0xf7, 0x7c, 0xa4, 0x74, + 0xc5, 0x71, 0x7e, 0x2d, 0xd6, 0x1c, 0x1c, 0xab, 0x56, 0xf1, 0x53, 0xe1, 0x0d, 0x72, 0xf8, 0x8e, 0x58, 0xd1, 0xbd, + 0x16, 0x84, 0x53, 0xdb, 0x0f, 0xc4, 0x81, 0xc5, 0x5e, 0x0b, 0xc5, 0x63, 0x93, 0x6d, 0x08, 0x09, 0x3f, 0x8d, 0x90, + 0x6f, 0x1f, 0x82, 0x8f, 0xf0, 0x0f, 0xc7, 0x47, 0x62, 0xe3, 0xa3, 0xe6, 0xcb, 0x97, 0x9e, 0x06, 0xe9, 0x29, 0x38, + 0x97, 0xcf, 0x1e, 0x1c, 0xa2, 0x6a, 0xb8, 0xfb, 0x14, 0x8a, 0x72, 0x57, 0x45, 0xbe, 0xde, 0xfd, 0x9a, 0xb0, 0x83, + 0x3a, 0x31, 0x49, 0x5c, 0xed, 0x96, 0x99, 0x4a, 0xa9, 0xa3, 0x1f, 0x4a, 0xa1, 0xd4, 0xf1, 0x5b, 0x7e, 0xab, 0x74, + 0xe9, 0xc0, 0x29, 0x59, 0xb2, 0xe0, 0x22, 0x84, 0x2f, 0xd6, 0x26, 0x7c, 0x2c, 0xbf, 0x6d, 0x0b, 0x5f, 0x13, 0x80, + 0xa4, 0x9f, 0xa1, 0xfa, 0x90, 0x43, 0xe0, 0xba, 0xfa, 0x6e, 0x0d, 0x38, 0x85, 0xf9, 0x0d, 0x9c, 0x54, 0x35, 0x51, + 0x89, 0x09, 0x78, 0x3b, 0x5e, 0xd1, 0x88, 0x85, 0x9e, 0xeb, 0x4d, 0x33, 0x3a, 0xa2, 0x59, 0xde, 0xac, 0x1d, 0xdf, + 0x94, 0x27, 0x37, 0x91, 0x6b, 0x3e, 0x8d, 0x9a, 0xc1, 0xed, 0xd8, 0x64, 0xa0, 0xfd, 0x8d, 0xae, 0x36, 0xc0, 0xdc, + 0x02, 0x9b, 0x92, 0x0c, 0x64, 0x6d, 0xa5, 0xb4, 0xb9, 0x4a, 0x6b, 0x6b, 0xfb, 0x9d, 0x23, 0xe4, 0xc8, 0x62, 0xb8, + 0x77, 0xf8, 0x7b, 0xaf, 0x79, 0xd0, 0xfa, 0x13, 0xb2, 0x9a, 0x95, 0x1d, 0x5d, 0x68, 0x77, 0x5b, 0x5a, 0x7d, 0x53, + 0xba, 0x7e, 0xb6, 0xd6, 0x55, 0x14, 0xf1, 0xb9, 0x9a, 0xbb, 0x8b, 0xba, 0xa9, 0x8e, 0x70, 0xab, 0x1b, 0x22, 0x46, + 0x6c, 0xec, 0xd9, 0x5f, 0x0c, 0x56, 0xf7, 0x1a, 0xcb, 0x0f, 0x8d, 0xa3, 0xa2, 0xaa, 0x92, 0xa2, 0x85, 0x8c, 0xb7, + 0xb0, 0xd4, 0x49, 0x97, 0x4b, 0x2f, 0x05, 0x17, 0x39, 0xb1, 0x70, 0x0a, 0xcf, 0xa8, 0x86, 0xe4, 0x14, 0x97, 0x00, + 0x49, 0x04, 0x93, 0x54, 0xfd, 0x5f, 0x15, 0x9b, 0x1f, 0xda, 0xf1, 0xe5, 0x27, 0x61, 0x3a, 0x06, 0x2a, 0x0c, 0xd3, + 0xf1, 0x9a, 0x5b, 0x4d, 0x85, 0x8c, 0x56, 0x4a, 0xab, 0xae, 0x2a, 0xf7, 0x59, 0xfe, 0xf4, 0xee, 0xbd, 0xbe, 0x00, + 0xcd, 0x05, 0xef, 0xb4, 0x8c, 0x70, 0x54, 0x97, 0x35, 0x37, 0xc8, 0x17, 0x27, 0x13, 0x2a, 0x42, 0x95, 0xaf, 0x09, + 0xfa, 0x04, 0x9c, 0x9a, 0x75, 0xb4, 0x35, 0x4a, 0x5c, 0x29, 0xdd, 0x49, 0x44, 0xe7, 0x6c, 0xa8, 0x45, 0x3d, 0x76, + 0xf4, 0xcd, 0x01, 0x4d, 0xb9, 0x34, 0xa4, 0x8d, 0x95, 0x3f, 0x66, 0x18, 0xca, 0x8c, 0x7c, 0x92, 0x72, 0xb7, 0xf7, + 0x45, 0xf9, 0xf5, 0xd3, 0x6d, 0x8b, 0x90, 0xb0, 0xf4, 0xe3, 0x20, 0xa3, 0xc9, 0x3f, 0x91, 0x2f, 0xd8, 0x90, 0xa7, + 0x5f, 0x5c, 0xc0, 0x57, 0xe9, 0xfd, 0x38, 0xa3, 0x23, 0xf2, 0x05, 0xc8, 0xf8, 0x40, 0x5a, 0x1f, 0xc0, 0x08, 0x1b, + 0xb7, 0x93, 0x04, 0x4b, 0x8d, 0xe9, 0x01, 0x0a, 0x91, 0x02, 0xd7, 0xed, 0x1c, 0xb9, 0x8e, 0xb2, 0x89, 0xe5, 0xef, + 0x9e, 0x12, 0xa7, 0x52, 0x09, 0x70, 0xda, 0x1d, 0xff, 0x28, 0xee, 0xf8, 0x4f, 0xe6, 0x8f, 0xfd, 0xe3, 0xb8, 0xfd, + 0x78, 0xde, 0x84, 0xff, 0x3b, 0xfe, 0x93, 0xa4, 0xd9, 0xf1, 0x9f, 0xc0, 0xdf, 0x6f, 0x0f, 0xfd, 0xa3, 0xb8, 0xd9, + 0xf6, 0x8f, 0xe7, 0x07, 0xfe, 0xc1, 0xcb, 0x76, 0xc7, 0x3f, 0x70, 0xda, 0x8e, 0x6a, 0x07, 0xec, 0x5a, 0x71, 0xe7, + 0x2f, 0x56, 0x36, 0xc4, 0x86, 0x70, 0x9c, 0xca, 0x39, 0x75, 0xb1, 0x57, 0x7e, 0x63, 0x51, 0xef, 0x4f, 0xed, 0xac, + 0x7b, 0x16, 0x66, 0xf0, 0xa1, 0x9b, 0xfa, 0xde, 0xad, 0xbd, 0xc3, 0x35, 0x7e, 0xb1, 0x61, 0x08, 0xd8, 0xe1, 0x2e, + 0xb6, 0x8f, 0xde, 0xc3, 0xb9, 0x75, 0x79, 0x2f, 0xb8, 0xb9, 0x1e, 0x71, 0x3b, 0x69, 0xab, 0x8a, 0xe6, 0x0a, 0x46, + 0xc9, 0x2c, 0x98, 0xfc, 0x02, 0x83, 0x1c, 0xe4, 0xab, 0xa8, 0x58, 0x1d, 0x1f, 0x52, 0x5f, 0x33, 0x6e, 0xdd, 0x3e, + 0x40, 0xab, 0x03, 0x1b, 0x11, 0x83, 0xfb, 0x22, 0x8a, 0xc2, 0x80, 0x5e, 0x73, 0xd3, 0x56, 0x58, 0x92, 0xfc, 0x82, + 0xe6, 0x7d, 0x17, 0x8a, 0xdc, 0xc0, 0x95, 0x2e, 0x3e, 0xb7, 0xfc, 0xd8, 0x4f, 0x49, 0xd8, 0x55, 0x01, 0x96, 0x87, + 0xae, 0x60, 0xd7, 0x02, 0x7e, 0x5c, 0xb4, 0xb7, 0xb7, 0x75, 0xbf, 0x48, 0x05, 0x12, 0xe6, 0x5a, 0x7d, 0x23, 0xc4, + 0x66, 0x45, 0xae, 0x8d, 0xe8, 0xb2, 0x5f, 0x89, 0x42, 0xa4, 0xf1, 0x74, 0x4d, 0x43, 0xe1, 0x87, 0xa9, 0x4a, 0xa2, + 0xb1, 0x18, 0x16, 0x6e, 0xd3, 0x03, 0x54, 0x70, 0x11, 0x5a, 0xdf, 0x01, 0xd6, 0xfb, 0x9c, 0x8b, 0xd0, 0x9c, 0xa5, + 0xb5, 0xae, 0x0d, 0x02, 0x47, 0x6f, 0xdc, 0xe9, 0xbd, 0x79, 0x7f, 0xea, 0xa8, 0xed, 0x79, 0xb2, 0x1f, 0x77, 0x7a, + 0x27, 0xd2, 0x67, 0xa2, 0x4e, 0xe2, 0x11, 0x75, 0x12, 0xcf, 0xd1, 0xa7, 0x32, 0x21, 0x92, 0x56, 0xec, 0xab, 0x69, + 0x4b, 0x9b, 0x41, 0x79, 0x7b, 0x27, 0xb3, 0x44, 0x30, 0xb8, 0xe3, 0x7a, 0x5f, 0x1e, 0xc3, 0x83, 0x05, 0x2b, 0xf3, + 0xb0, 0xb5, 0x76, 0x78, 0x2d, 0x52, 0xe3, 0x1b, 0x1e, 0xb1, 0x84, 0x9a, 0xcc, 0x6b, 0xdd, 0x55, 0x79, 0x52, 0x60, + 0xbd, 0x76, 0x3e, 0xbb, 0x9e, 0x30, 0xe1, 0x9a, 0xf3, 0x0c, 0x1f, 0x74, 0x83, 0x13, 0x39, 0x54, 0xef, 0xaa, 0xd0, + 0xce, 0x6b, 0xf3, 0x35, 0x9f, 0xfa, 0x92, 0xea, 0xd9, 0x6b, 0x09, 0x01, 0x27, 0xe4, 0xe2, 0x83, 0x5e, 0xe9, 0x2e, + 0xb6, 0xdf, 0x15, 0x27, 0xfb, 0xf1, 0x41, 0xef, 0x2a, 0x98, 0xea, 0xfe, 0x5e, 0xf2, 0xf1, 0xe6, 0xbe, 0x12, 0x3e, + 0xee, 0xcb, 0xa3, 0x20, 0xea, 0x90, 0xb2, 0x51, 0x7e, 0x79, 0xe2, 0xf6, 0x4e, 0xb4, 0x32, 0xe0, 0xc8, 0xc0, 0xba, + 0x7b, 0xd4, 0x32, 0xa7, 0x4b, 0x12, 0x3e, 0x86, 0x0d, 0xa9, 0x9a, 0x58, 0x83, 0xd4, 0x3c, 0xee, 0x71, 0xbb, 0x77, + 0x12, 0x3a, 0x92, 0xb7, 0x48, 0xe6, 0x91, 0x07, 0xfb, 0xd0, 0x38, 0xe6, 0x13, 0xea, 0x33, 0xbe, 0x7f, 0x43, 0xaf, + 0x9b, 0xe1, 0x94, 0x55, 0xee, 0x6d, 0x50, 0x3a, 0xca, 0x21, 0xb9, 0xf1, 0x88, 0xeb, 0xb3, 0x57, 0x9d, 0xca, 0xdd, + 0x76, 0x08, 0x36, 0x8f, 0x71, 0xcd, 0x49, 0x9f, 0x9c, 0x05, 0x16, 0xef, 0x9d, 0xec, 0x87, 0x2b, 0x18, 0x91, 0xfc, + 0xbe, 0xd0, 0x8e, 0x76, 0x30, 0x6c, 0x80, 0xde, 0x5c, 0x47, 0x89, 0x03, 0xe3, 0x90, 0xd7, 0x82, 0xba, 0x70, 0x7b, + 0xff, 0xfa, 0x3f, 0xfe, 0x97, 0xf6, 0xb1, 0x9f, 0xec, 0xc7, 0x6d, 0xd3, 0xd7, 0xca, 0xaa, 0x14, 0x27, 0x70, 0xdc, + 0xb3, 0x0a, 0x0a, 0xd3, 0xdb, 0xe6, 0x38, 0x63, 0x51, 0x33, 0x0e, 0x93, 0x91, 0xdb, 0xdb, 0x8e, 0x4d, 0xfb, 0xd8, + 0x96, 0x86, 0xba, 0x5e, 0x04, 0xf4, 0xfa, 0x9b, 0x0e, 0x1e, 0x99, 0xf3, 0x2b, 0x72, 0x6b, 0xdb, 0xc7, 0x90, 0xaa, + 0xdd, 0x57, 0x3b, 0x8a, 0x94, 0xea, 0x4f, 0x84, 0x69, 0x0e, 0x98, 0xd6, 0x4e, 0x20, 0x15, 0xae, 0x53, 0x06, 0xb5, + 0xfe, 0xef, 0xff, 0xfc, 0x2f, 0xff, 0xcd, 0x3c, 0x42, 0xac, 0xea, 0x5f, 0xff, 0xfb, 0x7f, 0xfe, 0x3f, 0xff, 0xfb, + 0xbf, 0xc2, 0xa9, 0x15, 0x1d, 0xcf, 0x92, 0x4c, 0xc5, 0xa9, 0x82, 0x59, 0x8a, 0xbb, 0x38, 0x90, 0xd8, 0x39, 0x61, + 0xb9, 0x60, 0xc3, 0xfa, 0x99, 0xa4, 0x73, 0x39, 0xa0, 0xdc, 0x99, 0x1a, 0x3a, 0xb9, 0xc3, 0x8b, 0x8a, 0xa0, 0x6a, + 0x28, 0x97, 0x84, 0x5b, 0x9c, 0xec, 0x03, 0xbe, 0x1f, 0x76, 0x8c, 0xd3, 0x2f, 0x97, 0x63, 0x61, 0xc8, 0x04, 0x4a, + 0x8a, 0xaa, 0xdc, 0x81, 0xd8, 0xca, 0x02, 0x1e, 0x83, 0x8e, 0x55, 0x2c, 0x57, 0xaf, 0xd6, 0xa6, 0xfb, 0xd3, 0x2c, + 0x17, 0x6c, 0x04, 0x28, 0x57, 0x7e, 0x62, 0x19, 0xc6, 0x6e, 0x82, 0xae, 0x98, 0xdc, 0x15, 0xb2, 0x17, 0x45, 0xa0, + 0x87, 0xc7, 0x7f, 0x2a, 0xfe, 0x32, 0x01, 0x8d, 0xcc, 0xf1, 0x26, 0xe1, 0xad, 0x36, 0xcf, 0x1f, 0xb5, 0x5a, 0xd3, + 0x5b, 0xb4, 0xa8, 0x46, 0xc0, 0xdb, 0x06, 0x93, 0x74, 0x6c, 0x77, 0x28, 0xe3, 0xdf, 0xa5, 0x1b, 0xbb, 0xe5, 0x80, + 0x2f, 0xdc, 0x69, 0x15, 0xc5, 0x9f, 0x17, 0xd2, 0x93, 0xca, 0x7e, 0x81, 0x38, 0xb5, 0x76, 0x3a, 0x5f, 0x73, 0x7b, + 0x72, 0x0b, 0xab, 0x55, 0x47, 0xb5, 0x8a, 0xdb, 0xeb, 0xa7, 0x13, 0xed, 0x38, 0xbb, 0x1d, 0x21, 0x3f, 0x84, 0x98, + 0x77, 0xdc, 0xc6, 0x71, 0x67, 0x51, 0x76, 0x2f, 0x04, 0x9f, 0xd8, 0x81, 0x75, 0x1a, 0xd2, 0x21, 0x1d, 0x19, 0x67, + 0xbd, 0x7e, 0xaf, 0x82, 0xe6, 0x45, 0x7c, 0xb0, 0x61, 0x2c, 0x0d, 0x92, 0x0c, 0xa8, 0x3b, 0xad, 0xe2, 0x73, 0xd8, + 0x81, 0x8b, 0x51, 0xc2, 0x43, 0x11, 0x48, 0x82, 0xed, 0xda, 0xe1, 0xf9, 0x10, 0x78, 0x12, 0x5f, 0x58, 0xf0, 0x74, + 0x55, 0x55, 0x70, 0x9b, 0xd7, 0xcf, 0x90, 0x16, 0xbe, 0x6c, 0x6e, 0x77, 0xa5, 0xbc, 0x6e, 0xdf, 0xea, 0xa8, 0xf7, + 0xbb, 0x9a, 0xbb, 0x4a, 0x0b, 0xa4, 0x0e, 0xda, 0xfc, 0x5e, 0xc9, 0x75, 0xf5, 0xf6, 0x6b, 0xe1, 0xb9, 0x12, 0x4c, + 0x77, 0xb5, 0x96, 0x2c, 0x84, 0x5a, 0xef, 0xc8, 0xb7, 0xa5, 0xc9, 0x14, 0x4e, 0xa7, 0xb2, 0x22, 0xea, 0x9e, 0xec, + 0x2b, 0x4d, 0x17, 0xb8, 0x87, 0x4c, 0xe9, 0x50, 0x19, 0x14, 0xba, 0x92, 0xde, 0x0a, 0xea, 0x97, 0xce, 0xad, 0x80, + 0x4f, 0xc7, 0xf5, 0xfe, 0x1f, 0xe7, 0xe0, 0x1c, 0x12, 0xcf, 0x89, 0x00, 0x00}; } // namespace web_server } // namespace esphome From 41f0d1c622c17fbdb3fd32c0ac20af2211a1e339 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Sep 2025 21:09:45 +0000 Subject: [PATCH 1027/1626] Bump ruff from 0.12.12 to 0.13.0 (#10670) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2b161cf05c..cda0384d54 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.12 + rev: v0.13.0 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index bae9246768..01661f3b7c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.8 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.12.12 # also change in .pre-commit-config.yaml when updating +ruff==0.13.0 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From 604074e3bfbb0266e526d0bd2075d6bca9effbfd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Sep 2025 21:02:33 -0500 Subject: [PATCH 1028/1626] [esp32_ble_tracker] Simplify BLE client state machine by removing READY_TO_CONNECT --- .../components/esp32_ble_client/ble_client_base.cpp | 11 +++-------- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 4 +--- .../components/esp32_ble_tracker/esp32_ble_tracker.h | 3 --- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index af5162afb0..641fb5dc44 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -43,13 +43,6 @@ void BLEClientBase::setup() { void BLEClientBase::set_state(espbt::ClientState st) { ESP_LOGV(TAG, "[%d] [%s] Set state %d", this->connection_index_, this->address_str_.c_str(), (int) st); ESPBTClient::set_state(st); - - if (st == espbt::ClientState::READY_TO_CONNECT) { - // Enable loop for state processing - this->enable_loop(); - // Connect immediately instead of waiting for next loop - this->connect(); - } } void BLEClientBase::loop() { @@ -111,6 +104,8 @@ void BLEClientBase::connect() { ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_.c_str(), this->remote_addr_type_); this->paired_ = false; + // Enable loop for state processing + this->enable_loop(); // Determine connection parameters based on connection type if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { @@ -168,7 +163,7 @@ void BLEClientBase::unconditional_disconnect() { this->log_gattc_warning_("esp_ble_gattc_close", err); } - if (this->state_ == espbt::ClientState::READY_TO_CONNECT || this->state_ == espbt::ClientState::DISCOVERED) { + if (this->state_ == espbt::ClientState::DISCOVERED) { this->set_address(0); this->set_state(espbt::ClientState::IDLE); } else { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 0edde169eb..bab1dd7c98 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -51,8 +51,6 @@ const char *client_state_to_string(ClientState state) { return "IDLE"; case ClientState::DISCOVERED: return "DISCOVERED"; - case ClientState::READY_TO_CONNECT: - return "READY_TO_CONNECT"; case ClientState::CONNECTING: return "CONNECTING"; case ClientState::CONNECTED: @@ -795,7 +793,7 @@ void ESP32BLETracker::try_promote_discovered_clients_() { #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE this->update_coex_preference_(true); #endif - client->set_state(ClientState::READY_TO_CONNECT); + client->connect(); break; } } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index dd67156108..e53c2ac097 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -159,8 +159,6 @@ enum class ClientState : uint8_t { IDLE, // Device advertisement found. DISCOVERED, - // Device is discovered and the scanner is stopped - READY_TO_CONNECT, // Connection in progress. CONNECTING, // Initial connection established. @@ -313,7 +311,6 @@ class ESP32BLETracker : public Component, counts.discovered++; break; case ClientState::CONNECTING: - case ClientState::READY_TO_CONNECT: counts.connecting++; break; default: From 386b52f4a4ad18cb178fc15d2cc080dea2e2b394 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Sep 2025 21:06:35 -0500 Subject: [PATCH 1029/1626] [esp32_ble_tracker] Simplify BLE client state machine by removing READY_TO_CONNECT --- esphome/components/esp32_ble_client/ble_client_base.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 641fb5dc44..79abc26d80 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -106,6 +106,8 @@ void BLEClientBase::connect() { this->paired_ = false; // Enable loop for state processing this->enable_loop(); + // Immediately transition to CONNECTING to prevent duplicate connection attempts + this->set_state(espbt::ClientState::CONNECTING); // Determine connection parameters based on connection type if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { @@ -207,8 +209,6 @@ void BLEClientBase::handle_connection_result_(esp_err_t ret) { if (ret) { this->log_gattc_warning_("esp_ble_gattc_open", ret); this->set_state(espbt::ClientState::IDLE); - } else { - this->set_state(espbt::ClientState::CONNECTING); } } From ec97a464f76df3d08ba59ffce698f7a6eb26d96f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Sep 2025 21:14:12 -0500 Subject: [PATCH 1030/1626] [esp32_ble_tracker] Simplify BLE client state machine by removing READY_TO_CONNECT --- .../components/esp32_ble_client/ble_client_base.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 79abc26d80..18321ef91c 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -58,8 +58,8 @@ void BLEClientBase::loop() { } this->set_state(espbt::ClientState::IDLE); } - // If its idle, we can disable the loop as set_state - // will enable it again when we need to connect. + // If idle, we can disable the loop as connect() + // will enable it again when a connection is needed. else if (this->state_ == espbt::ClientState::IDLE) { this->disable_loop(); } @@ -101,6 +101,13 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { #endif void BLEClientBase::connect() { + // Prevent duplicate connection attempts + if (this->state_ == espbt::ClientState::CONNECTING || this->state_ == espbt::ClientState::CONNECTED || + this->state_ == espbt::ClientState::ESTABLISHED) { + ESP_LOGW(TAG, "[%d] [%s] Connection already in progress, state=%s", this->connection_index_, + this->address_str_.c_str(), espbt::client_state_to_string(this->state_)); + return; + } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_.c_str(), this->remote_addr_type_); this->paired_ = false; From e3f8a36eaa63ac0d1a8369f05def7bf6012e1162 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Sep 2025 22:16:04 -0500 Subject: [PATCH 1031/1626] Add coverage for dashboard ahead of Path conversion (#10669) --- tests/dashboard/test_entries.py | 203 ++++++++++++++++++++ tests/dashboard/test_settings.py | 168 +++++++++++++++++ tests/dashboard/test_web_server_paths.py | 230 +++++++++++++++++++++++ 3 files changed, 601 insertions(+) create mode 100644 tests/dashboard/test_entries.py create mode 100644 tests/dashboard/test_settings.py create mode 100644 tests/dashboard/test_web_server_paths.py diff --git a/tests/dashboard/test_entries.py b/tests/dashboard/test_entries.py new file mode 100644 index 0000000000..a86c33a16f --- /dev/null +++ b/tests/dashboard/test_entries.py @@ -0,0 +1,203 @@ +"""Tests for dashboard entries Path-related functionality.""" + +from __future__ import annotations + +from pathlib import Path +import tempfile +from unittest.mock import MagicMock + +import pytest +import pytest_asyncio + +from esphome.core import CORE +from esphome.dashboard.entries import DashboardEntries, DashboardEntry + + +def create_cache_key() -> tuple[int, int, float, int]: + """Helper to create a valid DashboardCacheKeyType.""" + return (0, 0, 0.0, 0) + + +@pytest.fixture(autouse=True) +def setup_core(): + """Set up CORE for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + CORE.config_path = str(Path(tmpdir) / "test.yaml") + yield + CORE.reset() + + +@pytest.fixture +def mock_settings() -> MagicMock: + """Create mock dashboard settings.""" + settings = MagicMock() + settings.config_dir = "/test/config" + settings.absolute_config_dir = Path("/test/config") + return settings + + +@pytest_asyncio.fixture +async def dashboard_entries(mock_settings: MagicMock) -> DashboardEntries: + """Create a DashboardEntries instance for testing.""" + return DashboardEntries(mock_settings) + + +def test_dashboard_entry_path_initialization() -> None: + """Test DashboardEntry initializes with path correctly.""" + test_path = "/test/config/device.yaml" + cache_key = create_cache_key() + + entry = DashboardEntry(test_path, cache_key) + + assert entry.path == test_path + assert entry.cache_key == cache_key + + +def test_dashboard_entry_path_with_absolute_path() -> None: + """Test DashboardEntry handles absolute paths.""" + # Use a truly absolute path for the platform + test_path = Path.cwd() / "absolute" / "path" / "to" / "config.yaml" + cache_key = create_cache_key() + + entry = DashboardEntry(str(test_path), cache_key) + + assert entry.path == str(test_path) + assert Path(entry.path).is_absolute() + + +def test_dashboard_entry_path_with_relative_path() -> None: + """Test DashboardEntry handles relative paths.""" + test_path = "configs/device.yaml" + cache_key = create_cache_key() + + entry = DashboardEntry(test_path, cache_key) + + assert entry.path == test_path + assert not Path(entry.path).is_absolute() + + +@pytest.mark.asyncio +async def test_dashboard_entries_get_by_path( + dashboard_entries: DashboardEntries, +) -> None: + """Test getting entry by path.""" + test_path = "/test/config/device.yaml" + entry = DashboardEntry(test_path, create_cache_key()) + + dashboard_entries._entries[test_path] = entry + + result = dashboard_entries.get(test_path) + assert result == entry + + +@pytest.mark.asyncio +async def test_dashboard_entries_get_nonexistent_path( + dashboard_entries: DashboardEntries, +) -> None: + """Test getting non-existent entry returns None.""" + result = dashboard_entries.get("/nonexistent/path.yaml") + assert result is None + + +@pytest.mark.asyncio +async def test_dashboard_entries_path_normalization( + dashboard_entries: DashboardEntries, +) -> None: + """Test that paths are handled consistently.""" + path1 = "/test/config/device.yaml" + + entry = DashboardEntry(path1, create_cache_key()) + dashboard_entries._entries[path1] = entry + + result = dashboard_entries.get(path1) + assert result == entry + + +@pytest.mark.asyncio +async def test_dashboard_entries_path_with_spaces( + dashboard_entries: DashboardEntries, +) -> None: + """Test handling paths with spaces.""" + test_path = "/test/config/my device.yaml" + entry = DashboardEntry(test_path, create_cache_key()) + + dashboard_entries._entries[test_path] = entry + + result = dashboard_entries.get(test_path) + assert result == entry + assert result.path == test_path + + +@pytest.mark.asyncio +async def test_dashboard_entries_path_with_special_chars( + dashboard_entries: DashboardEntries, +) -> None: + """Test handling paths with special characters.""" + test_path = "/test/config/device-01_test.yaml" + entry = DashboardEntry(test_path, create_cache_key()) + + dashboard_entries._entries[test_path] = entry + + result = dashboard_entries.get(test_path) + assert result == entry + + +def test_dashboard_entries_windows_path() -> None: + """Test handling Windows-style paths.""" + test_path = r"C:\Users\test\esphome\device.yaml" + cache_key = create_cache_key() + + entry = DashboardEntry(test_path, cache_key) + + assert entry.path == test_path + + +@pytest.mark.asyncio +async def test_dashboard_entries_path_to_cache_key_mapping( + dashboard_entries: DashboardEntries, +) -> None: + """Test internal entries storage with paths and cache keys.""" + path1 = "/test/config/device1.yaml" + path2 = "/test/config/device2.yaml" + + entry1 = DashboardEntry(path1, create_cache_key()) + entry2 = DashboardEntry(path2, (1, 1, 1.0, 1)) + + dashboard_entries._entries[path1] = entry1 + dashboard_entries._entries[path2] = entry2 + + assert path1 in dashboard_entries._entries + assert path2 in dashboard_entries._entries + assert dashboard_entries._entries[path1].cache_key == create_cache_key() + assert dashboard_entries._entries[path2].cache_key == (1, 1, 1.0, 1) + + +def test_dashboard_entry_path_property() -> None: + """Test that path property returns expected value.""" + test_path = "/test/config/device.yaml" + entry = DashboardEntry(test_path, create_cache_key()) + + assert entry.path == test_path + assert isinstance(entry.path, str) + + +@pytest.mark.asyncio +async def test_dashboard_entries_all_returns_entries_with_paths( + dashboard_entries: DashboardEntries, +) -> None: + """Test that all() returns entries with their paths intact.""" + paths = [ + "/test/config/device1.yaml", + "/test/config/device2.yaml", + "/test/config/subfolder/device3.yaml", + ] + + for path in paths: + entry = DashboardEntry(path, create_cache_key()) + dashboard_entries._entries[path] = entry + + all_entries = dashboard_entries.async_all() + + assert len(all_entries) == len(paths) + retrieved_paths = [entry.path for entry in all_entries] + assert set(retrieved_paths) == set(paths) diff --git a/tests/dashboard/test_settings.py b/tests/dashboard/test_settings.py new file mode 100644 index 0000000000..90a79ac0f8 --- /dev/null +++ b/tests/dashboard/test_settings.py @@ -0,0 +1,168 @@ +"""Tests for dashboard settings Path-related functionality.""" + +from __future__ import annotations + +import os +from pathlib import Path +import tempfile + +import pytest + +from esphome.dashboard.settings import DashboardSettings + + +@pytest.fixture +def dashboard_settings(tmp_path: Path) -> DashboardSettings: + """Create DashboardSettings instance with temp directory.""" + settings = DashboardSettings() + # Resolve symlinks to ensure paths match + resolved_dir = tmp_path.resolve() + settings.config_dir = str(resolved_dir) + settings.absolute_config_dir = resolved_dir + return settings + + +def test_rel_path_simple(dashboard_settings: DashboardSettings) -> None: + """Test rel_path with simple relative path.""" + result = dashboard_settings.rel_path("config.yaml") + + expected = str(Path(dashboard_settings.config_dir) / "config.yaml") + assert result == expected + + +def test_rel_path_multiple_components(dashboard_settings: DashboardSettings) -> None: + """Test rel_path with multiple path components.""" + result = dashboard_settings.rel_path("subfolder", "device", "config.yaml") + + expected = str( + Path(dashboard_settings.config_dir) / "subfolder" / "device" / "config.yaml" + ) + assert result == expected + + +def test_rel_path_with_dots(dashboard_settings: DashboardSettings) -> None: + """Test rel_path prevents directory traversal.""" + # This should raise ValueError as it tries to go outside config_dir + with pytest.raises(ValueError): + dashboard_settings.rel_path("..", "outside.yaml") + + +def test_rel_path_absolute_path_within_config( + dashboard_settings: DashboardSettings, +) -> None: + """Test rel_path with absolute path that's within config dir.""" + internal_path = dashboard_settings.absolute_config_dir / "internal.yaml" + + internal_path.touch() + result = dashboard_settings.rel_path("internal.yaml") + expected = str(Path(dashboard_settings.config_dir) / "internal.yaml") + assert result == expected + + +def test_rel_path_absolute_path_outside_config( + dashboard_settings: DashboardSettings, +) -> None: + """Test rel_path with absolute path outside config dir raises error.""" + outside_path = "/tmp/outside/config.yaml" + + with pytest.raises(ValueError): + dashboard_settings.rel_path(outside_path) + + +def test_rel_path_empty_args(dashboard_settings: DashboardSettings) -> None: + """Test rel_path with no arguments returns config_dir.""" + result = dashboard_settings.rel_path() + assert result == dashboard_settings.config_dir + + +def test_rel_path_with_pathlib_path(dashboard_settings: DashboardSettings) -> None: + """Test rel_path works with Path objects as arguments.""" + path_obj = Path("subfolder") / "config.yaml" + result = dashboard_settings.rel_path(path_obj) + + expected = str(Path(dashboard_settings.config_dir) / "subfolder" / "config.yaml") + assert result == expected + + +def test_rel_path_normalizes_slashes(dashboard_settings: DashboardSettings) -> None: + """Test rel_path normalizes path separators.""" + # os.path.join normalizes slashes on Windows but preserves them on Unix + # Test that providing components separately gives same result + result1 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml") + result2 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml") + assert result1 == result2 + + # Also test that the result is as expected + expected = os.path.join( + dashboard_settings.config_dir, "folder", "subfolder", "file.yaml" + ) + assert result1 == expected + + +def test_rel_path_handles_spaces(dashboard_settings: DashboardSettings) -> None: + """Test rel_path handles paths with spaces.""" + result = dashboard_settings.rel_path("my folder", "my config.yaml") + + expected = str(Path(dashboard_settings.config_dir) / "my folder" / "my config.yaml") + assert result == expected + + +def test_rel_path_handles_special_chars(dashboard_settings: DashboardSettings) -> None: + """Test rel_path handles paths with special characters.""" + result = dashboard_settings.rel_path("device-01_test", "config.yaml") + + expected = str( + Path(dashboard_settings.config_dir) / "device-01_test" / "config.yaml" + ) + assert result == expected + + +def test_config_dir_as_path_property(dashboard_settings: DashboardSettings) -> None: + """Test that config_dir can be accessed and used with Path operations.""" + config_path = Path(dashboard_settings.config_dir) + + assert config_path.exists() + assert config_path.is_dir() + assert config_path.is_absolute() + + +def test_absolute_config_dir_property(dashboard_settings: DashboardSettings) -> None: + """Test absolute_config_dir is a Path object.""" + assert isinstance(dashboard_settings.absolute_config_dir, Path) + assert dashboard_settings.absolute_config_dir.exists() + assert dashboard_settings.absolute_config_dir.is_dir() + assert dashboard_settings.absolute_config_dir.is_absolute() + + +def test_rel_path_symlink_inside_config(dashboard_settings: DashboardSettings) -> None: + """Test rel_path with symlink that points inside config dir.""" + target = dashboard_settings.absolute_config_dir / "target.yaml" + target.touch() + symlink = dashboard_settings.absolute_config_dir / "link.yaml" + symlink.symlink_to(target) + result = dashboard_settings.rel_path("link.yaml") + expected = str(Path(dashboard_settings.config_dir) / "link.yaml") + assert result == expected + + +def test_rel_path_symlink_outside_config(dashboard_settings: DashboardSettings) -> None: + """Test rel_path with symlink that points outside config dir.""" + with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp: + symlink = dashboard_settings.absolute_config_dir / "external_link.yaml" + symlink.symlink_to(tmp.name) + with pytest.raises(ValueError): + dashboard_settings.rel_path("external_link.yaml") + + +def test_rel_path_with_none_arg(dashboard_settings: DashboardSettings) -> None: + """Test rel_path handles None arguments gracefully.""" + result = dashboard_settings.rel_path("None") + expected = str(Path(dashboard_settings.config_dir) / "None") + assert result == expected + + +def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> None: + """Test rel_path handles numeric arguments.""" + result = dashboard_settings.rel_path("123", "456.789") + expected = str(Path(dashboard_settings.config_dir) / "123" / "456.789") + assert result == expected diff --git a/tests/dashboard/test_web_server_paths.py b/tests/dashboard/test_web_server_paths.py new file mode 100644 index 0000000000..f66e6a7ec2 --- /dev/null +++ b/tests/dashboard/test_web_server_paths.py @@ -0,0 +1,230 @@ +"""Tests for dashboard web_server Path-related functionality.""" + +from __future__ import annotations + +import gzip +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +from esphome.dashboard import web_server + + +def test_get_base_frontend_path_production() -> None: + """Test get_base_frontend_path in production mode.""" + mock_module = MagicMock() + mock_module.where.return_value = "/usr/local/lib/esphome_dashboard" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.dict("sys.modules", {"esphome_dashboard": mock_module}), + ): + result = web_server.get_base_frontend_path() + assert result == "/usr/local/lib/esphome_dashboard" + mock_module.where.assert_called_once() + + +def test_get_base_frontend_path_dev_mode() -> None: + """Test get_base_frontend_path in development mode.""" + test_path = "/home/user/esphome/dashboard" + + with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): + result = web_server.get_base_frontend_path() + + # The function uses os.path.abspath which doesn't resolve symlinks + # We need to match that behavior + # The actual function adds "/" to the path, so we simulate that + test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" + expected = os.path.abspath( + os.path.join(os.getcwd(), test_path_with_slash, "esphome_dashboard") + ) + assert result == expected + + +def test_get_base_frontend_path_dev_mode_with_trailing_slash() -> None: + """Test get_base_frontend_path in dev mode with trailing slash.""" + test_path = "/home/user/esphome/dashboard/" + + with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): + result = web_server.get_base_frontend_path() + + # The function uses os.path.abspath which doesn't resolve symlinks + expected = os.path.abspath(str(Path.cwd() / test_path / "esphome_dashboard")) + assert result == expected + + +def test_get_base_frontend_path_dev_mode_relative_path() -> None: + """Test get_base_frontend_path with relative dev path.""" + test_path = "./dashboard" + + with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): + result = web_server.get_base_frontend_path() + + # The function uses os.path.abspath which doesn't resolve symlinks + # We need to match that behavior + # The actual function adds "/" to the path, so we simulate that + test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" + expected = os.path.abspath( + os.path.join(os.getcwd(), test_path_with_slash, "esphome_dashboard") + ) + assert result == expected + assert Path(result).is_absolute() + + +def test_get_static_path_single_component() -> None: + """Test get_static_path with single path component.""" + with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: + mock_base.return_value = "/base/frontend" + + result = web_server.get_static_path("file.js") + + assert result == os.path.join("/base/frontend", "static", "file.js") + + +def test_get_static_path_multiple_components() -> None: + """Test get_static_path with multiple path components.""" + with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: + mock_base.return_value = "/base/frontend" + + result = web_server.get_static_path("js", "esphome", "index.js") + + assert result == os.path.join( + "/base/frontend", "static", "js", "esphome", "index.js" + ) + + +def test_get_static_path_empty_args() -> None: + """Test get_static_path with no arguments.""" + with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: + mock_base.return_value = "/base/frontend" + + result = web_server.get_static_path() + + assert result == os.path.join("/base/frontend", "static") + + +def test_get_static_path_with_pathlib_path() -> None: + """Test get_static_path with Path objects.""" + with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: + mock_base.return_value = "/base/frontend" + + path_obj = Path("js") / "app.js" + result = web_server.get_static_path(str(path_obj)) + + assert result == os.path.join("/base/frontend", "static", "js", "app.js") + + +def test_get_static_file_url_production() -> None: + """Test get_static_file_url in production mode.""" + web_server.get_static_file_url.cache_clear() + mock_module = MagicMock() + mock_file = MagicMock() + mock_file.read.return_value = b"test content" + mock_file.__enter__ = MagicMock(return_value=mock_file) + mock_file.__exit__ = MagicMock(return_value=None) + + with ( + patch.dict(os.environ, {}, clear=True), + patch.dict("sys.modules", {"esphome_dashboard": mock_module}), + patch("esphome.dashboard.web_server.get_static_path") as mock_get_path, + patch("esphome.dashboard.web_server.open", create=True, return_value=mock_file), + ): + mock_get_path.return_value = "/fake/path/js/app.js" + result = web_server.get_static_file_url("js/app.js") + assert result.startswith("./static/js/app.js?hash=") + + +def test_get_static_file_url_dev_mode() -> None: + """Test get_static_file_url in development mode.""" + with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": "/dev/path"}): + web_server.get_static_file_url.cache_clear() + result = web_server.get_static_file_url("js/app.js") + + assert result == "./static/js/app.js" + + +def test_get_static_file_url_index_js_special_case() -> None: + """Test get_static_file_url replaces index.js with entrypoint.""" + web_server.get_static_file_url.cache_clear() + mock_module = MagicMock() + mock_module.entrypoint.return_value = "main.js" + + with ( + patch.dict(os.environ, {}, clear=True), + patch.dict("sys.modules", {"esphome_dashboard": mock_module}), + ): + result = web_server.get_static_file_url("js/esphome/index.js") + assert result == "./static/js/esphome/main.js" + + +def test_load_file_path(tmp_path: Path) -> None: + """Test loading a file.""" + test_file = tmp_path / "test.txt" + test_file.write_bytes(b"test content") + + with open(test_file, "rb") as f: + content = f.read() + assert content == b"test content" + + +def test_load_file_compressed_path(tmp_path: Path) -> None: + """Test loading a compressed file.""" + test_file = tmp_path / "test.txt.gz" + + with gzip.open(test_file, "wb") as gz: + gz.write(b"compressed content") + + with gzip.open(test_file, "rb") as gz: + content = gz.read() + assert content == b"compressed content" + + +def test_path_normalization_in_static_path() -> None: + """Test that paths are normalized correctly.""" + with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: + mock_base.return_value = "/base/frontend" + + # Test with separate components + result1 = web_server.get_static_path("js", "app.js") + result2 = web_server.get_static_path("js", "app.js") + + assert result1 == result2 + assert result1 == os.path.join("/base/frontend", "static", "js", "app.js") + + +def test_windows_path_handling() -> None: + """Test handling of Windows-style paths.""" + with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: + mock_base.return_value = r"C:\Program Files\esphome\frontend" + + result = web_server.get_static_path("js", "app.js") + + # os.path.join should handle this correctly on the platform + expected = os.path.join( + r"C:\Program Files\esphome\frontend", "static", "js", "app.js" + ) + assert result == expected + + +def test_path_with_special_characters() -> None: + """Test paths with special characters.""" + with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: + mock_base.return_value = "/base/frontend" + + result = web_server.get_static_path("js-modules", "app_v1.0.js") + + assert result == os.path.join( + "/base/frontend", "static", "js-modules", "app_v1.0.js" + ) + + +def test_path_with_spaces() -> None: + """Test paths with spaces.""" + with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: + mock_base.return_value = "/base/my frontend" + + result = web_server.get_static_path("my js", "my app.js") + + assert result == os.path.join( + "/base/my frontend", "static", "my js", "my app.js" + ) From fe1371f4dc9707e3cec70f2f72d9b4bfa795034a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:32:04 +1200 Subject: [PATCH 1032/1626] [adc] Fix `FILTER_SOURCE_FILES` location (#10673) --- esphome/components/adc/__init__.py | 27 +-------------------------- esphome/components/adc/sensor.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index f260e13242..15dc447b6c 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -11,15 +11,8 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) -from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.const import ( - CONF_ANALOG, - CONF_INPUT, - CONF_NUMBER, - PLATFORM_ESP8266, - PlatformFramework, -) +from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266 from esphome.core import CORE CODEOWNERS = ["@esphome/core"] @@ -273,21 +266,3 @@ def validate_adc_pin(value): )(value) raise NotImplementedError - - -FILTER_SOURCE_FILES = filter_source_files_from_platform( - { - "adc_sensor_esp32.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, - "adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, - "adc_sensor_libretiny.cpp": { - PlatformFramework.BK72XX_ARDUINO, - PlatformFramework.RTL87XX_ARDUINO, - PlatformFramework.LN882X_ARDUINO, - }, - "adc_sensor_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, - } -) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 49970c5e3d..607609bbc7 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -9,6 +9,7 @@ from esphome.components.zephyr import ( zephyr_add_prj_conf, zephyr_add_user, ) +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_ATTENUATION, @@ -20,6 +21,7 @@ from esphome.const import ( PLATFORM_NRF52, STATE_CLASS_MEASUREMENT, UNIT_VOLT, + PlatformFramework, ) from esphome.core import CORE @@ -174,3 +176,21 @@ async def to_code(config): }}; """ ) + + +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "adc_sensor_esp32.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, + "adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "adc_sensor_libretiny.cpp": { + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, + "adc_sensor_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + } +) From c45efe8f409ecf32fc5fe0e7bfe2ca39eb2f5a99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 00:01:11 -0500 Subject: [PATCH 1033/1626] Add additional coverage for yaml_util (#10674) --- .../fixtures/yaml_util/named_dir/.hidden.yaml | 3 + .../fixtures/yaml_util/named_dir/not_yaml.txt | 1 + .../fixtures/yaml_util/named_dir/sensor1.yaml | 4 + .../fixtures/yaml_util/named_dir/sensor2.yaml | 4 + .../yaml_util/named_dir/subdir/sensor3.yaml | 4 + .../fixtures/yaml_util/secrets.yaml | 4 + .../fixtures/yaml_util/test_secret.yaml | 17 ++ tests/unit_tests/test_yaml_util.py | 220 +++++++++++++++++- 8 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/fixtures/yaml_util/named_dir/.hidden.yaml create mode 100644 tests/unit_tests/fixtures/yaml_util/named_dir/not_yaml.txt create mode 100644 tests/unit_tests/fixtures/yaml_util/named_dir/sensor1.yaml create mode 100644 tests/unit_tests/fixtures/yaml_util/named_dir/sensor2.yaml create mode 100644 tests/unit_tests/fixtures/yaml_util/named_dir/subdir/sensor3.yaml create mode 100644 tests/unit_tests/fixtures/yaml_util/secrets.yaml create mode 100644 tests/unit_tests/fixtures/yaml_util/test_secret.yaml diff --git a/tests/unit_tests/fixtures/yaml_util/named_dir/.hidden.yaml b/tests/unit_tests/fixtures/yaml_util/named_dir/.hidden.yaml new file mode 100644 index 0000000000..75eb989ea5 --- /dev/null +++ b/tests/unit_tests/fixtures/yaml_util/named_dir/.hidden.yaml @@ -0,0 +1,3 @@ +# This file should be ignored +platform: template +name: "Hidden Sensor" diff --git a/tests/unit_tests/fixtures/yaml_util/named_dir/not_yaml.txt b/tests/unit_tests/fixtures/yaml_util/named_dir/not_yaml.txt new file mode 100644 index 0000000000..98efb74b0f --- /dev/null +++ b/tests/unit_tests/fixtures/yaml_util/named_dir/not_yaml.txt @@ -0,0 +1 @@ +This is not a YAML file and should be ignored diff --git a/tests/unit_tests/fixtures/yaml_util/named_dir/sensor1.yaml b/tests/unit_tests/fixtures/yaml_util/named_dir/sensor1.yaml new file mode 100644 index 0000000000..a4b0a11916 --- /dev/null +++ b/tests/unit_tests/fixtures/yaml_util/named_dir/sensor1.yaml @@ -0,0 +1,4 @@ +platform: template +name: "Sensor 1" +lambda: |- + return 42.0; diff --git a/tests/unit_tests/fixtures/yaml_util/named_dir/sensor2.yaml b/tests/unit_tests/fixtures/yaml_util/named_dir/sensor2.yaml new file mode 100644 index 0000000000..72d4b714b6 --- /dev/null +++ b/tests/unit_tests/fixtures/yaml_util/named_dir/sensor2.yaml @@ -0,0 +1,4 @@ +platform: template +name: "Sensor 2" +lambda: |- + return 100.0; diff --git a/tests/unit_tests/fixtures/yaml_util/named_dir/subdir/sensor3.yaml b/tests/unit_tests/fixtures/yaml_util/named_dir/subdir/sensor3.yaml new file mode 100644 index 0000000000..bcb8dd320d --- /dev/null +++ b/tests/unit_tests/fixtures/yaml_util/named_dir/subdir/sensor3.yaml @@ -0,0 +1,4 @@ +platform: template +name: "Sensor 3 in subdir" +lambda: |- + return 200.0; diff --git a/tests/unit_tests/fixtures/yaml_util/secrets.yaml b/tests/unit_tests/fixtures/yaml_util/secrets.yaml new file mode 100644 index 0000000000..4eef570926 --- /dev/null +++ b/tests/unit_tests/fixtures/yaml_util/secrets.yaml @@ -0,0 +1,4 @@ +test_secret: "my_secret_value" +another_secret: "another_value" +wifi_password: "super_secret_wifi" +api_key: "0123456789abcdef" diff --git a/tests/unit_tests/fixtures/yaml_util/test_secret.yaml b/tests/unit_tests/fixtures/yaml_util/test_secret.yaml new file mode 100644 index 0000000000..c23afaee94 --- /dev/null +++ b/tests/unit_tests/fixtures/yaml_util/test_secret.yaml @@ -0,0 +1,17 @@ +esphome: + name: test_device + platform: ESP32 + board: esp32dev + +wifi: + ssid: "TestNetwork" + password: !secret wifi_password + +api: + encryption: + key: !secret api_key + +sensor: + - platform: template + name: "Test Sensor" + id: !secret test_secret diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index f31e9554dc..bc3c89a64d 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1,9 +1,26 @@ -from esphome import yaml_util +from pathlib import Path +import shutil +from unittest.mock import patch + +import pytest + +from esphome import core, yaml_util from esphome.components import substitutions from esphome.core import EsphomeError +from esphome.util import OrderedDict -def test_include_with_vars(fixture_path): +@pytest.fixture(autouse=True) +def clear_secrets_cache() -> None: + """Clear the secrets cache before each test.""" + yaml_util._SECRET_VALUES.clear() + yaml_util._SECRET_CACHE.clear() + yield + yaml_util._SECRET_VALUES.clear() + yaml_util._SECRET_CACHE.clear() + + +def test_include_with_vars(fixture_path: Path) -> None: yaml_file = fixture_path / "yaml_util" / "includetest.yaml" actual = yaml_util.load_yaml(yaml_file) @@ -62,3 +79,202 @@ def test_parsing_with_custom_loader(fixture_path): assert loader_calls[0].endswith("includes/included.yaml") assert loader_calls[1].endswith("includes/list.yaml") assert loader_calls[2].endswith("includes/scalar.yaml") + + +def test_construct_secret_simple(fixture_path: Path) -> None: + """Test loading a YAML file with !secret tags.""" + yaml_file = fixture_path / "yaml_util" / "test_secret.yaml" + + actual = yaml_util.load_yaml(yaml_file) + + # Check that secrets were properly loaded + assert actual["wifi"]["password"] == "super_secret_wifi" + assert actual["api"]["encryption"]["key"] == "0123456789abcdef" + assert actual["sensor"][0]["id"] == "my_secret_value" + + +def test_construct_secret_missing(fixture_path: Path, tmp_path: Path) -> None: + """Test that missing secrets raise proper errors.""" + # Create a YAML file with a secret that doesn't exist + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text(""" +esphome: + name: test + +wifi: + password: !secret nonexistent_secret +""") + + # Create an empty secrets file + secrets_yaml = tmp_path / "secrets.yaml" + secrets_yaml.write_text("some_other_secret: value") + + with pytest.raises(EsphomeError, match="Secret 'nonexistent_secret' not defined"): + yaml_util.load_yaml(str(test_yaml)) + + +def test_construct_secret_no_secrets_file(tmp_path: Path) -> None: + """Test that missing secrets.yaml file raises proper error.""" + # Create a YAML file with a secret but no secrets.yaml + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text(""" +wifi: + password: !secret some_secret +""") + + # Mock CORE.config_path to avoid NoneType error + with ( + patch.object(core.CORE, "config_path", str(tmp_path / "main.yaml")), + pytest.raises(EsphomeError, match="secrets.yaml"), + ): + yaml_util.load_yaml(str(test_yaml)) + + +def test_construct_secret_fallback_to_main_config_dir( + fixture_path: Path, tmp_path: Path +) -> None: + """Test fallback to main config directory for secrets.""" + # Create a subdirectory with a YAML file that uses secrets + subdir = tmp_path / "subdir" + subdir.mkdir() + + test_yaml = subdir / "test.yaml" + test_yaml.write_text(""" +wifi: + password: !secret test_secret +""") + + # Create secrets.yaml in the main directory + main_secrets = tmp_path / "secrets.yaml" + main_secrets.write_text("test_secret: main_secret_value") + + # Mock CORE.config_path to point to main directory + with patch.object(core.CORE, "config_path", str(tmp_path / "main.yaml")): + actual = yaml_util.load_yaml(str(test_yaml)) + assert actual["wifi"]["password"] == "main_secret_value" + + +def test_construct_include_dir_named(fixture_path: Path, tmp_path: Path) -> None: + """Test !include_dir_named directive.""" + # Copy fixture directory to temporary location + src_dir = fixture_path / "yaml_util" + dst_dir = tmp_path / "yaml_util" + shutil.copytree(src_dir, dst_dir) + + # Create test YAML that uses include_dir_named + test_yaml = dst_dir / "test_include_named.yaml" + test_yaml.write_text(""" +sensor: !include_dir_named named_dir +""") + + actual = yaml_util.load_yaml(str(test_yaml)) + actual_sensor = actual["sensor"] + + # Check that files were loaded with their names as keys + assert isinstance(actual_sensor, OrderedDict) + assert "sensor1" in actual_sensor + assert "sensor2" in actual_sensor + assert "sensor3" in actual_sensor # Files from subdirs are included with basename + + # Check content of loaded files + assert actual_sensor["sensor1"]["platform"] == "template" + assert actual_sensor["sensor1"]["name"] == "Sensor 1" + assert actual_sensor["sensor2"]["platform"] == "template" + assert actual_sensor["sensor2"]["name"] == "Sensor 2" + + # Check that subdirectory files are included with their basename + assert actual_sensor["sensor3"]["platform"] == "template" + assert actual_sensor["sensor3"]["name"] == "Sensor 3 in subdir" + + # Check that hidden files and non-YAML files are not included + assert ".hidden" not in actual_sensor + assert "not_yaml" not in actual_sensor + + +def test_construct_include_dir_named_empty_dir(tmp_path: Path) -> None: + """Test !include_dir_named with empty directory.""" + # Create empty directory + empty_dir = tmp_path / "empty_dir" + empty_dir.mkdir() + + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text(""" +sensor: !include_dir_named empty_dir +""") + + actual = yaml_util.load_yaml(str(test_yaml)) + + # Should return empty OrderedDict + assert isinstance(actual["sensor"], OrderedDict) + assert len(actual["sensor"]) == 0 + + +def test_construct_include_dir_named_with_dots(tmp_path: Path) -> None: + """Test that include_dir_named ignores files starting with dots.""" + # Create directory with various files + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + # Create visible file + visible_file = test_dir / "visible.yaml" + visible_file.write_text("key: visible_value") + + # Create hidden file + hidden_file = test_dir / ".hidden.yaml" + hidden_file.write_text("key: hidden_value") + + # Create hidden directory with files + hidden_dir = test_dir / ".hidden_dir" + hidden_dir.mkdir() + hidden_subfile = hidden_dir / "subfile.yaml" + hidden_subfile.write_text("key: hidden_subfile_value") + + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text(""" +test: !include_dir_named test_dir +""") + + actual = yaml_util.load_yaml(str(test_yaml)) + + # Should only include visible file + assert "visible" in actual["test"] + assert actual["test"]["visible"]["key"] == "visible_value" + + # Should not include hidden files or directories + assert ".hidden" not in actual["test"] + assert ".hidden_dir" not in actual["test"] + + +def test_find_files_recursive(fixture_path: Path, tmp_path: Path) -> None: + """Test that _find_files works recursively through include_dir_named.""" + # Copy fixture directory to temporary location + src_dir = fixture_path / "yaml_util" + dst_dir = tmp_path / "yaml_util" + shutil.copytree(src_dir, dst_dir) + + # This indirectly tests _find_files by using include_dir_named + test_yaml = dst_dir / "test_include_recursive.yaml" + test_yaml.write_text(""" +all_sensors: !include_dir_named named_dir +""") + + actual = yaml_util.load_yaml(str(test_yaml)) + + # Should find sensor1.yaml, sensor2.yaml, and subdir/sensor3.yaml (all flattened) + assert len(actual["all_sensors"]) == 3 + assert "sensor1" in actual["all_sensors"] + assert "sensor2" in actual["all_sensors"] + assert "sensor3" in actual["all_sensors"] + + +def test_secret_values_tracking(fixture_path: Path) -> None: + """Test that secret values are properly tracked for dumping.""" + yaml_file = fixture_path / "yaml_util" / "test_secret.yaml" + + yaml_util.load_yaml(yaml_file) + + # Check that secret values are tracked + assert "super_secret_wifi" in yaml_util._SECRET_VALUES + assert yaml_util._SECRET_VALUES["super_secret_wifi"] == "wifi_password" + assert "0123456789abcdef" in yaml_util._SECRET_VALUES + assert yaml_util._SECRET_VALUES["0123456789abcdef"] == "api_key" From 625f108183fc58575315789033c5ed67fa2f0f8c Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Thu, 11 Sep 2025 01:23:58 -0400 Subject: [PATCH 1034/1626] Openthread Fix Factory Reset (#9281) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../button/factory_reset_button.cpp | 19 +++++++- .../button/factory_reset_button.h | 7 ++- .../switch/factory_reset_switch.cpp | 19 +++++++- .../switch/factory_reset_switch.h | 6 ++- esphome/components/openthread/openthread.cpp | 48 ++++++++++++++++--- esphome/components/openthread/openthread.h | 11 +++++ 6 files changed, 99 insertions(+), 11 deletions(-) diff --git a/esphome/components/factory_reset/button/factory_reset_button.cpp b/esphome/components/factory_reset/button/factory_reset_button.cpp index 585975c043..d582317767 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.cpp +++ b/esphome/components/factory_reset/button/factory_reset_button.cpp @@ -1,7 +1,13 @@ #include "factory_reset_button.h" + +#include "esphome/core/defines.h" + +#ifdef USE_OPENTHREAD +#include "esphome/components/openthread/openthread.h" +#endif +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" -#include "esphome/core/application.h" namespace esphome { namespace factory_reset { @@ -13,9 +19,20 @@ void FactoryResetButton::press_action() { ESP_LOGI(TAG, "Resetting"); // Let MQTT settle a bit delay(100); // NOLINT +#ifdef USE_OPENTHREAD + openthread::global_openthread_component->on_factory_reset(FactoryResetButton::factory_reset_callback); +#else + global_preferences->reset(); + App.safe_reboot(); +#endif +} + +#ifdef USE_OPENTHREAD +void FactoryResetButton::factory_reset_callback() { global_preferences->reset(); App.safe_reboot(); } +#endif } // namespace factory_reset } // namespace esphome diff --git a/esphome/components/factory_reset/button/factory_reset_button.h b/esphome/components/factory_reset/button/factory_reset_button.h index 9996a860d9..c68da2ca74 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.h +++ b/esphome/components/factory_reset/button/factory_reset_button.h @@ -1,7 +1,9 @@ #pragma once -#include "esphome/core/component.h" +#include "esphome/core/defines.h" + #include "esphome/components/button/button.h" +#include "esphome/core/component.h" namespace esphome { namespace factory_reset { @@ -9,6 +11,9 @@ namespace factory_reset { class FactoryResetButton : public button::Button, public Component { public: void dump_config() override; +#ifdef USE_OPENTHREAD + static void factory_reset_callback(); +#endif protected: void press_action() override; diff --git a/esphome/components/factory_reset/switch/factory_reset_switch.cpp b/esphome/components/factory_reset/switch/factory_reset_switch.cpp index 1282c73f4e..75449aa526 100644 --- a/esphome/components/factory_reset/switch/factory_reset_switch.cpp +++ b/esphome/components/factory_reset/switch/factory_reset_switch.cpp @@ -1,7 +1,13 @@ #include "factory_reset_switch.h" + +#include "esphome/core/defines.h" + +#ifdef USE_OPENTHREAD +#include "esphome/components/openthread/openthread.h" +#endif +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" -#include "esphome/core/application.h" namespace esphome { namespace factory_reset { @@ -17,10 +23,21 @@ void FactoryResetSwitch::write_state(bool state) { ESP_LOGI(TAG, "Resetting"); // Let MQTT settle a bit delay(100); // NOLINT +#ifdef USE_OPENTHREAD + openthread::global_openthread_component->on_factory_reset(FactoryResetSwitch::factory_reset_callback); +#else global_preferences->reset(); App.safe_reboot(); +#endif } } +#ifdef USE_OPENTHREAD +void FactoryResetSwitch::factory_reset_callback() { + global_preferences->reset(); + App.safe_reboot(); +} +#endif + } // namespace factory_reset } // namespace esphome diff --git a/esphome/components/factory_reset/switch/factory_reset_switch.h b/esphome/components/factory_reset/switch/factory_reset_switch.h index 2c914ea76d..8ea0c79108 100644 --- a/esphome/components/factory_reset/switch/factory_reset_switch.h +++ b/esphome/components/factory_reset/switch/factory_reset_switch.h @@ -1,7 +1,8 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/components/switch/switch.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" namespace esphome { namespace factory_reset { @@ -9,6 +10,9 @@ namespace factory_reset { class FactoryResetSwitch : public switch_::Switch, public Component { public: void dump_config() override; +#ifdef USE_OPENTHREAD + static void factory_reset_callback(); +#endif protected: void write_state(bool state) override; diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 322ff43238..5b5c113f83 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -11,8 +11,6 @@ #include #include #include -#include -#include #include #include @@ -77,8 +75,14 @@ std::optional OpenThreadComponent::get_omr_address_(InstanceLock & return {}; } -void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrpClientService *services, - const otSrpClientService *removed_services, void *context) { +void OpenThreadComponent::defer_factory_reset_external_callback() { + ESP_LOGD(TAG, "Defer factory_reset_external_callback_"); + this->defer([this]() { this->factory_reset_external_callback_(); }); +} + +void OpenThreadSrpComponent::srp_callback(otError err, const otSrpClientHostInfo *host_info, + const otSrpClientService *services, + const otSrpClientService *removed_services, void *context) { if (err != 0) { ESP_LOGW(TAG, "SRP client reported an error: %s", otThreadErrorToString(err)); for (const otSrpClientHostInfo *host = host_info; host; host = nullptr) { @@ -90,16 +94,30 @@ void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrp } } -void srp_start_callback(const otSockAddr *server_socket_address, void *context) { +void OpenThreadSrpComponent::srp_start_callback(const otSockAddr *server_socket_address, void *context) { ESP_LOGI(TAG, "SRP client has started"); } +void OpenThreadSrpComponent::srp_factory_reset_callback(otError err, const otSrpClientHostInfo *host_info, + const otSrpClientService *services, + const otSrpClientService *removed_services, void *context) { + OpenThreadComponent *obj = (OpenThreadComponent *) context; + if (err == OT_ERROR_NONE && removed_services != NULL && host_info != NULL && + host_info->mState == OT_SRP_CLIENT_ITEM_STATE_REMOVED) { + ESP_LOGD(TAG, "Successful Removal SRP Host and Services"); + } else if (err != OT_ERROR_NONE) { + // Handle other SRP client events or errors + ESP_LOGW(TAG, "SRP client event/error: %s", otThreadErrorToString(err)); + } + obj->defer_factory_reset_external_callback(); +} + void OpenThreadSrpComponent::setup() { otError error; InstanceLock lock = InstanceLock::acquire(); otInstance *instance = lock.get_instance(); - otSrpClientSetCallback(instance, srp_callback, nullptr); + otSrpClientSetCallback(instance, OpenThreadSrpComponent::srp_callback, nullptr); // set the host name uint16_t size; @@ -179,7 +197,8 @@ void OpenThreadSrpComponent::setup() { ESP_LOGD(TAG, "Added service: %s", full_service.c_str()); } - otSrpClientEnableAutoStartMode(instance, srp_start_callback, nullptr); + otSrpClientEnableAutoStartMode(instance, OpenThreadSrpComponent::srp_start_callback, nullptr); + ESP_LOGD(TAG, "Finished SRP setup"); } void *OpenThreadSrpComponent::pool_alloc_(size_t size) { @@ -217,6 +236,21 @@ bool OpenThreadComponent::teardown() { return this->teardown_complete_; } +void OpenThreadComponent::on_factory_reset(std::function callback) { + factory_reset_external_callback_ = callback; + ESP_LOGD(TAG, "Start Removal SRP Host and Services"); + otError error; + InstanceLock lock = InstanceLock::acquire(); + otInstance *instance = lock.get_instance(); + otSrpClientSetCallback(instance, OpenThreadSrpComponent::srp_factory_reset_callback, this); + error = otSrpClientRemoveHostAndServices(instance, true, true); + if (error != OT_ERROR_NONE) { + ESP_LOGW(TAG, "Failed to Remove SRP Host and Services"); + return; + } + ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); +} + } // namespace openthread } // namespace esphome diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index a0ea1b3f3a..a9aff78e56 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -6,6 +6,8 @@ #include "esphome/components/network/ip_address.h" #include "esphome/core/component.h" +#include +#include #include #include @@ -28,11 +30,14 @@ class OpenThreadComponent : public Component { network::IPAddresses get_ip_addresses(); std::optional get_omr_address(); void ot_main(); + void on_factory_reset(std::function callback); + void defer_factory_reset_external_callback(); protected: std::optional get_omr_address_(InstanceLock &lock); bool teardown_started_{false}; bool teardown_complete_{false}; + std::function factory_reset_external_callback_; }; extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -43,6 +48,12 @@ class OpenThreadSrpComponent : public Component { // This has to run after the mdns component or else no services are available to advertise float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0; } void setup() override; + static void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrpClientService *services, + const otSrpClientService *removed_services, void *context); + static void srp_start_callback(const otSockAddr *server_socket_address, void *context); + static void srp_factory_reset_callback(otError err, const otSrpClientHostInfo *host_info, + const otSrpClientService *services, const otSrpClientService *removed_services, + void *context); protected: esphome::mdns::MDNSComponent *mdns_{nullptr}; From 5fd64c5c89d0b936d9665d9e718c490830879101 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 14:25:55 -0500 Subject: [PATCH 1035/1626] [core] Add millisecond precision to logging timestamps (#10677) --- esphome/__main__.py | 4 +++- esphome/components/api/client.py | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 280f491924..bba254436e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -226,7 +226,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: .replace(b"\n", b"") .decode("utf8", "backslashreplace") ) - time_str = datetime.now().time().strftime("[%H:%M:%S]") + time_ = datetime.now() + nanoseconds = time_.microsecond // 1000 + time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" safe_print(parser.parse_line(line, time_str)) backtrace_state = platformio_api.process_stacktrace( diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index ce018b3b98..ca1fc089fa 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -62,9 +62,11 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: time_ = datetime.now() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") - for parsed_msg in parse_log_message( - text, f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}]" - ): + nanoseconds = time_.microsecond // 1000 + timestamp = ( + f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" + ) + for parsed_msg in parse_log_message(text, timestamp): print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) stop = await async_run(cli, on_log, name=name) From 61bac6c6e68eb7e0da9bce39135fb484cc3eccfc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:13:05 -0400 Subject: [PATCH 1036/1626] [esp32] Allow esp-idf 5.5.1 (#10680) --- esphome/components/esp32/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 12d84dd4b3..50a47765bf 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -353,6 +353,7 @@ SUPPORTED_PLATFORMIO_ESP_IDF_5X = [ # pioarduino versions that don't require a release number # List based on https://github.com/pioarduino/esp-idf/releases SUPPORTED_PIOARDUINO_ESP_IDF_5X = [ + cv.Version(5, 5, 1), cv.Version(5, 5, 0), cv.Version(5, 4, 2), cv.Version(5, 4, 1), From ac0cd946f0ef5405d52d0034287e5c7545207130 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 15:51:58 -0500 Subject: [PATCH 1037/1626] Add comprehensive tests for choose_upload_log_host to prevent regressions (#10679) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- tests/unit_tests/test_main.py | 512 ++++++++++++++++++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 tests/unit_tests/test_main.py diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py new file mode 100644 index 0000000000..2c7236c7f8 --- /dev/null +++ b/tests/unit_tests/test_main.py @@ -0,0 +1,512 @@ +"""Unit tests for esphome.__main__ module.""" + +from __future__ import annotations + +from collections.abc import Generator +from dataclasses import dataclass +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from esphome.__main__ import choose_upload_log_host +from esphome.const import CONF_BROKER, CONF_MQTT, CONF_USE_ADDRESS, CONF_WIFI +from esphome.core import CORE + + +@dataclass +class MockSerialPort: + """Mock serial port for testing. + + Attributes: + path (str): The device path of the mock serial port (e.g., '/dev/ttyUSB0'). + description (str): A human-readable description of the mock serial port. + """ + + path: str + description: str + + +def setup_core( + config: dict[str, Any] | None = None, address: str | None = None +) -> None: + """ + Helper to set up CORE configuration with optional address. + + Args: + config (dict[str, Any] | None): The configuration dictionary to set for CORE. If None, an empty dict is used. + address (str | None): Optional network address to set in the configuration. If provided, it is set under the wifi config. + """ + if config is None: + config = {} + + if address is not None: + # Set address via wifi config (could also use ethernet) + config[CONF_WIFI] = {CONF_USE_ADDRESS: address} + + CORE.config = config + + +@pytest.fixture +def mock_no_serial_ports() -> Generator[Mock]: + """Mock get_serial_ports to return no ports.""" + with patch("esphome.__main__.get_serial_ports", return_value=[]) as mock: + yield mock + + +@pytest.fixture +def mock_serial_ports() -> Generator[Mock]: + """Mock get_serial_ports to return test ports.""" + mock_ports = [ + MockSerialPort("/dev/ttyUSB0", "USB Serial"), + MockSerialPort("/dev/ttyUSB1", "Another USB Serial"), + ] + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports) as mock: + yield mock + + +@pytest.fixture +def mock_choose_prompt() -> Generator[Mock]: + """Mock choose_prompt to return default selection.""" + with patch("esphome.__main__.choose_prompt", return_value="/dev/ttyUSB0") as mock: + yield mock + + +@pytest.fixture +def mock_no_mqtt_logging() -> Generator[Mock]: + """Mock has_mqtt_logging to return False.""" + with patch("esphome.__main__.has_mqtt_logging", return_value=False) as mock: + yield mock + + +@pytest.fixture +def mock_has_mqtt_logging() -> Generator[Mock]: + """Mock has_mqtt_logging to return True.""" + with patch("esphome.__main__.has_mqtt_logging", return_value=True) as mock: + yield mock + + +def test_choose_upload_log_host_with_string_default() -> None: + """Test with a single string default device.""" + result = choose_upload_log_host( + default="192.168.1.100", + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + + +def test_choose_upload_log_host_with_list_default() -> None: + """Test with a list of default devices.""" + result = choose_upload_log_host( + default=["192.168.1.100", "192.168.1.101"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100", "192.168.1.101"] + + +def test_choose_upload_log_host_with_multiple_ip_addresses() -> None: + """Test with multiple IP addresses as defaults.""" + result = choose_upload_log_host( + default=["1.2.3.4", "4.5.5.6"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["1.2.3.4", "4.5.5.6"] + + +def test_choose_upload_log_host_with_mixed_hostnames_and_ips() -> None: + """Test with a mix of hostnames and IP addresses.""" + result = choose_upload_log_host( + default=["host.one", "host.one.local", "1.2.3.4"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["host.one", "host.one.local", "1.2.3.4"] + + +def test_choose_upload_log_host_with_ota_list() -> None: + """Test with OTA as the only item in the list.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default=["OTA"], + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + + +@pytest.mark.usefixtures("mock_has_mqtt_logging") +def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None: + """Test with OTA list falling back to MQTT when no address.""" + setup_core() + + result = choose_upload_log_host( + default=["OTA"], + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=False, + ) + assert result == ["MQTT"] + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_with_serial_device_no_ports( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test SERIAL device when no serial ports are found.""" + result = choose_upload_log_host( + default="SERIAL", + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == [] + assert "No serial ports found, skipping SERIAL device" in caplog.text + + +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_with_serial_device_with_ports( + mock_choose_prompt: Mock, +) -> None: + """Test SERIAL device when serial ports are available.""" + result = choose_upload_log_host( + default="SERIAL", + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + purpose="testing", + ) + assert result == ["/dev/ttyUSB0"] + mock_choose_prompt.assert_called_once_with( + [ + ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), + ("/dev/ttyUSB1 (Another USB Serial)", "/dev/ttyUSB1"), + ], + purpose="testing", + ) + + +def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: + """Test OTA device when OTA is configured.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + + +def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: + """Test OTA device when API is configured.""" + setup_core(config={"api": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=True, + ) + assert result == ["192.168.1.100"] + + +@pytest.mark.usefixtures("mock_has_mqtt_logging") +def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: + """Test OTA device fallback to MQTT when no OTA/API config.""" + setup_core() + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=False, + ) + assert result == ["MQTT"] + + +@pytest.mark.usefixtures("mock_no_mqtt_logging") +def test_choose_upload_log_host_with_ota_device_no_fallback() -> None: + """Test OTA device with no valid fallback options.""" + setup_core() + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=True, + show_mqtt=True, + show_api=False, + ) + assert result == [] + + +@pytest.mark.usefixtures("mock_choose_prompt") +def test_choose_upload_log_host_multiple_devices() -> None: + """Test with multiple devices including special identifiers.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] + + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): + result = choose_upload_log_host( + default=["192.168.1.50", "OTA", "SERIAL"], + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.50", "192.168.1.100", "/dev/ttyUSB0"] + + +def test_choose_upload_log_host_no_defaults_with_serial_ports( + mock_choose_prompt: Mock, +) -> None: + """Test interactive mode with serial ports available.""" + mock_ports = [ + MockSerialPort("/dev/ttyUSB0", "USB Serial"), + ] + + setup_core() + + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + purpose="uploading", + ) + assert result == ["/dev/ttyUSB0"] + mock_choose_prompt.assert_called_once_with( + [("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0")], + purpose="uploading", + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_no_defaults_with_ota() -> None: + """Test interactive mode with OTA option.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + with patch( + "esphome.__main__.choose_prompt", return_value="192.168.1.100" + ) as mock_prompt: + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + mock_prompt.assert_called_once_with( + [("Over The Air (192.168.1.100)", "192.168.1.100")], + purpose=None, + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_no_defaults_with_api() -> None: + """Test interactive mode with API option.""" + setup_core(config={"api": {}}, address="192.168.1.100") + + with patch( + "esphome.__main__.choose_prompt", return_value="192.168.1.100" + ) as mock_prompt: + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=True, + ) + assert result == ["192.168.1.100"] + mock_prompt.assert_called_once_with( + [("Over The Air (192.168.1.100)", "192.168.1.100")], + purpose=None, + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports", "mock_has_mqtt_logging") +def test_choose_upload_log_host_no_defaults_with_mqtt() -> None: + """Test interactive mode with MQTT option.""" + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) + + with patch("esphome.__main__.choose_prompt", return_value="MQTT") as mock_prompt: + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=False, + ) + assert result == ["MQTT"] + mock_prompt.assert_called_once_with( + [("MQTT (mqtt.local)", "MQTT")], + purpose=None, + ) + + +@pytest.mark.usefixtures("mock_has_mqtt_logging") +def test_choose_upload_log_host_no_defaults_with_all_options( + mock_choose_prompt: Mock, +) -> None: + """Test interactive mode with all options available.""" + setup_core( + config={"ota": {}, "api": {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, + address="192.168.1.100", + ) + + mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] + + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=True, + show_api=True, + purpose="testing", + ) + assert result == ["/dev/ttyUSB0"] + + expected_options = [ + ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), + ("Over The Air (192.168.1.100)", "192.168.1.100"), + ("MQTT (mqtt.local)", "MQTT"), + ] + mock_choose_prompt.assert_called_once_with(expected_options, purpose="testing") + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_check_default_matches() -> None: + """Test when check_default matches an available option.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default=None, + check_default="192.168.1.100", + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_check_default_no_match() -> None: + """Test when check_default doesn't match any available option.""" + setup_core() + + with patch( + "esphome.__main__.choose_prompt", return_value="fallback" + ) as mock_prompt: + result = choose_upload_log_host( + default=None, + check_default="192.168.1.100", + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["fallback"] + mock_prompt.assert_called_once() + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_empty_defaults_list() -> None: + """Test with an empty list as default.""" + with patch("esphome.__main__.choose_prompt", return_value="chosen") as mock_prompt: + result = choose_upload_log_host( + default=[], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["chosen"] + mock_prompt.assert_called_once() + + +@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging") +def test_choose_upload_log_host_all_devices_unresolved( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test when all specified devices cannot be resolved.""" + setup_core() + + result = choose_upload_log_host( + default=["SERIAL", "OTA"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == [] + assert ( + "All specified devices: ['SERIAL', 'OTA'] could not be resolved." in caplog.text + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging") +def test_choose_upload_log_host_mixed_resolved_unresolved() -> None: + """Test with a mix of resolved and unresolved devices.""" + setup_core() + + result = choose_upload_log_host( + default=["192.168.1.50", "SERIAL", "OTA"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.50"] + + +def test_choose_upload_log_host_ota_both_conditions() -> None: + """Test OTA device when both OTA and API are configured and enabled.""" + setup_core(config={"ota": {}, "api": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=True, + ) + assert result == ["192.168.1.100"] + + +@pytest.mark.usefixtures("mock_no_mqtt_logging") +def test_choose_upload_log_host_no_address_with_ota_config() -> None: + """Test OTA device when OTA is configured but no address is set.""" + setup_core(config={"ota": {}}) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == [] From 3240e19a7c843043f63116aa0327c9eda09a1278 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 15:52:46 -0500 Subject: [PATCH 1038/1626] Add some more coverage for dashboard web_server (#10682) --- tests/dashboard/test_web_server.py | 503 ++++++++++++++++++++++++++++- 1 file changed, 501 insertions(+), 2 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index b77ab7a7a3..a22f4a8b2a 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1,13 +1,16 @@ from __future__ import annotations import asyncio +from collections.abc import Generator +import gzip import json import os -from unittest.mock import Mock +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch import pytest import pytest_asyncio -from tornado.httpclient import AsyncHTTPClient, HTTPResponse +from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse from tornado.httpserver import HTTPServer from tornado.ioloop import IOLoop from tornado.testing import bind_unused_port @@ -34,6 +37,66 @@ class DashboardTestHelper: return await future +@pytest.fixture +def mock_async_run_system_command() -> Generator[MagicMock]: + """Fixture to mock async_run_system_command.""" + with patch("esphome.dashboard.web_server.async_run_system_command") as mock: + yield mock + + +@pytest.fixture +def mock_trash_storage_path(tmp_path: Path) -> Generator[MagicMock]: + """Fixture to mock trash_storage_path.""" + trash_dir = tmp_path / "trash" + with patch( + "esphome.dashboard.web_server.trash_storage_path", return_value=str(trash_dir) + ) as mock: + yield mock + + +@pytest.fixture +def mock_archive_storage_path(tmp_path: Path) -> Generator[MagicMock]: + """Fixture to mock archive_storage_path.""" + archive_dir = tmp_path / "archive" + with patch( + "esphome.dashboard.web_server.archive_storage_path", + return_value=str(archive_dir), + ) as mock: + yield mock + + +@pytest.fixture +def mock_dashboard_settings() -> Generator[MagicMock]: + """Fixture to mock dashboard settings.""" + with patch("esphome.dashboard.web_server.settings") as mock_settings: + # Set default auth settings to avoid authentication issues + mock_settings.using_auth = False + mock_settings.on_ha_addon = False + yield mock_settings + + +@pytest.fixture +def mock_ext_storage_path(tmp_path: Path) -> Generator[MagicMock]: + """Fixture to mock ext_storage_path.""" + with patch("esphome.dashboard.web_server.ext_storage_path") as mock: + mock.return_value = str(tmp_path / "storage.json") + yield mock + + +@pytest.fixture +def mock_storage_json() -> Generator[MagicMock]: + """Fixture to mock StorageJSON.""" + with patch("esphome.dashboard.web_server.StorageJSON") as mock: + yield mock + + +@pytest.fixture +def mock_idedata() -> Generator[MagicMock]: + """Fixture to mock platformio_api.IDEData.""" + with patch("esphome.dashboard.web_server.platformio_api.IDEData") as mock: + yield mock + + @pytest_asyncio.fixture() async def dashboard() -> DashboardTestHelper: sock, port = bind_unused_port() @@ -80,3 +143,439 @@ async def test_devices_page(dashboard: DashboardTestHelper) -> None: first_device = configured_devices[0] assert first_device["name"] == "pico" assert first_device["configuration"] == "pico.yaml" + + +@pytest.mark.asyncio +async def test_wizard_handler_invalid_input(dashboard: DashboardTestHelper) -> None: + """Test the WizardRequestHandler.post method with invalid inputs.""" + # Test with missing name (should fail with 422) + body_no_name = json.dumps( + { + "name": "", # Empty name + "platform": "ESP32", + "board": "esp32dev", + } + ) + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/wizard", + method="POST", + body=body_no_name, + headers={"Content-Type": "application/json"}, + ) + assert exc_info.value.code == 422 + + # Test with invalid wizard type (should fail with 422) + body_invalid_type = json.dumps( + { + "name": "test_device", + "type": "invalid_type", + "platform": "ESP32", + "board": "esp32dev", + } + ) + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/wizard", + method="POST", + body=body_invalid_type, + headers={"Content-Type": "application/json"}, + ) + assert exc_info.value.code == 422 + + +@pytest.mark.asyncio +async def test_wizard_handler_conflict(dashboard: DashboardTestHelper) -> None: + """Test the WizardRequestHandler.post when config already exists.""" + # Try to create a wizard for existing pico.yaml (should conflict) + body = json.dumps( + { + "name": "pico", # This already exists in fixtures + "platform": "ESP32", + "board": "esp32dev", + } + ) + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/wizard", + method="POST", + body=body, + headers={"Content-Type": "application/json"}, + ) + assert exc_info.value.code == 409 + + +@pytest.mark.asyncio +async def test_download_binary_handler_not_found( + dashboard: DashboardTestHelper, +) -> None: + """Test the DownloadBinaryRequestHandler.get with non-existent config.""" + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/download.bin?configuration=nonexistent.yaml", + method="GET", + ) + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_no_file_param( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get without file parameter.""" + # Mock storage to exist, but still should fail without file param + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(tmp_path / "firmware.bin") + mock_storage_json.load.return_value = mock_storage + + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/download.bin?configuration=pico.yaml", + method="GET", + ) + assert exc_info.value.code == 400 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_with_file( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with existing binary file.""" + # Create a fake binary file + build_dir = tmp_path / ".esphome" / "build" / "test" + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"fake firmware content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(firmware_file) + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=firmware.bin", + method="GET", + ) + assert response.code == 200 + assert response.body == b"fake firmware content" + assert response.headers["Content-Type"] == "application/octet-stream" + assert "attachment" in response.headers["Content-Disposition"] + assert "test_device-firmware.bin" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_compressed( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with compression.""" + # Create a fake binary file + build_dir = tmp_path / ".esphome" / "build" / "test" + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + original_content = b"fake firmware content for compression test" + firmware_file.write_bytes(original_content) + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(firmware_file) + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=firmware.bin&compressed=1", + method="GET", + ) + assert response.code == 200 + # Decompress and verify content + decompressed = gzip.decompress(response.body) + assert decompressed == original_content + assert response.headers["Content-Type"] == "application/octet-stream" + assert "firmware.bin.gz" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_custom_download_name( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with custom download name.""" + # Create a fake binary file + build_dir = tmp_path / ".esphome" / "build" / "test" + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(firmware_file) + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=firmware.bin&download=custom_name.bin", + method="GET", + ) + assert response.code == 200 + assert "custom_name.bin" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_idedata_fallback( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_async_run_system_command: MagicMock, + mock_storage_json: MagicMock, + mock_idedata: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get falling back to idedata for extra images.""" + # Create build directory but no bootloader file initially + build_dir = tmp_path / ".esphome" / "build" / "test" + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"firmware") + + # Create bootloader file that idedata will find + bootloader_file = tmp_path / "bootloader.bin" + bootloader_file.write_bytes(b"bootloader content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(firmware_file) + mock_storage_json.load.return_value = mock_storage + + # Mock idedata response + mock_image = Mock() + mock_image.path = str(bootloader_file) + mock_idedata_instance = Mock() + mock_idedata_instance.extra_flash_images = [mock_image] + mock_idedata.return_value = mock_idedata_instance + + # Mock async_run_system_command to return idedata JSON + mock_async_run_system_command.return_value = (0, '{"extra_flash_images": []}', "") + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=bootloader.bin", + method="GET", + ) + assert response.code == 200 + assert response.body == b"bootloader content" + + +@pytest.mark.asyncio +async def test_edit_request_handler_post_invalid_file( + dashboard: DashboardTestHelper, +) -> None: + """Test the EditRequestHandler.post with non-yaml file.""" + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/edit?configuration=test.txt", + method="POST", + body=b"content", + ) + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +async def test_edit_request_handler_post_existing( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_dashboard_settings: MagicMock, +) -> None: + """Test the EditRequestHandler.post with existing yaml file.""" + # Create a temporary yaml file to edit (don't modify fixtures) + test_file = tmp_path / "test_edit.yaml" + test_file.write_text("esphome:\n name: original\n") + + # Configure the mock settings + mock_dashboard_settings.rel_path.return_value = str(test_file) + mock_dashboard_settings.absolute_config_dir = test_file.parent + + new_content = "esphome:\n name: modified\n" + response = await dashboard.fetch( + "/edit?configuration=test_edit.yaml", + method="POST", + body=new_content.encode(), + ) + assert response.code == 200 + + # Verify the file was actually modified + assert test_file.read_text() == new_content + + +@pytest.mark.asyncio +async def test_unarchive_request_handler( + dashboard: DashboardTestHelper, + mock_archive_storage_path: MagicMock, + mock_dashboard_settings: MagicMock, + tmp_path: Path, +) -> None: + """Test the UnArchiveRequestHandler.post method.""" + # Set up an archived file + archive_dir = Path(mock_archive_storage_path.return_value) + archive_dir.mkdir(parents=True, exist_ok=True) + archived_file = archive_dir / "archived.yaml" + archived_file.write_text("test content") + + # Set up the destination path where the file should be moved + config_dir = tmp_path / "config" + config_dir.mkdir(parents=True, exist_ok=True) + destination_file = config_dir / "archived.yaml" + mock_dashboard_settings.rel_path.return_value = str(destination_file) + + response = await dashboard.fetch( + "/unarchive?configuration=archived.yaml", + method="POST", + body=b"", + ) + assert response.code == 200 + + # Verify the file was actually moved from archive to config + assert not archived_file.exists() # File should be gone from archive + assert destination_file.exists() # File should now be in config + assert destination_file.read_text() == "test content" # Content preserved + + +@pytest.mark.asyncio +async def test_secret_keys_handler_no_file(dashboard: DashboardTestHelper) -> None: + """Test the SecretKeysRequestHandler.get when no secrets file exists.""" + # By default, there's no secrets file in the test fixtures + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/secret_keys", method="GET") + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +async def test_secret_keys_handler_with_file( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_dashboard_settings: MagicMock, +) -> None: + """Test the SecretKeysRequestHandler.get when secrets file exists.""" + # Create a secrets file in temp directory + secrets_file = tmp_path / "secrets.yaml" + secrets_file.write_text( + "wifi_ssid: TestNetwork\nwifi_password: TestPass123\napi_key: test_key\n" + ) + + # Configure mock to return our temp secrets file + # Since the file actually exists, os.path.isfile will return True naturally + mock_dashboard_settings.rel_path.return_value = str(secrets_file) + + response = await dashboard.fetch("/secret_keys", method="GET") + assert response.code == 200 + data = json.loads(response.body.decode()) + assert "wifi_ssid" in data + assert "wifi_password" in data + assert "api_key" in data + + +@pytest.mark.asyncio +async def test_json_config_handler( + dashboard: DashboardTestHelper, + mock_async_run_system_command: MagicMock, +) -> None: + """Test the JsonConfigRequestHandler.get method.""" + # This will actually run the esphome config command on pico.yaml + mock_output = json.dumps( + { + "esphome": {"name": "pico"}, + "esp32": {"board": "esp32dev"}, + } + ) + mock_async_run_system_command.return_value = (0, mock_output, "") + + response = await dashboard.fetch( + "/json-config?configuration=pico.yaml", method="GET" + ) + assert response.code == 200 + data = json.loads(response.body.decode()) + assert data["esphome"]["name"] == "pico" + + +@pytest.mark.asyncio +async def test_json_config_handler_invalid_config( + dashboard: DashboardTestHelper, + mock_async_run_system_command: MagicMock, +) -> None: + """Test the JsonConfigRequestHandler.get with invalid config.""" + # Simulate esphome config command failure + mock_async_run_system_command.return_value = (1, "", "Error: Invalid configuration") + + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/json-config?configuration=pico.yaml", method="GET") + assert exc_info.value.code == 422 + + +@pytest.mark.asyncio +async def test_json_config_handler_not_found(dashboard: DashboardTestHelper) -> None: + """Test the JsonConfigRequestHandler.get with non-existent file.""" + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/json-config?configuration=nonexistent.yaml", method="GET" + ) + assert exc_info.value.code == 404 + + +def test_start_web_server_with_address_port( + tmp_path: Path, + mock_trash_storage_path: MagicMock, + mock_archive_storage_path: MagicMock, +) -> None: + """Test the start_web_server function with address and port.""" + app = Mock() + trash_dir = Path(mock_trash_storage_path.return_value) + archive_dir = Path(mock_archive_storage_path.return_value) + + # Create trash dir to test migration + trash_dir.mkdir() + (trash_dir / "old.yaml").write_text("old") + + web_server.start_web_server(app, None, "127.0.0.1", 6052, str(tmp_path / "config")) + + # The function calls app.listen directly for non-socket mode + app.listen.assert_called_once_with(6052, "127.0.0.1") + + # Verify trash was moved to archive + assert not trash_dir.exists() + assert archive_dir.exists() + assert (archive_dir / "old.yaml").exists() + + +@pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") +@pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") +def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: + """Test the start_web_server function with unix socket.""" + app = Mock() + socket_path = tmp_path / "test.sock" + + # Don't create trash_dir - it doesn't exist, so no migration needed + with ( + patch("tornado.httpserver.HTTPServer") as mock_server_class, + patch("tornado.netutil.bind_unix_socket") as mock_bind, + ): + server = Mock() + mock_server_class.return_value = server + mock_bind.return_value = Mock() + + web_server.start_web_server( + app, str(socket_path), None, None, str(tmp_path / "config") + ) + + mock_server_class.assert_called_once_with(app) + mock_bind.assert_called_once_with(str(socket_path), mode=0o666) + server.add_socket.assert_called_once() From bbef0e173e5042fe288d0908cf1889d718a27f77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 15:54:34 -0500 Subject: [PATCH 1039/1626] [esp32_ble_tracker] Simplify BLE client state machine by removing READY_TO_CONNECT (#10672) --- .../esp32_ble_client/ble_client_base.cpp | 26 ++++++++++--------- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 4 +-- .../esp32_ble_tracker/esp32_ble_tracker.h | 3 --- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index af5162afb0..18321ef91c 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -43,13 +43,6 @@ void BLEClientBase::setup() { void BLEClientBase::set_state(espbt::ClientState st) { ESP_LOGV(TAG, "[%d] [%s] Set state %d", this->connection_index_, this->address_str_.c_str(), (int) st); ESPBTClient::set_state(st); - - if (st == espbt::ClientState::READY_TO_CONNECT) { - // Enable loop for state processing - this->enable_loop(); - // Connect immediately instead of waiting for next loop - this->connect(); - } } void BLEClientBase::loop() { @@ -65,8 +58,8 @@ void BLEClientBase::loop() { } this->set_state(espbt::ClientState::IDLE); } - // If its idle, we can disable the loop as set_state - // will enable it again when we need to connect. + // If idle, we can disable the loop as connect() + // will enable it again when a connection is needed. else if (this->state_ == espbt::ClientState::IDLE) { this->disable_loop(); } @@ -108,9 +101,20 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { #endif void BLEClientBase::connect() { + // Prevent duplicate connection attempts + if (this->state_ == espbt::ClientState::CONNECTING || this->state_ == espbt::ClientState::CONNECTED || + this->state_ == espbt::ClientState::ESTABLISHED) { + ESP_LOGW(TAG, "[%d] [%s] Connection already in progress, state=%s", this->connection_index_, + this->address_str_.c_str(), espbt::client_state_to_string(this->state_)); + return; + } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_.c_str(), this->remote_addr_type_); this->paired_ = false; + // Enable loop for state processing + this->enable_loop(); + // Immediately transition to CONNECTING to prevent duplicate connection attempts + this->set_state(espbt::ClientState::CONNECTING); // Determine connection parameters based on connection type if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { @@ -168,7 +172,7 @@ void BLEClientBase::unconditional_disconnect() { this->log_gattc_warning_("esp_ble_gattc_close", err); } - if (this->state_ == espbt::ClientState::READY_TO_CONNECT || this->state_ == espbt::ClientState::DISCOVERED) { + if (this->state_ == espbt::ClientState::DISCOVERED) { this->set_address(0); this->set_state(espbt::ClientState::IDLE); } else { @@ -212,8 +216,6 @@ void BLEClientBase::handle_connection_result_(esp_err_t ret) { if (ret) { this->log_gattc_warning_("esp_ble_gattc_open", ret); this->set_state(espbt::ClientState::IDLE); - } else { - this->set_state(espbt::ClientState::CONNECTING); } } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 0edde169eb..bab1dd7c98 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -51,8 +51,6 @@ const char *client_state_to_string(ClientState state) { return "IDLE"; case ClientState::DISCOVERED: return "DISCOVERED"; - case ClientState::READY_TO_CONNECT: - return "READY_TO_CONNECT"; case ClientState::CONNECTING: return "CONNECTING"; case ClientState::CONNECTED: @@ -795,7 +793,7 @@ void ESP32BLETracker::try_promote_discovered_clients_() { #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE this->update_coex_preference_(true); #endif - client->set_state(ClientState::READY_TO_CONNECT); + client->connect(); break; } } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index dd67156108..e53c2ac097 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -159,8 +159,6 @@ enum class ClientState : uint8_t { IDLE, // Device advertisement found. DISCOVERED, - // Device is discovered and the scanner is stopped - READY_TO_CONNECT, // Connection in progress. CONNECTING, // Initial connection established. @@ -313,7 +311,6 @@ class ESP32BLETracker : public Component, counts.discovered++; break; case ClientState::CONNECTING: - case ClientState::READY_TO_CONNECT: counts.connecting++; break; default: From 4d3405340d4abfd4e3e0341c668aef8a3278d332 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:21:01 -0500 Subject: [PATCH 1040/1626] Fix dashboard dns lookup delay --- esphome/__main__.py | 22 ++++++ esphome/address_cache.py | 131 +++++++++++++++++++++++++++++++ esphome/core/__init__.py | 3 + esphome/dashboard/dns.py | 11 +++ esphome/dashboard/status/mdns.py | 16 ++++ esphome/dashboard/web_server.py | 70 ++++++++++------- esphome/espota2.py | 6 +- esphome/helpers.py | 88 ++++++++++++++------- tests/unit_tests/test_helpers.py | 87 ++++++++++++++++++++ tests/unit_tests/test_main.py | 71 +++++++++++++++++ 10 files changed, 448 insertions(+), 57 deletions(-) create mode 100644 esphome/address_cache.py diff --git a/esphome/__main__.py b/esphome/__main__.py index bba254436e..15c29e6cdf 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -889,6 +889,18 @@ def parse_args(argv): help="Add a substitution", metavar=("key", "value"), ) + options_parser.add_argument( + "--mdns-lookup-cache", + help="mDNS lookup cache mapping in format 'hostname=ip1,ip2'", + action="append", + default=[], + ) + options_parser.add_argument( + "--dns-lookup-cache", + help="DNS lookup cache mapping in format 'hostname=ip1,ip2'", + action="append", + default=[], + ) parser = argparse.ArgumentParser( description=f"ESPHome {const.__version__}", parents=[options_parser] @@ -1136,9 +1148,19 @@ def parse_args(argv): def run_esphome(argv): + from esphome.address_cache import AddressCache + args = parse_args(argv) CORE.dashboard = args.dashboard + # Create address cache from command-line arguments + address_cache = AddressCache.from_cli_args( + args.mdns_lookup_cache, args.dns_lookup_cache + ) + + # Store cache in CORE for access throughout the application + CORE.address_cache = address_cache + # Override log level if verbose is set if args.verbose: args.log_level = "DEBUG" diff --git a/esphome/address_cache.py b/esphome/address_cache.py new file mode 100644 index 0000000000..6e5881716d --- /dev/null +++ b/esphome/address_cache.py @@ -0,0 +1,131 @@ +"""Address cache for DNS and mDNS lookups.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + +_LOGGER = logging.getLogger(__name__) + + +def normalize_hostname(hostname: str) -> str: + """Normalize hostname for cache lookups. + + Removes trailing dots and converts to lowercase. + """ + return hostname.rstrip(".").lower() + + +class AddressCache: + """Cache for DNS and mDNS address lookups. + + This cache stores pre-resolved addresses from command-line arguments + to avoid slow DNS/mDNS lookups during builds. + """ + + def __init__( + self, + mdns_cache: dict[str, list[str]] | None = None, + dns_cache: dict[str, list[str]] | None = None, + ) -> None: + """Initialize the address cache. + + Args: + mdns_cache: Pre-populated mDNS addresses (hostname -> IPs) + dns_cache: Pre-populated DNS addresses (hostname -> IPs) + """ + self.mdns_cache = mdns_cache or {} + self.dns_cache = dns_cache or {} + + def get_mdns_addresses(self, hostname: str) -> list[str] | None: + """Get cached mDNS addresses for a hostname. + + Args: + hostname: The hostname to look up (should end with .local) + + Returns: + List of IP addresses if found in cache, None otherwise + """ + normalized = normalize_hostname(hostname) + if addresses := self.mdns_cache.get(normalized): + _LOGGER.debug("Using mDNS cache for %s: %s", hostname, addresses) + return addresses + return None + + def get_dns_addresses(self, hostname: str) -> list[str] | None: + """Get cached DNS addresses for a hostname. + + Args: + hostname: The hostname to look up + + Returns: + List of IP addresses if found in cache, None otherwise + """ + normalized = normalize_hostname(hostname) + if addresses := self.dns_cache.get(normalized): + _LOGGER.debug("Using DNS cache for %s: %s", hostname, addresses) + return addresses + return None + + def get_addresses(self, hostname: str) -> list[str] | None: + """Get cached addresses for a hostname. + + Checks mDNS cache for .local domains, DNS cache otherwise. + + Args: + hostname: The hostname to look up + + Returns: + List of IP addresses if found in cache, None otherwise + """ + normalized = normalize_hostname(hostname) + if normalized.endswith(".local"): + return self.get_mdns_addresses(hostname) + return self.get_dns_addresses(hostname) + + def has_cache(self) -> bool: + """Check if any cache entries exist.""" + return bool(self.mdns_cache or self.dns_cache) + + @classmethod + def from_cli_args( + cls, mdns_args: Iterable[str], dns_args: Iterable[str] + ) -> AddressCache: + """Create cache from command-line arguments. + + Args: + mdns_args: List of mDNS cache entries like ['host=ip1,ip2'] + dns_args: List of DNS cache entries like ['host=ip1,ip2'] + + Returns: + Configured AddressCache instance + """ + mdns_cache = cls._parse_cache_args(mdns_args) + dns_cache = cls._parse_cache_args(dns_args) + return cls(mdns_cache=mdns_cache, dns_cache=dns_cache) + + @staticmethod + def _parse_cache_args(cache_args: Iterable[str]) -> dict[str, list[str]]: + """Parse cache arguments into a dictionary. + + Args: + cache_args: List of cache mappings like ['host1=ip1,ip2', 'host2=ip3'] + + Returns: + Dictionary mapping normalized hostnames to list of IP addresses + """ + cache: dict[str, list[str]] = {} + for arg in cache_args: + if "=" not in arg: + _LOGGER.warning( + "Invalid cache format: %s (expected 'hostname=ip1,ip2')", arg + ) + continue + hostname, ips = arg.split("=", 1) + # Normalize hostname for consistent lookups + normalized = normalize_hostname(hostname) + cache[normalized] = [ip.strip() for ip in ips.split(",")] + return cache diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 89e3eff7d8..0d4ddf56d4 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -583,6 +583,8 @@ class EsphomeCore: self.id_classes = {} # The current component being processed during validation self.current_component: str | None = None + # Address cache for DNS and mDNS lookups from command line arguments + self.address_cache: object | None = None def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -610,6 +612,7 @@ class EsphomeCore: self.platform_counts = defaultdict(int) self.unique_ids = {} self.current_component = None + self.address_cache = None PIN_SCHEMA_REGISTRY.reset() @contextmanager diff --git a/esphome/dashboard/dns.py b/esphome/dashboard/dns.py index 98134062f4..4f1ef71dd0 100644 --- a/esphome/dashboard/dns.py +++ b/esphome/dashboard/dns.py @@ -28,6 +28,17 @@ class DNSCache: self._cache: dict[str, tuple[float, list[str] | Exception]] = {} self._ttl = ttl + def get_cached(self, hostname: str, now_monotonic: float) -> list[str] | None: + """Get cached address without triggering resolution. + + Returns None if not in cache, list of addresses if found. + """ + if expire_time_addresses := self._cache.get(hostname): + expire_time, addresses = expire_time_addresses + if expire_time > now_monotonic and not isinstance(addresses, Exception): + return addresses + return None + async def async_resolve( self, hostname: str, now_monotonic: float ) -> list[str] | Exception: diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index f9ac7b4289..0977a89c3a 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -50,6 +50,22 @@ class MDNSStatus: return await aiozc.async_resolve_host(host_name) return None + def get_cached_addresses(self, host_name: str) -> list[str] | None: + """Get cached addresses for a host without triggering resolution. + + Returns None if not in cache or no zeroconf available. + """ + if not self.aiozc: + return None + + from zeroconf import AddressResolver, IPVersion + + # Try to load from zeroconf cache without triggering resolution + info = AddressResolver(f"{host_name.partition('.')[0]}.local.") + if info.load_from_cache(self.aiozc.zeroconf): + return info.parsed_scoped_addresses(IPVersion.All) + return None + async def async_refresh_hosts(self) -> None: """Refresh the hosts to track.""" dashboard = self.dashboard diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 294a180794..767144fd19 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -326,52 +326,64 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): configuration = json_message["configuration"] config_file = settings.rel_path(configuration) port = json_message["port"] + + # Only get cached addresses - no async resolution addresses: list[str] = [] + cache_args: list[str] = [] + if ( port == "OTA" # pylint: disable=too-many-boolean-expressions and (entry := entries.get(config_file)) and entry.loaded_integrations and "api" in entry.loaded_integrations ): - # First priority: entry.address AKA use_address - if ( - (use_address := entry.address) - and ( - address_list := await dashboard.dns_cache.async_resolve( - use_address, time.monotonic() - ) - ) - and not isinstance(address_list, Exception) - ): - addresses.extend(sort_ip_addresses(address_list)) + now = time.monotonic() - # Second priority: mDNS - if ( - (mdns := dashboard.mdns_status) - and (address_list := await mdns.async_resolve_host(entry.name)) - and ( - new_addresses := [ - addr for addr in address_list if addr not in addresses - ] - ) + # Collect all cached addresses for this device + dns_cache_entries: dict[str, set[str]] = {} + mdns_cache_entries: dict[str, set[str]] = {} + + # First priority: entry.address AKA use_address (from DNS cache only) + if (use_address := entry.address) and ( + cached := dashboard.dns_cache.get_cached(use_address, now) ): - # Use the IP address if available but only - # if the API is loaded and the device is online - # since MQTT logging will not work otherwise - addresses.extend(sort_ip_addresses(new_addresses)) + addresses.extend(sort_ip_addresses(cached)) + dns_cache_entries[use_address] = set(cached) + + # Second priority: mDNS cache for device name + if entry.name and not addresses: # Only if we don't have addresses yet + if entry.name.endswith(".local"): + # Check mDNS cache (zeroconf) + if (mdns := dashboard.mdns_status) and ( + cached := mdns.get_cached_addresses(entry.name) + ): + addresses.extend(sort_ip_addresses(cached)) + mdns_cache_entries[entry.name] = set(cached) + # Check DNS cache for non-.local names + elif cached := dashboard.dns_cache.get_cached(entry.name, now): + addresses.extend(sort_ip_addresses(cached)) + dns_cache_entries[entry.name] = set(cached) + + # Build cache arguments to pass to CLI + for hostname, addrs in dns_cache_entries.items(): + cache_args.extend( + ["--dns-lookup-cache", f"{hostname}={','.join(sorted(addrs))}"] + ) + for hostname, addrs in mdns_cache_entries.items(): + cache_args.extend( + ["--mdns-lookup-cache", f"{hostname}={','.join(sorted(addrs))}"] + ) if not addresses: - # If no address was found, use the port directly - # as otherwise they will get the chooser which - # does not work with the dashboard as there is no - # interactive way to get keyboard input + # If no cached address was found, use the port directly + # The CLI will do the resolution with the cache hints we provide addresses = [port] device_args: list[str] = [ arg for address in addresses for arg in ("--device", address) ] - return [*DASHBOARD_COMMAND, *args, config_file, *device_args] + return [*DASHBOARD_COMMAND, *args, config_file, *device_args, *cache_args] class EsphomeLogsHandler(EsphomePortCommandWebSocket): diff --git a/esphome/espota2.py b/esphome/espota2.py index 3d25af985b..f808d558d7 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -311,10 +311,14 @@ def perform_ota( def run_ota_impl_( remote_host: str | list[str], remote_port: int, password: str, filename: str ) -> tuple[int, str | None]: + from esphome.core import CORE + # Handle both single host and list of hosts try: # Resolve all hosts at once for parallel DNS resolution - res = resolve_ip_address(remote_host, remote_port) + res = resolve_ip_address( + remote_host, remote_port, address_cache=getattr(CORE, "address_cache", None) + ) except EsphomeError as err: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", diff --git a/esphome/helpers.py b/esphome/helpers.py index 6beaa24a96..f4b321b26f 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -173,7 +173,9 @@ def addr_preference_(res: AddrInfo) -> int: return 1 -def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: +def resolve_ip_address( + host: str | list[str], port: int, address_cache: object | None = None +) -> list[AddrInfo]: import socket # There are five cases here. The host argument could be one of: @@ -194,8 +196,9 @@ def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: hosts = [host] res: list[AddrInfo] = [] + + # Fast path: if all hosts are already IP addresses if all(is_ip_address(h) for h in hosts): - # Fast path: all are IP addresses, use socket.getaddrinfo with AI_NUMERICHOST for addr in hosts: try: res += socket.getaddrinfo( @@ -207,34 +210,65 @@ def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: res.sort(key=addr_preference_) return res - from esphome.resolver import AsyncResolver + # Check if we have cached addresses for these hosts + cached_hosts: list[str] = [] + uncached_hosts: list[str] = [] - resolver = AsyncResolver(hosts, port) - addr_infos = resolver.resolve() - # Convert aioesphomeapi AddrInfo to our format - for addr_info in addr_infos: - sockaddr = addr_info.sockaddr - if addr_info.family == socket.AF_INET6: - # IPv6 - sockaddr_tuple = ( - sockaddr.address, - sockaddr.port, - sockaddr.flowinfo, - sockaddr.scope_id, - ) - else: - # IPv4 - sockaddr_tuple = (sockaddr.address, sockaddr.port) + for h in hosts: + # Check if it's already an IP address + if is_ip_address(h): + cached_hosts.append(h) + continue - res.append( - ( - addr_info.family, - addr_info.type, - addr_info.proto, - "", # canonname - sockaddr_tuple, + # Check cache if provided + if address_cache and (cached_addresses := address_cache.get_addresses(h)): + cached_hosts.extend(cached_addresses) + continue + + # Not in cache, need to resolve + if address_cache and address_cache.has_cache(): + _LOGGER.info("Host %s not in cache, will need to resolve", h) + uncached_hosts.append(h) + + # Process cached addresses (all should be IP addresses) + for addr in cached_hosts: + try: + res += socket.getaddrinfo( + addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + except OSError: + _LOGGER.debug("Failed to parse IP address '%s'", addr) + + # If we have uncached hosts, resolve them + if uncached_hosts: + from esphome.resolver import AsyncResolver + + resolver = AsyncResolver(uncached_hosts, port) + addr_infos = resolver.resolve() + # Convert aioesphomeapi AddrInfo to our format + for addr_info in addr_infos: + sockaddr = addr_info.sockaddr + if addr_info.family == socket.AF_INET6: + # IPv6 + sockaddr_tuple = ( + sockaddr.address, + sockaddr.port, + sockaddr.flowinfo, + sockaddr.scope_id, + ) + else: + # IPv4 + sockaddr_tuple = (sockaddr.address, sockaddr.port) + + res.append( + ( + addr_info.family, + addr_info.type, + addr_info.proto, + "", # canonname + sockaddr_tuple, + ) ) - ) # Sort by preference res.sort(key=addr_preference_) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 9f51206ff9..631d6a878e 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -594,3 +594,90 @@ def test_resolve_ip_address_sorting() -> None: assert result[0][4][0] == "2001:db8::1" # IPv6 (preference 1) assert result[1][4][0] == "192.168.1.100" # IPv4 (preference 2) assert result[2][4][0] == "fe80::1" # Link-local no scope (preference 3) + + +def test_resolve_ip_address_with_cache() -> None: + """Test that the cache is used when provided.""" + from esphome.address_cache import AddressCache + + cache = AddressCache( + mdns_cache={"test.local": ["192.168.1.100", "192.168.1.101"]}, + dns_cache={ + "example.com": ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"] + }, + ) + + # Test mDNS cache hit + result = helpers.resolve_ip_address("test.local", 6053, address_cache=cache) + + # Should return cached addresses without calling resolver + assert len(result) == 2 + assert result[0][4][0] == "192.168.1.100" + assert result[1][4][0] == "192.168.1.101" + + # Test DNS cache hit + result = helpers.resolve_ip_address("example.com", 6053, address_cache=cache) + + # Should return cached addresses with IPv6 first due to preference + assert len(result) == 2 + assert result[0][4][0] == "2606:2800:220:1:248:1893:25c8:1946" # IPv6 first + assert result[1][4][0] == "93.184.216.34" # IPv4 second + + +def test_resolve_ip_address_cache_miss() -> None: + """Test that resolver is called when not in cache.""" + from esphome.address_cache import AddressCache + + cache = AddressCache(mdns_cache={"other.local": ["192.168.1.200"]}) + + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address("test.local", 6053, address_cache=cache) + + # Should call resolver since test.local is not in cache + MockResolver.assert_called_once_with(["test.local"], 6053) + assert len(result) == 1 + assert result[0][4][0] == "192.168.1.100" + + +def test_resolve_ip_address_mixed_cached_uncached() -> None: + """Test resolution with mix of cached and uncached hosts.""" + from esphome.address_cache import AddressCache + + cache = AddressCache(mdns_cache={"cached.local": ["192.168.1.50"]}) + + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.return_value = [mock_addr_info] + + # Pass a list with cached IP, cached hostname, and uncached hostname + result = helpers.resolve_ip_address( + ["192.168.1.10", "cached.local", "uncached.local"], + 6053, + address_cache=cache, + ) + + # Should only resolve uncached.local + MockResolver.assert_called_once_with(["uncached.local"], 6053) + + # Results should include all addresses + addresses = [r[4][0] for r in result] + assert "192.168.1.10" in addresses # Direct IP + assert "192.168.1.50" in addresses # From cache + assert "192.168.1.100" in addresses # From resolver diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 2c7236c7f8..ce19f18a1f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -10,6 +10,7 @@ from unittest.mock import Mock, patch import pytest from esphome.__main__ import choose_upload_log_host +from esphome.address_cache import AddressCache from esphome.const import CONF_BROKER, CONF_MQTT, CONF_USE_ADDRESS, CONF_WIFI from esphome.core import CORE @@ -510,3 +511,73 @@ def test_choose_upload_log_host_no_address_with_ota_config() -> None: show_api=False, ) assert result == [] + + +def test_address_cache_from_cli_args() -> None: + """Test parsing address cache from CLI arguments.""" + # Test empty lists + cache = AddressCache.from_cli_args([], []) + assert cache.mdns_cache == {} + assert cache.dns_cache == {} + + # Test single entry with single IP + cache = AddressCache.from_cli_args( + ["host.local=192.168.1.1"], ["example.com=10.0.0.1"] + ) + assert cache.mdns_cache == {"host.local": ["192.168.1.1"]} + assert cache.dns_cache == {"example.com": ["10.0.0.1"]} + + # Test multiple IPs + cache = AddressCache.from_cli_args(["host.local=192.168.1.1,192.168.1.2"], []) + assert cache.mdns_cache == {"host.local": ["192.168.1.1", "192.168.1.2"]} + + # Test multiple entries + cache = AddressCache.from_cli_args( + ["host1.local=192.168.1.1", "host2.local=192.168.1.2"], + ["example.com=10.0.0.1", "test.org=10.0.0.2,10.0.0.3"], + ) + assert cache.mdns_cache == { + "host1.local": ["192.168.1.1"], + "host2.local": ["192.168.1.2"], + } + assert cache.dns_cache == { + "example.com": ["10.0.0.1"], + "test.org": ["10.0.0.2", "10.0.0.3"], + } + + # Test with IPv6 + cache = AddressCache.from_cli_args(["host.local=2001:db8::1,fe80::1"], []) + assert cache.mdns_cache == {"host.local": ["2001:db8::1", "fe80::1"]} + + # Test invalid format (should be skipped with warning) + with patch("esphome.address_cache._LOGGER") as mock_logger: + cache = AddressCache.from_cli_args(["invalid_format"], []) + assert cache.mdns_cache == {} + mock_logger.warning.assert_called_once() + + +def test_address_cache_get_methods() -> None: + """Test the AddressCache get methods.""" + cache = AddressCache( + mdns_cache={"test.local": ["192.168.1.1"]}, + dns_cache={"example.com": ["10.0.0.1"]}, + ) + + # Test mDNS lookup + assert cache.get_mdns_addresses("test.local") == ["192.168.1.1"] + assert cache.get_mdns_addresses("other.local") is None + + # Test DNS lookup + assert cache.get_dns_addresses("example.com") == ["10.0.0.1"] + assert cache.get_dns_addresses("other.com") is None + + # Test automatic selection based on domain + assert cache.get_addresses("test.local") == ["192.168.1.1"] + assert cache.get_addresses("example.com") == ["10.0.0.1"] + assert cache.get_addresses("unknown.local") is None + assert cache.get_addresses("unknown.com") is None + + # Test has_cache + assert cache.has_cache() is True + empty_cache = AddressCache() + assert empty_cache.has_cache() is False From 519bc5ef9e77f608defb8993b1da2543fe6ba234 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:22:16 -0500 Subject: [PATCH 1041/1626] Fix dashboard dns lookup delay --- esphome/dashboard/dns.py | 4 +++- esphome/dashboard/status/mdns.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/esphome/dashboard/dns.py b/esphome/dashboard/dns.py index 4f1ef71dd0..b94d816c74 100644 --- a/esphome/dashboard/dns.py +++ b/esphome/dashboard/dns.py @@ -33,7 +33,9 @@ class DNSCache: Returns None if not in cache, list of addresses if found. """ - if expire_time_addresses := self._cache.get(hostname): + # Normalize hostname for consistent lookups + normalized = hostname.rstrip(".").lower() + if expire_time_addresses := self._cache.get(normalized): expire_time, addresses = expire_time_addresses if expire_time > now_monotonic and not isinstance(addresses, Exception): return addresses diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index 0977a89c3a..a5ce69f30c 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -4,6 +4,8 @@ import asyncio import logging import typing +from zeroconf import AddressResolver, IPVersion + from esphome.zeroconf import ( ESPHOME_SERVICE_TYPE, AsyncEsphomeZeroconf, @@ -58,10 +60,12 @@ class MDNSStatus: if not self.aiozc: return None - from zeroconf import AddressResolver, IPVersion + # Normalize hostname: remove trailing dots and get the base name + normalized = host_name.rstrip(".").lower() + base_name = normalized.partition(".")[0] # Try to load from zeroconf cache without triggering resolution - info = AddressResolver(f"{host_name.partition('.')[0]}.local.") + info = AddressResolver(f"{base_name}.local.") if info.load_from_cache(self.aiozc.zeroconf): return info.parsed_scoped_addresses(IPVersion.All) return None From bc9d16289e8115c8b60c807ba7d0ceedae8b5b87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:25:06 -0500 Subject: [PATCH 1042/1626] Fix dashboard dns lookup delay --- esphome/core/__init__.py | 5 +++- esphome/dashboard/web_server.py | 8 +++--- tests/unit_tests/test_main.py | 44 +++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 0d4ddf56d4..476ff1c618 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -6,6 +6,9 @@ import os import re from typing import TYPE_CHECKING +if TYPE_CHECKING: + from esphome.address_cache import AddressCache + from esphome.const import ( CONF_COMMENT, CONF_ESPHOME, @@ -584,7 +587,7 @@ class EsphomeCore: # The current component being processed during validation self.current_component: str | None = None # Address cache for DNS and mDNS lookups from command line arguments - self.address_cache: object | None = None + self.address_cache: AddressCache | None = None def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 767144fd19..ff92fea958 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -364,14 +364,16 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): addresses.extend(sort_ip_addresses(cached)) dns_cache_entries[entry.name] = set(cached) - # Build cache arguments to pass to CLI + # Build cache arguments to pass to CLI (normalize hostnames) for hostname, addrs in dns_cache_entries.items(): + normalized = hostname.rstrip(".").lower() cache_args.extend( - ["--dns-lookup-cache", f"{hostname}={','.join(sorted(addrs))}"] + ["--dns-lookup-cache", f"{normalized}={','.join(sorted(addrs))}"] ) for hostname, addrs in mdns_cache_entries.items(): + normalized = hostname.rstrip(".").lower() cache_args.extend( - ["--mdns-lookup-cache", f"{hostname}={','.join(sorted(addrs))}"] + ["--mdns-lookup-cache", f"{normalized}={','.join(sorted(addrs))}"] ) if not addresses: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index ce19f18a1f..a00d8ce43a 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -581,3 +581,47 @@ def test_address_cache_get_methods() -> None: assert cache.has_cache() is True empty_cache = AddressCache() assert empty_cache.has_cache() is False + + +def test_address_cache_hostname_normalization() -> None: + """Test that hostnames are normalized for cache lookups.""" + from esphome.address_cache import normalize_hostname + + # Test normalize_hostname function + assert normalize_hostname("test.local") == "test.local" + assert normalize_hostname("test.local.") == "test.local" + assert normalize_hostname("TEST.LOCAL") == "test.local" + assert normalize_hostname("TeSt.LoCaL.") == "test.local" + assert normalize_hostname("example.com.") == "example.com" + + # Test cache with normalized lookups + cache = AddressCache( + mdns_cache={"test.local": ["192.168.1.1"]}, + dns_cache={"example.com": ["10.0.0.1"]}, + ) + + # Should find with different case and trailing dots + assert cache.get_mdns_addresses("test.local") == ["192.168.1.1"] + assert cache.get_mdns_addresses("TEST.LOCAL") == ["192.168.1.1"] + assert cache.get_mdns_addresses("test.local.") == ["192.168.1.1"] + assert cache.get_mdns_addresses("TEST.LOCAL.") == ["192.168.1.1"] + + assert cache.get_dns_addresses("example.com") == ["10.0.0.1"] + assert cache.get_dns_addresses("EXAMPLE.COM") == ["10.0.0.1"] + assert cache.get_dns_addresses("example.com.") == ["10.0.0.1"] + assert cache.get_dns_addresses("EXAMPLE.COM.") == ["10.0.0.1"] + + # Test from_cli_args also normalizes + cache = AddressCache.from_cli_args( + ["TEST.LOCAL.=192.168.1.1"], ["EXAMPLE.COM.=10.0.0.1"] + ) + + # Should store as normalized + assert "test.local" in cache.mdns_cache + assert "example.com" in cache.dns_cache + + # Should find with any variation + assert cache.get_addresses("test.local") == ["192.168.1.1"] + assert cache.get_addresses("TEST.LOCAL.") == ["192.168.1.1"] + assert cache.get_addresses("example.com") == ["10.0.0.1"] + assert cache.get_addresses("EXAMPLE.COM.") == ["10.0.0.1"] From 29525febe1da6c9c03aca62fb275e98513d670d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:28:28 -0500 Subject: [PATCH 1043/1626] cleanup --- esphome/address_cache.py | 31 +++++++++++++++++--------- esphome/dashboard/status/mdns.py | 5 +++-- esphome/helpers.py | 38 +++++++++++++++++--------------- 3 files changed, 44 insertions(+), 30 deletions(-) diff --git a/esphome/address_cache.py b/esphome/address_cache.py index 6e5881716d..7c20be90f0 100644 --- a/esphome/address_cache.py +++ b/esphome/address_cache.py @@ -40,6 +40,25 @@ class AddressCache: self.mdns_cache = mdns_cache or {} self.dns_cache = dns_cache or {} + def _get_cached_addresses( + self, hostname: str, cache: dict[str, list[str]], cache_type: str + ) -> list[str] | None: + """Get cached addresses from a specific cache. + + Args: + hostname: The hostname to look up + cache: The cache dictionary to check + cache_type: Type of cache for logging ("mDNS" or "DNS") + + Returns: + List of IP addresses if found in cache, None otherwise + """ + normalized = normalize_hostname(hostname) + if addresses := cache.get(normalized): + _LOGGER.debug("Using %s cache for %s: %s", cache_type, hostname, addresses) + return addresses + return None + def get_mdns_addresses(self, hostname: str) -> list[str] | None: """Get cached mDNS addresses for a hostname. @@ -49,11 +68,7 @@ class AddressCache: Returns: List of IP addresses if found in cache, None otherwise """ - normalized = normalize_hostname(hostname) - if addresses := self.mdns_cache.get(normalized): - _LOGGER.debug("Using mDNS cache for %s: %s", hostname, addresses) - return addresses - return None + return self._get_cached_addresses(hostname, self.mdns_cache, "mDNS") def get_dns_addresses(self, hostname: str) -> list[str] | None: """Get cached DNS addresses for a hostname. @@ -64,11 +79,7 @@ class AddressCache: Returns: List of IP addresses if found in cache, None otherwise """ - normalized = normalize_hostname(hostname) - if addresses := self.dns_cache.get(normalized): - _LOGGER.debug("Using DNS cache for %s: %s", hostname, addresses) - return addresses - return None + return self._get_cached_addresses(hostname, self.dns_cache, "DNS") def get_addresses(self, hostname: str) -> list[str] | None: """Get cached addresses for a hostname. diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index a5ce69f30c..576bade7cd 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -6,6 +6,7 @@ import typing from zeroconf import AddressResolver, IPVersion +from esphome.address_cache import normalize_hostname from esphome.zeroconf import ( ESPHOME_SERVICE_TYPE, AsyncEsphomeZeroconf, @@ -60,8 +61,8 @@ class MDNSStatus: if not self.aiozc: return None - # Normalize hostname: remove trailing dots and get the base name - normalized = host_name.rstrip(".").lower() + # Normalize hostname and get the base name + normalized = normalize_hostname(host_name) base_name = normalized.partition(".")[0] # Try to load from zeroconf cache without triggering resolution diff --git a/esphome/helpers.py b/esphome/helpers.py index f4b321b26f..7eb560646b 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -210,28 +210,30 @@ def resolve_ip_address( res.sort(key=addr_preference_) return res - # Check if we have cached addresses for these hosts - cached_hosts: list[str] = [] + # Process hosts + cached_addresses: list[str] = [] uncached_hosts: list[str] = [] + has_cache = address_cache is not None for h in hosts: - # Check if it's already an IP address if is_ip_address(h): - cached_hosts.append(h) - continue + if has_cache: + # If we have a cache, treat IPs as cached + cached_addresses.append(h) + else: + # If no cache, pass IPs through to resolver with hostnames + uncached_hosts.append(h) + elif address_cache and (cached := address_cache.get_addresses(h)): + # Found in cache + cached_addresses.extend(cached) + else: + # Not cached, need to resolve + if address_cache and address_cache.has_cache(): + _LOGGER.info("Host %s not in cache, will need to resolve", h) + uncached_hosts.append(h) - # Check cache if provided - if address_cache and (cached_addresses := address_cache.get_addresses(h)): - cached_hosts.extend(cached_addresses) - continue - - # Not in cache, need to resolve - if address_cache and address_cache.has_cache(): - _LOGGER.info("Host %s not in cache, will need to resolve", h) - uncached_hosts.append(h) - - # Process cached addresses (all should be IP addresses) - for addr in cached_hosts: + # Process cached addresses (includes direct IPs and cached lookups) + for addr in cached_addresses: try: res += socket.getaddrinfo( addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST @@ -239,7 +241,7 @@ def resolve_ip_address( except OSError: _LOGGER.debug("Failed to parse IP address '%s'", addr) - # If we have uncached hosts, resolve them + # If we have uncached hosts (only non-IP hostnames), resolve them if uncached_hosts: from esphome.resolver import AsyncResolver From 80240437c53967dfbf5a3cb00a1ea604bd26e263 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:35:51 -0500 Subject: [PATCH 1044/1626] cleanup --- esphome/dashboard/web_server.py | 65 ++++++++++++++------------------- 1 file changed, 27 insertions(+), 38 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index ff92fea958..71b10aaef1 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -327,8 +327,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): config_file = settings.rel_path(configuration) port = json_message["port"] - # Only get cached addresses - no async resolution - addresses: list[str] = [] + # Build cache arguments to pass to CLI cache_args: list[str] = [] if ( @@ -339,53 +338,43 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): ): now = time.monotonic() - # Collect all cached addresses for this device - dns_cache_entries: dict[str, set[str]] = {} - mdns_cache_entries: dict[str, set[str]] = {} - - # First priority: entry.address AKA use_address (from DNS cache only) + # Build cache entries for any cached addresses we have + # First check entry.address (use_address) if (use_address := entry.address) and ( cached := dashboard.dns_cache.get_cached(use_address, now) ): - addresses.extend(sort_ip_addresses(cached)) - dns_cache_entries[use_address] = set(cached) + normalized = use_address.rstrip(".").lower() + cache_args.extend( + [ + "--dns-lookup-cache", + f"{normalized}={','.join(sort_ip_addresses(cached))}", + ] + ) - # Second priority: mDNS cache for device name - if entry.name and not addresses: # Only if we don't have addresses yet + # Also check entry.name for cache entries + if entry.name: if entry.name.endswith(".local"): # Check mDNS cache (zeroconf) if (mdns := dashboard.mdns_status) and ( cached := mdns.get_cached_addresses(entry.name) ): - addresses.extend(sort_ip_addresses(cached)) - mdns_cache_entries[entry.name] = set(cached) - # Check DNS cache for non-.local names + normalized = entry.name.rstrip(".").lower() + cache_args.extend( + [ + "--mdns-lookup-cache", + f"{normalized}={','.join(sort_ip_addresses(cached))}", + ] + ) elif cached := dashboard.dns_cache.get_cached(entry.name, now): - addresses.extend(sort_ip_addresses(cached)) - dns_cache_entries[entry.name] = set(cached) + normalized = entry.name.rstrip(".").lower() + cache_args.extend( + [ + "--dns-lookup-cache", + f"{normalized}={','.join(sort_ip_addresses(cached))}", + ] + ) - # Build cache arguments to pass to CLI (normalize hostnames) - for hostname, addrs in dns_cache_entries.items(): - normalized = hostname.rstrip(".").lower() - cache_args.extend( - ["--dns-lookup-cache", f"{normalized}={','.join(sorted(addrs))}"] - ) - for hostname, addrs in mdns_cache_entries.items(): - normalized = hostname.rstrip(".").lower() - cache_args.extend( - ["--mdns-lookup-cache", f"{normalized}={','.join(sorted(addrs))}"] - ) - - if not addresses: - # If no cached address was found, use the port directly - # The CLI will do the resolution with the cache hints we provide - addresses = [port] - - device_args: list[str] = [ - arg for address in addresses for arg in ("--device", address) - ] - - return [*DASHBOARD_COMMAND, *args, config_file, *device_args, *cache_args] + return [*DASHBOARD_COMMAND, *args, config_file, "--device", port, *cache_args] class EsphomeLogsHandler(EsphomePortCommandWebSocket): From 7fb8c84d6a3913d7a881f8d4322b104422ce4f83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:47:09 -0500 Subject: [PATCH 1045/1626] cleanup --- esphome/__main__.py | 9 +++- esphome/dashboard/web_server.py | 82 ++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 15c29e6cdf..4d2da21e7c 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -136,7 +136,14 @@ def choose_upload_log_host( (show_ota and "ota" in CORE.config) or (show_api and "api" in CORE.config) ): - resolved.append(CORE.address) + # Check if we have cached addresses for CORE.address + if CORE.address_cache and ( + cached := CORE.address_cache.get_addresses(CORE.address) + ): + _LOGGER.debug("Using cached addresses for OTA: %s", cached) + resolved.extend(cached) + else: + resolved.append(CORE.address) elif show_mqtt and has_mqtt_logging(): resolved.append("MQTT") else: diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 71b10aaef1..9637bc6b88 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -337,44 +337,74 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): and "api" in entry.loaded_integrations ): now = time.monotonic() + _LOGGER.debug( + "Building cache for %s (address=%s, name=%s)", + configuration, + entry.address, + entry.name, + ) # Build cache entries for any cached addresses we have # First check entry.address (use_address) - if (use_address := entry.address) and ( - cached := dashboard.dns_cache.get_cached(use_address, now) - ): - normalized = use_address.rstrip(".").lower() - cache_args.extend( - [ - "--dns-lookup-cache", - f"{normalized}={','.join(sort_ip_addresses(cached))}", - ] - ) + if use_address := entry.address: + if use_address.endswith(".local"): + # Check mDNS cache for .local addresses + if mdns := dashboard.mdns_status: + cached = mdns.get_cached_addresses(use_address) + _LOGGER.debug( + "mDNS cache lookup for address %s: %s", use_address, cached + ) + if cached: + normalized = use_address.rstrip(".").lower() + cache_args.extend( + [ + "--mdns-lookup-cache", + f"{normalized}={','.join(sort_ip_addresses(cached))}", + ] + ) + else: + # Check DNS cache for non-.local addresses + cached = dashboard.dns_cache.get_cached(use_address, now) + _LOGGER.debug( + "DNS cache lookup for address %s: %s", use_address, cached + ) + if cached: + normalized = use_address.rstrip(".").lower() + cache_args.extend( + [ + "--dns-lookup-cache", + f"{normalized}={','.join(sort_ip_addresses(cached))}", + ] + ) # Also check entry.name for cache entries - if entry.name: - if entry.name.endswith(".local"): - # Check mDNS cache (zeroconf) - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(entry.name) - ): - normalized = entry.name.rstrip(".").lower() + # For mDNS devices, entry.name typically doesn't have .local suffix + # but we should check both with and without .local + if ( + entry.name and not use_address + ): # Only if we didn't already check address + # Try mDNS cache with .local suffix + mdns_name = ( + f"{entry.name}.local" + if not entry.name.endswith(".local") + else entry.name + ) + if mdns := dashboard.mdns_status: + cached = mdns.get_cached_addresses(mdns_name) + _LOGGER.debug("mDNS cache lookup for %s: %s", mdns_name, cached) + if cached: + normalized = mdns_name.rstrip(".").lower() cache_args.extend( [ "--mdns-lookup-cache", f"{normalized}={','.join(sort_ip_addresses(cached))}", ] ) - elif cached := dashboard.dns_cache.get_cached(entry.name, now): - normalized = entry.name.rstrip(".").lower() - cache_args.extend( - [ - "--dns-lookup-cache", - f"{normalized}={','.join(sort_ip_addresses(cached))}", - ] - ) - return [*DASHBOARD_COMMAND, *args, config_file, "--device", port, *cache_args] + # Cache arguments must come before the subcommand + cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port] + _LOGGER.debug("Built command: %s", cmd) + return cmd class EsphomeLogsHandler(EsphomePortCommandWebSocket): From 817dba3d5348a4c8ccbd768853bfbdae441affef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:50:28 -0500 Subject: [PATCH 1046/1626] preen --- esphome/espota2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index f808d558d7..99c91d94e2 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -317,7 +317,7 @@ def run_ota_impl_( try: # Resolve all hosts at once for parallel DNS resolution res = resolve_ip_address( - remote_host, remote_port, address_cache=getattr(CORE, "address_cache", None) + remote_host, remote_port, address_cache=CORE.address_cache ) except EsphomeError as err: _LOGGER.error( From 158236f819d1655ff794111e11aa42a8c65bfac6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:51:44 -0500 Subject: [PATCH 1047/1626] preen --- tests/unit_tests/test_address_cache.py | 260 +++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 tests/unit_tests/test_address_cache.py diff --git a/tests/unit_tests/test_address_cache.py b/tests/unit_tests/test_address_cache.py new file mode 100644 index 0000000000..f02c129256 --- /dev/null +++ b/tests/unit_tests/test_address_cache.py @@ -0,0 +1,260 @@ +"""Tests for the address_cache module.""" + +from esphome.address_cache import AddressCache, normalize_hostname + + +class TestNormalizeHostname: + """Test the normalize_hostname function.""" + + def test_normalize_simple_hostname(self): + """Test normalizing a simple hostname.""" + assert normalize_hostname("device") == "device" + assert normalize_hostname("device.local") == "device.local" + assert normalize_hostname("server.example.com") == "server.example.com" + + def test_normalize_removes_trailing_dots(self): + """Test that trailing dots are removed.""" + assert normalize_hostname("device.") == "device" + assert normalize_hostname("device.local.") == "device.local" + assert normalize_hostname("server.example.com.") == "server.example.com" + assert normalize_hostname("device...") == "device" + + def test_normalize_converts_to_lowercase(self): + """Test that hostnames are converted to lowercase.""" + assert normalize_hostname("DEVICE") == "device" + assert normalize_hostname("Device.Local") == "device.local" + assert normalize_hostname("Server.Example.COM") == "server.example.com" + + def test_normalize_combined(self): + """Test combination of trailing dots and case conversion.""" + assert normalize_hostname("DEVICE.LOCAL.") == "device.local" + assert normalize_hostname("Server.Example.COM...") == "server.example.com" + + +class TestAddressCache: + """Test the AddressCache class.""" + + def test_init_empty(self): + """Test initialization with empty caches.""" + cache = AddressCache() + assert cache.mdns_cache == {} + assert cache.dns_cache == {} + assert not cache.has_cache() + + def test_init_with_caches(self): + """Test initialization with provided caches.""" + mdns_cache = {"device.local": ["192.168.1.10"]} + dns_cache = {"server.com": ["10.0.0.1"]} + cache = AddressCache(mdns_cache=mdns_cache, dns_cache=dns_cache) + assert cache.mdns_cache == mdns_cache + assert cache.dns_cache == dns_cache + assert cache.has_cache() + + def test_get_mdns_addresses(self): + """Test getting mDNS addresses.""" + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10", "192.168.1.11"]} + ) + + # Direct lookup + assert cache.get_mdns_addresses("device.local") == [ + "192.168.1.10", + "192.168.1.11", + ] + + # Case insensitive lookup + assert cache.get_mdns_addresses("Device.Local") == [ + "192.168.1.10", + "192.168.1.11", + ] + + # With trailing dot + assert cache.get_mdns_addresses("device.local.") == [ + "192.168.1.10", + "192.168.1.11", + ] + + # Not found + assert cache.get_mdns_addresses("unknown.local") is None + + def test_get_dns_addresses(self): + """Test getting DNS addresses.""" + cache = AddressCache(dns_cache={"server.com": ["10.0.0.1", "10.0.0.2"]}) + + # Direct lookup + assert cache.get_dns_addresses("server.com") == ["10.0.0.1", "10.0.0.2"] + + # Case insensitive lookup + assert cache.get_dns_addresses("Server.COM") == ["10.0.0.1", "10.0.0.2"] + + # With trailing dot + assert cache.get_dns_addresses("server.com.") == ["10.0.0.1", "10.0.0.2"] + + # Not found + assert cache.get_dns_addresses("unknown.com") is None + + def test_get_addresses_auto_detection(self): + """Test automatic cache selection based on hostname.""" + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + + # Should use mDNS cache for .local domains + assert cache.get_addresses("device.local") == ["192.168.1.10"] + assert cache.get_addresses("device.local.") == ["192.168.1.10"] + assert cache.get_addresses("Device.Local") == ["192.168.1.10"] + + # Should use DNS cache for non-.local domains + assert cache.get_addresses("server.com") == ["10.0.0.1"] + assert cache.get_addresses("server.com.") == ["10.0.0.1"] + assert cache.get_addresses("Server.COM") == ["10.0.0.1"] + + # Not found + assert cache.get_addresses("unknown.local") is None + assert cache.get_addresses("unknown.com") is None + + def test_has_cache(self): + """Test checking if cache has entries.""" + # Empty cache + cache = AddressCache() + assert not cache.has_cache() + + # Only mDNS cache + cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10"]}) + assert cache.has_cache() + + # Only DNS cache + cache = AddressCache(dns_cache={"server.com": ["10.0.0.1"]}) + assert cache.has_cache() + + # Both caches + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + assert cache.has_cache() + + def test_from_cli_args_empty(self): + """Test creating cache from empty CLI arguments.""" + cache = AddressCache.from_cli_args([], []) + assert cache.mdns_cache == {} + assert cache.dns_cache == {} + + def test_from_cli_args_single_entry(self): + """Test creating cache from single CLI argument.""" + mdns_args = ["device.local=192.168.1.10"] + dns_args = ["server.com=10.0.0.1"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1"]} + + def test_from_cli_args_multiple_ips(self): + """Test creating cache with multiple IPs per host.""" + mdns_args = ["device.local=192.168.1.10,192.168.1.11"] + dns_args = ["server.com=10.0.0.1,10.0.0.2,10.0.0.3"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2", "10.0.0.3"]} + + def test_from_cli_args_multiple_entries(self): + """Test creating cache with multiple host entries.""" + mdns_args = [ + "device1.local=192.168.1.10", + "device2.local=192.168.1.20,192.168.1.21", + ] + dns_args = ["server1.com=10.0.0.1", "server2.com=10.0.0.2"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == { + "device1.local": ["192.168.1.10"], + "device2.local": ["192.168.1.20", "192.168.1.21"], + } + assert cache.dns_cache == { + "server1.com": ["10.0.0.1"], + "server2.com": ["10.0.0.2"], + } + + def test_from_cli_args_normalization(self): + """Test that CLI arguments are normalized.""" + mdns_args = ["Device1.Local.=192.168.1.10", "DEVICE2.LOCAL=192.168.1.20"] + dns_args = ["Server1.COM.=10.0.0.1", "SERVER2.com=10.0.0.2"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + # Hostnames should be normalized (lowercase, no trailing dots) + assert cache.mdns_cache == { + "device1.local": ["192.168.1.10"], + "device2.local": ["192.168.1.20"], + } + assert cache.dns_cache == { + "server1.com": ["10.0.0.1"], + "server2.com": ["10.0.0.2"], + } + + def test_from_cli_args_whitespace_handling(self): + """Test that whitespace in IPs is handled.""" + mdns_args = ["device.local= 192.168.1.10 , 192.168.1.11 "] + dns_args = ["server.com= 10.0.0.1 , 10.0.0.2 "] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2"]} + + def test_from_cli_args_invalid_format(self, caplog): + """Test handling of invalid argument format.""" + mdns_args = ["invalid_format", "device.local=192.168.1.10"] + dns_args = ["server.com=10.0.0.1", "also_invalid"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + # Valid entries should still be processed + assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1"]} + + # Check that warnings were logged for invalid entries + assert "Invalid cache format: invalid_format" in caplog.text + assert "Invalid cache format: also_invalid" in caplog.text + + def test_from_cli_args_ipv6(self): + """Test handling of IPv6 addresses.""" + mdns_args = ["device.local=fe80::1,2001:db8::1"] + dns_args = ["server.com=2001:db8::2,::1"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["fe80::1", "2001:db8::1"]} + assert cache.dns_cache == {"server.com": ["2001:db8::2", "::1"]} + + def test_logging_output(self, caplog): + """Test that appropriate debug logging occurs.""" + import logging + + caplog.set_level(logging.DEBUG) + + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + + # Test successful lookups log at debug level + result = cache.get_mdns_addresses("device.local") + assert result == ["192.168.1.10"] + assert "Using mDNS cache for device.local" in caplog.text + + caplog.clear() + result = cache.get_dns_addresses("server.com") + assert result == ["10.0.0.1"] + assert "Using DNS cache for server.com" in caplog.text + + # Test that failed lookups don't log + caplog.clear() + result = cache.get_mdns_addresses("unknown.local") + assert result is None + assert "Using mDNS cache" not in caplog.text From 23d82f8368f38999577d7e8bb586a8f6aa0c6f02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:54:06 -0500 Subject: [PATCH 1048/1626] preen --- esphome/dashboard/dns.py | 6 ++++-- esphome/dashboard/status/mdns.py | 5 ++++- esphome/dashboard/web_server.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/dashboard/dns.py b/esphome/dashboard/dns.py index b94d816c74..58867f7bc1 100644 --- a/esphome/dashboard/dns.py +++ b/esphome/dashboard/dns.py @@ -28,8 +28,10 @@ class DNSCache: self._cache: dict[str, tuple[float, list[str] | Exception]] = {} self._ttl = ttl - def get_cached(self, hostname: str, now_monotonic: float) -> list[str] | None: - """Get cached address without triggering resolution. + def get_cached_addresses( + self, hostname: str, now_monotonic: float + ) -> list[str] | None: + """Get cached addresses without triggering resolution. Returns None if not in cache, list of addresses if found. """ diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index 576bade7cd..c1bf1ce21f 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import logging +import time import typing from zeroconf import AddressResolver, IPVersion @@ -67,7 +68,9 @@ class MDNSStatus: # Try to load from zeroconf cache without triggering resolution info = AddressResolver(f"{base_name}.local.") - if info.load_from_cache(self.aiozc.zeroconf): + # Pass current time in milliseconds for cache expiry checking + now = time.time() * 1000 + if info.load_from_cache(self.aiozc.zeroconf, now): return info.parsed_scoped_addresses(IPVersion.All) return None diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 9637bc6b88..90a7cab3b5 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -364,7 +364,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): ) else: # Check DNS cache for non-.local addresses - cached = dashboard.dns_cache.get_cached(use_address, now) + cached = dashboard.dns_cache.get_cached_addresses(use_address, now) _LOGGER.debug( "DNS cache lookup for address %s: %s", use_address, cached ) From b9bf81fffc4c0fe3b14e1ec7c74485848461cda6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 18:57:35 -0500 Subject: [PATCH 1049/1626] fixes --- esphome/__main__.py | 6 ++++++ esphome/dashboard/status/mdns.py | 15 +++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 4d2da21e7c..7d32d1f119 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1167,6 +1167,12 @@ def run_esphome(argv): # Store cache in CORE for access throughout the application CORE.address_cache = address_cache + if address_cache.has_cache(): + _LOGGER.debug( + "Address cache initialized with %d mDNS and %d DNS entries", + len(address_cache.mdns_cache), + len(address_cache.dns_cache), + ) # Override log level if verbose is set if args.verbose: diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index c1bf1ce21f..989517e1c3 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio import logging -import time import typing from zeroconf import AddressResolver, IPVersion @@ -60,6 +59,7 @@ class MDNSStatus: Returns None if not in cache or no zeroconf available. """ if not self.aiozc: + _LOGGER.debug("No zeroconf instance available for %s", host_name) return None # Normalize hostname and get the base name @@ -67,11 +67,14 @@ class MDNSStatus: base_name = normalized.partition(".")[0] # Try to load from zeroconf cache without triggering resolution - info = AddressResolver(f"{base_name}.local.") - # Pass current time in milliseconds for cache expiry checking - now = time.time() * 1000 - if info.load_from_cache(self.aiozc.zeroconf, now): - return info.parsed_scoped_addresses(IPVersion.All) + resolver_name = f"{base_name}.local." + info = AddressResolver(resolver_name) + # Let zeroconf use its own current time for cache checking + if info.load_from_cache(self.aiozc.zeroconf): + addresses = info.parsed_scoped_addresses(IPVersion.All) + _LOGGER.debug("Found %s in zeroconf cache: %s", resolver_name, addresses) + return addresses + _LOGGER.debug("Not found in zeroconf cache: %s", resolver_name) return None async def async_refresh_hosts(self) -> None: From 7dcedbae093317cd1b37d9f3291bd17c7e68f645 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:00:31 -0500 Subject: [PATCH 1050/1626] fixes --- tests/unit_tests/test_address_cache.py | 453 ++++++++++++++----------- 1 file changed, 249 insertions(+), 204 deletions(-) diff --git a/tests/unit_tests/test_address_cache.py b/tests/unit_tests/test_address_cache.py index f02c129256..de43830d53 100644 --- a/tests/unit_tests/test_address_cache.py +++ b/tests/unit_tests/test_address_cache.py @@ -1,260 +1,305 @@ """Tests for the address_cache module.""" +from __future__ import annotations + +import logging + +import pytest +from pytest import LogCaptureFixture + from esphome.address_cache import AddressCache, normalize_hostname -class TestNormalizeHostname: - """Test the normalize_hostname function.""" - - def test_normalize_simple_hostname(self): - """Test normalizing a simple hostname.""" - assert normalize_hostname("device") == "device" - assert normalize_hostname("device.local") == "device.local" - assert normalize_hostname("server.example.com") == "server.example.com" - - def test_normalize_removes_trailing_dots(self): - """Test that trailing dots are removed.""" - assert normalize_hostname("device.") == "device" - assert normalize_hostname("device.local.") == "device.local" - assert normalize_hostname("server.example.com.") == "server.example.com" - assert normalize_hostname("device...") == "device" - - def test_normalize_converts_to_lowercase(self): - """Test that hostnames are converted to lowercase.""" - assert normalize_hostname("DEVICE") == "device" - assert normalize_hostname("Device.Local") == "device.local" - assert normalize_hostname("Server.Example.COM") == "server.example.com" - - def test_normalize_combined(self): - """Test combination of trailing dots and case conversion.""" - assert normalize_hostname("DEVICE.LOCAL.") == "device.local" - assert normalize_hostname("Server.Example.COM...") == "server.example.com" +def test_normalize_simple_hostname() -> None: + """Test normalizing a simple hostname.""" + assert normalize_hostname("device") == "device" + assert normalize_hostname("device.local") == "device.local" + assert normalize_hostname("server.example.com") == "server.example.com" -class TestAddressCache: - """Test the AddressCache class.""" +def test_normalize_removes_trailing_dots() -> None: + """Test that trailing dots are removed.""" + assert normalize_hostname("device.") == "device" + assert normalize_hostname("device.local.") == "device.local" + assert normalize_hostname("server.example.com.") == "server.example.com" + assert normalize_hostname("device...") == "device" - def test_init_empty(self): - """Test initialization with empty caches.""" - cache = AddressCache() - assert cache.mdns_cache == {} - assert cache.dns_cache == {} - assert not cache.has_cache() - def test_init_with_caches(self): - """Test initialization with provided caches.""" - mdns_cache = {"device.local": ["192.168.1.10"]} - dns_cache = {"server.com": ["10.0.0.1"]} - cache = AddressCache(mdns_cache=mdns_cache, dns_cache=dns_cache) - assert cache.mdns_cache == mdns_cache - assert cache.dns_cache == dns_cache - assert cache.has_cache() +def test_normalize_converts_to_lowercase() -> None: + """Test that hostnames are converted to lowercase.""" + assert normalize_hostname("DEVICE") == "device" + assert normalize_hostname("Device.Local") == "device.local" + assert normalize_hostname("Server.Example.COM") == "server.example.com" - def test_get_mdns_addresses(self): - """Test getting mDNS addresses.""" - cache = AddressCache( - mdns_cache={"device.local": ["192.168.1.10", "192.168.1.11"]} - ) - # Direct lookup - assert cache.get_mdns_addresses("device.local") == [ - "192.168.1.10", - "192.168.1.11", - ] +def test_normalize_combined() -> None: + """Test combination of trailing dots and case conversion.""" + assert normalize_hostname("DEVICE.LOCAL.") == "device.local" + assert normalize_hostname("Server.Example.COM...") == "server.example.com" - # Case insensitive lookup - assert cache.get_mdns_addresses("Device.Local") == [ - "192.168.1.10", - "192.168.1.11", - ] - # With trailing dot - assert cache.get_mdns_addresses("device.local.") == [ - "192.168.1.10", - "192.168.1.11", - ] +def test_init_empty() -> None: + """Test initialization with empty caches.""" + cache = AddressCache() + assert cache.mdns_cache == {} + assert cache.dns_cache == {} + assert not cache.has_cache() - # Not found - assert cache.get_mdns_addresses("unknown.local") is None - def test_get_dns_addresses(self): - """Test getting DNS addresses.""" - cache = AddressCache(dns_cache={"server.com": ["10.0.0.1", "10.0.0.2"]}) +def test_init_with_caches() -> None: + """Test initialization with provided caches.""" + mdns_cache: dict[str, list[str]] = {"device.local": ["192.168.1.10"]} + dns_cache: dict[str, list[str]] = {"server.com": ["10.0.0.1"]} + cache = AddressCache(mdns_cache=mdns_cache, dns_cache=dns_cache) + assert cache.mdns_cache == mdns_cache + assert cache.dns_cache == dns_cache + assert cache.has_cache() - # Direct lookup - assert cache.get_dns_addresses("server.com") == ["10.0.0.1", "10.0.0.2"] - # Case insensitive lookup - assert cache.get_dns_addresses("Server.COM") == ["10.0.0.1", "10.0.0.2"] +def test_get_mdns_addresses() -> None: + """Test getting mDNS addresses.""" + cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10", "192.168.1.11"]}) - # With trailing dot - assert cache.get_dns_addresses("server.com.") == ["10.0.0.1", "10.0.0.2"] + # Direct lookup + assert cache.get_mdns_addresses("device.local") == [ + "192.168.1.10", + "192.168.1.11", + ] - # Not found - assert cache.get_dns_addresses("unknown.com") is None + # Case insensitive lookup + assert cache.get_mdns_addresses("Device.Local") == [ + "192.168.1.10", + "192.168.1.11", + ] - def test_get_addresses_auto_detection(self): - """Test automatic cache selection based on hostname.""" - cache = AddressCache( - mdns_cache={"device.local": ["192.168.1.10"]}, - dns_cache={"server.com": ["10.0.0.1"]}, - ) + # With trailing dot + assert cache.get_mdns_addresses("device.local.") == [ + "192.168.1.10", + "192.168.1.11", + ] - # Should use mDNS cache for .local domains - assert cache.get_addresses("device.local") == ["192.168.1.10"] - assert cache.get_addresses("device.local.") == ["192.168.1.10"] - assert cache.get_addresses("Device.Local") == ["192.168.1.10"] + # Not found + assert cache.get_mdns_addresses("unknown.local") is None - # Should use DNS cache for non-.local domains - assert cache.get_addresses("server.com") == ["10.0.0.1"] - assert cache.get_addresses("server.com.") == ["10.0.0.1"] - assert cache.get_addresses("Server.COM") == ["10.0.0.1"] - # Not found - assert cache.get_addresses("unknown.local") is None - assert cache.get_addresses("unknown.com") is None +def test_get_dns_addresses() -> None: + """Test getting DNS addresses.""" + cache = AddressCache(dns_cache={"server.com": ["10.0.0.1", "10.0.0.2"]}) - def test_has_cache(self): - """Test checking if cache has entries.""" - # Empty cache - cache = AddressCache() - assert not cache.has_cache() + # Direct lookup + assert cache.get_dns_addresses("server.com") == ["10.0.0.1", "10.0.0.2"] - # Only mDNS cache - cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10"]}) - assert cache.has_cache() + # Case insensitive lookup + assert cache.get_dns_addresses("Server.COM") == ["10.0.0.1", "10.0.0.2"] - # Only DNS cache - cache = AddressCache(dns_cache={"server.com": ["10.0.0.1"]}) - assert cache.has_cache() + # With trailing dot + assert cache.get_dns_addresses("server.com.") == ["10.0.0.1", "10.0.0.2"] - # Both caches - cache = AddressCache( - mdns_cache={"device.local": ["192.168.1.10"]}, - dns_cache={"server.com": ["10.0.0.1"]}, - ) - assert cache.has_cache() + # Not found + assert cache.get_dns_addresses("unknown.com") is None - def test_from_cli_args_empty(self): - """Test creating cache from empty CLI arguments.""" - cache = AddressCache.from_cli_args([], []) - assert cache.mdns_cache == {} - assert cache.dns_cache == {} - def test_from_cli_args_single_entry(self): - """Test creating cache from single CLI argument.""" - mdns_args = ["device.local=192.168.1.10"] - dns_args = ["server.com=10.0.0.1"] +def test_get_addresses_auto_detection() -> None: + """Test automatic cache selection based on hostname.""" + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) - cache = AddressCache.from_cli_args(mdns_args, dns_args) + # Should use mDNS cache for .local domains + assert cache.get_addresses("device.local") == ["192.168.1.10"] + assert cache.get_addresses("device.local.") == ["192.168.1.10"] + assert cache.get_addresses("Device.Local") == ["192.168.1.10"] - assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} - assert cache.dns_cache == {"server.com": ["10.0.0.1"]} + # Should use DNS cache for non-.local domains + assert cache.get_addresses("server.com") == ["10.0.0.1"] + assert cache.get_addresses("server.com.") == ["10.0.0.1"] + assert cache.get_addresses("Server.COM") == ["10.0.0.1"] - def test_from_cli_args_multiple_ips(self): - """Test creating cache with multiple IPs per host.""" - mdns_args = ["device.local=192.168.1.10,192.168.1.11"] - dns_args = ["server.com=10.0.0.1,10.0.0.2,10.0.0.3"] + # Not found + assert cache.get_addresses("unknown.local") is None + assert cache.get_addresses("unknown.com") is None - cache = AddressCache.from_cli_args(mdns_args, dns_args) - assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} - assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2", "10.0.0.3"]} +def test_has_cache() -> None: + """Test checking if cache has entries.""" + # Empty cache + cache = AddressCache() + assert not cache.has_cache() - def test_from_cli_args_multiple_entries(self): - """Test creating cache with multiple host entries.""" - mdns_args = [ - "device1.local=192.168.1.10", - "device2.local=192.168.1.20,192.168.1.21", - ] - dns_args = ["server1.com=10.0.0.1", "server2.com=10.0.0.2"] + # Only mDNS cache + cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10"]}) + assert cache.has_cache() - cache = AddressCache.from_cli_args(mdns_args, dns_args) + # Only DNS cache + cache = AddressCache(dns_cache={"server.com": ["10.0.0.1"]}) + assert cache.has_cache() - assert cache.mdns_cache == { - "device1.local": ["192.168.1.10"], - "device2.local": ["192.168.1.20", "192.168.1.21"], - } - assert cache.dns_cache == { - "server1.com": ["10.0.0.1"], - "server2.com": ["10.0.0.2"], - } + # Both caches + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + assert cache.has_cache() - def test_from_cli_args_normalization(self): - """Test that CLI arguments are normalized.""" - mdns_args = ["Device1.Local.=192.168.1.10", "DEVICE2.LOCAL=192.168.1.20"] - dns_args = ["Server1.COM.=10.0.0.1", "SERVER2.com=10.0.0.2"] - cache = AddressCache.from_cli_args(mdns_args, dns_args) +def test_from_cli_args_empty() -> None: + """Test creating cache from empty CLI arguments.""" + cache = AddressCache.from_cli_args([], []) + assert cache.mdns_cache == {} + assert cache.dns_cache == {} - # Hostnames should be normalized (lowercase, no trailing dots) - assert cache.mdns_cache == { - "device1.local": ["192.168.1.10"], - "device2.local": ["192.168.1.20"], - } - assert cache.dns_cache == { - "server1.com": ["10.0.0.1"], - "server2.com": ["10.0.0.2"], - } - def test_from_cli_args_whitespace_handling(self): - """Test that whitespace in IPs is handled.""" - mdns_args = ["device.local= 192.168.1.10 , 192.168.1.11 "] - dns_args = ["server.com= 10.0.0.1 , 10.0.0.2 "] +def test_from_cli_args_single_entry() -> None: + """Test creating cache from single CLI argument.""" + mdns_args: list[str] = ["device.local=192.168.1.10"] + dns_args: list[str] = ["server.com=10.0.0.1"] - cache = AddressCache.from_cli_args(mdns_args, dns_args) + cache = AddressCache.from_cli_args(mdns_args, dns_args) - assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} - assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2"]} + assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1"]} - def test_from_cli_args_invalid_format(self, caplog): - """Test handling of invalid argument format.""" - mdns_args = ["invalid_format", "device.local=192.168.1.10"] - dns_args = ["server.com=10.0.0.1", "also_invalid"] - cache = AddressCache.from_cli_args(mdns_args, dns_args) +def test_from_cli_args_multiple_ips() -> None: + """Test creating cache with multiple IPs per host.""" + mdns_args: list[str] = ["device.local=192.168.1.10,192.168.1.11"] + dns_args: list[str] = ["server.com=10.0.0.1,10.0.0.2,10.0.0.3"] - # Valid entries should still be processed - assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} - assert cache.dns_cache == {"server.com": ["10.0.0.1"]} + cache = AddressCache.from_cli_args(mdns_args, dns_args) - # Check that warnings were logged for invalid entries - assert "Invalid cache format: invalid_format" in caplog.text - assert "Invalid cache format: also_invalid" in caplog.text + assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2", "10.0.0.3"]} - def test_from_cli_args_ipv6(self): - """Test handling of IPv6 addresses.""" - mdns_args = ["device.local=fe80::1,2001:db8::1"] - dns_args = ["server.com=2001:db8::2,::1"] - cache = AddressCache.from_cli_args(mdns_args, dns_args) +def test_from_cli_args_multiple_entries() -> None: + """Test creating cache with multiple host entries.""" + mdns_args: list[str] = [ + "device1.local=192.168.1.10", + "device2.local=192.168.1.20,192.168.1.21", + ] + dns_args: list[str] = ["server1.com=10.0.0.1", "server2.com=10.0.0.2"] - assert cache.mdns_cache == {"device.local": ["fe80::1", "2001:db8::1"]} - assert cache.dns_cache == {"server.com": ["2001:db8::2", "::1"]} + cache = AddressCache.from_cli_args(mdns_args, dns_args) - def test_logging_output(self, caplog): - """Test that appropriate debug logging occurs.""" - import logging + assert cache.mdns_cache == { + "device1.local": ["192.168.1.10"], + "device2.local": ["192.168.1.20", "192.168.1.21"], + } + assert cache.dns_cache == { + "server1.com": ["10.0.0.1"], + "server2.com": ["10.0.0.2"], + } - caplog.set_level(logging.DEBUG) - cache = AddressCache( - mdns_cache={"device.local": ["192.168.1.10"]}, - dns_cache={"server.com": ["10.0.0.1"]}, - ) +def test_from_cli_args_normalization() -> None: + """Test that CLI arguments are normalized.""" + mdns_args: list[str] = ["Device1.Local.=192.168.1.10", "DEVICE2.LOCAL=192.168.1.20"] + dns_args: list[str] = ["Server1.COM.=10.0.0.1", "SERVER2.com=10.0.0.2"] - # Test successful lookups log at debug level - result = cache.get_mdns_addresses("device.local") - assert result == ["192.168.1.10"] - assert "Using mDNS cache for device.local" in caplog.text + cache = AddressCache.from_cli_args(mdns_args, dns_args) - caplog.clear() - result = cache.get_dns_addresses("server.com") - assert result == ["10.0.0.1"] - assert "Using DNS cache for server.com" in caplog.text + # Hostnames should be normalized (lowercase, no trailing dots) + assert cache.mdns_cache == { + "device1.local": ["192.168.1.10"], + "device2.local": ["192.168.1.20"], + } + assert cache.dns_cache == { + "server1.com": ["10.0.0.1"], + "server2.com": ["10.0.0.2"], + } - # Test that failed lookups don't log - caplog.clear() - result = cache.get_mdns_addresses("unknown.local") - assert result is None - assert "Using mDNS cache" not in caplog.text + +def test_from_cli_args_whitespace_handling() -> None: + """Test that whitespace in IPs is handled.""" + mdns_args: list[str] = ["device.local= 192.168.1.10 , 192.168.1.11 "] + dns_args: list[str] = ["server.com= 10.0.0.1 , 10.0.0.2 "] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2"]} + + +def test_from_cli_args_invalid_format(caplog: LogCaptureFixture) -> None: + """Test handling of invalid argument format.""" + mdns_args: list[str] = ["invalid_format", "device.local=192.168.1.10"] + dns_args: list[str] = ["server.com=10.0.0.1", "also_invalid"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + # Valid entries should still be processed + assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1"]} + + # Check that warnings were logged for invalid entries + assert "Invalid cache format: invalid_format" in caplog.text + assert "Invalid cache format: also_invalid" in caplog.text + + +def test_from_cli_args_ipv6() -> None: + """Test handling of IPv6 addresses.""" + mdns_args: list[str] = ["device.local=fe80::1,2001:db8::1"] + dns_args: list[str] = ["server.com=2001:db8::2,::1"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["fe80::1", "2001:db8::1"]} + assert cache.dns_cache == {"server.com": ["2001:db8::2", "::1"]} + + +def test_logging_output(caplog: LogCaptureFixture) -> None: + """Test that appropriate debug logging occurs.""" + caplog.set_level(logging.DEBUG) + + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + + # Test successful lookups log at debug level + result: list[str] | None = cache.get_mdns_addresses("device.local") + assert result == ["192.168.1.10"] + assert "Using mDNS cache for device.local" in caplog.text + + caplog.clear() + result = cache.get_dns_addresses("server.com") + assert result == ["10.0.0.1"] + assert "Using DNS cache for server.com" in caplog.text + + # Test that failed lookups don't log + caplog.clear() + result = cache.get_mdns_addresses("unknown.local") + assert result is None + assert "Using mDNS cache" not in caplog.text + + +@pytest.mark.parametrize( + "hostname,expected", + [ + ("test.local", "test.local"), + ("Test.Local.", "test.local"), + ("TEST.LOCAL...", "test.local"), + ("example.com", "example.com"), + ("EXAMPLE.COM.", "example.com"), + ], +) +def test_normalize_hostname_parametrized(hostname: str, expected: str) -> None: + """Test hostname normalization with various inputs.""" + assert normalize_hostname(hostname) == expected + + +@pytest.mark.parametrize( + "mdns_arg,expected", + [ + ("host=1.2.3.4", {"host": ["1.2.3.4"]}), + ("Host.Local=1.2.3.4,5.6.7.8", {"host.local": ["1.2.3.4", "5.6.7.8"]}), + ("HOST.LOCAL.=::1", {"host.local": ["::1"]}), + ], +) +def test_parse_cache_args_parametrized( + mdns_arg: str, expected: dict[str, list[str]] +) -> None: + """Test parsing of cache arguments with various formats.""" + cache = AddressCache.from_cli_args([mdns_arg], []) + assert cache.mdns_cache == expected From fd9df3a62901668db1496ade0c0e33ee5c1b47e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:02:33 -0500 Subject: [PATCH 1051/1626] fixes --- tests/unit_tests/test_main.py | 44 ----------------------------------- 1 file changed, 44 deletions(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index a00d8ce43a..ce19f18a1f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -581,47 +581,3 @@ def test_address_cache_get_methods() -> None: assert cache.has_cache() is True empty_cache = AddressCache() assert empty_cache.has_cache() is False - - -def test_address_cache_hostname_normalization() -> None: - """Test that hostnames are normalized for cache lookups.""" - from esphome.address_cache import normalize_hostname - - # Test normalize_hostname function - assert normalize_hostname("test.local") == "test.local" - assert normalize_hostname("test.local.") == "test.local" - assert normalize_hostname("TEST.LOCAL") == "test.local" - assert normalize_hostname("TeSt.LoCaL.") == "test.local" - assert normalize_hostname("example.com.") == "example.com" - - # Test cache with normalized lookups - cache = AddressCache( - mdns_cache={"test.local": ["192.168.1.1"]}, - dns_cache={"example.com": ["10.0.0.1"]}, - ) - - # Should find with different case and trailing dots - assert cache.get_mdns_addresses("test.local") == ["192.168.1.1"] - assert cache.get_mdns_addresses("TEST.LOCAL") == ["192.168.1.1"] - assert cache.get_mdns_addresses("test.local.") == ["192.168.1.1"] - assert cache.get_mdns_addresses("TEST.LOCAL.") == ["192.168.1.1"] - - assert cache.get_dns_addresses("example.com") == ["10.0.0.1"] - assert cache.get_dns_addresses("EXAMPLE.COM") == ["10.0.0.1"] - assert cache.get_dns_addresses("example.com.") == ["10.0.0.1"] - assert cache.get_dns_addresses("EXAMPLE.COM.") == ["10.0.0.1"] - - # Test from_cli_args also normalizes - cache = AddressCache.from_cli_args( - ["TEST.LOCAL.=192.168.1.1"], ["EXAMPLE.COM.=10.0.0.1"] - ) - - # Should store as normalized - assert "test.local" in cache.mdns_cache - assert "example.com" in cache.dns_cache - - # Should find with any variation - assert cache.get_addresses("test.local") == ["192.168.1.1"] - assert cache.get_addresses("TEST.LOCAL.") == ["192.168.1.1"] - assert cache.get_addresses("example.com") == ["10.0.0.1"] - assert cache.get_addresses("EXAMPLE.COM.") == ["10.0.0.1"] From b416f7c1fb48d381544f294622f45046d56a096d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:05:31 -0500 Subject: [PATCH 1052/1626] fixes --- tests/unit_tests/test_helpers.py | 7 +--- tests/unit_tests/test_main.py | 71 -------------------------------- 2 files changed, 1 insertion(+), 77 deletions(-) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 631d6a878e..0dc782e87e 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -8,6 +8,7 @@ from hypothesis.strategies import ip_addresses import pytest from esphome import helpers +from esphome.address_cache import AddressCache from esphome.core import EsphomeError @@ -598,8 +599,6 @@ def test_resolve_ip_address_sorting() -> None: def test_resolve_ip_address_with_cache() -> None: """Test that the cache is used when provided.""" - from esphome.address_cache import AddressCache - cache = AddressCache( mdns_cache={"test.local": ["192.168.1.100", "192.168.1.101"]}, dns_cache={ @@ -626,8 +625,6 @@ def test_resolve_ip_address_with_cache() -> None: def test_resolve_ip_address_cache_miss() -> None: """Test that resolver is called when not in cache.""" - from esphome.address_cache import AddressCache - cache = AddressCache(mdns_cache={"other.local": ["192.168.1.200"]}) mock_addr_info = AddrInfo( @@ -651,8 +648,6 @@ def test_resolve_ip_address_cache_miss() -> None: def test_resolve_ip_address_mixed_cached_uncached() -> None: """Test resolution with mix of cached and uncached hosts.""" - from esphome.address_cache import AddressCache - cache = AddressCache(mdns_cache={"cached.local": ["192.168.1.50"]}) mock_addr_info = AddrInfo( diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index ce19f18a1f..2c7236c7f8 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -10,7 +10,6 @@ from unittest.mock import Mock, patch import pytest from esphome.__main__ import choose_upload_log_host -from esphome.address_cache import AddressCache from esphome.const import CONF_BROKER, CONF_MQTT, CONF_USE_ADDRESS, CONF_WIFI from esphome.core import CORE @@ -511,73 +510,3 @@ def test_choose_upload_log_host_no_address_with_ota_config() -> None: show_api=False, ) assert result == [] - - -def test_address_cache_from_cli_args() -> None: - """Test parsing address cache from CLI arguments.""" - # Test empty lists - cache = AddressCache.from_cli_args([], []) - assert cache.mdns_cache == {} - assert cache.dns_cache == {} - - # Test single entry with single IP - cache = AddressCache.from_cli_args( - ["host.local=192.168.1.1"], ["example.com=10.0.0.1"] - ) - assert cache.mdns_cache == {"host.local": ["192.168.1.1"]} - assert cache.dns_cache == {"example.com": ["10.0.0.1"]} - - # Test multiple IPs - cache = AddressCache.from_cli_args(["host.local=192.168.1.1,192.168.1.2"], []) - assert cache.mdns_cache == {"host.local": ["192.168.1.1", "192.168.1.2"]} - - # Test multiple entries - cache = AddressCache.from_cli_args( - ["host1.local=192.168.1.1", "host2.local=192.168.1.2"], - ["example.com=10.0.0.1", "test.org=10.0.0.2,10.0.0.3"], - ) - assert cache.mdns_cache == { - "host1.local": ["192.168.1.1"], - "host2.local": ["192.168.1.2"], - } - assert cache.dns_cache == { - "example.com": ["10.0.0.1"], - "test.org": ["10.0.0.2", "10.0.0.3"], - } - - # Test with IPv6 - cache = AddressCache.from_cli_args(["host.local=2001:db8::1,fe80::1"], []) - assert cache.mdns_cache == {"host.local": ["2001:db8::1", "fe80::1"]} - - # Test invalid format (should be skipped with warning) - with patch("esphome.address_cache._LOGGER") as mock_logger: - cache = AddressCache.from_cli_args(["invalid_format"], []) - assert cache.mdns_cache == {} - mock_logger.warning.assert_called_once() - - -def test_address_cache_get_methods() -> None: - """Test the AddressCache get methods.""" - cache = AddressCache( - mdns_cache={"test.local": ["192.168.1.1"]}, - dns_cache={"example.com": ["10.0.0.1"]}, - ) - - # Test mDNS lookup - assert cache.get_mdns_addresses("test.local") == ["192.168.1.1"] - assert cache.get_mdns_addresses("other.local") is None - - # Test DNS lookup - assert cache.get_dns_addresses("example.com") == ["10.0.0.1"] - assert cache.get_dns_addresses("other.com") is None - - # Test automatic selection based on domain - assert cache.get_addresses("test.local") == ["192.168.1.1"] - assert cache.get_addresses("example.com") == ["10.0.0.1"] - assert cache.get_addresses("unknown.local") is None - assert cache.get_addresses("unknown.com") is None - - # Test has_cache - assert cache.has_cache() is True - empty_cache = AddressCache() - assert empty_cache.has_cache() is False From 99403c5a36781f6cf194f4fafd39dd19b6f95cc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:08:43 -0500 Subject: [PATCH 1053/1626] wip --- tests/dashboard/status/__init__.py | 0 tests/dashboard/status/test_mdns.py | 172 ++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 tests/dashboard/status/__init__.py create mode 100644 tests/dashboard/status/test_mdns.py diff --git a/tests/dashboard/status/__init__.py b/tests/dashboard/status/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py new file mode 100644 index 0000000000..1d6b096b7b --- /dev/null +++ b/tests/dashboard/status/test_mdns.py @@ -0,0 +1,172 @@ +"""Unit tests for esphome.dashboard.status.mdns module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import Mock, patch + +import pytest +from zeroconf import AddressResolver, IPVersion + +from esphome.dashboard.status.mdns import MDNSStatus + +if TYPE_CHECKING: + from esphome.dashboard.core import ESPHomeDashboard + + +@pytest.fixture +def mock_dashboard() -> Mock: + """Create a mock dashboard.""" + dashboard = Mock(spec=ESPHomeDashboard) + dashboard.entries = Mock() + dashboard.entries.async_all.return_value = [] + dashboard.stop_event = Mock() + dashboard.stop_event.is_set.return_value = True + dashboard.ping_request = Mock() + return dashboard + + +@pytest.fixture +def mdns_status(mock_dashboard: Mock) -> MDNSStatus: + """Create an MDNSStatus instance.""" + return MDNSStatus(mock_dashboard) + + +def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses when no zeroconf instance is available.""" + mdns_status.aiozc = None + result = mdns_status.get_cached_addresses("device.local") + assert result is None + + +def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses when address is not in cache.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = False + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local") + assert result is None + mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) + + +def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses when address is found in cache.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10", "fe80::1"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local") + assert result == ["192.168.1.10", "fe80::1"] + mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) + mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All) + + +def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses with hostname having trailing dot.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local.") + assert result == ["192.168.1.10"] + # Should normalize to device.local. for zeroconf + mock_resolver.assert_called_once_with("device.local.") + + +def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses with uppercase hostname.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("DEVICE.LOCAL") + assert result == ["192.168.1.10"] + # Should normalize to device.local. for zeroconf + mock_resolver.assert_called_once_with("device.local.") + + +def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses with simple hostname (no domain).""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device") + assert result == ["192.168.1.10"] + # Should append .local. for zeroconf + mock_resolver.assert_called_once_with("device.local.") + + +def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses returning only IPv6 addresses.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["fe80::1", "2001:db8::1"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local") + assert result == ["fe80::1", "2001:db8::1"] + + +def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses returning empty list from cache.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = [] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local") + assert result == [] + + +def test_async_setup_success(mock_dashboard: Mock) -> None: + """Test successful async_setup.""" + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.return_value = Mock() + result = mdns_status.async_setup() + assert result is True + assert mdns_status.aiozc is not None + + +def test_async_setup_failure(mock_dashboard: Mock) -> None: + """Test async_setup with OSError.""" + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.side_effect = OSError("Network error") + result = mdns_status.async_setup() + assert result is False + assert mdns_status.aiozc is None From 5dbe56849a101fc8a10a79ffd5b5afc58eb671f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:10:58 -0500 Subject: [PATCH 1054/1626] wip --- esphome/__main__.py | 10 +- tests/dashboard/status/test_dns.py | 202 ++++++++++++++++++++++++++++ tests/dashboard/status/test_mdns.py | 9 +- 3 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 tests/dashboard/status/test_dns.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 7d32d1f119..3223371c96 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -897,14 +897,14 @@ def parse_args(argv): metavar=("key", "value"), ) options_parser.add_argument( - "--mdns-lookup-cache", - help="mDNS lookup cache mapping in format 'hostname=ip1,ip2'", + "--mdns-address-cache", + help="mDNS address cache mapping in format 'hostname=ip1,ip2'", action="append", default=[], ) options_parser.add_argument( - "--dns-lookup-cache", - help="DNS lookup cache mapping in format 'hostname=ip1,ip2'", + "--dns-address-cache", + help="DNS address cache mapping in format 'hostname=ip1,ip2'", action="append", default=[], ) @@ -1162,7 +1162,7 @@ def run_esphome(argv): # Create address cache from command-line arguments address_cache = AddressCache.from_cli_args( - args.mdns_lookup_cache, args.dns_lookup_cache + args.mdns_address_cache, args.dns_address_cache ) # Store cache in CORE for access throughout the application diff --git a/tests/dashboard/status/test_dns.py b/tests/dashboard/status/test_dns.py new file mode 100644 index 0000000000..519defcbe1 --- /dev/null +++ b/tests/dashboard/status/test_dns.py @@ -0,0 +1,202 @@ +"""Unit tests for esphome.dashboard.dns module.""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +import pytest + +from esphome.dashboard.dns import DNSCache + + +@pytest.fixture +def dns_cache() -> DNSCache: + """Create a DNSCache instance.""" + return DNSCache() + + +def test_get_cached_addresses_not_in_cache(dns_cache: DNSCache) -> None: + """Test get_cached_addresses when hostname is not in cache.""" + now = time.monotonic() + result = dns_cache.get_cached_addresses("unknown.example.com", now) + assert result is None + + +def test_get_cached_addresses_expired(dns_cache: DNSCache) -> None: + """Test get_cached_addresses when cache entry is expired.""" + now = time.monotonic() + # Add entry that's already expired + dns_cache.cache["example.com"] = (["192.168.1.10"], now - 1) + + result = dns_cache.get_cached_addresses("example.com", now) + assert result is None + # Expired entry should be removed + assert "example.com" not in dns_cache.cache + + +def test_get_cached_addresses_valid(dns_cache: DNSCache) -> None: + """Test get_cached_addresses with valid cache entry.""" + now = time.monotonic() + # Add entry that expires in 60 seconds + dns_cache.cache["example.com"] = (["192.168.1.10", "192.168.1.11"], now + 60) + + result = dns_cache.get_cached_addresses("example.com", now) + assert result == ["192.168.1.10", "192.168.1.11"] + # Entry should still be in cache + assert "example.com" in dns_cache.cache + + +def test_get_cached_addresses_hostname_normalization(dns_cache: DNSCache) -> None: + """Test get_cached_addresses normalizes hostname.""" + now = time.monotonic() + # Add entry with lowercase hostname + dns_cache.cache["example.com"] = (["192.168.1.10"], now + 60) + + # Test with various forms + assert dns_cache.get_cached_addresses("EXAMPLE.COM", now) == ["192.168.1.10"] + assert dns_cache.get_cached_addresses("example.com.", now) == ["192.168.1.10"] + assert dns_cache.get_cached_addresses("EXAMPLE.COM.", now) == ["192.168.1.10"] + + +def test_get_cached_addresses_ipv6(dns_cache: DNSCache) -> None: + """Test get_cached_addresses with IPv6 addresses.""" + now = time.monotonic() + dns_cache.cache["example.com"] = (["2001:db8::1", "fe80::1"], now + 60) + + result = dns_cache.get_cached_addresses("example.com", now) + assert result == ["2001:db8::1", "fe80::1"] + + +def test_get_cached_addresses_empty_list(dns_cache: DNSCache) -> None: + """Test get_cached_addresses with empty address list.""" + now = time.monotonic() + dns_cache.cache["example.com"] = ([], now + 60) + + result = dns_cache.get_cached_addresses("example.com", now) + assert result == [] + + +def test_resolve_addresses_already_cached(dns_cache: DNSCache) -> None: + """Test resolve_addresses when hostname is already cached.""" + now = time.monotonic() + dns_cache.cache["example.com"] = (["192.168.1.10"], now + 60) + + with patch("socket.getaddrinfo") as mock_getaddrinfo: + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["192.168.1.10"] + # Should not call getaddrinfo for cached entry + mock_getaddrinfo.assert_not_called() + + +def test_resolve_addresses_not_cached(dns_cache: DNSCache) -> None: + """Test resolve_addresses when hostname needs resolution.""" + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (None, None, None, None, ("192.168.1.10", 0)), + (None, None, None, None, ("192.168.1.11", 0)), + ] + + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["192.168.1.10", "192.168.1.11"] + mock_getaddrinfo.assert_called_once_with("example.com", 0) + + # Should be cached now + assert "example.com" in dns_cache.cache + + +def test_resolve_addresses_multiple_hostnames(dns_cache: DNSCache) -> None: + """Test resolve_addresses with multiple hostnames.""" + now = time.monotonic() + dns_cache.cache["cached.com"] = (["192.168.1.10"], now + 60) + + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (None, None, None, None, ("10.0.0.1", 0)), + ] + + result = dns_cache.resolve_addresses( + "primary.com", ["cached.com", "primary.com", "fallback.com"] + ) + # Should return cached result for first match + assert result == ["192.168.1.10"] + mock_getaddrinfo.assert_not_called() + + +def test_resolve_addresses_resolution_error(dns_cache: DNSCache) -> None: + """Test resolve_addresses when resolution fails.""" + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.side_effect = OSError("Name resolution failed") + + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == [] + # Failed resolution should not be cached + assert "example.com" not in dns_cache.cache + + +def test_resolve_addresses_ipv6_resolution(dns_cache: DNSCache) -> None: + """Test resolve_addresses with IPv6 results.""" + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (None, None, None, None, ("2001:db8::1", 0, 0, 0)), + (None, None, None, None, ("fe80::1", 0, 0, 0)), + ] + + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["2001:db8::1", "fe80::1"] + + +def test_resolve_addresses_duplicate_removal(dns_cache: DNSCache) -> None: + """Test resolve_addresses removes duplicate addresses.""" + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (None, None, None, None, ("192.168.1.10", 0)), + (None, None, None, None, ("192.168.1.10", 0)), # Duplicate + (None, None, None, None, ("192.168.1.11", 0)), + ] + + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["192.168.1.10", "192.168.1.11"] + + +def test_resolve_addresses_hostname_normalization(dns_cache: DNSCache) -> None: + """Test resolve_addresses normalizes hostnames.""" + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (None, None, None, None, ("192.168.1.10", 0)), + ] + + # Resolve with uppercase and trailing dot + result = dns_cache.resolve_addresses("EXAMPLE.COM.", ["EXAMPLE.COM."]) + assert result == ["192.168.1.10"] + + # Should be cached with normalized name + assert "example.com" in dns_cache.cache + + # Should use cached result for different forms + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["192.168.1.10"] + # Only called once due to caching + mock_getaddrinfo.assert_called_once() + + +def test_cache_expiration_ttl(dns_cache: DNSCache) -> None: + """Test that cache entries expire after TTL.""" + with patch("socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (None, None, None, None, ("192.168.1.10", 0)), + ] + + # First resolution + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["192.168.1.10"] + assert mock_getaddrinfo.call_count == 1 + + # Simulate time passing beyond TTL + with patch("time.monotonic") as mock_time: + mock_time.return_value = time.monotonic() + 301 # TTL is 300 seconds + + # Should trigger new resolution + result = dns_cache.resolve_addresses("example.com", ["example.com"]) + assert result == ["192.168.1.10"] + assert mock_getaddrinfo.call_count == 2 diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py index 1d6b096b7b..b451157922 100644 --- a/tests/dashboard/status/test_mdns.py +++ b/tests/dashboard/status/test_mdns.py @@ -2,17 +2,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING from unittest.mock import Mock, patch import pytest from zeroconf import AddressResolver, IPVersion +from esphome.dashboard.core import ESPHomeDashboard from esphome.dashboard.status.mdns import MDNSStatus -if TYPE_CHECKING: - from esphome.dashboard.core import ESPHomeDashboard - @pytest.fixture def mock_dashboard() -> Mock: @@ -29,7 +26,9 @@ def mock_dashboard() -> Mock: @pytest.fixture def mdns_status(mock_dashboard: Mock) -> MDNSStatus: """Create an MDNSStatus instance.""" - return MDNSStatus(mock_dashboard) + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value = Mock() + return MDNSStatus(mock_dashboard) def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: From 305b4504de66ee14157a17ed16ac21977de6726d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:14:03 -0500 Subject: [PATCH 1055/1626] wip --- esphome/dashboard/web_server.py | 6 +- tests/dashboard/status/test_dns.py | 195 ++++++++-------------------- tests/dashboard/status/test_mdns.py | 28 ++-- 3 files changed, 76 insertions(+), 153 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 90a7cab3b5..2ab449b8da 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -358,7 +358,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): normalized = use_address.rstrip(".").lower() cache_args.extend( [ - "--mdns-lookup-cache", + "--mdns-address-cache", f"{normalized}={','.join(sort_ip_addresses(cached))}", ] ) @@ -372,7 +372,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): normalized = use_address.rstrip(".").lower() cache_args.extend( [ - "--dns-lookup-cache", + "--dns-address-cache", f"{normalized}={','.join(sort_ip_addresses(cached))}", ] ) @@ -396,7 +396,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): normalized = mdns_name.rstrip(".").lower() cache_args.extend( [ - "--mdns-lookup-cache", + "--mdns-address-cache", f"{normalized}={','.join(sort_ip_addresses(cached))}", ] ) diff --git a/tests/dashboard/status/test_dns.py b/tests/dashboard/status/test_dns.py index 519defcbe1..9ca48ba2d8 100644 --- a/tests/dashboard/status/test_dns.py +++ b/tests/dashboard/status/test_dns.py @@ -11,192 +11,111 @@ from esphome.dashboard.dns import DNSCache @pytest.fixture -def dns_cache() -> DNSCache: +def dns_cache_fixture() -> DNSCache: """Create a DNSCache instance.""" return DNSCache() -def test_get_cached_addresses_not_in_cache(dns_cache: DNSCache) -> None: +def test_get_cached_addresses_not_in_cache(dns_cache_fixture: DNSCache) -> None: """Test get_cached_addresses when hostname is not in cache.""" now = time.monotonic() - result = dns_cache.get_cached_addresses("unknown.example.com", now) + result = dns_cache_fixture.get_cached_addresses("unknown.example.com", now) assert result is None -def test_get_cached_addresses_expired(dns_cache: DNSCache) -> None: +def test_get_cached_addresses_expired(dns_cache_fixture: DNSCache) -> None: """Test get_cached_addresses when cache entry is expired.""" now = time.monotonic() # Add entry that's already expired - dns_cache.cache["example.com"] = (["192.168.1.10"], now - 1) + dns_cache_fixture._cache["example.com"] = (now - 1, ["192.168.1.10"]) - result = dns_cache.get_cached_addresses("example.com", now) + result = dns_cache_fixture.get_cached_addresses("example.com", now) assert result is None - # Expired entry should be removed - assert "example.com" not in dns_cache.cache + # Expired entry should still be in cache (not removed by get_cached_addresses) + assert "example.com" in dns_cache_fixture._cache -def test_get_cached_addresses_valid(dns_cache: DNSCache) -> None: +def test_get_cached_addresses_valid(dns_cache_fixture: DNSCache) -> None: """Test get_cached_addresses with valid cache entry.""" now = time.monotonic() # Add entry that expires in 60 seconds - dns_cache.cache["example.com"] = (["192.168.1.10", "192.168.1.11"], now + 60) + dns_cache_fixture._cache["example.com"] = ( + now + 60, + ["192.168.1.10", "192.168.1.11"], + ) - result = dns_cache.get_cached_addresses("example.com", now) + result = dns_cache_fixture.get_cached_addresses("example.com", now) assert result == ["192.168.1.10", "192.168.1.11"] # Entry should still be in cache - assert "example.com" in dns_cache.cache + assert "example.com" in dns_cache_fixture._cache -def test_get_cached_addresses_hostname_normalization(dns_cache: DNSCache) -> None: +def test_get_cached_addresses_hostname_normalization( + dns_cache_fixture: DNSCache, +) -> None: """Test get_cached_addresses normalizes hostname.""" now = time.monotonic() # Add entry with lowercase hostname - dns_cache.cache["example.com"] = (["192.168.1.10"], now + 60) + dns_cache_fixture._cache["example.com"] = (now + 60, ["192.168.1.10"]) # Test with various forms - assert dns_cache.get_cached_addresses("EXAMPLE.COM", now) == ["192.168.1.10"] - assert dns_cache.get_cached_addresses("example.com.", now) == ["192.168.1.10"] - assert dns_cache.get_cached_addresses("EXAMPLE.COM.", now) == ["192.168.1.10"] + assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM", now) == [ + "192.168.1.10" + ] + assert dns_cache_fixture.get_cached_addresses("example.com.", now) == [ + "192.168.1.10" + ] + assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM.", now) == [ + "192.168.1.10" + ] -def test_get_cached_addresses_ipv6(dns_cache: DNSCache) -> None: +def test_get_cached_addresses_ipv6(dns_cache_fixture: DNSCache) -> None: """Test get_cached_addresses with IPv6 addresses.""" now = time.monotonic() - dns_cache.cache["example.com"] = (["2001:db8::1", "fe80::1"], now + 60) + dns_cache_fixture._cache["example.com"] = (now + 60, ["2001:db8::1", "fe80::1"]) - result = dns_cache.get_cached_addresses("example.com", now) + result = dns_cache_fixture.get_cached_addresses("example.com", now) assert result == ["2001:db8::1", "fe80::1"] -def test_get_cached_addresses_empty_list(dns_cache: DNSCache) -> None: +def test_get_cached_addresses_empty_list(dns_cache_fixture: DNSCache) -> None: """Test get_cached_addresses with empty address list.""" now = time.monotonic() - dns_cache.cache["example.com"] = ([], now + 60) + dns_cache_fixture._cache["example.com"] = (now + 60, []) - result = dns_cache.get_cached_addresses("example.com", now) + result = dns_cache_fixture.get_cached_addresses("example.com", now) assert result == [] -def test_resolve_addresses_already_cached(dns_cache: DNSCache) -> None: - """Test resolve_addresses when hostname is already cached.""" +def test_get_cached_addresses_exception_in_cache(dns_cache_fixture: DNSCache) -> None: + """Test get_cached_addresses when cache contains an exception.""" now = time.monotonic() - dns_cache.cache["example.com"] = (["192.168.1.10"], now + 60) + # Store an exception (from failed resolution) + dns_cache_fixture._cache["example.com"] = (now + 60, OSError("Resolution failed")) - with patch("socket.getaddrinfo") as mock_getaddrinfo: - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["192.168.1.10"] - # Should not call getaddrinfo for cached entry - mock_getaddrinfo.assert_not_called() + result = dns_cache_fixture.get_cached_addresses("example.com", now) + assert result is None # Should return None for exceptions -def test_resolve_addresses_not_cached(dns_cache: DNSCache) -> None: - """Test resolve_addresses when hostname needs resolution.""" - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.return_value = [ - (None, None, None, None, ("192.168.1.10", 0)), - (None, None, None, None, ("192.168.1.11", 0)), - ] - - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["192.168.1.10", "192.168.1.11"] - mock_getaddrinfo.assert_called_once_with("example.com", 0) - - # Should be cached now - assert "example.com" in dns_cache.cache - - -def test_resolve_addresses_multiple_hostnames(dns_cache: DNSCache) -> None: - """Test resolve_addresses with multiple hostnames.""" +def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None: + """Test that get_cached_addresses never calls async_resolve.""" now = time.monotonic() - dns_cache.cache["cached.com"] = (["192.168.1.10"], now + 60) - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.return_value = [ - (None, None, None, None, ("10.0.0.1", 0)), - ] + with patch.object(dns_cache_fixture, "async_resolve") as mock_resolve: + # Test non-cached + result = dns_cache_fixture.get_cached_addresses("uncached.com", now) + assert result is None + mock_resolve.assert_not_called() - result = dns_cache.resolve_addresses( - "primary.com", ["cached.com", "primary.com", "fallback.com"] - ) - # Should return cached result for first match + # Test expired + dns_cache_fixture._cache["expired.com"] = (now - 1, ["192.168.1.10"]) + result = dns_cache_fixture.get_cached_addresses("expired.com", now) + assert result is None + mock_resolve.assert_not_called() + + # Test valid + dns_cache_fixture._cache["valid.com"] = (now + 60, ["192.168.1.10"]) + result = dns_cache_fixture.get_cached_addresses("valid.com", now) assert result == ["192.168.1.10"] - mock_getaddrinfo.assert_not_called() - - -def test_resolve_addresses_resolution_error(dns_cache: DNSCache) -> None: - """Test resolve_addresses when resolution fails.""" - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.side_effect = OSError("Name resolution failed") - - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == [] - # Failed resolution should not be cached - assert "example.com" not in dns_cache.cache - - -def test_resolve_addresses_ipv6_resolution(dns_cache: DNSCache) -> None: - """Test resolve_addresses with IPv6 results.""" - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.return_value = [ - (None, None, None, None, ("2001:db8::1", 0, 0, 0)), - (None, None, None, None, ("fe80::1", 0, 0, 0)), - ] - - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["2001:db8::1", "fe80::1"] - - -def test_resolve_addresses_duplicate_removal(dns_cache: DNSCache) -> None: - """Test resolve_addresses removes duplicate addresses.""" - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.return_value = [ - (None, None, None, None, ("192.168.1.10", 0)), - (None, None, None, None, ("192.168.1.10", 0)), # Duplicate - (None, None, None, None, ("192.168.1.11", 0)), - ] - - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["192.168.1.10", "192.168.1.11"] - - -def test_resolve_addresses_hostname_normalization(dns_cache: DNSCache) -> None: - """Test resolve_addresses normalizes hostnames.""" - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.return_value = [ - (None, None, None, None, ("192.168.1.10", 0)), - ] - - # Resolve with uppercase and trailing dot - result = dns_cache.resolve_addresses("EXAMPLE.COM.", ["EXAMPLE.COM."]) - assert result == ["192.168.1.10"] - - # Should be cached with normalized name - assert "example.com" in dns_cache.cache - - # Should use cached result for different forms - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["192.168.1.10"] - # Only called once due to caching - mock_getaddrinfo.assert_called_once() - - -def test_cache_expiration_ttl(dns_cache: DNSCache) -> None: - """Test that cache entries expire after TTL.""" - with patch("socket.getaddrinfo") as mock_getaddrinfo: - mock_getaddrinfo.return_value = [ - (None, None, None, None, ("192.168.1.10", 0)), - ] - - # First resolution - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["192.168.1.10"] - assert mock_getaddrinfo.call_count == 1 - - # Simulate time passing beyond TTL - with patch("time.monotonic") as mock_time: - mock_time.return_value = time.monotonic() + 301 # TTL is 300 seconds - - # Should trigger new resolution - result = dns_cache.resolve_addresses("example.com", ["example.com"]) - assert result == ["192.168.1.10"] - assert mock_getaddrinfo.call_count == 2 + mock_resolve.assert_not_called() diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py index b451157922..b20ba6699d 100644 --- a/tests/dashboard/status/test_mdns.py +++ b/tests/dashboard/status/test_mdns.py @@ -153,19 +153,23 @@ def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: def test_async_setup_success(mock_dashboard: Mock) -> None: """Test successful async_setup.""" - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.return_value = Mock() - result = mdns_status.async_setup() - assert result is True - assert mdns_status.aiozc is not None + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value = Mock() + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.return_value = Mock() + result = mdns_status.async_setup() + assert result is True + assert mdns_status.aiozc is not None def test_async_setup_failure(mock_dashboard: Mock) -> None: """Test async_setup with OSError.""" - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.side_effect = OSError("Network error") - result = mdns_status.async_setup() - assert result is False - assert mdns_status.aiozc is None + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value = Mock() + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.side_effect = OSError("Network error") + result = mdns_status.async_setup() + assert result is False + assert mdns_status.aiozc is None From 384ded539dfb68a150f51ce7816bb32d126887e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:14:51 -0500 Subject: [PATCH 1056/1626] wip --- tests/dashboard/status/test_mdns.py | 70 ++++++++++++++++------------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py index b20ba6699d..ad66eed2c2 100644 --- a/tests/dashboard/status/test_mdns.py +++ b/tests/dashboard/status/test_mdns.py @@ -5,6 +5,7 @@ from __future__ import annotations from unittest.mock import Mock, patch import pytest +import pytest_asyncio from zeroconf import AddressResolver, IPVersion from esphome.dashboard.core import ESPHomeDashboard @@ -23,22 +24,23 @@ def mock_dashboard() -> Mock: return dashboard -@pytest.fixture -def mdns_status(mock_dashboard: Mock) -> MDNSStatus: - """Create an MDNSStatus instance.""" - with patch("asyncio.get_running_loop") as mock_loop: - mock_loop.return_value = Mock() - return MDNSStatus(mock_dashboard) +@pytest_asyncio.fixture +async def mdns_status(mock_dashboard: Mock) -> MDNSStatus: + """Create an MDNSStatus instance in async context.""" + # We're in an async context so get_running_loop will work + return MDNSStatus(mock_dashboard) -def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses when no zeroconf instance is available.""" mdns_status.aiozc = None result = mdns_status.get_cached_addresses("device.local") assert result is None -def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses when address is not in cache.""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -53,7 +55,8 @@ def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) -def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses when address is found in cache.""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -70,7 +73,8 @@ def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All) -def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses with hostname having trailing dot.""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -87,7 +91,8 @@ def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None mock_resolver.assert_called_once_with("device.local.") -def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses with uppercase hostname.""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -104,7 +109,8 @@ def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> Non mock_resolver.assert_called_once_with("device.local.") -def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses with simple hostname (no domain).""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -121,7 +127,8 @@ def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: mock_resolver.assert_called_once_with("device.local.") -def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses returning only IPv6 addresses.""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -136,7 +143,8 @@ def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: assert result == ["fe80::1", "2001:db8::1"] -def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: +@pytest.mark.asyncio +async def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: """Test get_cached_addresses returning empty list from cache.""" mdns_status.aiozc = Mock() mdns_status.aiozc.zeroconf = Mock() @@ -151,25 +159,23 @@ def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: assert result == [] -def test_async_setup_success(mock_dashboard: Mock) -> None: +@pytest.mark.asyncio +async def test_async_setup_success(mock_dashboard: Mock) -> None: """Test successful async_setup.""" - with patch("asyncio.get_running_loop") as mock_loop: - mock_loop.return_value = Mock() - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.return_value = Mock() - result = mdns_status.async_setup() - assert result is True - assert mdns_status.aiozc is not None + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.return_value = Mock() + result = mdns_status.async_setup() + assert result is True + assert mdns_status.aiozc is not None -def test_async_setup_failure(mock_dashboard: Mock) -> None: +@pytest.mark.asyncio +async def test_async_setup_failure(mock_dashboard: Mock) -> None: """Test async_setup with OSError.""" - with patch("asyncio.get_running_loop") as mock_loop: - mock_loop.return_value = Mock() - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.side_effect = OSError("Network error") - result = mdns_status.async_setup() - assert result is False - assert mdns_status.aiozc is None + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.side_effect = OSError("Network error") + result = mdns_status.async_setup() + assert result is False + assert mdns_status.aiozc is None From 854a4158050aac61b2d8649334cf5fde470c3aca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:17:54 -0500 Subject: [PATCH 1057/1626] wip --- esphome/dashboard/web_server.py | 81 ++++++++++++----------------- tests/dashboard/conftest.py | 21 ++++++++ tests/dashboard/status/test_mdns.py | 13 ----- 3 files changed, 55 insertions(+), 60 deletions(-) create mode 100644 tests/dashboard/conftest.py diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 2ab449b8da..9524611d76 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -344,62 +344,49 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): entry.name, ) - # Build cache entries for any cached addresses we have - # First check entry.address (use_address) + def add_cache_entry( + hostname: str, addresses: list[str], cache_type: str + ) -> None: + """Add a cache entry to the command arguments.""" + if not addresses: + return + normalized = hostname.rstrip(".").lower() + cache_args.extend( + [ + f"--{cache_type}-address-cache", + f"{normalized}={','.join(sort_ip_addresses(addresses))}", + ] + ) + + # Check entry.address for cached addresses if use_address := entry.address: if use_address.endswith(".local"): - # Check mDNS cache for .local addresses - if mdns := dashboard.mdns_status: - cached = mdns.get_cached_addresses(use_address) - _LOGGER.debug( - "mDNS cache lookup for address %s: %s", use_address, cached - ) - if cached: - normalized = use_address.rstrip(".").lower() - cache_args.extend( - [ - "--mdns-address-cache", - f"{normalized}={','.join(sort_ip_addresses(cached))}", - ] - ) - else: - # Check DNS cache for non-.local addresses - cached = dashboard.dns_cache.get_cached_addresses(use_address, now) - _LOGGER.debug( - "DNS cache lookup for address %s: %s", use_address, cached - ) - if cached: - normalized = use_address.rstrip(".").lower() - cache_args.extend( - [ - "--dns-address-cache", - f"{normalized}={','.join(sort_ip_addresses(cached))}", - ] - ) + # mDNS cache for .local addresses + if (mdns := dashboard.mdns_status) and ( + cached := mdns.get_cached_addresses(use_address) + ): + _LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached) + add_cache_entry(use_address, cached, "mdns") + # DNS cache for non-.local addresses + elif cached := dashboard.dns_cache.get_cached_addresses( + use_address, now + ): + _LOGGER.debug("DNS cache hit for %s: %s", use_address, cached) + add_cache_entry(use_address, cached, "dns") - # Also check entry.name for cache entries + # Check entry.name if we haven't already cached via address # For mDNS devices, entry.name typically doesn't have .local suffix - # but we should check both with and without .local - if ( - entry.name and not use_address - ): # Only if we didn't already check address - # Try mDNS cache with .local suffix + if entry.name and not use_address: mdns_name = ( f"{entry.name}.local" if not entry.name.endswith(".local") else entry.name ) - if mdns := dashboard.mdns_status: - cached = mdns.get_cached_addresses(mdns_name) - _LOGGER.debug("mDNS cache lookup for %s: %s", mdns_name, cached) - if cached: - normalized = mdns_name.rstrip(".").lower() - cache_args.extend( - [ - "--mdns-address-cache", - f"{normalized}={','.join(sort_ip_addresses(cached))}", - ] - ) + if (mdns := dashboard.mdns_status) and ( + cached := mdns.get_cached_addresses(mdns_name) + ): + _LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached) + add_cache_entry(mdns_name, cached, "mdns") # Cache arguments must come before the subcommand cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port] diff --git a/tests/dashboard/conftest.py b/tests/dashboard/conftest.py new file mode 100644 index 0000000000..358be1bf5d --- /dev/null +++ b/tests/dashboard/conftest.py @@ -0,0 +1,21 @@ +"""Common fixtures for dashboard tests.""" + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from esphome.dashboard.core import ESPHomeDashboard + + +@pytest.fixture +def mock_dashboard() -> Mock: + """Create a mock dashboard.""" + dashboard = Mock(spec=ESPHomeDashboard) + dashboard.entries = Mock() + dashboard.entries.async_all.return_value = [] + dashboard.stop_event = Mock() + dashboard.stop_event.is_set.return_value = True + dashboard.ping_request = Mock() + return dashboard diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py index ad66eed2c2..7130c2c73a 100644 --- a/tests/dashboard/status/test_mdns.py +++ b/tests/dashboard/status/test_mdns.py @@ -8,22 +8,9 @@ import pytest import pytest_asyncio from zeroconf import AddressResolver, IPVersion -from esphome.dashboard.core import ESPHomeDashboard from esphome.dashboard.status.mdns import MDNSStatus -@pytest.fixture -def mock_dashboard() -> Mock: - """Create a mock dashboard.""" - dashboard = Mock(spec=ESPHomeDashboard) - dashboard.entries = Mock() - dashboard.entries.async_all.return_value = [] - dashboard.stop_event = Mock() - dashboard.stop_event.is_set.return_value = True - dashboard.ping_request = Mock() - return dashboard - - @pytest_asyncio.fixture async def mdns_status(mock_dashboard: Mock) -> MDNSStatus: """Create an MDNSStatus instance in async context.""" From 89259661198269f5c92e7d5291e53bc00e4d0442 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:26:18 -0500 Subject: [PATCH 1058/1626] reorder --- esphome/core/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 476ff1c618..242a6854df 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -6,9 +6,6 @@ import os import re from typing import TYPE_CHECKING -if TYPE_CHECKING: - from esphome.address_cache import AddressCache - from esphome.const import ( CONF_COMMENT, CONF_ESPHOME, @@ -42,6 +39,8 @@ from esphome.helpers import ensure_unique_string, get_str_env, is_ha_addon from esphome.util import OrderedDict if TYPE_CHECKING: + from esphome.address_cache import AddressCache + from ..cpp_generator import MockObj, MockObjClass, Statement from ..types import ConfigType, EntityMetadata From 46c83c8824437da727842afcebc8d5869485dc01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:28:54 -0500 Subject: [PATCH 1059/1626] fix type --- esphome/helpers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index 7eb560646b..d37f549658 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -9,10 +9,14 @@ from pathlib import Path import platform import re import tempfile +from typing import TYPE_CHECKING from urllib.parse import urlparse from esphome.const import __version__ as ESPHOME_VERSION +if TYPE_CHECKING: + from esphome.address_cache import AddressCache + # Type aliases for socket address information AddrInfo = tuple[ int, # family (AF_INET, AF_INET6, etc.) @@ -174,7 +178,7 @@ def addr_preference_(res: AddrInfo) -> int: def resolve_ip_address( - host: str | list[str], port: int, address_cache: object | None = None + host: str | list[str], port: int, address_cache: AddressCache | None = None ) -> list[AddrInfo]: import socket From a86f35dbb6bc4e541d874ef0010d4f84094e8c15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:34:06 -0500 Subject: [PATCH 1060/1626] break it up --- esphome/dashboard/web_server.py | 123 ++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 53 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 9524611d76..30b792676a 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -50,8 +50,8 @@ from esphome.util import get_serial_ports, shlex_quote from esphome.yaml_util import FastestAvailableSafeLoader from .const import DASHBOARD_COMMAND -from .core import DASHBOARD -from .entries import UNKNOWN_STATE, entry_state_to_bool +from .core import DASHBOARD, ESPHomeDashboard +from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool from .util.file import write_file from .util.subprocess import async_run_system_command from .util.text import friendly_name_slugify @@ -314,6 +314,73 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): raise NotImplementedError +def build_cache_arguments( + entry: DashboardEntry | None, + dashboard: ESPHomeDashboard, + now: float, +) -> list[str]: + """Build cache arguments for passing to CLI. + + Args: + entry: Dashboard entry for the configuration + dashboard: Dashboard instance with cache access + now: Current monotonic time for DNS cache expiry checks + + Returns: + List of cache arguments to pass to CLI + """ + cache_args: list[str] = [] + + if not entry: + return cache_args + + _LOGGER.debug( + "Building cache for entry (address=%s, name=%s)", + entry.address, + entry.name, + ) + + def add_cache_entry(hostname: str, addresses: list[str], cache_type: str) -> None: + """Add a cache entry to the command arguments.""" + if not addresses: + return + normalized = hostname.rstrip(".").lower() + cache_args.extend( + [ + f"--{cache_type}-address-cache", + f"{normalized}={','.join(sort_ip_addresses(addresses))}", + ] + ) + + # Check entry.address for cached addresses + if use_address := entry.address: + if use_address.endswith(".local"): + # mDNS cache for .local addresses + if (mdns := dashboard.mdns_status) and ( + cached := mdns.get_cached_addresses(use_address) + ): + _LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached) + add_cache_entry(use_address, cached, "mdns") + # DNS cache for non-.local addresses + elif cached := dashboard.dns_cache.get_cached_addresses(use_address, now): + _LOGGER.debug("DNS cache hit for %s: %s", use_address, cached) + add_cache_entry(use_address, cached, "dns") + + # Check entry.name if we haven't already cached via address + # For mDNS devices, entry.name typically doesn't have .local suffix + if entry.name and not use_address: + mdns_name = ( + f"{entry.name}.local" if not entry.name.endswith(".local") else entry.name + ) + if (mdns := dashboard.mdns_status) and ( + cached := mdns.get_cached_addresses(mdns_name) + ): + _LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached) + add_cache_entry(mdns_name, cached, "mdns") + + return cache_args + + class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): """Base class for commands that require a port.""" @@ -336,57 +403,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): and entry.loaded_integrations and "api" in entry.loaded_integrations ): - now = time.monotonic() - _LOGGER.debug( - "Building cache for %s (address=%s, name=%s)", - configuration, - entry.address, - entry.name, - ) - - def add_cache_entry( - hostname: str, addresses: list[str], cache_type: str - ) -> None: - """Add a cache entry to the command arguments.""" - if not addresses: - return - normalized = hostname.rstrip(".").lower() - cache_args.extend( - [ - f"--{cache_type}-address-cache", - f"{normalized}={','.join(sort_ip_addresses(addresses))}", - ] - ) - - # Check entry.address for cached addresses - if use_address := entry.address: - if use_address.endswith(".local"): - # mDNS cache for .local addresses - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(use_address) - ): - _LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached) - add_cache_entry(use_address, cached, "mdns") - # DNS cache for non-.local addresses - elif cached := dashboard.dns_cache.get_cached_addresses( - use_address, now - ): - _LOGGER.debug("DNS cache hit for %s: %s", use_address, cached) - add_cache_entry(use_address, cached, "dns") - - # Check entry.name if we haven't already cached via address - # For mDNS devices, entry.name typically doesn't have .local suffix - if entry.name and not use_address: - mdns_name = ( - f"{entry.name}.local" - if not entry.name.endswith(".local") - else entry.name - ) - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(mdns_name) - ): - _LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached) - add_cache_entry(mdns_name, cached, "mdns") + cache_args = build_cache_arguments(entry, dashboard, time.monotonic()) # Cache arguments must come before the subcommand cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port] From aaeb541bd02981ff245d1cc4aa68032a69b1085a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:38:04 -0500 Subject: [PATCH 1061/1626] break it out --- tests/dashboard/test_web_server.py | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index a22f4a8b2a..e481a1b273 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -579,3 +579,86 @@ def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: mock_server_class.assert_called_once_with(app) mock_bind.assert_called_once_with(str(socket_path), mode=0o666) server.add_socket.assert_called_once() + + +# Tests for build_cache_arguments function + + +def test_build_cache_arguments_no_entry(mock_dashboard: Mock) -> None: + """Test with no entry returns empty list.""" + result = web_server.build_cache_arguments(None, mock_dashboard, 0.0) + assert result == [] + + +def test_build_cache_arguments_no_address_no_name(mock_dashboard: Mock) -> None: + """Test with entry but no address or name.""" + entry = Mock(spec=web_server.DashboardEntry) + entry.address = None + entry.name = None + result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) + assert result == [] + + +def test_build_cache_arguments_mdns_address_cached(mock_dashboard: Mock) -> None: + """Test with .local address that has cached mDNS results.""" + entry = Mock(spec=web_server.DashboardEntry) + entry.address = "device.local" + entry.name = None + mock_dashboard.mdns_status = Mock() + mock_dashboard.mdns_status.get_cached_addresses.return_value = [ + "192.168.1.10", + "fe80::1", + ] + + result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) + + assert result == [ + "--mdns-address-cache", + "device.local=192.168.1.10,fe80::1", + ] + mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( + "device.local" + ) + + +def test_build_cache_arguments_dns_address_cached(mock_dashboard: Mock) -> None: + """Test with non-.local address that has cached DNS results.""" + entry = Mock(spec=web_server.DashboardEntry) + entry.address = "example.com" + entry.name = None + mock_dashboard.dns_cache = Mock() + mock_dashboard.dns_cache.get_cached_addresses.return_value = [ + "93.184.216.34", + "2606:2800:220:1:248:1893:25c8:1946", + ] + + now = 100.0 + result = web_server.build_cache_arguments(entry, mock_dashboard, now) + + # IPv6 addresses are sorted before IPv4 + assert result == [ + "--dns-address-cache", + "example.com=2606:2800:220:1:248:1893:25c8:1946,93.184.216.34", + ] + mock_dashboard.dns_cache.get_cached_addresses.assert_called_once_with( + "example.com", now + ) + + +def test_build_cache_arguments_name_without_address(mock_dashboard: Mock) -> None: + """Test with name but no address - should check mDNS with .local suffix.""" + entry = Mock(spec=web_server.DashboardEntry) + entry.name = "my-device" + entry.address = None + mock_dashboard.mdns_status = Mock() + mock_dashboard.mdns_status.get_cached_addresses.return_value = ["192.168.1.20"] + + result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) + + assert result == [ + "--mdns-address-cache", + "my-device.local=192.168.1.20", + ] + mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( + "my-device.local" + ) From 674415643487f73deb3e6951da059183f8e426e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:38:18 -0500 Subject: [PATCH 1062/1626] break it out --- tests/dashboard/test_web_server.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index e481a1b273..891c91a55f 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -581,9 +581,6 @@ def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: server.add_socket.assert_called_once() -# Tests for build_cache_arguments function - - def test_build_cache_arguments_no_entry(mock_dashboard: Mock) -> None: """Test with no entry returns empty list.""" result = web_server.build_cache_arguments(None, mock_dashboard, 0.0) From 0be3387d377e89fd6d0f33596ab55e0312c51ff6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:39:36 -0500 Subject: [PATCH 1063/1626] break it out --- esphome/__main__.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 3223371c96..137043f626 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1161,19 +1161,9 @@ def run_esphome(argv): CORE.dashboard = args.dashboard # Create address cache from command-line arguments - address_cache = AddressCache.from_cli_args( + CORE.address_cache = AddressCache.from_cli_args( args.mdns_address_cache, args.dns_address_cache ) - - # Store cache in CORE for access throughout the application - CORE.address_cache = address_cache - if address_cache.has_cache(): - _LOGGER.debug( - "Address cache initialized with %d mDNS and %d DNS entries", - len(address_cache.mdns_cache), - len(address_cache.dns_cache), - ) - # Override log level if verbose is set if args.verbose: args.log_level = "DEBUG" From 801c15a1e0a918ca6fb380871b70f0c2cba814ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 19:43:08 -0500 Subject: [PATCH 1064/1626] dry --- esphome/helpers.py | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index d37f549658..2b7221355c 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -177,6 +177,21 @@ def addr_preference_(res: AddrInfo) -> int: return 1 +def _add_ip_addresses_to_addrinfo( + addresses: list[str], port: int, res: list[AddrInfo] +) -> None: + """Helper to add IP addresses to addrinfo results with error handling.""" + import socket + + for addr in addresses: + try: + res += socket.getaddrinfo( + addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + except OSError: + _LOGGER.debug("Failed to parse IP address '%s'", addr) + + def resolve_ip_address( host: str | list[str], port: int, address_cache: AddressCache | None = None ) -> list[AddrInfo]: @@ -203,13 +218,7 @@ def resolve_ip_address( # Fast path: if all hosts are already IP addresses if all(is_ip_address(h) for h in hosts): - for addr in hosts: - try: - res += socket.getaddrinfo( - addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST - ) - except OSError: - _LOGGER.debug("Failed to parse IP address '%s'", addr) + _add_ip_addresses_to_addrinfo(hosts, port, res) # Sort by preference res.sort(key=addr_preference_) return res @@ -237,13 +246,7 @@ def resolve_ip_address( uncached_hosts.append(h) # Process cached addresses (includes direct IPs and cached lookups) - for addr in cached_addresses: - try: - res += socket.getaddrinfo( - addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST - ) - except OSError: - _LOGGER.debug("Failed to parse IP address '%s'", addr) + _add_ip_addresses_to_addrinfo(cached_addresses, port, res) # If we have uncached hosts (only non-IP hostnames), resolve them if uncached_hosts: @@ -296,14 +299,7 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]: # First "resolve" all the IP addresses to getaddrinfo() tuples of the form # (family, type, proto, canonname, sockaddr) res: list[AddrInfo] = [] - for addr in address_list: - # This should always work as these are supposed to be IP addresses - try: - res += socket.getaddrinfo( - addr, 0, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST - ) - except OSError: - _LOGGER.info("Failed to parse IP address '%s'", addr) + _add_ip_addresses_to_addrinfo(address_list, 0, res) # Now use that information to sort them. res.sort(key=addr_preference_) From 4813c5134e73fccfe1ee74b1cfe506b0b846bcc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Sep 2025 00:04:22 -0500 Subject: [PATCH 1065/1626] [tests] Add upload_program and show_logs test coverage to prevent regressions (#10684) --- tests/unit_tests/test_main.py | 553 +++++++++++++++++++++++++++++++++- 1 file changed, 548 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 2c7236c7f8..41b62ec196 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -4,14 +4,33 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass +from pathlib import Path from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest -from esphome.__main__ import choose_upload_log_host -from esphome.const import CONF_BROKER, CONF_MQTT, CONF_USE_ADDRESS, CONF_WIFI -from esphome.core import CORE +from esphome.__main__ import choose_upload_log_host, show_logs, upload_program +from esphome.const import ( + CONF_BROKER, + CONF_DISABLED, + CONF_ESPHOME, + CONF_MDNS, + CONF_MQTT, + CONF_OTA, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_PORT, + CONF_USE_ADDRESS, + CONF_WIFI, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2040, +) +from esphome.core import CORE, EsphomeError @dataclass @@ -28,7 +47,11 @@ class MockSerialPort: def setup_core( - config: dict[str, Any] | None = None, address: str | None = None + config: dict[str, Any] | None = None, + address: str | None = None, + platform: str | None = None, + tmp_path: Path | None = None, + name: str = "test", ) -> None: """ Helper to set up CORE configuration with optional address. @@ -36,6 +59,9 @@ def setup_core( Args: config (dict[str, Any] | None): The configuration dictionary to set for CORE. If None, an empty dict is used. address (str | None): Optional network address to set in the configuration. If provided, it is set under the wifi config. + platform (str | None): Optional target platform to set in CORE.data. + tmp_path (Path | None): Optional temp path for setting up build paths. + name (str): The name of the device (defaults to "test"). """ if config is None: config = {} @@ -46,6 +72,15 @@ def setup_core( CORE.config = config + if platform is not None: + CORE.data[KEY_CORE] = {} + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform + + if tmp_path is not None: + CORE.config_path = str(tmp_path / f"{name}.yaml") + CORE.name = name + CORE.build_path = str(tmp_path / ".esphome" / "build" / name) + @pytest.fixture def mock_no_serial_ports() -> Generator[Mock]: @@ -54,6 +89,55 @@ def mock_no_serial_ports() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_get_port_type() -> Generator[Mock]: + """Mock get_port_type for testing.""" + with patch("esphome.__main__.get_port_type") as mock: + yield mock + + +@pytest.fixture +def mock_check_permissions() -> Generator[Mock]: + """Mock check_permissions for testing.""" + with patch("esphome.__main__.check_permissions") as mock: + yield mock + + +@pytest.fixture +def mock_run_miniterm() -> Generator[Mock]: + """Mock run_miniterm for testing.""" + with patch("esphome.__main__.run_miniterm") as mock: + yield mock + + +@pytest.fixture +def mock_upload_using_esptool() -> Generator[Mock]: + """Mock upload_using_esptool for testing.""" + with patch("esphome.__main__.upload_using_esptool") as mock: + yield mock + + +@pytest.fixture +def mock_upload_using_platformio() -> Generator[Mock]: + """Mock upload_using_platformio for testing.""" + with patch("esphome.__main__.upload_using_platformio") as mock: + yield mock + + +@pytest.fixture +def mock_run_ota() -> Generator[Mock]: + """Mock espota2.run_ota for testing.""" + with patch("esphome.espota2.run_ota") as mock: + yield mock + + +@pytest.fixture +def mock_is_ip_address() -> Generator[Mock]: + """Mock is_ip_address for testing.""" + with patch("esphome.__main__.is_ip_address") as mock: + yield mock + + @pytest.fixture def mock_serial_ports() -> Generator[Mock]: """Mock get_serial_ports to return test ports.""" @@ -510,3 +594,462 @@ def test_choose_upload_log_host_no_address_with_ota_config() -> None: show_api=False, ) assert result == [] + + +@dataclass +class MockArgs: + """Mock args for testing.""" + + file: str | None = None + upload_speed: int = 460800 + username: str | None = None + password: str | None = None + client_id: str | None = None + topic: str | None = None + + +def test_upload_program_serial_esp32( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Test upload_program with serial port for ESP32.""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_esptool.return_value = 0 + + config = {} + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "/dev/ttyUSB0" + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + mock_upload_using_esptool.assert_called_once() + + +def test_upload_program_serial_esp8266_with_file( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Test upload_program with serial port for ESP8266 with custom file.""" + setup_core(platform=PLATFORM_ESP8266) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_esptool.return_value = 0 + + config = {} + args = MockArgs(file="firmware.bin") + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "/dev/ttyUSB0" + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + mock_upload_using_esptool.assert_called_once_with( + config, "/dev/ttyUSB0", "firmware.bin", 460800 + ) + + +@pytest.mark.parametrize( + "platform,device", + [ + (PLATFORM_RP2040, "/dev/ttyACM0"), + (PLATFORM_BK72XX, "/dev/ttyUSB0"), # LibreTiny platform + ], +) +def test_upload_program_serial_platformio_platforms( + mock_upload_using_platformio: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, + platform: str, + device: str, +) -> None: + """Test upload_program with serial port for platformio platforms (RP2040/LibreTiny).""" + setup_core(platform=platform) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_platformio.return_value = 0 + + config = {} + args = MockArgs() + devices = [device] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == device + mock_check_permissions.assert_called_once_with(device) + mock_upload_using_platformio.assert_called_once_with(config, device) + + +def test_upload_program_serial_upload_failed( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Test upload_program when serial upload fails.""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_esptool.return_value = 1 # Failed + + config = {} + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 1 + assert host is None + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + mock_upload_using_esptool.assert_called_once() + + +def test_upload_program_ota_success( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_PASSWORD: "secret", + } + ] + } + args = MockArgs() + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + expected_firmware = str( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], 3232, "secret", expected_firmware + ) + + +def test_upload_program_ota_with_file_arg( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA and custom file.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ] + } + args = MockArgs(file="custom.bin") + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", "custom.bin") + + +def test_upload_program_ota_no_config( + mock_get_port_type: Mock, +) -> None: + """Test upload_program with OTA but no OTA config.""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "NETWORK" + + config = {} # No OTA config + args = MockArgs() + devices = ["192.168.1.100"] + + with pytest.raises(EsphomeError, match="Cannot upload Over the Air"): + upload_program(config, args, devices) + + +@patch("esphome.mqtt.get_esphome_device_ip") +def test_upload_program_ota_with_mqtt_resolution( + mock_mqtt_get_ip: Mock, + mock_is_ip_address: Mock, + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA using MQTT for address resolution.""" + setup_core(address="device.local", platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.side_effect = ["MQTT", "NETWORK"] + mock_is_ip_address.return_value = False + mock_mqtt_get_ip.return_value = ["192.168.1.100"] + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ], + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + } + args = MockArgs(username="user", password="pass", client_id="client") + devices = ["MQTT"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + expected_firmware = str( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_ota.assert_called_once_with( + [["192.168.1.100"]], 3232, "", expected_firmware + ) + + +@patch("esphome.__main__.importlib.import_module") +def test_upload_program_platform_specific_handler( + mock_import: Mock, + mock_get_port_type: Mock, +) -> None: + """Test upload_program with platform-specific upload handler.""" + setup_core(platform="custom_platform") + mock_get_port_type.return_value = "CUSTOM" + + mock_module = MagicMock() + mock_module.upload_program.return_value = True + mock_import.return_value = mock_module + + config = {} + args = MockArgs() + devices = ["custom_device"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "custom_device" + mock_import.assert_called_once_with("esphome.components.custom_platform") + mock_module.upload_program.assert_called_once_with(config, args, "custom_device") + + +def test_show_logs_serial( + mock_get_port_type: Mock, + mock_check_permissions: Mock, + mock_run_miniterm: Mock, +) -> None: + """Test show_logs with serial port.""" + setup_core(config={"logger": {}}, platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "SERIAL" + mock_run_miniterm.return_value = 0 + + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + mock_run_miniterm.assert_called_once_with(CORE.config, "/dev/ttyUSB0", args) + + +def test_show_logs_no_logger() -> None: + """Test show_logs when logger is not configured.""" + setup_core(config={}, platform=PLATFORM_ESP32) # No logger config + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + with pytest.raises(EsphomeError, match="Logger is not configured"): + show_logs(CORE.config, args, devices) + + +@patch("esphome.components.api.client.run_logs") +def test_show_logs_api( + mock_run_logs: Mock, + mock_get_port_type: Mock, +) -> None: + """Test show_logs with API.""" + setup_core( + config={ + "logger": {}, + "api": {}, + CONF_MDNS: {CONF_DISABLED: False}, + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "NETWORK" + mock_run_logs.return_value = 0 + + args = MockArgs() + devices = ["192.168.1.100", "192.168.1.101"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_run_logs.assert_called_once_with( + CORE.config, ["192.168.1.100", "192.168.1.101"] + ) + + +@patch("esphome.mqtt.get_esphome_device_ip") +@patch("esphome.components.api.client.run_logs") +def test_show_logs_api_with_mqtt_fallback( + mock_run_logs: Mock, + mock_mqtt_get_ip: Mock, + mock_get_port_type: Mock, +) -> None: + """Test show_logs with API using MQTT for address resolution.""" + setup_core( + config={ + "logger": {}, + "api": {}, + CONF_MDNS: {CONF_DISABLED: True}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "NETWORK" + mock_run_logs.return_value = 0 + mock_mqtt_get_ip.return_value = ["192.168.1.200"] + + args = MockArgs(username="user", password="pass", client_id="client") + devices = ["device.local"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.200"]) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_mqtt( + mock_mqtt_show_logs: Mock, + mock_get_port_type: Mock, +) -> None: + """Test show_logs with MQTT.""" + setup_core( + config={ + "logger": {}, + "mqtt": {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "MQTT" + mock_mqtt_show_logs.return_value = 0 + + args = MockArgs( + topic="esphome/logs", + username="user", + password="pass", + client_id="client", + ) + devices = ["MQTT"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" + ) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_network_with_mqtt_only( + mock_mqtt_show_logs: Mock, + mock_get_port_type: Mock, +) -> None: + """Test show_logs with network port but only MQTT configured.""" + setup_core( + config={ + "logger": {}, + "mqtt": {CONF_BROKER: "mqtt.local"}, + # No API configured + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "NETWORK" + mock_mqtt_show_logs.return_value = 0 + + args = MockArgs( + topic="esphome/logs", + username="user", + password="pass", + client_id="client", + ) + devices = ["192.168.1.100"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" + ) + + +def test_show_logs_no_method_configured( + mock_get_port_type: Mock, +) -> None: + """Test show_logs when no remote logging method is configured.""" + setup_core( + config={ + "logger": {}, + # No API or MQTT configured + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "NETWORK" + + args = MockArgs() + devices = ["192.168.1.100"] + + with pytest.raises( + EsphomeError, match="No remote or local logging method configured" + ): + show_logs(CORE.config, args, devices) + + +@patch("esphome.__main__.importlib.import_module") +def test_show_logs_platform_specific_handler( + mock_import: Mock, +) -> None: + """Test show_logs with platform-specific logs handler.""" + setup_core(platform="custom_platform", config={"logger": {}}) + + mock_module = MagicMock() + mock_module.show_logs.return_value = True + mock_import.return_value = mock_module + + config = {"logger": {}} + args = MockArgs() + devices = ["custom_device"] + + result = show_logs(config, args, devices) + + assert result == 0 + mock_import.assert_called_once_with("esphome.components.custom_platform") + mock_module.show_logs.assert_called_once_with(config, args, devices) From 4390fd80a3b3bba35648cd4ac658fb116dda186c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Sep 2025 00:04:51 -0500 Subject: [PATCH 1066/1626] Add additional coverage for util and writer (#10683) --- tests/unit_tests/test_util.py | 167 +++++++++++++ tests/unit_tests/test_writer.py | 416 +++++++++++++++++++++++++++++++- 2 files changed, 582 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 74d6a74709..34f40a651f 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -141,3 +141,170 @@ def test_list_yaml_files_mixed_extensions(tmp_path: Path) -> None: str(yaml_file), str(yml_file), } + + +def test_list_yaml_files_does_not_recurse_into_subdirectories(tmp_path: Path) -> None: + """Test that list_yaml_files only finds files in specified directory, not subdirectories.""" + # Create directory structure with YAML files at different depths + root = tmp_path / "configs" + root.mkdir() + + # Create YAML files in the root directory + (root / "config1.yaml").write_text("test: 1") + (root / "config2.yml").write_text("test: 2") + (root / "device.yaml").write_text("test: device") + + # Create subdirectory with YAML files (should NOT be found) + subdir = root / "subdir" + subdir.mkdir() + (subdir / "nested1.yaml").write_text("test: nested1") + (subdir / "nested2.yml").write_text("test: nested2") + + # Create deeper subdirectory (should NOT be found) + deep_subdir = subdir / "deeper" + deep_subdir.mkdir() + (deep_subdir / "very_nested.yaml").write_text("test: very_nested") + + # Test listing files from the root directory + result = util.list_yaml_files([str(root)]) + + # Should only find the 3 files in root, not the 3 in subdirectories + assert len(result) == 3 + + # Check that only root-level files are found + assert str(root / "config1.yaml") in result + assert str(root / "config2.yml") in result + assert str(root / "device.yaml") in result + + # Ensure nested files are NOT found + for r in result: + assert "subdir" not in r + assert "deeper" not in r + assert "nested1.yaml" not in r + assert "nested2.yml" not in r + assert "very_nested.yaml" not in r + + +def test_list_yaml_files_excludes_secrets(tmp_path: Path) -> None: + """Test that secrets.yaml and secrets.yml are excluded.""" + root = tmp_path / "configs" + root.mkdir() + + # Create various YAML files including secrets + (root / "config.yaml").write_text("test: config") + (root / "secrets.yaml").write_text("wifi_password: secret123") + (root / "secrets.yml").write_text("api_key: secret456") + (root / "device.yaml").write_text("test: device") + + result = util.list_yaml_files([str(root)]) + + # Should find 2 files (config.yaml and device.yaml), not secrets + assert len(result) == 2 + assert str(root / "config.yaml") in result + assert str(root / "device.yaml") in result + assert str(root / "secrets.yaml") not in result + assert str(root / "secrets.yml") not in result + + +def test_list_yaml_files_excludes_hidden_files(tmp_path: Path) -> None: + """Test that hidden files (starting with .) are excluded.""" + root = tmp_path / "configs" + root.mkdir() + + # Create regular and hidden YAML files + (root / "config.yaml").write_text("test: config") + (root / ".hidden.yaml").write_text("test: hidden") + (root / ".backup.yml").write_text("test: backup") + (root / "device.yaml").write_text("test: device") + + result = util.list_yaml_files([str(root)]) + + # Should find only non-hidden files + assert len(result) == 2 + assert str(root / "config.yaml") in result + assert str(root / "device.yaml") in result + assert str(root / ".hidden.yaml") not in result + assert str(root / ".backup.yml") not in result + + +def test_filter_yaml_files_basic() -> None: + """Test filter_yaml_files function.""" + files = [ + "/path/to/config.yaml", + "/path/to/device.yml", + "/path/to/readme.txt", + "/path/to/script.py", + "/path/to/data.json", + "/path/to/another.yaml", + ] + + result = util.filter_yaml_files(files) + + assert len(result) == 3 + assert "/path/to/config.yaml" in result + assert "/path/to/device.yml" in result + assert "/path/to/another.yaml" in result + assert "/path/to/readme.txt" not in result + assert "/path/to/script.py" not in result + assert "/path/to/data.json" not in result + + +def test_filter_yaml_files_excludes_secrets() -> None: + """Test that filter_yaml_files excludes secrets files.""" + files = [ + "/path/to/config.yaml", + "/path/to/secrets.yaml", + "/path/to/secrets.yml", + "/path/to/device.yaml", + "/some/dir/secrets.yaml", + ] + + result = util.filter_yaml_files(files) + + assert len(result) == 2 + assert "/path/to/config.yaml" in result + assert "/path/to/device.yaml" in result + assert "/path/to/secrets.yaml" not in result + assert "/path/to/secrets.yml" not in result + assert "/some/dir/secrets.yaml" not in result + + +def test_filter_yaml_files_excludes_hidden() -> None: + """Test that filter_yaml_files excludes hidden files.""" + files = [ + "/path/to/config.yaml", + "/path/to/.hidden.yaml", + "/path/to/.backup.yml", + "/path/to/device.yaml", + "/some/dir/.config.yaml", + ] + + result = util.filter_yaml_files(files) + + assert len(result) == 2 + assert "/path/to/config.yaml" in result + assert "/path/to/device.yaml" in result + assert "/path/to/.hidden.yaml" not in result + assert "/path/to/.backup.yml" not in result + assert "/some/dir/.config.yaml" not in result + + +def test_filter_yaml_files_case_sensitive() -> None: + """Test that filter_yaml_files is case-sensitive for extensions.""" + files = [ + "/path/to/config.yaml", + "/path/to/config.YAML", + "/path/to/config.YML", + "/path/to/config.Yaml", + "/path/to/config.yml", + ] + + result = util.filter_yaml_files(files) + + # Should only match lowercase .yaml and .yml + assert len(result) == 2 + assert "/path/to/config.yaml" in result + assert "/path/to/config.yml" in result + assert "/path/to/config.YAML" not in result + assert "/path/to/config.YML" not in result + assert "/path/to/config.Yaml" not in result diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index f47947ff37..f1f86a322e 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1,13 +1,34 @@ """Test writer module functionality.""" from collections.abc import Callable +from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch import pytest +from esphome.core import EsphomeError from esphome.storage_json import StorageJSON -from esphome.writer import storage_should_clean, update_storage_json +from esphome.writer import ( + CPP_AUTO_GENERATE_BEGIN, + CPP_AUTO_GENERATE_END, + CPP_INCLUDE_BEGIN, + CPP_INCLUDE_END, + GITIGNORE_CONTENT, + clean_build, + clean_cmake_cache, + storage_should_clean, + update_storage_json, + write_cpp, + write_gitignore, +) + + +@pytest.fixture +def mock_copy_src_tree(): + """Mock copy_src_tree to avoid side effects during tests.""" + with patch("esphome.writer.copy_src_tree"): + yield @pytest.fixture @@ -218,3 +239,396 @@ def test_update_storage_json_logging_components_removed( # Verify save was called new_storage.save.assert_called_once_with("/test/path") + + +@patch("esphome.writer.CORE") +def test_clean_cmake_cache( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_cmake_cache removes CMakeCache.txt file.""" + # Create directory structure + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + device_dir = pioenvs_dir / "test_device" + device_dir.mkdir() + cmake_cache_file = device_dir / "CMakeCache.txt" + cmake_cache_file.write_text("# CMake cache file") + + # Setup mocks + mock_core.relative_pioenvs_path.side_effect = [ + str(pioenvs_dir), # First call for directory check + str(cmake_cache_file), # Second call for file path + ] + mock_core.name = "test_device" + + # Verify file exists before + assert cmake_cache_file.exists() + + # Call the function + with caplog.at_level("INFO"): + clean_cmake_cache() + + # Verify file was removed + assert not cmake_cache_file.exists() + + # Verify logging + assert "Deleting" in caplog.text + assert "CMakeCache.txt" in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_cmake_cache_no_pioenvs_dir( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_cmake_cache when pioenvs directory doesn't exist.""" + # Setup non-existent directory path + pioenvs_dir = tmp_path / ".pioenvs" + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + + # Verify directory doesn't exist + assert not pioenvs_dir.exists() + + # Call the function - should not crash + clean_cmake_cache() + + # Verify directory still doesn't exist + assert not pioenvs_dir.exists() + + +@patch("esphome.writer.CORE") +def test_clean_cmake_cache_no_cmake_file( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_cmake_cache when CMakeCache.txt doesn't exist.""" + # Create directory structure without CMakeCache.txt + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + device_dir = pioenvs_dir / "test_device" + device_dir.mkdir() + cmake_cache_file = device_dir / "CMakeCache.txt" + + # Setup mocks + mock_core.relative_pioenvs_path.side_effect = [ + str(pioenvs_dir), # First call for directory check + str(cmake_cache_file), # Second call for file path + ] + mock_core.name = "test_device" + + # Verify file doesn't exist + assert not cmake_cache_file.exists() + + # Call the function - should not crash + clean_cmake_cache() + + # Verify file still doesn't exist + assert not cmake_cache_file.exists() + + +@patch("esphome.writer.CORE") +def test_clean_build( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_build removes all build artifacts.""" + # Create directory structure and files + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + (pioenvs_dir / "test_file.o").write_text("object file") + + piolibdeps_dir = tmp_path / ".piolibdeps" + piolibdeps_dir.mkdir() + (piolibdeps_dir / "library").mkdir() + + dependencies_lock = tmp_path / "dependencies.lock" + dependencies_lock.write_text("lock file") + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) + mock_core.relative_build_path.return_value = str(dependencies_lock) + + # Verify all exist before + assert pioenvs_dir.exists() + assert piolibdeps_dir.exists() + assert dependencies_lock.exists() + + # Call the function + with caplog.at_level("INFO"): + clean_build() + + # Verify all were removed + assert not pioenvs_dir.exists() + assert not piolibdeps_dir.exists() + assert not dependencies_lock.exists() + + # Verify logging + assert "Deleting" in caplog.text + assert ".pioenvs" in caplog.text + assert ".piolibdeps" in caplog.text + assert "dependencies.lock" in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_build_partial_exists( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_build when only some paths exist.""" + # Create only pioenvs directory + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + (pioenvs_dir / "test_file.o").write_text("object file") + + piolibdeps_dir = tmp_path / ".piolibdeps" + dependencies_lock = tmp_path / "dependencies.lock" + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) + mock_core.relative_build_path.return_value = str(dependencies_lock) + + # Verify only pioenvs exists + assert pioenvs_dir.exists() + assert not piolibdeps_dir.exists() + assert not dependencies_lock.exists() + + # Call the function + with caplog.at_level("INFO"): + clean_build() + + # Verify only existing path was removed + assert not pioenvs_dir.exists() + assert not piolibdeps_dir.exists() + assert not dependencies_lock.exists() + + # Verify logging - only pioenvs should be logged + assert "Deleting" in caplog.text + assert ".pioenvs" in caplog.text + assert ".piolibdeps" not in caplog.text + assert "dependencies.lock" not in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_build_nothing_exists( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_build when no build artifacts exist.""" + # Setup paths that don't exist + pioenvs_dir = tmp_path / ".pioenvs" + piolibdeps_dir = tmp_path / ".piolibdeps" + dependencies_lock = tmp_path / "dependencies.lock" + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) + mock_core.relative_build_path.return_value = str(dependencies_lock) + + # Verify nothing exists + assert not pioenvs_dir.exists() + assert not piolibdeps_dir.exists() + assert not dependencies_lock.exists() + + # Call the function - should not crash + clean_build() + + # Verify nothing was created + assert not pioenvs_dir.exists() + assert not piolibdeps_dir.exists() + assert not dependencies_lock.exists() + + +@patch("esphome.writer.CORE") +def test_write_gitignore_creates_new_file( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test write_gitignore creates a new .gitignore file when it doesn't exist.""" + gitignore_path = tmp_path / ".gitignore" + + # Setup mocks + mock_core.relative_config_path.return_value = str(gitignore_path) + + # Verify file doesn't exist + assert not gitignore_path.exists() + + # Call the function + write_gitignore() + + # Verify file was created with correct content + assert gitignore_path.exists() + assert gitignore_path.read_text() == GITIGNORE_CONTENT + + +@patch("esphome.writer.CORE") +def test_write_gitignore_skips_existing_file( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test write_gitignore doesn't overwrite existing .gitignore file.""" + gitignore_path = tmp_path / ".gitignore" + existing_content = "# Custom gitignore\n/custom_dir/\n" + gitignore_path.write_text(existing_content) + + # Setup mocks + mock_core.relative_config_path.return_value = str(gitignore_path) + + # Verify file exists with custom content + assert gitignore_path.exists() + assert gitignore_path.read_text() == existing_content + + # Call the function + write_gitignore() + + # Verify file was not modified + assert gitignore_path.exists() + assert gitignore_path.read_text() == existing_content + + +@patch("esphome.writer.write_file_if_changed") # Mock to capture output +@patch("esphome.writer.copy_src_tree") # Keep this mock as it's complex +@patch("esphome.writer.CORE") +def test_write_cpp_with_existing_file( + mock_core: MagicMock, + mock_copy_src_tree: MagicMock, + mock_write_file: MagicMock, + tmp_path: Path, +) -> None: + """Test write_cpp when main.cpp already exists.""" + # Create a real file with markers + main_cpp = tmp_path / "main.cpp" + existing_content = f"""#include "esphome.h" +{CPP_INCLUDE_BEGIN} +// Old includes +{CPP_INCLUDE_END} +void setup() {{ +{CPP_AUTO_GENERATE_BEGIN} +// Old code +{CPP_AUTO_GENERATE_END} +}} +void loop() {{}}""" + main_cpp.write_text(existing_content) + + # Setup mocks + mock_core.relative_src_path.return_value = str(main_cpp) + mock_core.cpp_global_section = "// Global section" + + # Call the function + test_code = " // New generated code" + write_cpp(test_code) + + # Verify copy_src_tree was called + mock_copy_src_tree.assert_called_once() + + # Get the content that would be written + mock_write_file.assert_called_once() + written_path, written_content = mock_write_file.call_args[0] + + # Check that markers are preserved and content is updated + assert CPP_INCLUDE_BEGIN in written_content + assert CPP_INCLUDE_END in written_content + assert CPP_AUTO_GENERATE_BEGIN in written_content + assert CPP_AUTO_GENERATE_END in written_content + assert test_code in written_content + assert "// Global section" in written_content + + +@patch("esphome.writer.write_file_if_changed") # Mock to capture output +@patch("esphome.writer.copy_src_tree") # Keep this mock as it's complex +@patch("esphome.writer.CORE") +def test_write_cpp_creates_new_file( + mock_core: MagicMock, + mock_copy_src_tree: MagicMock, + mock_write_file: MagicMock, + tmp_path: Path, +) -> None: + """Test write_cpp when main.cpp doesn't exist.""" + # Setup path for new file + main_cpp = tmp_path / "main.cpp" + + # Setup mocks + mock_core.relative_src_path.return_value = str(main_cpp) + mock_core.cpp_global_section = "// Global section" + + # Verify file doesn't exist + assert not main_cpp.exists() + + # Call the function + test_code = " // Generated code" + write_cpp(test_code) + + # Verify copy_src_tree was called + mock_copy_src_tree.assert_called_once() + + # Get the content that would be written + mock_write_file.assert_called_once() + written_path, written_content = mock_write_file.call_args[0] + assert written_path == str(main_cpp) + + # Check that all necessary parts are in the new file + assert '#include "esphome.h"' in written_content + assert CPP_INCLUDE_BEGIN in written_content + assert CPP_INCLUDE_END in written_content + assert CPP_AUTO_GENERATE_BEGIN in written_content + assert CPP_AUTO_GENERATE_END in written_content + assert test_code in written_content + assert "void setup()" in written_content + assert "void loop()" in written_content + assert "App.setup();" in written_content + assert "App.loop();" in written_content + + +@pytest.mark.usefixtures("mock_copy_src_tree") +@patch("esphome.writer.CORE") +def test_write_cpp_with_missing_end_marker( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test write_cpp raises error when end marker is missing.""" + # Create a file with begin marker but no end marker + main_cpp = tmp_path / "main.cpp" + existing_content = f"""#include "esphome.h" +{CPP_AUTO_GENERATE_BEGIN} +// Code without end marker""" + main_cpp.write_text(existing_content) + + # Setup mocks + mock_core.relative_src_path.return_value = str(main_cpp) + + # Call should raise an error + with pytest.raises(EsphomeError, match="Could not find auto generated code end"): + write_cpp("// New code") + + +@pytest.mark.usefixtures("mock_copy_src_tree") +@patch("esphome.writer.CORE") +def test_write_cpp_with_duplicate_markers( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test write_cpp raises error when duplicate markers exist.""" + # Create a file with duplicate begin markers + main_cpp = tmp_path / "main.cpp" + existing_content = f"""#include "esphome.h" +{CPP_AUTO_GENERATE_BEGIN} +// First section +{CPP_AUTO_GENERATE_END} +{CPP_AUTO_GENERATE_BEGIN} +// Duplicate section +{CPP_AUTO_GENERATE_END}""" + main_cpp.write_text(existing_content) + + # Setup mocks + mock_core.relative_src_path.return_value = str(main_cpp) + + # Call should raise an error + with pytest.raises(EsphomeError, match="Found multiple auto generate code begins"): + write_cpp("// New code") From 28bba0666c13998b1c645b823787b71c06a84714 Mon Sep 17 00:00:00 2001 From: fakuivan Date: Fri, 12 Sep 2025 16:02:41 -0300 Subject: [PATCH 1067/1626] [packet_transport] initialize packet data after flushing (#10686) --- esphome/components/packet_transport/packet_transport.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index b6ce24bc1b..8bde4ee505 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -270,6 +270,7 @@ void PacketTransport::add_binary_data_(uint8_t key, const char *id, bool data) { auto len = 1 + 1 + 1 + strlen(id); if (len + this->header_.size() + this->data_.size() > this->get_max_packet_size()) { this->flush_(); + this->init_data_(); } add(this->data_, key); add(this->data_, (uint8_t) data); @@ -284,6 +285,7 @@ void PacketTransport::add_data_(uint8_t key, const char *id, uint32_t data) { auto len = 4 + 1 + 1 + strlen(id); if (len + this->header_.size() + this->data_.size() > this->get_max_packet_size()) { this->flush_(); + this->init_data_(); } add(this->data_, key); add(this->data_, data); From cf1fef8cfb83b92a08f52146dabcef4cb174ab8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Sep 2025 14:09:38 -0500 Subject: [PATCH 1068/1626] Bump pytest-asyncio from 1.1.0 to 1.2.0 (#10691) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 01661f3b7c..5ec9c98408 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ pre-commit pytest==8.4.2 pytest-cov==7.0.0 pytest-mock==3.15.0 -pytest-asyncio==1.1.0 +pytest-asyncio==1.2.0 pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 From 24eb33a1c02d4ef30cdfe1d3c44d9f5de4a3ea99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Sep 2025 16:04:56 -0500 Subject: [PATCH 1069/1626] Add additional dashboard and main tests (#10688) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/dashboard/test_web_server.py | 60 +++++++++ tests/unit_tests/test_main.py | 197 ++++++++++++++++++++++++++++- 2 files changed, 256 insertions(+), 1 deletion(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index a22f4a8b2a..e206090ac0 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -556,6 +556,66 @@ def test_start_web_server_with_address_port( assert (archive_dir / "old.yaml").exists() +@pytest.mark.asyncio +async def test_edit_request_handler_get(dashboard: DashboardTestHelper) -> None: + """Test EditRequestHandler.get method.""" + # Test getting a valid yaml file + response = await dashboard.fetch("/edit?configuration=pico.yaml") + assert response.code == 200 + assert response.headers["content-type"] == "application/yaml" + content = response.body.decode() + assert "esphome:" in content # Verify it's a valid ESPHome config + + # Test getting a non-existent file + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/edit?configuration=nonexistent.yaml") + assert exc_info.value.code == 404 + + # Test getting a non-yaml file + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/edit?configuration=test.txt") + assert exc_info.value.code == 404 + + # Test path traversal attempt + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/edit?configuration=../../../etc/passwd") + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +async def test_archive_request_handler_post( + dashboard: DashboardTestHelper, + mock_archive_storage_path: MagicMock, + mock_ext_storage_path: MagicMock, + tmp_path: Path, +) -> None: + """Test ArchiveRequestHandler.post method.""" + + # Set up temp directories + config_dir = Path(get_fixture_path("conf")) + archive_dir = tmp_path / "archive" + + # Create a test configuration file + test_config = config_dir / "test_archive.yaml" + test_config.write_text("esphome:\n name: test_archive\n") + + # Archive the configuration + response = await dashboard.fetch( + "/archive", + method="POST", + body="configuration=test_archive.yaml", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 + + # Verify file was moved to archive + assert not test_config.exists() + assert (archive_dir / "test_archive.yaml").exists() + assert ( + archive_dir / "test_archive.yaml" + ).read_text() == "esphome:\n name: test_archive\n" + + @pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") @pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 41b62ec196..96ee43a55f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -9,18 +9,27 @@ from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest +from pytest import CaptureFixture -from esphome.__main__ import choose_upload_log_host, show_logs, upload_program +from esphome.__main__ import ( + choose_upload_log_host, + command_rename, + command_wizard, + show_logs, + upload_program, +) from esphome.const import ( CONF_BROKER, CONF_DISABLED, CONF_ESPHOME, CONF_MDNS, CONF_MQTT, + CONF_NAME, CONF_OTA, CONF_PASSWORD, CONF_PLATFORM, CONF_PORT, + CONF_SUBSTITUTIONS, CONF_USE_ADDRESS, CONF_WIFI, KEY_CORE, @@ -170,6 +179,14 @@ def mock_has_mqtt_logging() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_run_external_process() -> Generator[Mock]: + """Mock run_external_process for testing.""" + with patch("esphome.__main__.run_external_process") as mock: + mock.return_value = 0 # Default to success + yield mock + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" result = choose_upload_log_host( @@ -606,6 +623,9 @@ class MockArgs: password: str | None = None client_id: str | None = None topic: str | None = None + configuration: str | None = None + name: str | None = None + dashboard: bool = False def test_upload_program_serial_esp32( @@ -1053,3 +1073,178 @@ def test_show_logs_platform_specific_handler( assert result == 0 mock_import.assert_called_once_with("esphome.components.custom_platform") mock_module.show_logs.assert_called_once_with(config, args, devices) + + +def test_command_wizard(tmp_path: Path) -> None: + """Test command_wizard function.""" + config_file = tmp_path / "test.yaml" + + # Mock wizard.wizard to avoid interactive prompts + with patch("esphome.wizard.wizard") as mock_wizard: + mock_wizard.return_value = 0 + + args = MockArgs(configuration=str(config_file)) + result = command_wizard(args) + + assert result == 0 + mock_wizard.assert_called_once_with(str(config_file)) + + +def test_command_rename_invalid_characters( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """Test command_rename with invalid characters in name.""" + setup_core(tmp_path=tmp_path) + + # Test with invalid character (space) + args = MockArgs(name="invalid name") + result = command_rename(args, {}) + + assert result == 1 + captured = capfd.readouterr() + assert "invalid character" in captured.out.lower() + + +def test_command_rename_complex_yaml( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """Test command_rename with complex YAML that cannot be renamed.""" + config_file = tmp_path / "test.yaml" + config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = str(config_file) + + args = MockArgs(name="newname") + result = command_rename(args, {}) + + assert result == 1 + captured = capfd.readouterr() + assert "complex yaml" in captured.out.lower() + + +def test_command_rename_success( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test successful rename of a simple configuration.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s + +wifi: + ssid: "test" + password: "test1234" +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = str(config_file) + + # Set up CORE.config to avoid ValueError when accessing CORE.address + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + + # Simulate successful validation and upload + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + # Verify new file was created + new_file = tmp_path / "newname.yaml" + assert new_file.exists() + + # Verify old file was removed + assert not config_file.exists() + + # Verify content was updated + content = new_file.read_text() + assert ( + 'name: "newname"' in content + or "name: 'newname'" in content + or "name: newname" in content + ) + + captured = capfd.readouterr() + assert "SUCCESS" in captured.out + + +def test_command_rename_with_substitutions( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename with substitutions in YAML.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +substitutions: + device_name: oldname + +esphome: + name: ${device_name} + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = str(config_file) + + # Set up CORE.config to avoid ValueError when accessing CORE.address + CORE.config = { + CONF_ESPHOME: {CONF_NAME: "oldname"}, + CONF_SUBSTITUTIONS: {"device_name": "oldname"}, + } + + args = MockArgs(name="newname", dashboard=False) + + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + # Verify substitution was updated + new_file = tmp_path / "newname.yaml" + content = new_file.read_text() + assert 'device_name: "newname"' in content + + +def test_command_rename_validation_failure( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename when validation fails.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = str(config_file) + + args = MockArgs(name="newname", dashboard=False) + + # First call for validation fails + mock_run_external_process.return_value = 1 + + result = command_rename(args, {}) + + assert result == 1 + + # Verify new file was created but then removed due to failure + new_file = tmp_path / "newname.yaml" + assert not new_file.exists() + + # Verify old file still exists (not removed on failure) + assert config_file.exists() + + captured = capfd.readouterr() + assert "Rename failed" in captured.out From d3592c451bc8570419126c8d753ec05ff5c8d55e Mon Sep 17 00:00:00 2001 From: Markus <974709+Links2004@users.noreply.github.com> Date: Fri, 12 Sep 2025 23:31:53 +0200 Subject: [PATCH 1070/1626] [core] fix upload to device via MQTT IP lookup (e.g. when mDNS is disable) (#10632) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/__main__.py | 210 +++++++++----- esphome/const.py | 1 + tests/unit_tests/test_main.py | 523 ++++++++++++++++++++++++++-------- 3 files changed, 547 insertions(+), 187 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index bba254436e..0147a82530 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -15,9 +15,11 @@ import argcomplete from esphome import const, writer, yaml_util import esphome.codegen as cg +from esphome.components.mqtt import CONF_DISCOVER_IP from esphome.config import iter_component_configs, read_config, strip_default_ids from esphome.const import ( ALLOWED_NAME_CHARS, + CONF_API, CONF_BAUD_RATE, CONF_BROKER, CONF_DEASSERT_RTS_DTR, @@ -43,6 +45,7 @@ from esphome.const import ( SECRETS_FILES, ) from esphome.core import CORE, EsphomeError, coroutine +from esphome.enum import StrEnum from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log from esphome.types import ConfigType @@ -106,13 +109,15 @@ def choose_prompt(options, purpose: str = None): return options[opt - 1][1] +class Purpose(StrEnum): + UPLOADING = "uploading" + LOGGING = "logging" + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, - show_ota: bool, - show_mqtt: bool, - show_api: bool, - purpose: str | None = None, + purpose: Purpose, ) -> list[str]: # Convert to list for uniform handling defaults = [default] if isinstance(default, str) else default or [] @@ -132,13 +137,30 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": - if CORE.address and ( - (show_ota and "ota" in CORE.config) - or (show_api and "api" in CORE.config) + # ensure IP adresses are used first + if is_ip_address(CORE.address) and ( + (purpose == Purpose.LOGGING and has_api()) + or (purpose == Purpose.UPLOADING and has_ota()) ): resolved.append(CORE.address) - elif show_mqtt and has_mqtt_logging(): - resolved.append("MQTT") + + if purpose == Purpose.LOGGING: + if has_api() and has_mqtt_ip_lookup(): + resolved.append("MQTTIP") + + if has_mqtt_logging(): + resolved.append("MQTT") + + if has_api() and has_non_ip_address(): + resolved.append(CORE.address) + + elif purpose == Purpose.UPLOADING: + if has_ota() and has_mqtt_ip_lookup(): + resolved.append("MQTTIP") + + if has_ota() and has_non_ip_address(): + resolved.append(CORE.address) + else: resolved.append(device) if not resolved: @@ -149,39 +171,111 @@ def choose_upload_log_host( options = [ (f"{port.path} ({port.description})", port.path) for port in get_serial_ports() ] - if (show_ota and "ota" in CORE.config) or (show_api and "api" in CORE.config): - options.append((f"Over The Air ({CORE.address})", CORE.address)) - if show_mqtt and has_mqtt_logging(): - mqtt_config = CORE.config[CONF_MQTT] - options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) + + if purpose == Purpose.LOGGING: + if has_mqtt_logging(): + mqtt_config = CORE.config[CONF_MQTT] + options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) + + if has_api(): + if has_resolvable_address(): + options.append((f"Over The Air ({CORE.address})", CORE.address)) + if has_mqtt_ip_lookup(): + options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + + elif purpose == Purpose.UPLOADING and has_ota(): + if has_resolvable_address(): + options.append((f"Over The Air ({CORE.address})", CORE.address)) + if has_mqtt_ip_lookup(): + options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) if check_default is not None and check_default in [opt[1] for opt in options]: return [check_default] return [choose_prompt(options, purpose=purpose)] -def mqtt_logging_enabled(mqtt_config): +def has_mqtt_logging() -> bool: + """Check if MQTT logging is available.""" + if CONF_MQTT not in CORE.config: + return False + + mqtt_config = CORE.config[CONF_MQTT] + + # enabled by default + if CONF_LOG_TOPIC not in mqtt_config: + return True + log_topic = mqtt_config[CONF_LOG_TOPIC] if log_topic is None: return False + if CONF_TOPIC not in log_topic: return False - return log_topic.get(CONF_LEVEL, None) != "NONE" + + return log_topic[CONF_LEVEL] != "NONE" -def has_mqtt_logging() -> bool: - """Check if MQTT logging is available.""" - return (mqtt_config := CORE.config.get(CONF_MQTT)) and mqtt_logging_enabled( - mqtt_config - ) +def has_mqtt() -> bool: + """Check if MQTT is available.""" + return CONF_MQTT in CORE.config + + +def has_api() -> bool: + """Check if API is available.""" + return CONF_API in CORE.config + + +def has_ota() -> bool: + """Check if OTA is available.""" + return CONF_OTA in CORE.config + + +def has_mqtt_ip_lookup() -> bool: + """Check if MQTT is available and IP lookup is supported.""" + if CONF_MQTT not in CORE.config: + return False + # Default Enabled + if CONF_DISCOVER_IP not in CORE.config[CONF_MQTT]: + return True + return CORE.config[CONF_MQTT][CONF_DISCOVER_IP] + + +def has_mdns() -> bool: + """Check if MDNS is available.""" + return CONF_MDNS not in CORE.config or not CORE.config[CONF_MDNS][CONF_DISABLED] + + +def has_non_ip_address() -> bool: + """Check if CORE.address is set and is not an IP address.""" + return CORE.address is not None and not is_ip_address(CORE.address) + + +def has_ip_address() -> bool: + """Check if CORE.address is a valid IP address.""" + return CORE.address is not None and is_ip_address(CORE.address) + + +def has_resolvable_address() -> bool: + """Check if CORE.address is resolvable (via mDNS or is an IP address).""" + return has_mdns() or has_ip_address() + + +def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str): + from esphome import mqtt + + return mqtt.get_esphome_device_ip(config, username, password, client_id) + + +_PORT_TO_PORT_TYPE = { + "MQTT": "MQTT", + "MQTTIP": "MQTTIP", +} def get_port_type(port: str) -> str: if port.startswith("/") or port.startswith("COM"): return "SERIAL" - if port == "MQTT": - return "MQTT" - return "NETWORK" + return _PORT_TO_PORT_TYPE.get(port, "NETWORK") def run_miniterm(config: ConfigType, port: str, args) -> int: @@ -439,23 +533,9 @@ def upload_program( password = ota_conf.get(CONF_PASSWORD, "") binary = args.file if getattr(args, "file", None) is not None else CORE.firmware_bin - # Check if we should use MQTT for address resolution - # This happens when no device was specified, or the current host is "MQTT"/"OTA" - if ( - CONF_MQTT in config # pylint: disable=too-many-boolean-expressions - and (not devices or host in ("MQTT", "OTA")) - and ( - ((config[CONF_MDNS][CONF_DISABLED]) and not is_ip_address(CORE.address)) - or get_port_type(host) == "MQTT" - ) - ): - from esphome import mqtt - - devices = [ - mqtt.get_esphome_device_ip( - config, args.username, args.password, args.client_id - ) - ] + # MQTT address resolution + if get_port_type(host) in ("MQTT", "MQTTIP"): + devices = mqtt_get_ip(config, args.username, args.password, args.client_id) return espota2.run_ota(devices, remote_port, password, binary) @@ -476,20 +556,28 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int if get_port_type(port) == "SERIAL": check_permissions(port) return run_miniterm(config, port, args) - if get_port_type(port) == "NETWORK" and "api" in config: - addresses_to_use = devices - if config[CONF_MDNS][CONF_DISABLED] and CONF_MQTT in config: - from esphome import mqtt - mqtt_address = mqtt.get_esphome_device_ip( + port_type = get_port_type(port) + + # Check if we should use API for logging + if has_api(): + addresses_to_use: list[str] | None = None + + if port_type == "NETWORK" and (has_mdns() or is_ip_address(port)): + addresses_to_use = devices + elif port_type in ("NETWORK", "MQTT", "MQTTIP") and has_mqtt_ip_lookup(): + # Only use MQTT IP lookup if the first condition didn't match + # (for MQTT/MQTTIP types, or for NETWORK when mdns/ip check fails) + addresses_to_use = mqtt_get_ip( config, args.username, args.password, args.client_id - )[0] - addresses_to_use = [mqtt_address] + ) - from esphome.components.api.client import run_logs + if addresses_to_use is not None: + from esphome.components.api.client import run_logs - return run_logs(config, addresses_to_use) - if get_port_type(port) in ("NETWORK", "MQTT") and "mqtt" in config: + return run_logs(config, addresses_to_use) + + if port_type in ("NETWORK", "MQTT") and has_mqtt_logging(): from esphome import mqtt return mqtt.show_logs( @@ -555,10 +643,7 @@ def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: devices = choose_upload_log_host( default=args.device, check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, - purpose="uploading", + purpose=Purpose.UPLOADING, ) exit_code, _ = upload_program(config, args, devices) @@ -583,10 +668,7 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: devices = choose_upload_log_host( default=args.device, check_default=None, - show_ota=False, - show_mqtt=True, - show_api=True, - purpose="logging", + purpose=Purpose.LOGGING, ) return show_logs(config, args, devices) @@ -612,10 +694,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: devices = choose_upload_log_host( default=args.device, check_default=None, - show_ota=True, - show_mqtt=False, - show_api=True, - purpose="uploading", + purpose=Purpose.UPLOADING, ) exit_code, successful_device = upload_program(config, args, devices) @@ -632,10 +711,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: devices = choose_upload_log_host( default=successful_device, check_default=successful_device, - show_ota=False, - show_mqtt=True, - show_api=True, - purpose="logging", + purpose=Purpose.LOGGING, ) return show_logs(config, args, devices) diff --git a/esphome/const.py b/esphome/const.py index 308abe7706..677b9173ec 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -114,6 +114,7 @@ CONF_AND = "and" CONF_ANGLE = "angle" CONF_ANY = "any" CONF_AP = "ap" +CONF_API = "api" CONF_APPARENT_POWER = "apparent_power" CONF_ARDUINO_VERSION = "arduino_version" CONF_AREA = "area" diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 96ee43a55f..bfebb44545 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -12,16 +12,28 @@ import pytest from pytest import CaptureFixture from esphome.__main__ import ( + Purpose, choose_upload_log_host, command_rename, command_wizard, + get_port_type, + has_ip_address, + has_mqtt, + has_mqtt_ip_lookup, + has_mqtt_logging, + has_non_ip_address, + has_resolvable_address, + mqtt_get_ip, show_logs, upload_program, ) from esphome.const import ( + CONF_API, CONF_BROKER, CONF_DISABLED, CONF_ESPHOME, + CONF_LEVEL, + CONF_LOG_TOPIC, CONF_MDNS, CONF_MQTT, CONF_NAME, @@ -30,6 +42,7 @@ from esphome.const import ( CONF_PLATFORM, CONF_PORT, CONF_SUBSTITUTIONS, + CONF_TOPIC, CONF_USE_ADDRESS, CONF_WIFI, KEY_CORE, @@ -147,6 +160,13 @@ def mock_is_ip_address() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_mqtt_get_ip() -> Generator[Mock]: + """Mock mqtt_get_ip for testing.""" + with patch("esphome.__main__.mqtt_get_ip") as mock: + yield mock + + @pytest.fixture def mock_serial_ports() -> Generator[Mock]: """Mock get_serial_ports to return test ports.""" @@ -189,62 +209,56 @@ def mock_run_external_process() -> Generator[Mock]: def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" + setup_core() result = choose_upload_log_host( default="192.168.1.100", check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] def test_choose_upload_log_host_with_list_default() -> None: """Test with a list of default devices.""" + setup_core() result = choose_upload_log_host( default=["192.168.1.100", "192.168.1.101"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100", "192.168.1.101"] def test_choose_upload_log_host_with_multiple_ip_addresses() -> None: """Test with multiple IP addresses as defaults.""" + setup_core() result = choose_upload_log_host( default=["1.2.3.4", "4.5.5.6"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.LOGGING, ) assert result == ["1.2.3.4", "4.5.5.6"] def test_choose_upload_log_host_with_mixed_hostnames_and_ips() -> None: """Test with a mix of hostnames and IP addresses.""" + setup_core() result = choose_upload_log_host( default=["host.one", "host.one.local", "1.2.3.4"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["host.one", "host.one.local", "1.2.3.4"] def test_choose_upload_log_host_with_ota_list() -> None: """Test with OTA as the only item in the list.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") result = choose_upload_log_host( default=["OTA"], check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] @@ -252,16 +266,27 @@ def test_choose_upload_log_host_with_ota_list() -> None: @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None: """Test with OTA list falling back to MQTT when no address.""" - setup_core() + setup_core(config={CONF_OTA: {}, "mqtt": {}}) result = choose_upload_log_host( default=["OTA"], check_default=None, - show_ota=False, - show_mqtt=True, - show_api=False, + purpose=Purpose.UPLOADING, ) - assert result == ["MQTT"] + assert result == ["MQTTIP"] + + +@pytest.mark.usefixtures("mock_has_mqtt_logging") +def test_choose_upload_log_host_with_ota_list_mqtt_fallback_logging() -> None: + """Test with OTA list with API and MQTT when no address.""" + setup_core(config={CONF_API: {}, "mqtt": {}}) + + result = choose_upload_log_host( + default=["OTA"], + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["MQTTIP", "MQTT"] @pytest.mark.usefixtures("mock_no_serial_ports") @@ -269,12 +294,11 @@ def test_choose_upload_log_host_with_serial_device_no_ports( caplog: pytest.LogCaptureFixture, ) -> None: """Test SERIAL device when no serial ports are found.""" + setup_core() result = choose_upload_log_host( default="SERIAL", check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == [] assert "No serial ports found, skipping SERIAL device" in caplog.text @@ -285,13 +309,11 @@ def test_choose_upload_log_host_with_serial_device_with_ports( mock_choose_prompt: Mock, ) -> None: """Test SERIAL device when serial ports are available.""" + setup_core() result = choose_upload_log_host( default="SERIAL", check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, - purpose="testing", + purpose=Purpose.UPLOADING, ) assert result == ["/dev/ttyUSB0"] mock_choose_prompt.assert_called_once_with( @@ -299,34 +321,42 @@ def test_choose_upload_log_host_with_serial_device_with_ports( ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), ("/dev/ttyUSB1 (Another USB Serial)", "/dev/ttyUSB1"), ], - purpose="testing", + purpose=Purpose.UPLOADING, ) def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: """Test OTA device when OTA is configured.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: - """Test OTA device when API is configured.""" - setup_core(config={"api": {}}, address="192.168.1.100") + """Test OTA device when API is configured (no upload without OTA in config).""" + setup_core(config={CONF_API: {}}, address="192.168.1.100") result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=False, - show_mqtt=False, - show_api=True, + purpose=Purpose.UPLOADING, + ) + assert result == [] + + +def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> None: + """Test OTA device when API is configured.""" + setup_core(config={CONF_API: {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, ) assert result == ["192.168.1.100"] @@ -334,14 +364,12 @@ def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: """Test OTA device fallback to MQTT when no OTA/API config.""" - setup_core() + setup_core(config={"mqtt": {}}) result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=False, - show_mqtt=True, - show_api=False, + purpose=Purpose.LOGGING, ) assert result == ["MQTT"] @@ -354,9 +382,7 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None: result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=True, - show_mqtt=True, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == [] @@ -364,7 +390,7 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None: @pytest.mark.usefixtures("mock_choose_prompt") def test_choose_upload_log_host_multiple_devices() -> None: """Test with multiple devices including special identifiers.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] @@ -372,9 +398,7 @@ def test_choose_upload_log_host_multiple_devices() -> None: result = choose_upload_log_host( default=["192.168.1.50", "OTA", "SERIAL"], check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.50", "192.168.1.100", "/dev/ttyUSB0"] @@ -393,22 +417,19 @@ def test_choose_upload_log_host_no_defaults_with_serial_ports( result = choose_upload_log_host( default=None, check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, - purpose="uploading", + purpose=Purpose.UPLOADING, ) assert result == ["/dev/ttyUSB0"] mock_choose_prompt.assert_called_once_with( [("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0")], - purpose="uploading", + purpose=Purpose.UPLOADING, ) @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_no_defaults_with_ota() -> None: """Test interactive mode with OTA option.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") with patch( "esphome.__main__.choose_prompt", return_value="192.168.1.100" @@ -416,21 +437,19 @@ def test_choose_upload_log_host_no_defaults_with_ota() -> None: result = choose_upload_log_host( default=None, check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] mock_prompt.assert_called_once_with( [("Over The Air (192.168.1.100)", "192.168.1.100")], - purpose=None, + purpose=Purpose.UPLOADING, ) @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_no_defaults_with_api() -> None: """Test interactive mode with API option.""" - setup_core(config={"api": {}}, address="192.168.1.100") + setup_core(config={CONF_API: {}}, address="192.168.1.100") with patch( "esphome.__main__.choose_prompt", return_value="192.168.1.100" @@ -438,14 +457,12 @@ def test_choose_upload_log_host_no_defaults_with_api() -> None: result = choose_upload_log_host( default=None, check_default=None, - show_ota=False, - show_mqtt=False, - show_api=True, + purpose=Purpose.LOGGING, ) assert result == ["192.168.1.100"] mock_prompt.assert_called_once_with( [("Over The Air (192.168.1.100)", "192.168.1.100")], - purpose=None, + purpose=Purpose.LOGGING, ) @@ -458,14 +475,12 @@ def test_choose_upload_log_host_no_defaults_with_mqtt() -> None: result = choose_upload_log_host( default=None, check_default=None, - show_ota=False, - show_mqtt=True, - show_api=False, + purpose=Purpose.LOGGING, ) assert result == ["MQTT"] mock_prompt.assert_called_once_with( [("MQTT (mqtt.local)", "MQTT")], - purpose=None, + purpose=Purpose.LOGGING, ) @@ -475,7 +490,7 @@ def test_choose_upload_log_host_no_defaults_with_all_options( ) -> None: """Test interactive mode with all options available.""" setup_core( - config={"ota": {}, "api": {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, + config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, address="192.168.1.100", ) @@ -485,32 +500,59 @@ def test_choose_upload_log_host_no_defaults_with_all_options( result = choose_upload_log_host( default=None, check_default=None, - show_ota=True, - show_mqtt=True, - show_api=True, - purpose="testing", + purpose=Purpose.UPLOADING, ) assert result == ["/dev/ttyUSB0"] expected_options = [ ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), ("Over The Air (192.168.1.100)", "192.168.1.100"), - ("MQTT (mqtt.local)", "MQTT"), + ("Over The Air (MQTT IP lookup)", "MQTTIP"), ] - mock_choose_prompt.assert_called_once_with(expected_options, purpose="testing") + mock_choose_prompt.assert_called_once_with( + expected_options, purpose=Purpose.UPLOADING + ) + + +def test_choose_upload_log_host_no_defaults_with_all_options_logging( + mock_choose_prompt: Mock, +) -> None: + """Test interactive mode with all options available.""" + setup_core( + config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, + address="192.168.1.100", + ) + + mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] + + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): + result = choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["/dev/ttyUSB0"] + + expected_options = [ + ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), + ("MQTT (mqtt.local)", "MQTT"), + ("Over The Air (192.168.1.100)", "192.168.1.100"), + ("Over The Air (MQTT IP lookup)", "MQTTIP"), + ] + mock_choose_prompt.assert_called_once_with( + expected_options, purpose=Purpose.LOGGING + ) @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_check_default_matches() -> None: """Test when check_default matches an available option.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") result = choose_upload_log_host( default=None, check_default="192.168.1.100", - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] @@ -526,9 +568,7 @@ def test_choose_upload_log_host_check_default_no_match() -> None: result = choose_upload_log_host( default=None, check_default="192.168.1.100", - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["fallback"] mock_prompt.assert_called_once() @@ -537,13 +577,12 @@ def test_choose_upload_log_host_check_default_no_match() -> None: @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_empty_defaults_list() -> None: """Test with an empty list as default.""" + setup_core() with patch("esphome.__main__.choose_prompt", return_value="chosen") as mock_prompt: result = choose_upload_log_host( default=[], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["chosen"] mock_prompt.assert_called_once() @@ -559,9 +598,7 @@ def test_choose_upload_log_host_all_devices_unresolved( result = choose_upload_log_host( default=["SERIAL", "OTA"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == [] assert ( @@ -577,38 +614,132 @@ def test_choose_upload_log_host_mixed_resolved_unresolved() -> None: result = choose_upload_log_host( default=["192.168.1.50", "SERIAL", "OTA"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.50"] def test_choose_upload_log_host_ota_both_conditions() -> None: """Test OTA device when both OTA and API are configured and enabled.""" - setup_core(config={"ota": {}, "api": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}, CONF_API: {}}, address="192.168.1.100") result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=True, - show_mqtt=False, - show_api=True, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_ota_ip_all_options() -> None: + """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" + setup_core( + config={ + CONF_OTA: {}, + CONF_API: {}, + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + }, + address="192.168.1.100", + ) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) + assert result == ["192.168.1.100", "MQTTIP"] + + +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_ota_local_all_options() -> None: + """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" + setup_core( + config={ + CONF_OTA: {}, + CONF_API: {}, + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + }, + address="test.local", + ) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) + assert result == ["MQTTIP", "test.local"] + + +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_ota_ip_all_options_logging() -> None: + """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" + setup_core( + config={ + CONF_OTA: {}, + CONF_API: {}, + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + }, + address="192.168.1.100", + ) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["192.168.1.100", "MQTTIP", "MQTT"] + + +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_ota_local_all_options_logging() -> None: + """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" + setup_core( + config={ + CONF_OTA: {}, + CONF_API: {}, + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + }, + address="test.local", + ) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["MQTTIP", "MQTT", "test.local"] + + @pytest.mark.usefixtures("mock_no_mqtt_logging") def test_choose_upload_log_host_no_address_with_ota_config() -> None: """Test OTA device when OTA is configured but no address is set.""" - setup_core(config={"ota": {}}) + setup_core(config={CONF_OTA: {}}) result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == [] @@ -806,18 +937,15 @@ def test_upload_program_ota_no_config( upload_program(config, args, devices) -@patch("esphome.mqtt.get_esphome_device_ip") def test_upload_program_ota_with_mqtt_resolution( mock_mqtt_get_ip: Mock, mock_is_ip_address: Mock, mock_run_ota: Mock, - mock_get_port_type: Mock, tmp_path: Path, ) -> None: """Test upload_program with OTA using MQTT for address resolution.""" setup_core(address="device.local", platform=PLATFORM_ESP32, tmp_path=tmp_path) - mock_get_port_type.side_effect = ["MQTT", "NETWORK"] mock_is_ip_address.return_value = False mock_mqtt_get_ip.return_value = ["192.168.1.100"] mock_run_ota.return_value = (0, "192.168.1.100") @@ -847,9 +975,7 @@ def test_upload_program_ota_with_mqtt_resolution( expected_firmware = str( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) - mock_run_ota.assert_called_once_with( - [["192.168.1.100"]], 3232, "", expected_firmware - ) + mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", expected_firmware) @patch("esphome.__main__.importlib.import_module") @@ -910,18 +1036,16 @@ def test_show_logs_no_logger() -> None: @patch("esphome.components.api.client.run_logs") def test_show_logs_api( mock_run_logs: Mock, - mock_get_port_type: Mock, ) -> None: """Test show_logs with API.""" setup_core( config={ "logger": {}, - "api": {}, + CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}, }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "NETWORK" mock_run_logs.return_value = 0 args = MockArgs() @@ -935,24 +1059,21 @@ def test_show_logs_api( ) -@patch("esphome.mqtt.get_esphome_device_ip") @patch("esphome.components.api.client.run_logs") def test_show_logs_api_with_mqtt_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, - mock_get_port_type: Mock, ) -> None: """Test show_logs with API using MQTT for address resolution.""" setup_core( config={ "logger": {}, - "api": {}, + CONF_API: {}, CONF_MDNS: {CONF_DISABLED: True}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}, }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "NETWORK" mock_run_logs.return_value = 0 mock_mqtt_get_ip.return_value = ["192.168.1.200"] @@ -969,7 +1090,6 @@ def test_show_logs_api_with_mqtt_fallback( @patch("esphome.mqtt.show_logs") def test_show_logs_mqtt( mock_mqtt_show_logs: Mock, - mock_get_port_type: Mock, ) -> None: """Test show_logs with MQTT.""" setup_core( @@ -979,7 +1099,6 @@ def test_show_logs_mqtt( }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "MQTT" mock_mqtt_show_logs.return_value = 0 args = MockArgs( @@ -1001,7 +1120,6 @@ def test_show_logs_mqtt( @patch("esphome.mqtt.show_logs") def test_show_logs_network_with_mqtt_only( mock_mqtt_show_logs: Mock, - mock_get_port_type: Mock, ) -> None: """Test show_logs with network port but only MQTT configured.""" setup_core( @@ -1012,7 +1130,6 @@ def test_show_logs_network_with_mqtt_only( }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "NETWORK" mock_mqtt_show_logs.return_value = 0 args = MockArgs( @@ -1031,9 +1148,7 @@ def test_show_logs_network_with_mqtt_only( ) -def test_show_logs_no_method_configured( - mock_get_port_type: Mock, -) -> None: +def test_show_logs_no_method_configured() -> None: """Test show_logs when no remote logging method is configured.""" setup_core( config={ @@ -1042,7 +1157,6 @@ def test_show_logs_no_method_configured( }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "NETWORK" args = MockArgs() devices = ["192.168.1.100"] @@ -1075,6 +1189,175 @@ def test_show_logs_platform_specific_handler( mock_module.show_logs.assert_called_once_with(config, args, devices) +def test_has_mqtt_logging_no_log_topic() -> None: + """Test has_mqtt_logging returns True when CONF_LOG_TOPIC is not in mqtt_config.""" + + # Setup MQTT config without CONF_LOG_TOPIC (defaults to enabled - this is the missing test case) + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) + assert has_mqtt_logging() is True + + # Setup MQTT config with CONF_LOG_TOPIC set to None (explicitly disabled) + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local", CONF_LOG_TOPIC: None}}) + assert has_mqtt_logging() is False + + # Setup MQTT config with CONF_LOG_TOPIC set with topic and level (explicitly enabled) + setup_core( + config={ + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + CONF_LOG_TOPIC: {CONF_TOPIC: "esphome/logs", CONF_LEVEL: "DEBUG"}, + } + } + ) + assert has_mqtt_logging() is True + + # Setup MQTT config with CONF_LOG_TOPIC set but level is NONE (disabled) + setup_core( + config={ + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + CONF_LOG_TOPIC: {CONF_TOPIC: "esphome/logs", CONF_LEVEL: "NONE"}, + } + } + ) + assert has_mqtt_logging() is False + + # Setup without MQTT config at all + setup_core(config={}) + assert has_mqtt_logging() is False + + +def test_has_mqtt() -> None: + """Test has_mqtt function.""" + + # Test with MQTT configured + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) + assert has_mqtt() is True + + # Test without MQTT configured + setup_core(config={}) + assert has_mqtt() is False + + # Test with other components but no MQTT + setup_core(config={CONF_API: {}, CONF_OTA: {}}) + assert has_mqtt() is False + + +def test_get_port_type() -> None: + """Test get_port_type function.""" + + assert get_port_type("/dev/ttyUSB0") == "SERIAL" + assert get_port_type("/dev/ttyACM0") == "SERIAL" + assert get_port_type("COM1") == "SERIAL" + assert get_port_type("COM10") == "SERIAL" + + assert get_port_type("MQTT") == "MQTT" + assert get_port_type("MQTTIP") == "MQTTIP" + + assert get_port_type("192.168.1.100") == "NETWORK" + assert get_port_type("esphome-device.local") == "NETWORK" + assert get_port_type("10.0.0.1") == "NETWORK" + + +def test_has_mqtt_ip_lookup() -> None: + """Test has_mqtt_ip_lookup function.""" + + CONF_DISCOVER_IP = "discover_ip" + + setup_core(config={}) + assert has_mqtt_ip_lookup() is False + + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) + assert has_mqtt_ip_lookup() is True + + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local", CONF_DISCOVER_IP: True}}) + assert has_mqtt_ip_lookup() is True + + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local", CONF_DISCOVER_IP: False}}) + assert has_mqtt_ip_lookup() is False + + +def test_has_non_ip_address() -> None: + """Test has_non_ip_address function.""" + + setup_core(address=None) + assert has_non_ip_address() is False + + setup_core(address="192.168.1.100") + assert has_non_ip_address() is False + + setup_core(address="10.0.0.1") + assert has_non_ip_address() is False + + setup_core(address="esphome-device.local") + assert has_non_ip_address() is True + + setup_core(address="my-device") + assert has_non_ip_address() is True + + +def test_has_ip_address() -> None: + """Test has_ip_address function.""" + + setup_core(address=None) + assert has_ip_address() is False + + setup_core(address="192.168.1.100") + assert has_ip_address() is True + + setup_core(address="10.0.0.1") + assert has_ip_address() is True + + setup_core(address="esphome-device.local") + assert has_ip_address() is False + + setup_core(address="my-device") + assert has_ip_address() is False + + +def test_mqtt_get_ip() -> None: + """Test mqtt_get_ip function.""" + config = {CONF_MQTT: {CONF_BROKER: "mqtt.local"}} + + with patch("esphome.mqtt.get_esphome_device_ip") as mock_get_ip: + mock_get_ip.return_value = ["192.168.1.100", "192.168.1.101"] + + result = mqtt_get_ip(config, "user", "pass", "client-id") + + assert result == ["192.168.1.100", "192.168.1.101"] + mock_get_ip.assert_called_once_with(config, "user", "pass", "client-id") + + +def test_has_resolvable_address() -> None: + """Test has_resolvable_address function.""" + + # Test with mDNS enabled and hostname address + setup_core(config={}, address="esphome-device.local") + assert has_resolvable_address() is True + + # Test with mDNS disabled and hostname address + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + assert has_resolvable_address() is False + + # Test with IP address (mDNS doesn't matter) + setup_core(config={}, address="192.168.1.100") + assert has_resolvable_address() is True + + # Test with IP address and mDNS disabled + setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address="192.168.1.100") + assert has_resolvable_address() is True + + # Test with no address but mDNS enabled (can still resolve mDNS names) + setup_core(config={}, address=None) + assert has_resolvable_address() is True + + # Test with no address and mDNS disabled + setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None) + assert has_resolvable_address() is False + + def test_command_wizard(tmp_path: Path) -> None: """Test command_wizard function.""" config_file = tmp_path / "test.yaml" From 4b15421d428d038583d6b8cb48c92096d2becc7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Sep 2025 16:36:57 -0500 Subject: [PATCH 1071/1626] dry --- esphome/__main__.py | 39 +++++++++++---------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 29ddabcd19..b04b74c15b 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -114,6 +114,14 @@ class Purpose(StrEnum): LOGGING = "logging" +def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]: + """Resolve an address using cache if available, otherwise return the address itself.""" + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(address)): + _LOGGER.debug("Using cached addresses for %s: %s", purpose.value, cached) + return cached + return [address] + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, @@ -142,14 +150,7 @@ def choose_upload_log_host( (purpose == Purpose.LOGGING and has_api()) or (purpose == Purpose.UPLOADING and has_ota()) ): - # Check if we have cached addresses for CORE.address - if CORE.address_cache and ( - cached := CORE.address_cache.get_addresses(CORE.address) - ): - _LOGGER.debug("Using cached addresses for OTA: %s", cached) - resolved.extend(cached) - else: - resolved.append(CORE.address) + resolved.extend(_resolve_with_cache(CORE.address, purpose)) if purpose == Purpose.LOGGING: if has_api() and has_mqtt_ip_lookup(): @@ -159,32 +160,14 @@ def choose_upload_log_host( resolved.append("MQTT") if has_api() and has_non_ip_address(): - # Check if we have cached addresses for CORE.address - if CORE.address_cache and ( - cached := CORE.address_cache.get_addresses(CORE.address) - ): - _LOGGER.debug( - "Using cached addresses for logging: %s", cached - ) - resolved.extend(cached) - else: - resolved.append(CORE.address) + resolved.extend(_resolve_with_cache(CORE.address, purpose)) elif purpose == Purpose.UPLOADING: if has_ota() and has_mqtt_ip_lookup(): resolved.append("MQTTIP") if has_ota() and has_non_ip_address(): - # Check if we have cached addresses for CORE.address - if CORE.address_cache and ( - cached := CORE.address_cache.get_addresses(CORE.address) - ): - _LOGGER.debug( - "Using cached addresses for uploading: %s", cached - ) - resolved.extend(cached) - else: - resolved.append(CORE.address) + resolved.extend(_resolve_with_cache(CORE.address, purpose)) else: resolved.append(device) if not resolved: From adcba4fd9a53068bd3f8b0db500b2d6527ff176c Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 13 Sep 2025 17:02:04 -0500 Subject: [PATCH 1072/1626] [api_protobuf.py] Use type based on size/length (#10696) --- script/api_protobuf/api_protobuf.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 205bac4937..81653c3302 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -848,10 +848,17 @@ class FixedArrayBytesType(TypeInfo): @property def public_content(self) -> list[str]: + len_type = ( + "uint8_t" + if self.array_size <= 255 + else "uint16_t" + if self.array_size <= 65535 + else "size_t" + ) # Add both the array and length fields return [ f"uint8_t {self.field_name}[{self.array_size}]{{}};", - f"uint8_t {self.field_name}_len{{0}};", + f"{len_type} {self.field_name}_len{{0}};", ] @property From 1ea97e9cafde14e0a6854527e3a4b611b1a60a62 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Sep 2025 17:15:05 -0500 Subject: [PATCH 1073/1626] [api] Optimize HelloResponse server_info to reduce memory usage --- esphome/components/api/api_connection.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 99a0bc9044..0cbb9e9cf1 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -42,6 +42,9 @@ static constexpr uint8_t MAX_PING_RETRIES = 60; static constexpr uint16_t PING_RETRY_INTERVAL = 1000; static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2; +// Compile-time StringRef constant for ESPHome version +static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); + static const char *const TAG = "api.connection"; #ifdef USE_CAMERA static const int CAMERA_STOP_STREAM = 5000; @@ -1376,9 +1379,8 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; resp.api_version_minor = 12; - // Temporary string for concatenation - will be valid during send_message call - std::string server_info = App.get_name() + " (esphome v" ESPHOME_VERSION ")"; - resp.set_server_info(StringRef(server_info)); + // Send only the version string - the client only logs this for debugging and doesn't use it otherwise + resp.set_server_info(ESPHOME_VERSION_REF); resp.set_name(StringRef(App.get_name())); #ifdef USE_API_PASSWORD @@ -1425,8 +1427,7 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { std::string mac_address = get_mac_address_pretty(); resp.set_mac_address(StringRef(mac_address)); - // Compile-time StringRef constants - static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); + // Use the ESPHOME_VERSION_REF constant defined in send_hello_response resp.set_esphome_version(ESPHOME_VERSION_REF); resp.set_compilation_time(App.get_compilation_time_ref()); From 51c943d21eb4c03eef22e74873334116cf2db05d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Sep 2025 17:16:09 -0500 Subject: [PATCH 1074/1626] [api] Optimize HelloResponse server_info to reduce memory usage --- esphome/components/api/api_connection.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0cbb9e9cf1..58a8547647 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1427,7 +1427,6 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { std::string mac_address = get_mac_address_pretty(); resp.set_mac_address(StringRef(mac_address)); - // Use the ESPHOME_VERSION_REF constant defined in send_hello_response resp.set_esphome_version(ESPHOME_VERSION_REF); resp.set_compilation_time(App.get_compilation_time_ref()); From 38ef33fe5a117ed3882d31d11442010c58f140ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Sep 2025 17:16:14 -0500 Subject: [PATCH 1075/1626] [api] Optimize HelloResponse server_info to reduce memory usage --- esphome/components/api/api_connection.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 58a8547647..52eeec02ed 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -42,7 +42,6 @@ static constexpr uint8_t MAX_PING_RETRIES = 60; static constexpr uint16_t PING_RETRY_INTERVAL = 1000; static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2; -// Compile-time StringRef constant for ESPHome version static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); static const char *const TAG = "api.connection"; From 722548e39349d9ff750f3f8d81f6266982296aff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Sep 2025 18:06:12 -0500 Subject: [PATCH 1076/1626] Revert unneeded GetTime bidirectional support added in #9790 --- esphome/components/api/api.proto | 7 ++----- esphome/components/api/api_connection.cpp | 6 ------ esphome/components/api/api_connection.h | 1 - esphome/components/api/api_pb2.cpp | 8 -------- esphome/components/api/api_pb2.h | 4 ---- esphome/components/api/api_pb2_dump.cpp | 8 +------- esphome/components/api/api_pb2_service.cpp | 14 -------------- esphome/components/api/api_pb2_service.h | 4 +--- 8 files changed, 4 insertions(+), 48 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 208187d598..471127e93a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -27,9 +27,6 @@ service APIConnection { rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} rpc subscribe_homeassistant_services (SubscribeHomeassistantServicesRequest) returns (void) {} rpc subscribe_home_assistant_states (SubscribeHomeAssistantStatesRequest) returns (void) {} - rpc get_time (GetTimeRequest) returns (GetTimeResponse) { - option (needs_authentication) = false; - } rpc execute_service (ExecuteServiceRequest) returns (void) {} rpc noise_encryption_set_key (NoiseEncryptionSetKeyRequest) returns (NoiseEncryptionSetKeyResponse) {} @@ -809,12 +806,12 @@ message HomeAssistantStateResponse { // ==================== IMPORT TIME ==================== message GetTimeRequest { option (id) = 36; - option (source) = SOURCE_BOTH; + option (source) = SOURCE_SERVER; } message GetTimeResponse { option (id) = 37; - option (source) = SOURCE_BOTH; + option (source) = SOURCE_CLIENT; option (no_delay) = true; fixed32 epoch_seconds = 1; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 99a0bc9044..1fb65f3a2b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1081,12 +1081,6 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { } #endif -bool APIConnection::send_get_time_response(const GetTimeRequest &msg) { - GetTimeResponse resp; - resp.epoch_seconds = ::time(nullptr); - return this->send_message(resp, GetTimeResponse::MESSAGE_TYPE); -} - #ifdef USE_BLUETOOTH_PROXY void APIConnection::subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->subscribe_api_connection(this, msg.flags); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7ee82e0c68..8f93f38203 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -219,7 +219,6 @@ class APIConnection final : public APIServerConnection { #ifdef USE_API_HOMEASSISTANT_STATES void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override; #endif - bool send_get_time_response(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES void execute_service(const ExecuteServiceRequest &msg) override; #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 022ac55cf3..a92fca70d6 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -921,14 +921,6 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { } return true; } -void GetTimeResponse::encode(ProtoWriteBuffer buffer) const { - buffer.encode_fixed32(1, this->epoch_seconds); - buffer.encode_string(2, this->timezone_ref_); -} -void GetTimeResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32(1, this->epoch_seconds); - size.add_length(1, this->timezone_ref_.size()); -} #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index fd124e7bfe..5b6d694e3b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1180,10 +1180,6 @@ class GetTimeResponse final : public ProtoDecodableMessage { #endif uint32_t epoch_seconds{0}; std::string timezone{}; - StringRef timezone_ref_{}; - void set_timezone(const StringRef &ref) { this->timezone_ref_ = ref; } - void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 9795999953..b5e98a9f28 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1113,13 +1113,7 @@ void GetTimeRequest::dump_to(std::string &out) const { out.append("GetTimeReques void GetTimeResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "GetTimeResponse"); dump_field(out, "epoch_seconds", this->epoch_seconds); - out.append(" timezone: "); - if (!this->timezone_ref_.empty()) { - out.append("'").append(this->timezone_ref_.c_str()).append("'"); - } else { - out.append("'").append(this->timezone).append("'"); - } - out.append("\n"); + dump_field(out, "timezone", this->timezone); } #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::dump_to(std::string &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 6b7b8b9ebd..2598e9a0fb 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -160,15 +160,6 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #endif - case GetTimeRequest::MESSAGE_TYPE: { - GetTimeRequest msg; - // Empty message: no decode needed -#ifdef HAS_PROTO_MESSAGE_DUMP - ESP_LOGVV(TAG, "on_get_time_request: %s", msg.dump().c_str()); -#endif - this->on_get_time_request(msg); - break; - } case GetTimeResponse::MESSAGE_TYPE: { GetTimeResponse msg; msg.decode(msg_data, msg_size); @@ -656,11 +647,6 @@ void APIServerConnection::on_subscribe_home_assistant_states_request(const Subsc } } #endif -void APIServerConnection::on_get_time_request(const GetTimeRequest &msg) { - if (this->check_connection_setup_() && !this->send_get_time_response(msg)) { - this->on_fatal_error(); - } -} #ifdef USE_API_SERVICES void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { if (this->check_authenticated_()) { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 6172e33bf6..5b7508e786 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -71,7 +71,7 @@ class APIServerConnectionBase : public ProtoService { #ifdef USE_API_HOMEASSISTANT_STATES virtual void on_home_assistant_state_response(const HomeAssistantStateResponse &value){}; #endif - virtual void on_get_time_request(const GetTimeRequest &value){}; + virtual void on_get_time_response(const GetTimeResponse &value){}; #ifdef USE_API_SERVICES @@ -226,7 +226,6 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_API_HOMEASSISTANT_STATES virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; #endif - virtual bool send_get_time_response(const GetTimeRequest &msg) = 0; #ifdef USE_API_SERVICES virtual void execute_service(const ExecuteServiceRequest &msg) = 0; #endif @@ -348,7 +347,6 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; #endif - void on_get_time_request(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES void on_execute_service_request(const ExecuteServiceRequest &msg) override; #endif From 7e6b11ce84feccc750ea4bd48728ae4cd1a58bbe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 23:25:19 +0000 Subject: [PATCH 1077/1626] Bump aioesphomeapi from 40.1.0 to 40.2.0 (#10703) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0b9a37005d..4736e2a024 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==40.1.0 +aioesphomeapi==40.2.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From c2f0e14e122f7469a9e0a8415d34af9d11fb0ed2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Sep 2025 22:39:52 -0500 Subject: [PATCH 1078/1626] [api] Exclude ConnectRequest/Response when password is disabled --- esphome/components/api/api.proto | 2 ++ esphome/components/api/api_connection.cpp | 11 ++++------- esphome/components/api/api_connection.h | 2 ++ esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 2 ++ esphome/components/api/api_pb2_dump.cpp | 2 ++ esphome/components/api/api_pb2_service.cpp | 4 ++++ esphome/components/api/api_pb2_service.h | 6 ++++++ 8 files changed, 24 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 471127e93a..f82e762345 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -133,6 +133,7 @@ message ConnectRequest { option (id) = 3; option (source) = SOURCE_CLIENT; option (no_delay) = true; + option (ifdef) = "USE_API_PASSWORD"; // The password to log in with string password = 1; @@ -144,6 +145,7 @@ message ConnectResponse { option (id) = 4; option (source) = SOURCE_SERVER; option (no_delay) = true; + option (ifdef) = "USE_API_PASSWORD"; bool invalid_password = 1; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 1fb65f3a2b..814f567a6d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1385,20 +1385,17 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { return this->send_message(resp, HelloResponse::MESSAGE_TYPE); } -bool APIConnection::send_connect_response(const ConnectRequest &msg) { - bool correct = true; #ifdef USE_API_PASSWORD - correct = this->parent_->check_password(msg.password); -#endif - +bool APIConnection::send_connect_response(const ConnectRequest &msg) { ConnectResponse resp; // bool invalid_password = 1; - resp.invalid_password = !correct; - if (correct) { + resp.invalid_password = !this->parent_->check_password(msg.password); + if (!resp.invalid_password) { this->complete_authentication_(); } return this->send_message(resp, ConnectResponse::MESSAGE_TYPE); } +#endif // USE_API_PASSWORD bool APIConnection::send_ping_response(const PingRequest &msg) { PingResponse resp; diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 8f93f38203..70fc881a82 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -197,7 +197,9 @@ class APIConnection final : public APIServerConnection { void on_get_time_response(const GetTimeResponse &value) override; #endif bool send_hello_response(const HelloRequest &msg) override; +#ifdef USE_API_PASSWORD bool send_connect_response(const ConnectRequest &msg) override; +#endif 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; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index a92fca70d6..7b1601515e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -42,6 +42,7 @@ void HelloResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->server_info_ref_.size()); size.add_length(1, this->name_ref_.size()); } +#ifdef USE_API_PASSWORD bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: @@ -54,6 +55,7 @@ bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value } void ConnectResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->invalid_password); } void ConnectResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->invalid_password); } +#endif #ifdef USE_AREAS void AreaInfo::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->area_id); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5b6d694e3b..421789a770 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -360,6 +360,7 @@ class HelloResponse final : public ProtoMessage { protected: }; +#ifdef USE_API_PASSWORD class ConnectRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 3; @@ -391,6 +392,7 @@ class ConnectResponse final : public ProtoMessage { protected: }; +#endif class DisconnectRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 5; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index b5e98a9f28..53af7eefef 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -669,8 +669,10 @@ void HelloResponse::dump_to(std::string &out) const { dump_field(out, "server_info", this->server_info_ref_); dump_field(out, "name", this->name_ref_); } +#ifdef USE_API_PASSWORD void ConnectRequest::dump_to(std::string &out) const { dump_field(out, "password", this->password); } void ConnectResponse::dump_to(std::string &out) const { dump_field(out, "invalid_password", this->invalid_password); } +#endif void DisconnectRequest::dump_to(std::string &out) const { out.append("DisconnectRequest {}"); } void DisconnectResponse::dump_to(std::string &out) const { out.append("DisconnectResponse {}"); } void PingRequest::dump_to(std::string &out) const { out.append("PingRequest {}"); } diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 2598e9a0fb..1bcbfe1ba2 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -24,6 +24,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_hello_request(msg); break; } +#ifdef USE_API_PASSWORD case ConnectRequest::MESSAGE_TYPE: { ConnectRequest msg; msg.decode(msg_data, msg_size); @@ -33,6 +34,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_connect_request(msg); break; } +#endif case DisconnectRequest::MESSAGE_TYPE: { DisconnectRequest msg; // Empty message: no decode needed @@ -597,11 +599,13 @@ void APIServerConnection::on_hello_request(const HelloRequest &msg) { this->on_fatal_error(); } } +#ifdef USE_API_PASSWORD void APIServerConnection::on_connect_request(const ConnectRequest &msg) { if (!this->send_connect_response(msg)) { this->on_fatal_error(); } } +#endif void APIServerConnection::on_disconnect_request(const DisconnectRequest &msg) { if (!this->send_disconnect_response(msg)) { this->on_fatal_error(); diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 5b7508e786..cc7604c083 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -26,7 +26,9 @@ class APIServerConnectionBase : public ProtoService { virtual void on_hello_request(const HelloRequest &value){}; +#ifdef USE_API_PASSWORD virtual void on_connect_request(const ConnectRequest &value){}; +#endif virtual void on_disconnect_request(const DisconnectRequest &value){}; virtual void on_disconnect_response(const DisconnectResponse &value){}; @@ -213,7 +215,9 @@ class APIServerConnectionBase : public ProtoService { class APIServerConnection : public APIServerConnectionBase { public: virtual bool send_hello_response(const HelloRequest &msg) = 0; +#ifdef USE_API_PASSWORD virtual bool send_connect_response(const ConnectRequest &msg) = 0; +#endif 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; @@ -334,7 +338,9 @@ class APIServerConnection : public APIServerConnectionBase { #endif protected: void on_hello_request(const HelloRequest &msg) override; +#ifdef USE_API_PASSWORD void on_connect_request(const ConnectRequest &msg) override; +#endif 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; From 3a4a01ac51651c41a6fc49ece31c05b691eace21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 09:05:28 -0500 Subject: [PATCH 1079/1626] [ethernet] Fix permanent component failure from undocumented ESP_FAIL in IPv6 setup --- .../ethernet/ethernet_component.cpp | 32 ++++++++++++++++++- .../components/ethernet/ethernet_component.h | 2 ++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 844a30bd8b..a3d3c88c1a 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -300,6 +300,7 @@ void EthernetComponent::loop() { this->state_ = EthernetComponentState::CONNECTING; this->start_connect_(); } else { + this->finish_connect_(); // When connected and stable, disable the loop to save CPU cycles this->disable_loop(); } @@ -486,10 +487,27 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_ } #endif /* USE_NETWORK_IPV6 */ +void EthernetComponent::finish_connect_() { +#if USE_NETWORK_IPV6 + // Retry IPv6 link-local setup if it failed during initial connect + // This handles the case where IPv6 setup failed in start_connect_() + // due to the interface not being fully ready (timing issue). + // By now the interface is stable since we're in CONNECTED state. + if (!this->ipv6_setup_done_) { + esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); + if (err == ESP_OK) { + ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); + } + this->ipv6_setup_done_ = true; // Only try once in CONNECTED state + } +#endif /* USE_NETWORK_IPV6 */ +} + void EthernetComponent::start_connect_() { global_eth_component->got_ipv4_address_ = false; #if USE_NETWORK_IPV6 global_eth_component->ipv6_count_ = 0; + this->ipv6_setup_done_ = false; #endif /* USE_NETWORK_IPV6 */ this->connect_begin_ = millis(); this->status_set_warning(LOG_STR("waiting for IP configuration")); @@ -545,9 +563,21 @@ void EthernetComponent::start_connect_() { } } #if USE_NETWORK_IPV6 + // Attempt to create IPv6 link-local address + // Note: this may fail with ESP_FAIL if the interface is not fully up yet, + // which can happen after network interruptions. We'll retry in the CONNECTED state if it fails here. err = esp_netif_create_ip6_linklocal(this->eth_netif_); if (err != ESP_OK) { - ESPHL_ERROR_CHECK(err, "Enable IPv6 link local failed"); + if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { + // This is a programming error, not a transient failure + ESPHL_ERROR_CHECK(err, "esp_netif_create_ip6_linklocal invalid parameters"); + } else { + // ESP_FAIL typically means the interface isn't fully up yet + // This is a timing issue that can occur after network interruptions + // We'll retry once we reach CONNECTED state and the interface is stable + ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed: %s", esp_err_to_name(err)); + // Don't mark component as failed - this is a transient error + } } #endif /* USE_NETWORK_IPV6 */ diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index bdcda6afb4..3d2713ee5c 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -102,6 +102,7 @@ class EthernetComponent : public Component { #endif /* LWIP_IPV6 */ void start_connect_(); + void finish_connect_(); void dump_connect_params_(); /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. void ksz8081_set_clock_reference_(esp_eth_mac_t *mac); @@ -144,6 +145,7 @@ class EthernetComponent : public Component { bool got_ipv4_address_{false}; #if LWIP_IPV6 uint8_t ipv6_count_{0}; + bool ipv6_setup_done_{false}; #endif /* LWIP_IPV6 */ // Pointers at the end (naturally aligned) From dc8c5a6cb381481b9e12b40fe193e4b93316e12f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 09:12:07 -0500 Subject: [PATCH 1080/1626] comments --- esphome/components/ethernet/ethernet_component.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index a3d3c88c1a..e0c8c95152 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -498,7 +498,11 @@ void EthernetComponent::finish_connect_() { if (err == ESP_OK) { ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); } - this->ipv6_setup_done_ = true; // Only try once in CONNECTED state + // Always set the flag to prevent continuous retries + // If IPv6 setup fails here with the interface up and stable, it's likely + // a persistent issue (IPv6 disabled at router, hardware limitation, etc.) + // that won't be resolved by further retries. The device continues to work with IPv4. + this->ipv6_setup_done_ = true; } #endif /* USE_NETWORK_IPV6 */ } From c1c4fabc285dccd36b8bffbc01f8e1fe550962e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 09:16:11 -0500 Subject: [PATCH 1081/1626] comments --- esphome/components/ethernet/ethernet_component.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index e0c8c95152..66c6701ea1 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -576,9 +576,9 @@ void EthernetComponent::start_connect_() { // This is a programming error, not a transient failure ESPHL_ERROR_CHECK(err, "esp_netif_create_ip6_linklocal invalid parameters"); } else { - // ESP_FAIL typically means the interface isn't fully up yet - // This is a timing issue that can occur after network interruptions - // We'll retry once we reach CONNECTED state and the interface is stable + // ESP_FAIL means the interface isn't up yet (e.g., cable unplugged, link down) + // This is expected during reconnection attempts after network interruptions + // We'll retry once we reach CONNECTED state and the interface is actually up ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed: %s", esp_err_to_name(err)); // Don't mark component as failed - this is a transient error } From 09a4d51120cd3ab1095fa55d73c44c5479f0b56b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 09:17:24 -0500 Subject: [PATCH 1082/1626] comments --- esphome/components/ethernet/ethernet_component.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 66c6701ea1..ece42279f0 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -491,17 +491,18 @@ void EthernetComponent::finish_connect_() { #if USE_NETWORK_IPV6 // Retry IPv6 link-local setup if it failed during initial connect // This handles the case where IPv6 setup failed in start_connect_() - // due to the interface not being fully ready (timing issue). - // By now the interface is stable since we're in CONNECTED state. + // because the interface was still down (e.g., cable unplugged). + // By now we're in CONNECTED state so the interface is up. if (!this->ipv6_setup_done_) { esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); if (err == ESP_OK) { ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); } // Always set the flag to prevent continuous retries - // If IPv6 setup fails here with the interface up and stable, it's likely - // a persistent issue (IPv6 disabled at router, hardware limitation, etc.) - // that won't be resolved by further retries. The device continues to work with IPv4. + // If IPv6 setup fails here with the interface up and stable, it's + // likely a persistent issue (IPv6 disabled at router, hardware + // limitation, etc.) that won't be resolved by further retries. + // The device continues to work with IPv4. this->ipv6_setup_done_ = true; } #endif /* USE_NETWORK_IPV6 */ @@ -569,7 +570,8 @@ void EthernetComponent::start_connect_() { #if USE_NETWORK_IPV6 // Attempt to create IPv6 link-local address // Note: this may fail with ESP_FAIL if the interface is not fully up yet, - // which can happen after network interruptions. We'll retry in the CONNECTED state if it fails here. + // which can happen after network interruptions. We'll retry in the + // CONNECTED state if it fails here. err = esp_netif_create_ip6_linklocal(this->eth_netif_); if (err != ESP_OK) { if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { From 4bb40418c56b798d4a12029ab17b225a7138b840 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 09:17:51 -0500 Subject: [PATCH 1083/1626] comments --- esphome/components/ethernet/ethernet_component.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index ece42279f0..68f57b4589 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -491,8 +491,9 @@ void EthernetComponent::finish_connect_() { #if USE_NETWORK_IPV6 // Retry IPv6 link-local setup if it failed during initial connect // This handles the case where IPv6 setup failed in start_connect_() - // because the interface was still down (e.g., cable unplugged). - // By now we're in CONNECTED state so the interface is up. + // because the interface wasn't ready (usually cable unplugged/link down, + // rarely a timing issue during state transitions). + // By now we're in CONNECTED state so the interface is definitely up. if (!this->ipv6_setup_done_) { esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); if (err == ESP_OK) { @@ -569,9 +570,9 @@ void EthernetComponent::start_connect_() { } #if USE_NETWORK_IPV6 // Attempt to create IPv6 link-local address - // Note: this may fail with ESP_FAIL if the interface is not fully up yet, - // which can happen after network interruptions. We'll retry in the - // CONNECTED state if it fails here. + // Note: this may fail with ESP_FAIL if the interface is not up yet + // (typically cable unplugged, but could be timing during link transitions). + // We'll retry in the CONNECTED state if it fails here. err = esp_netif_create_ip6_linklocal(this->eth_netif_); if (err != ESP_OK) { if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { From dfc7382c353ca66c81b4e8c858f19b415ae6e34c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 09:25:46 -0500 Subject: [PATCH 1084/1626] comments --- .../ethernet/ethernet_component.cpp | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 68f57b4589..0e4f4fc583 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -490,10 +490,11 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_ void EthernetComponent::finish_connect_() { #if USE_NETWORK_IPV6 // Retry IPv6 link-local setup if it failed during initial connect - // This handles the case where IPv6 setup failed in start_connect_() - // because the interface wasn't ready (usually cable unplugged/link down, - // rarely a timing issue during state transitions). - // By now we're in CONNECTED state so the interface is definitely up. + // This handles the case where min_ipv6_addr_count is NOT set (or is 0), + // allowing us to reach CONNECTED state with just IPv4. + // If IPv6 setup failed in start_connect_() because the interface wasn't ready + // (usually cable unplugged/link down, rarely a timing issue during state transitions), + // we can now retry since we're in CONNECTED state and the interface is definitely up. if (!this->ipv6_setup_done_) { esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); if (err == ESP_OK) { @@ -570,18 +571,23 @@ void EthernetComponent::start_connect_() { } #if USE_NETWORK_IPV6 // Attempt to create IPv6 link-local address - // Note: this may fail with ESP_FAIL if the interface is not up yet - // (typically cable unplugged, but could be timing during link transitions). - // We'll retry in the CONNECTED state if it fails here. + // We MUST attempt this here, not just in finish_connect_(), because with + // min_ipv6_addr_count set, the component won't reach CONNECTED state without IPv6. + // However, this may fail with ESP_FAIL if the interface is not up yet: + // - At bootup when link isn't ready (#10281) + // - After disconnection/cable unplugged (#10705) + // We'll retry in finish_connect_() if it fails here. err = esp_netif_create_ip6_linklocal(this->eth_netif_); if (err != ESP_OK) { if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { // This is a programming error, not a transient failure ESPHL_ERROR_CHECK(err, "esp_netif_create_ip6_linklocal invalid parameters"); } else { - // ESP_FAIL means the interface isn't up yet (e.g., cable unplugged, link down) - // This is expected during reconnection attempts after network interruptions - // We'll retry once we reach CONNECTED state and the interface is actually up + // ESP_FAIL means the interface isn't up yet + // This is expected and non-fatal, happens in multiple scenarios: + // - During reconnection after network interruptions (#10705) + // - At bootup when the link isn't ready yet (#10281) + // We'll retry once we reach CONNECTED state and the interface is up ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed: %s", esp_err_to_name(err)); // Don't mark component as failed - this is a transient error } From 2eb02d5440aacc698443b3b32e2616cef50c154d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 09:25:59 -0500 Subject: [PATCH 1085/1626] comments --- esphome/components/ethernet/ethernet_component.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 0e4f4fc583..cd73333222 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -492,9 +492,10 @@ void EthernetComponent::finish_connect_() { // Retry IPv6 link-local setup if it failed during initial connect // This handles the case where min_ipv6_addr_count is NOT set (or is 0), // allowing us to reach CONNECTED state with just IPv4. - // If IPv6 setup failed in start_connect_() because the interface wasn't ready - // (usually cable unplugged/link down, rarely a timing issue during state transitions), - // we can now retry since we're in CONNECTED state and the interface is definitely up. + // If IPv6 setup failed in start_connect_() because the interface wasn't ready: + // - Bootup timing issues (#10281) + // - Cable unplugged/network interruption (#10705) + // We can now retry since we're in CONNECTED state and the interface is definitely up. if (!this->ipv6_setup_done_) { esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); if (err == ESP_OK) { From b03a651499f2f8f98860d0fedfbe93e2cceb190b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 10:48:26 -0500 Subject: [PATCH 1086/1626] [md5] Optimize MD5::get_hex() to eliminate sprintf dependency --- esphome/components/md5/md5.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index 980cb98699..fb0d9f401f 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -1,4 +1,3 @@ -#include #include #include "md5.h" #ifdef USE_MD5 @@ -44,7 +43,11 @@ void MD5Digest::get_bytes(uint8_t *output) { memcpy(output, this->digest_, 16); void MD5Digest::get_hex(char *output) { for (size_t i = 0; i < 16; i++) { - sprintf(output + i * 2, "%02x", this->digest_[i]); + uint8_t byte = this->digest_[i]; + uint8_t high = byte >> 4; + uint8_t low = byte & 0x0F; + output[i * 2] = high < 10 ? '0' + high : 'a' + (high - 10); + output[i * 2 + 1] = low < 10 ? '0' + low : 'a' + (low - 10); } } From 0b422509006464d19a13d9df3f11a8cfcc334916 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 13:49:11 -0500 Subject: [PATCH 1087/1626] [core] Optimize MAC address formatting to eliminate sprintf dependency --- .../ethernet/ethernet_component.cpp | 4 ++- esphome/core/helpers.cpp | 17 ++++++----- esphome/core/helpers.h | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 844a30bd8b..57f7a13d39 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -638,7 +638,9 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { std::string EthernetComponent::get_eth_mac_address_pretty() { uint8_t mac[6]; get_eth_mac_address_raw(mac); - return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); } eth_duplex_t EthernetComponent::get_duplex_mode() { diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 43d6f1153c..471fc79ecf 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -255,23 +255,22 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { } std::string format_mac_address_pretty(const uint8_t *mac) { - return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); } -static char format_hex_char(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } std::string format_hex(const uint8_t *data, size_t length) { std::string ret; ret.resize(length * 2); for (size_t i = 0; i < length; i++) { - ret[2 * i] = format_hex_char((data[i] & 0xF0) >> 4); - ret[2 * i + 1] = format_hex_char(data[i] & 0x0F); + ret[2 * i] = format_hex_char_lower(data[i] >> 4); + ret[2 * i + 1] = format_hex_char_lower(data[i] & 0x0F); } return ret; } std::string format_hex(const std::vector &data) { return format_hex(data.data(), data.size()); } -static char format_hex_pretty_char(uint8_t v) { return v >= 10 ? 'A' + (v - 10) : '0' + v; } - // Shared implementation for uint8_t and string hex formatting static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, char separator, bool show_length) { if (data == nullptr || length == 0) @@ -280,7 +279,7 @@ static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, c uint8_t multiple = separator ? 3 : 2; // 3 if separator is not \0, 2 otherwise ret.resize(multiple * length - (separator ? 1 : 0)); for (size_t i = 0; i < length; i++) { - ret[multiple * i] = format_hex_pretty_char((data[i] & 0xF0) >> 4); + ret[multiple * i] = format_hex_pretty_char(data[i] >> 4); ret[multiple * i + 1] = format_hex_pretty_char(data[i] & 0x0F); if (separator && i != length - 1) ret[multiple * i + 2] = separator; @@ -591,7 +590,9 @@ bool HighFrequencyLoopRequester::is_high_frequency() { return num_requests > 0; std::string get_mac_address() { uint8_t mac[6]; get_mac_address_raw(mac); - return str_snprintf("%02x%02x%02x%02x%02x%02x", 12, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[13]; + format_mac_addr_lower_no_sep(mac, buf); + return std::string(buf); } std::string get_mac_address_pretty() { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a6741925d0..f79f8824f1 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -380,6 +380,35 @@ template::value, int> = 0> optional< return parse_hex(str.c_str(), str.length()); } +/// Convert a nibble (0-15) to lowercase hex char +inline char format_hex_char_lower(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } + +/// Convert a nibble (0-15) to uppercase hex char (used for pretty printing) +/// This always uses uppercase (A-F) for pretty/human-readable output +inline char format_hex_pretty_char(uint8_t v) { return v >= 10 ? 'A' + (v - 10) : '0' + v; } + +/// Format MAC address as XX:XX:XX:XX:XX:XX (uppercase) +inline void format_mac_addr_upper(const uint8_t *mac, char *output) { + for (size_t i = 0; i < 6; i++) { + uint8_t byte = mac[i]; + output[i * 3] = format_hex_pretty_char(byte >> 4); + output[i * 3 + 1] = format_hex_pretty_char(byte & 0x0F); + if (i < 5) + output[i * 3 + 2] = ':'; + } + output[17] = '\0'; +} + +/// Format MAC address as xxxxxxxxxxxxxx (lowercase, no separators) +inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { + for (size_t i = 0; i < 6; i++) { + uint8_t byte = mac[i]; + output[i * 2] = format_hex_char_lower(byte >> 4); + output[i * 2 + 1] = format_hex_char_lower(byte & 0x0F); + } + output[12] = '\0'; +} + /// Format the six-byte array \p mac into a MAC address. std::string format_mac_address_pretty(const uint8_t mac[6]); /// Format the byte array \p data of length \p len in lowercased hex. From 22c91dfadc5ba2bcf0c0c291eaf8b21c4d285274 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 13:51:10 -0500 Subject: [PATCH 1088/1626] cleanup --- esphome/components/md5/md5.cpp | 6 ++---- esphome/core/helpers.cpp | 1 - esphome/core/helpers.h | 3 +++ 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index fb0d9f401f..21bd2e1cab 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -44,10 +44,8 @@ void MD5Digest::get_bytes(uint8_t *output) { memcpy(output, this->digest_, 16); void MD5Digest::get_hex(char *output) { for (size_t i = 0; i < 16; i++) { uint8_t byte = this->digest_[i]; - uint8_t high = byte >> 4; - uint8_t low = byte & 0x0F; - output[i * 2] = high < 10 ? '0' + high : 'a' + (high - 10); - output[i * 2 + 1] = low < 10 ? '0' + low : 'a' + (low - 10); + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); } } diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 43d6f1153c..7f977c5d40 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -258,7 +258,6 @@ std::string format_mac_address_pretty(const uint8_t *mac) { return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); } -static char format_hex_char(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } std::string format_hex(const uint8_t *data, size_t length) { std::string ret; ret.resize(length * 2); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a6741925d0..a2bac19b25 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -380,6 +380,9 @@ template::value, int> = 0> optional< return parse_hex(str.c_str(), str.length()); } +/// Convert a nibble (0-15) to lowercase hex char +inline char format_hex_char(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } + /// Format the six-byte array \p mac into a MAC address. std::string format_mac_address_pretty(const uint8_t mac[6]); /// Format the byte array \p data of length \p len in lowercased hex. From 16b77149906823997336363e150b10c1eb3ffdbc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 13:53:36 -0500 Subject: [PATCH 1089/1626] preen --- esphome/core/helpers.cpp | 4 ++-- esphome/core/helpers.h | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 471fc79ecf..f1560711ef 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -264,8 +264,8 @@ std::string format_hex(const uint8_t *data, size_t length) { std::string ret; ret.resize(length * 2); for (size_t i = 0; i < length; i++) { - ret[2 * i] = format_hex_char_lower(data[i] >> 4); - ret[2 * i + 1] = format_hex_char_lower(data[i] & 0x0F); + ret[2 * i] = format_hex_char(data[i] >> 4); + ret[2 * i + 1] = format_hex_char(data[i] & 0x0F); } return ret; } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index f79f8824f1..21aa159b25 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -381,7 +381,7 @@ template::value, int> = 0> optional< } /// Convert a nibble (0-15) to lowercase hex char -inline char format_hex_char_lower(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } +inline char format_hex_char(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } /// Convert a nibble (0-15) to uppercase hex char (used for pretty printing) /// This always uses uppercase (A-F) for pretty/human-readable output @@ -403,8 +403,8 @@ inline void format_mac_addr_upper(const uint8_t *mac, char *output) { inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { for (size_t i = 0; i < 6; i++) { uint8_t byte = mac[i]; - output[i * 2] = format_hex_char_lower(byte >> 4); - output[i * 2 + 1] = format_hex_char_lower(byte & 0x0F); + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); } output[12] = '\0'; } From 4e680020d18bfc2141a87dfaa0db4f167764ec74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 14:20:34 -0500 Subject: [PATCH 1090/1626] [esp32_ble] Optimize BLE hex formatting to eliminate sprintf dependency --- .../bluetooth_proxy/bluetooth_proxy.h | 4 +- esphome/components/esp32_ble/ble_uuid.cpp | 39 ++++++++++++++----- .../esp32_ble_beacon/esp32_ble_beacon.cpp | 8 ++-- .../esp32_ble_client/ble_client_base.h | 13 ++++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 5 +-- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 4b262dbe86..1ce2321bee 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -130,7 +130,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ std::string get_bluetooth_mac_address_pretty() { const uint8_t *mac = esp_bt_dev_get_address(); - return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); } protected: diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index be9c6945d7..5f83e2ba0b 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -7,6 +7,7 @@ #include #include #include "esphome/core/log.h" +#include "esphome/core/helpers.h" namespace esphome::esp32_ble { @@ -169,22 +170,42 @@ bool ESPBTUUID::operator==(const ESPBTUUID &uuid) const { } esp_bt_uuid_t ESPBTUUID::get_uuid() const { return this->uuid_; } std::string ESPBTUUID::to_string() const { + char buf[40]; // Enough for 128-bit UUID with dashes + char *pos = buf; + switch (this->uuid_.len) { case ESP_UUID_LEN_16: - return str_snprintf("0x%02X%02X", 6, this->uuid_.uuid.uuid16 >> 8, this->uuid_.uuid.uuid16 & 0xff); + *pos++ = '0'; + *pos++ = 'x'; + *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 >> 12); + *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 8) & 0x0F); + *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 4) & 0x0F); + *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 & 0x0F); + *pos = '\0'; + return std::string(buf); + case ESP_UUID_LEN_32: - return str_snprintf("0x%02" PRIX32 "%02" PRIX32 "%02" PRIX32 "%02" PRIX32, 10, (this->uuid_.uuid.uuid32 >> 24), - (this->uuid_.uuid.uuid32 >> 16 & 0xff), (this->uuid_.uuid.uuid32 >> 8 & 0xff), - this->uuid_.uuid.uuid32 & 0xff); + *pos++ = '0'; + *pos++ = 'x'; + for (int shift = 28; shift >= 0; shift -= 4) { + *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid32 >> shift) & 0x0F); + } + *pos = '\0'; + return std::string(buf); + default: case ESP_UUID_LEN_128: - std::string buf; + // Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX for (int8_t i = 15; i >= 0; i--) { - buf += str_snprintf("%02X", 2, this->uuid_.uuid.uuid128[i]); - if (i == 6 || i == 8 || i == 10 || i == 12) - buf += "-"; + uint8_t byte = this->uuid_.uuid.uuid128[i]; + *pos++ = format_hex_pretty_char(byte >> 4); + *pos++ = format_hex_pretty_char(byte & 0x0F); + if (i == 12 || i == 10 || i == 8 || i == 6) { + *pos++ = '-'; + } } - return buf; + *pos = '\0'; + return std::string(buf); } return ""; } diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 423fe61592..ad69334f62 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -1,5 +1,6 @@ #include "esp32_ble_beacon.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" #ifdef USE_ESP32 @@ -31,12 +32,13 @@ void ESP32BLEBeacon::dump_config() { char uuid[37]; char *bpos = uuid; for (int8_t ii = 0; ii < 16; ++ii) { - bpos += sprintf(bpos, "%02X", this->uuid_[ii]); + *bpos++ = format_hex_pretty_char(this->uuid_[ii] >> 4); + *bpos++ = format_hex_pretty_char(this->uuid_[ii] & 0x0F); if (ii == 3 || ii == 5 || ii == 7 || ii == 9) { - bpos += sprintf(bpos, "-"); + *bpos++ = '-'; } } - uuid[36] = '\0'; + *bpos = '\0'; ESP_LOGCONFIG(TAG, " UUID: %s, Major: %u, Minor: %u, Min Interval: %ums, Max Interval: %ums, Measured Power: %d" ", TX Power: %ddBm", diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index acfad9e9b0..f2edd6c2b3 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -60,11 +60,14 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { if (address == 0) { this->address_str_ = ""; } else { - this->address_str_ = - str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, (uint8_t) (this->address_ >> 40) & 0xff, - (uint8_t) (this->address_ >> 32) & 0xff, (uint8_t) (this->address_ >> 24) & 0xff, - (uint8_t) (this->address_ >> 16) & 0xff, (uint8_t) (this->address_ >> 8) & 0xff, - (uint8_t) (this->address_ >> 0) & 0xff); + char buf[18]; + uint8_t mac[6] = { + (uint8_t) ((this->address_ >> 40) & 0xff), (uint8_t) ((this->address_ >> 32) & 0xff), + (uint8_t) ((this->address_ >> 24) & 0xff), (uint8_t) ((this->address_ >> 16) & 0xff), + (uint8_t) ((this->address_ >> 8) & 0xff), (uint8_t) ((this->address_ >> 0) & 0xff), + }; + format_mac_addr_upper(mac, buf); + this->address_str_ = buf; } } const std::string &address_str() const { return this->address_str_; } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index bab1dd7c98..908bb36c9e 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -605,9 +605,8 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) { } std::string ESPBTDevice::address_str() const { - char mac[24]; - snprintf(mac, sizeof(mac), "%02X:%02X:%02X:%02X:%02X:%02X", this->address_[0], this->address_[1], this->address_[2], - this->address_[3], this->address_[4], this->address_[5]); + char mac[18]; + format_mac_addr_upper(this->address_, mac); return mac; } From 35060416ba235744fa33a716857668e2ca8d0f37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 14:29:25 -0500 Subject: [PATCH 1091/1626] [wifi] Optimize WiFi MAC formatting to eliminate sprintf dependency --- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi_info/wifi_info_text_sensor.h | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index e57bf25b8c..36a22c5b43 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -593,7 +593,7 @@ void WiFiComponent::check_scanning_finished() { for (auto &res : this->scan_result_) { char bssid_s[18]; auto bssid = res.get_bssid(); - sprintf(bssid_s, "%02X:%02X:%02X:%02X:%02X:%02X", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]); + format_mac_addr_upper(bssid, bssid_s); if (res.get_matches()) { ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 68b5f438e4..2cb96123a0 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/components/wifi/wifi_component.h" #ifdef USE_WIFI @@ -106,8 +107,8 @@ class BSSIDWiFiInfo : public PollingComponent, public text_sensor::TextSensor { wifi::bssid_t bssid = wifi::global_wifi_component->wifi_bssid(); if (memcmp(bssid.data(), last_bssid_.data(), 6) != 0) { std::copy(bssid.begin(), bssid.end(), last_bssid_.begin()); - char buf[30]; - sprintf(buf, "%02X:%02X:%02X:%02X:%02X:%02X", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]); + char buf[18]; + format_mac_addr_upper(bssid.data(), buf); this->publish_state(buf); } } From 682d98f9b4778ad83d5cc4a5871c35923087a025 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 14:30:30 -0500 Subject: [PATCH 1092/1626] [wifi] Optimize WiFi MAC formatting to eliminate sprintf dependency --- esphome/components/wifi/wifi_component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 36a22c5b43..43ece636e5 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -593,7 +593,7 @@ void WiFiComponent::check_scanning_finished() { for (auto &res : this->scan_result_) { char bssid_s[18]; auto bssid = res.get_bssid(); - format_mac_addr_upper(bssid, bssid_s); + format_mac_addr_upper(bssid.data(), bssid_s); if (res.get_matches()) { ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), From 99649c3a8f6f4dd2aa9cfe345255bb34663b6c03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:16:46 -0500 Subject: [PATCH 1093/1626] [scheduler] Fix timing accumulation in scheduler causing incorrect execution measurements --- esphome/core/scheduler.cpp | 8 ++++---- esphome/core/scheduler.h | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 68da0a56ca..71e2a00fbe 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -345,7 +345,7 @@ void HOT Scheduler::call(uint32_t now) { // Execute callback without holding lock to prevent deadlocks // if the callback tries to call defer() again if (!this->should_skip_item_(item.get())) { - this->execute_item_(item.get(), now); + now = this->execute_item_(item.get(), now); } // Recycle the defer item after execution this->recycle_item_(std::move(item)); @@ -483,7 +483,7 @@ void HOT Scheduler::call(uint32_t now) { // Warning: During callback(), a lot of stuff can happen, including: // - timeouts/intervals get added, potentially invalidating vector pointers // - timeouts/intervals get cancelled - this->execute_item_(item.get(), now); + now = this->execute_item_(item.get(), now); LockGuard guard{this->lock_}; @@ -568,11 +568,11 @@ void HOT Scheduler::pop_raw_() { } // Helper to execute a scheduler item -void HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { +uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); - guard.finish(); + return guard.finish(); } // Common implementation for cancel operations diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 301342e8c2..885ee13754 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -254,7 +254,7 @@ class Scheduler { } // Helper to execute a scheduler item - void execute_item_(SchedulerItem *item, uint32_t now); + uint32_t execute_item_(SchedulerItem *item, uint32_t now); // Helper to check if item should be skipped bool should_skip_item_(SchedulerItem *item) const { From 35dce3c80d783aa7c04c02cb1529c3aba842efef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:31:38 -0500 Subject: [PATCH 1094/1626] Add additional test coverage ahead of Path conversion (#10700) --- tests/unit_tests/conftest.py | 44 ++ tests/unit_tests/core/test_config.py | 329 ++++++++++++++- tests/unit_tests/test_platformio_api.py | 521 +++++++++++++++++++++++- tests/unit_tests/test_storage_json.py | 492 +++++++++++++++++++++- 4 files changed, 1369 insertions(+), 17 deletions(-) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index a1e438b577..06d06d0506 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -9,8 +9,10 @@ not be part of a unit test suite. """ +from collections.abc import Generator from pathlib import Path import sys +from unittest.mock import Mock, patch import pytest @@ -43,3 +45,45 @@ def setup_core(tmp_path: Path) -> Path: """Set up CORE with test paths.""" CORE.config_path = str(tmp_path / "test.yaml") return tmp_path + + +@pytest.fixture +def mock_write_file_if_changed() -> Generator[Mock, None, None]: + """Mock write_file_if_changed for storage_json.""" + with patch("esphome.storage_json.write_file_if_changed") as mock: + yield mock + + +@pytest.fixture +def mock_copy_file_if_changed() -> Generator[Mock, None, None]: + """Mock copy_file_if_changed for core.config.""" + with patch("esphome.core.config.copy_file_if_changed") as mock: + yield mock + + +@pytest.fixture +def mock_run_platformio_cli() -> Generator[Mock, None, None]: + """Mock run_platformio_cli for platformio_api.""" + with patch("esphome.platformio_api.run_platformio_cli") as mock: + yield mock + + +@pytest.fixture +def mock_run_platformio_cli_run() -> Generator[Mock, None, None]: + """Mock run_platformio_cli_run for platformio_api.""" + with patch("esphome.platformio_api.run_platformio_cli_run") as mock: + yield mock + + +@pytest.fixture +def mock_decode_pc() -> Generator[Mock, None, None]: + """Mock _decode_pc for platformio_api.""" + with patch("esphome.platformio_api._decode_pc") as mock: + yield mock + + +@pytest.fixture +def mock_run_external_command() -> Generator[Mock, None, None]: + """Mock run_external_command for platformio_api.""" + with patch("esphome.platformio_api.run_external_command") as mock: + yield mock diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index f5ba5221ed..484c30d59e 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1,15 +1,34 @@ """Unit tests for core config functionality including areas and devices.""" from collections.abc import Callable +import os from pathlib import Path +import types from typing import Any +from unittest.mock import MagicMock, Mock, patch import pytest from esphome import config_validation as cv, core -from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES -from esphome.core import config -from esphome.core.config import Area, validate_area_config +from esphome.const import ( + CONF_AREA, + CONF_AREAS, + CONF_BUILD_PATH, + CONF_DEVICES, + CONF_ESPHOME, + CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, + KEY_CORE, +) +from esphome.core import CORE, config +from esphome.core.config import ( + Area, + preload_core_config, + valid_include, + valid_project_name, + validate_area_config, + validate_hostname, +) from .common import load_config_from_fixture @@ -245,3 +264,307 @@ def test_add_platform_defines_priority() -> None: f"_add_platform_defines priority ({config._add_platform_defines.priority}) must be lower than " f"globals priority ({globals_to_code.priority}) to fix issue #10431 (sensor count bug with lambdas)" ) + + +def test_valid_include_with_angle_brackets() -> None: + """Test valid_include accepts angle bracket includes.""" + assert valid_include("") == "" + + +def test_valid_include_with_valid_file(tmp_path: Path) -> None: + """Test valid_include accepts valid include files.""" + CORE.config_path = str(tmp_path / "test.yaml") + include_file = tmp_path / "include.h" + include_file.touch() + + assert valid_include(str(include_file)) == str(include_file) + + +def test_valid_include_with_valid_directory(tmp_path: Path) -> None: + """Test valid_include accepts valid directories.""" + CORE.config_path = str(tmp_path / "test.yaml") + include_dir = tmp_path / "includes" + include_dir.mkdir() + + assert valid_include(str(include_dir)) == str(include_dir) + + +def test_valid_include_invalid_extension(tmp_path: Path) -> None: + """Test valid_include rejects files with invalid extensions.""" + CORE.config_path = str(tmp_path / "test.yaml") + invalid_file = tmp_path / "file.txt" + invalid_file.touch() + + with pytest.raises(cv.Invalid, match="Include has invalid file extension"): + valid_include(str(invalid_file)) + + +def test_valid_project_name_valid() -> None: + """Test valid_project_name accepts valid project names.""" + assert valid_project_name("esphome.my_project") == "esphome.my_project" + + +def test_valid_project_name_no_namespace() -> None: + """Test valid_project_name rejects names without namespace.""" + with pytest.raises(cv.Invalid, match="project name needs to have a namespace"): + valid_project_name("my_project") + + +def test_valid_project_name_multiple_dots() -> None: + """Test valid_project_name rejects names with multiple dots.""" + with pytest.raises(cv.Invalid, match="project name needs to have a namespace"): + valid_project_name("esphome.my.project") + + +def test_validate_hostname_valid() -> None: + """Test validate_hostname accepts valid hostnames.""" + config = {CONF_NAME: "my-device", CONF_NAME_ADD_MAC_SUFFIX: False} + assert validate_hostname(config) == config + + +def test_validate_hostname_too_long() -> None: + """Test validate_hostname rejects hostnames that are too long.""" + config = { + CONF_NAME: "a" * 32, # 32 chars, max is 31 + CONF_NAME_ADD_MAC_SUFFIX: False, + } + with pytest.raises(cv.Invalid, match="Hostnames can only be 31 characters long"): + validate_hostname(config) + + +def test_validate_hostname_too_long_with_mac_suffix() -> None: + """Test validate_hostname accounts for MAC suffix length.""" + config = { + CONF_NAME: "a" * 25, # 25 chars, max is 24 with MAC suffix + CONF_NAME_ADD_MAC_SUFFIX: True, + } + with pytest.raises(cv.Invalid, match="Hostnames can only be 24 characters long"): + validate_hostname(config) + + +def test_validate_hostname_with_underscore(caplog) -> None: + """Test validate_hostname warns about underscores.""" + config = {CONF_NAME: "my_device", CONF_NAME_ADD_MAC_SUFFIX: False} + assert validate_hostname(config) == config + assert ( + "Using the '_' (underscore) character in the hostname is discouraged" + in caplog.text + ) + + +def test_preload_core_config_basic(setup_core: Path) -> None: + """Test preload_core_config sets basic CORE attributes.""" + config = { + CONF_ESPHOME: { + CONF_NAME: "test_device", + }, + "esp32": {}, + } + result = {} + + platform = preload_core_config(config, result) + + assert CORE.name == "test_device" + assert platform == "esp32" + assert KEY_CORE in CORE.data + assert CONF_BUILD_PATH in config[CONF_ESPHOME] + + +def test_preload_core_config_with_build_path(setup_core: Path) -> None: + """Test preload_core_config uses provided build path.""" + config = { + CONF_ESPHOME: { + CONF_NAME: "test_device", + CONF_BUILD_PATH: "/custom/build/path", + }, + "esp8266": {}, + } + result = {} + + platform = preload_core_config(config, result) + + assert config[CONF_ESPHOME][CONF_BUILD_PATH] == "/custom/build/path" + assert platform == "esp8266" + + +def test_preload_core_config_env_build_path(setup_core: Path) -> None: + """Test preload_core_config uses ESPHOME_BUILD_PATH env var.""" + config = { + CONF_ESPHOME: { + CONF_NAME: "test_device", + }, + "rp2040": {}, + } + result = {} + + with patch.dict(os.environ, {"ESPHOME_BUILD_PATH": "/env/build"}): + platform = preload_core_config(config, result) + + assert CONF_BUILD_PATH in config[CONF_ESPHOME] + assert "test_device" in config[CONF_ESPHOME][CONF_BUILD_PATH] + assert platform == "rp2040" + + +def test_preload_core_config_no_platform(setup_core: Path) -> None: + """Test preload_core_config raises when no platform is specified.""" + config = { + CONF_ESPHOME: { + CONF_NAME: "test_device", + }, + } + result = {} + + # Mock _is_target_platform to avoid expensive component loading + with patch("esphome.core.config._is_target_platform") as mock_is_platform: + # Return True for known platforms + mock_is_platform.side_effect = lambda name: name in [ + "esp32", + "esp8266", + "rp2040", + ] + + with pytest.raises(cv.Invalid, match="Platform missing"): + preload_core_config(config, result) + + +def test_preload_core_config_multiple_platforms(setup_core: Path) -> None: + """Test preload_core_config raises when multiple platforms are specified.""" + config = { + CONF_ESPHOME: { + CONF_NAME: "test_device", + }, + "esp32": {}, + "esp8266": {}, + } + result = {} + + # Mock _is_target_platform to avoid expensive component loading + with patch("esphome.core.config._is_target_platform") as mock_is_platform: + # Return True for known platforms + mock_is_platform.side_effect = lambda name: name in [ + "esp32", + "esp8266", + "rp2040", + ] + + with pytest.raises(cv.Invalid, match="Found multiple target platform blocks"): + preload_core_config(config, result) + + +def test_include_file_header(tmp_path: Path, mock_copy_file_if_changed: Mock) -> None: + """Test include_file adds include statement for header files.""" + src_file = tmp_path / "source.h" + src_file.write_text("// Header content") + + CORE.build_path = str(tmp_path / "build") + + with patch("esphome.core.config.cg") as mock_cg: + # Mock RawStatement to capture the text + mock_raw_statement = MagicMock() + mock_raw_statement.text = "" + + def raw_statement_side_effect(text): + mock_raw_statement.text = text + return mock_raw_statement + + mock_cg.RawStatement.side_effect = raw_statement_side_effect + + config.include_file(str(src_file), "test.h") + + mock_copy_file_if_changed.assert_called_once() + mock_cg.add_global.assert_called_once() + # Check that include statement was added + assert '#include "test.h"' in mock_raw_statement.text + + +def test_include_file_cpp(tmp_path: Path, mock_copy_file_if_changed: Mock) -> None: + """Test include_file does not add include for cpp files.""" + src_file = tmp_path / "source.cpp" + src_file.write_text("// CPP content") + + CORE.build_path = str(tmp_path / "build") + + with patch("esphome.core.config.cg") as mock_cg: + config.include_file(str(src_file), "test.cpp") + + mock_copy_file_if_changed.assert_called_once() + # Should not add include statement for .cpp files + mock_cg.add_global.assert_not_called() + + +def test_get_usable_cpu_count() -> None: + """Test get_usable_cpu_count returns CPU count.""" + count = config.get_usable_cpu_count() + assert isinstance(count, int) + assert count > 0 + + +def test_get_usable_cpu_count_with_process_cpu_count() -> None: + """Test get_usable_cpu_count uses process_cpu_count when available.""" + # Test with process_cpu_count (Python 3.13+) + # Create a mock os module with process_cpu_count + + mock_os = types.SimpleNamespace(process_cpu_count=lambda: 8, cpu_count=lambda: 4) + + with patch("esphome.core.config.os", mock_os): + # When process_cpu_count exists, it should be used + count = config.get_usable_cpu_count() + assert count == 8 + + # Test fallback to cpu_count when process_cpu_count not available + mock_os_no_process = types.SimpleNamespace(cpu_count=lambda: 4) + + with patch("esphome.core.config.os", mock_os_no_process): + count = config.get_usable_cpu_count() + assert count == 4 + + +def test_list_target_platforms(tmp_path: Path) -> None: + """Test _list_target_platforms returns available platforms.""" + # Create mock components directory structure + components_dir = tmp_path / "components" + components_dir.mkdir() + + # Create platform and non-platform directories with __init__.py + platforms = ["esp32", "esp8266", "rp2040", "libretiny", "host"] + non_platforms = ["sensor"] + + for component in platforms + non_platforms: + component_dir = components_dir / component + component_dir.mkdir() + (component_dir / "__init__.py").touch() + + # Create a file (not a directory) + (components_dir / "README.md").touch() + + # Create a directory without __init__.py + (components_dir / "no_init").mkdir() + + # Mock Path(__file__).parents[1] to return our tmp_path + with patch("esphome.core.config.Path") as mock_path: + mock_file_path = MagicMock() + mock_file_path.parents = [MagicMock(), tmp_path] + mock_path.return_value = mock_file_path + + platforms = config._list_target_platforms() + + assert isinstance(platforms, list) + # Should include platform components + assert "esp32" in platforms + assert "esp8266" in platforms + assert "rp2040" in platforms + assert "libretiny" in platforms + assert "host" in platforms + # Should not include non-platform components + assert "sensor" not in platforms + assert "README.md" not in platforms + assert "no_init" not in platforms + + +def test_is_target_platform() -> None: + """Test _is_target_platform identifies valid platforms.""" + assert config._is_target_platform("esp32") is True + assert config._is_target_platform("esp8266") is True + assert config._is_target_platform("rp2040") is True + assert config._is_target_platform("invalid_platform") is False + assert config._is_target_platform("api") is False # Component but not platform diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index a1fa963e51..7c7883d391 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -1,10 +1,16 @@ """Tests for platformio_api.py path functions.""" +import json +import os from pathlib import Path -from unittest.mock import patch +import shutil +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock, patch + +import pytest from esphome import platformio_api -from esphome.core import CORE +from esphome.core import CORE, EsphomeError def test_idedata_firmware_elf_path(setup_core: Path) -> None: @@ -104,7 +110,9 @@ def test_flash_image_dataclass() -> None: assert image.offset == "0x10000" -def test_load_idedata_returns_dict(setup_core: Path) -> None: +def test_load_idedata_returns_dict( + setup_core: Path, mock_run_platformio_cli_run +) -> None: """Test _load_idedata returns parsed idedata dict when successful.""" CORE.build_path = str(setup_core / "build" / "test") CORE.name = "test" @@ -118,12 +126,511 @@ def test_load_idedata_returns_dict(setup_core: Path) -> None: idedata_path.parent.mkdir(parents=True, exist_ok=True) idedata_path.write_text('{"prog_path": "/test/firmware.elf"}') - with patch("esphome.platformio_api.run_platformio_cli_run") as mock_run: - mock_run.return_value = '{"prog_path": "/test/firmware.elf"}' + mock_run_platformio_cli_run.return_value = '{"prog_path": "/test/firmware.elf"}' - config = {"name": "test"} - result = platformio_api._load_idedata(config) + config = {"name": "test"} + result = platformio_api._load_idedata(config) assert result is not None assert isinstance(result, dict) assert result["prog_path"] == "/test/firmware.elf" + + +def test_load_idedata_uses_cache_when_valid( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test _load_idedata uses cached data when unchanged.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + + # Create platformio.ini + platformio_ini = setup_core / "build" / "test" / "platformio.ini" + platformio_ini.parent.mkdir(parents=True, exist_ok=True) + platformio_ini.write_text("content") + + # Create idedata cache file that's newer + idedata_path = setup_core / ".esphome" / "idedata" / "test.json" + idedata_path.parent.mkdir(parents=True, exist_ok=True) + idedata_path.write_text('{"prog_path": "/cached/firmware.elf"}') + + # Make idedata newer than platformio.ini + platformio_ini_mtime = platformio_ini.stat().st_mtime + os.utime(idedata_path, (platformio_ini_mtime + 1, platformio_ini_mtime + 1)) + + config = {"name": "test"} + result = platformio_api._load_idedata(config) + + # Should not call _run_idedata since cache is valid + mock_run_platformio_cli_run.assert_not_called() + + assert result["prog_path"] == "/cached/firmware.elf" + + +def test_load_idedata_regenerates_when_platformio_ini_newer( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test _load_idedata regenerates when platformio.ini is newer.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + + # Create idedata cache file first + idedata_path = setup_core / ".esphome" / "idedata" / "test.json" + idedata_path.parent.mkdir(parents=True, exist_ok=True) + idedata_path.write_text('{"prog_path": "/old/firmware.elf"}') + + # Create platformio.ini that's newer + idedata_mtime = idedata_path.stat().st_mtime + platformio_ini = setup_core / "build" / "test" / "platformio.ini" + platformio_ini.parent.mkdir(parents=True, exist_ok=True) + platformio_ini.write_text("content") + # Make platformio.ini newer than idedata + os.utime(platformio_ini, (idedata_mtime + 1, idedata_mtime + 1)) + + # Mock platformio to return new data + new_data = {"prog_path": "/new/firmware.elf"} + mock_run_platformio_cli_run.return_value = json.dumps(new_data) + + config = {"name": "test"} + result = platformio_api._load_idedata(config) + + # Should call _run_idedata since platformio.ini is newer + mock_run_platformio_cli_run.assert_called_once() + + assert result["prog_path"] == "/new/firmware.elf" + + +def test_load_idedata_regenerates_on_corrupted_cache( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test _load_idedata regenerates when cache file is corrupted.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + + # Create platformio.ini + platformio_ini = setup_core / "build" / "test" / "platformio.ini" + platformio_ini.parent.mkdir(parents=True, exist_ok=True) + platformio_ini.write_text("content") + + # Create corrupted idedata cache file + idedata_path = setup_core / ".esphome" / "idedata" / "test.json" + idedata_path.parent.mkdir(parents=True, exist_ok=True) + idedata_path.write_text('{"prog_path": invalid json') + + # Make idedata newer so it would be used if valid + platformio_ini_mtime = platformio_ini.stat().st_mtime + os.utime(idedata_path, (platformio_ini_mtime + 1, platformio_ini_mtime + 1)) + + # Mock platformio to return new data + new_data = {"prog_path": "/new/firmware.elf"} + mock_run_platformio_cli_run.return_value = json.dumps(new_data) + + config = {"name": "test"} + result = platformio_api._load_idedata(config) + + # Should call _run_idedata since cache is corrupted + mock_run_platformio_cli_run.assert_called_once() + + assert result["prog_path"] == "/new/firmware.elf" + + +def test_run_idedata_parses_json_from_output( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test _run_idedata extracts JSON from platformio output.""" + config = {"name": "test"} + + expected_data = { + "prog_path": "/path/to/firmware.elf", + "cc_path": "/path/to/gcc", + "extra": {"flash_images": []}, + } + + # Simulate platformio output with JSON embedded + mock_run_platformio_cli_run.return_value = ( + f"Some preamble\n{json.dumps(expected_data)}\nSome postamble" + ) + + result = platformio_api._run_idedata(config) + + assert result == expected_data + + +def test_run_idedata_raises_on_no_json( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test _run_idedata raises EsphomeError when no JSON found.""" + config = {"name": "test"} + + mock_run_platformio_cli_run.return_value = "No JSON in this output" + + with pytest.raises(EsphomeError): + platformio_api._run_idedata(config) + + +def test_run_idedata_raises_on_invalid_json( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test _run_idedata raises on malformed JSON.""" + config = {"name": "test"} + mock_run_platformio_cli_run.return_value = '{"invalid": json"}' + + # The ValueError from json.loads is re-raised + with pytest.raises(ValueError): + platformio_api._run_idedata(config) + + +def test_run_platformio_cli_sets_environment_variables( + setup_core: Path, mock_run_external_command: Mock +) -> None: + """Test run_platformio_cli sets correct environment variables.""" + CORE.build_path = str(setup_core / "build" / "test") + + with patch.dict(os.environ, {}, clear=False): + mock_run_external_command.return_value = 0 + platformio_api.run_platformio_cli("test", "arg") + + # Check environment variables were set + assert os.environ["PLATFORMIO_FORCE_COLOR"] == "true" + assert ( + setup_core / "build" / "test" + in Path(os.environ["PLATFORMIO_BUILD_DIR"]).parents + or Path(os.environ["PLATFORMIO_BUILD_DIR"]) == setup_core / "build" / "test" + ) + assert "PLATFORMIO_LIBDEPS_DIR" in os.environ + assert "PYTHONWARNINGS" in os.environ + + # Check command was called correctly + mock_run_external_command.assert_called_once() + args = mock_run_external_command.call_args[0] + assert "platformio" in args + assert "test" in args + assert "arg" in args + + +def test_run_platformio_cli_run_builds_command( + setup_core: Path, mock_run_platformio_cli: Mock +) -> None: + """Test run_platformio_cli_run builds correct command.""" + CORE.build_path = str(setup_core / "build" / "test") + mock_run_platformio_cli.return_value = 0 + + config = {"name": "test"} + platformio_api.run_platformio_cli_run(config, True, "extra", "args") + + mock_run_platformio_cli.assert_called_once_with( + "run", "-d", CORE.build_path, "-v", "extra", "args" + ) + + +def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> None: + """Test run_compile with process limit.""" + from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME + + CORE.build_path = str(setup_core / "build" / "test") + config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}} + mock_run_platformio_cli_run.return_value = 0 + + platformio_api.run_compile(config, verbose=True) + + mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4") + + +def test_get_idedata_caches_result( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test get_idedata caches result in CORE.data.""" + from esphome.const import KEY_CORE + + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + CORE.data[KEY_CORE] = {} + + # Create platformio.ini to avoid regeneration + platformio_ini = setup_core / "build" / "test" / "platformio.ini" + platformio_ini.parent.mkdir(parents=True, exist_ok=True) + platformio_ini.write_text("content") + + # Mock platformio to return data + idedata = {"prog_path": "/test/firmware.elf"} + mock_run_platformio_cli_run.return_value = json.dumps(idedata) + + config = {"name": "test"} + + # First call should load and cache + result1 = platformio_api.get_idedata(config) + mock_run_platformio_cli_run.assert_called_once() + + # Second call should use cache from CORE.data + result2 = platformio_api.get_idedata(config) + mock_run_platformio_cli_run.assert_called_once() # Still only called once + + assert result1 is result2 + assert isinstance(result1, platformio_api.IDEData) + assert result1.firmware_elf_path == "/test/firmware.elf" + + +def test_idedata_addr2line_path_windows(setup_core: Path) -> None: + """Test IDEData.addr2line_path on Windows.""" + raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"} + idedata = platformio_api.IDEData(raw_data) + + result = idedata.addr2line_path + assert result == "C:\\tools\\addr2line.exe" + + +def test_idedata_addr2line_path_unix(setup_core: Path) -> None: + """Test IDEData.addr2line_path on Unix.""" + raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"} + idedata = platformio_api.IDEData(raw_data) + + result = idedata.addr2line_path + assert result == "/usr/bin/addr2line" + + +def test_patch_structhash(setup_core: Path) -> None: + """Test patch_structhash monkey patches platformio functions.""" + # Create simple namespace objects to act as modules + mock_cli = SimpleNamespace() + mock_helpers = SimpleNamespace() + mock_run = SimpleNamespace(cli=mock_cli, helpers=mock_helpers) + + # Mock platformio modules + with patch.dict( + "sys.modules", + { + "platformio.run.cli": mock_cli, + "platformio.run.helpers": mock_helpers, + "platformio.run": mock_run, + "platformio.project.helpers": MagicMock(), + "platformio.fs": MagicMock(), + "platformio": MagicMock(), + }, + ): + # Call patch_structhash + platformio_api.patch_structhash() + + # Verify both modules had clean_build_dir patched + # Check that clean_build_dir was set on both modules + assert hasattr(mock_cli, "clean_build_dir") + assert hasattr(mock_helpers, "clean_build_dir") + + # Verify they got the same function assigned + assert mock_cli.clean_build_dir is mock_helpers.clean_build_dir + + # Verify it's a real function (not a Mock) + assert callable(mock_cli.clean_build_dir) + assert mock_cli.clean_build_dir.__name__ == "patched_clean_build_dir" + + +def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None: + """Test patched_clean_build_dir removes build dir when platformio.ini is newer.""" + build_dir = setup_core / "build" + build_dir.mkdir() + platformio_ini = setup_core / "platformio.ini" + platformio_ini.write_text("config") + + # Make platformio.ini newer than build_dir + build_mtime = build_dir.stat().st_mtime + os.utime(platformio_ini, (build_mtime + 1, build_mtime + 1)) + + # Track if directory was removed + removed_paths: list[str] = [] + + def track_rmtree(path: str) -> None: + removed_paths.append(path) + shutil.rmtree(path) + + # Create mock modules that patch_structhash expects + mock_cli = SimpleNamespace() + mock_helpers = SimpleNamespace() + mock_project_helpers = MagicMock() + mock_project_helpers.get_project_dir.return_value = str(setup_core) + mock_fs = SimpleNamespace(rmtree=track_rmtree) + + with patch.dict( + "sys.modules", + { + "platformio": SimpleNamespace(fs=mock_fs), + "platformio.fs": mock_fs, + "platformio.project.helpers": mock_project_helpers, + "platformio.run": SimpleNamespace(cli=mock_cli, helpers=mock_helpers), + "platformio.run.cli": mock_cli, + "platformio.run.helpers": mock_helpers, + }, + ): + # Call patch_structhash to install the patched function + platformio_api.patch_structhash() + + # Call the patched function + mock_helpers.clean_build_dir(str(build_dir), []) + + # Verify directory was removed and recreated + assert len(removed_paths) == 1 + assert removed_paths[0] == str(build_dir) + assert build_dir.exists() # makedirs recreated it + + +def test_patched_clean_build_dir_keeps_updated(setup_core: Path) -> None: + """Test patched_clean_build_dir keeps build dir when it's up to date.""" + build_dir = setup_core / "build" + build_dir.mkdir() + test_file = build_dir / "test.txt" + test_file.write_text("test content") + + platformio_ini = setup_core / "platformio.ini" + platformio_ini.write_text("config") + + # Make build_dir newer than platformio.ini + ini_mtime = platformio_ini.stat().st_mtime + os.utime(build_dir, (ini_mtime + 1, ini_mtime + 1)) + + # Track if rmtree is called + removed_paths: list[str] = [] + + def track_rmtree(path: str) -> None: + removed_paths.append(path) + + # Create mock modules + mock_cli = SimpleNamespace() + mock_helpers = SimpleNamespace() + mock_project_helpers = MagicMock() + mock_project_helpers.get_project_dir.return_value = str(setup_core) + mock_fs = SimpleNamespace(rmtree=track_rmtree) + + with patch.dict( + "sys.modules", + { + "platformio": SimpleNamespace(fs=mock_fs), + "platformio.fs": mock_fs, + "platformio.project.helpers": mock_project_helpers, + "platformio.run": SimpleNamespace(cli=mock_cli, helpers=mock_helpers), + "platformio.run.cli": mock_cli, + "platformio.run.helpers": mock_helpers, + }, + ): + # Call patch_structhash to install the patched function + platformio_api.patch_structhash() + + # Call the patched function + mock_helpers.clean_build_dir(str(build_dir), []) + + # Verify rmtree was NOT called + assert len(removed_paths) == 0 + + # Verify directory and file still exist + assert build_dir.exists() + assert test_file.exists() + assert test_file.read_text() == "test content" + + +def test_patched_clean_build_dir_creates_missing(setup_core: Path) -> None: + """Test patched_clean_build_dir creates build dir when it doesn't exist.""" + build_dir = setup_core / "build" + platformio_ini = setup_core / "platformio.ini" + platformio_ini.write_text("config") + + # Ensure build_dir doesn't exist + assert not build_dir.exists() + + # Track if rmtree is called + removed_paths: list[str] = [] + + def track_rmtree(path: str) -> None: + removed_paths.append(path) + + # Create mock modules + mock_cli = SimpleNamespace() + mock_helpers = SimpleNamespace() + mock_project_helpers = MagicMock() + mock_project_helpers.get_project_dir.return_value = str(setup_core) + mock_fs = SimpleNamespace(rmtree=track_rmtree) + + with patch.dict( + "sys.modules", + { + "platformio": SimpleNamespace(fs=mock_fs), + "platformio.fs": mock_fs, + "platformio.project.helpers": mock_project_helpers, + "platformio.run": SimpleNamespace(cli=mock_cli, helpers=mock_helpers), + "platformio.run.cli": mock_cli, + "platformio.run.helpers": mock_helpers, + }, + ): + # Call patch_structhash to install the patched function + platformio_api.patch_structhash() + + # Call the patched function + mock_helpers.clean_build_dir(str(build_dir), []) + + # Verify rmtree was NOT called + assert len(removed_paths) == 0 + + # Verify directory was created + assert build_dir.exists() + + +def test_process_stacktrace_esp8266_exception(setup_core: Path, caplog) -> None: + """Test process_stacktrace handles ESP8266 exceptions.""" + config = {"name": "test"} + + # Test exception type parsing + line = "Exception (28):" + backtrace_state = False + + result = platformio_api.process_stacktrace(config, line, backtrace_state) + + assert "Access to invalid address: LOAD (wild pointer?)" in caplog.text + assert result is False + + +def test_process_stacktrace_esp8266_backtrace( + setup_core: Path, mock_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP8266 multi-line backtrace.""" + config = {"name": "test"} + + # Start of backtrace + line1 = ">>>stack>>>" + state = platformio_api.process_stacktrace(config, line1, False) + assert state is True + + # Backtrace content with addresses + line2 = "40201234 40205678" + state = platformio_api.process_stacktrace(config, line2, state) + assert state is True + assert mock_decode_pc.call_count == 2 + + # End of backtrace + line3 = "<< None: + """Test process_stacktrace handles ESP32 single-line backtrace.""" + config = {"name": "test"} + + line = "Backtrace: 0x40081234:0x3ffb1234 0x40085678:0x3ffb5678" + state = platformio_api.process_stacktrace(config, line, False) + + # Should decode both addresses + assert mock_decode_pc.call_count == 2 + mock_decode_pc.assert_any_call(config, "40081234") + mock_decode_pc.assert_any_call(config, "40085678") + assert state is False + + +def test_process_stacktrace_bad_alloc( + setup_core: Path, mock_decode_pc: Mock, caplog +) -> None: + """Test process_stacktrace handles bad alloc messages.""" + config = {"name": "test"} + + line = "last failed alloc call: 40201234(512)" + state = platformio_api.process_stacktrace(config, line, False) + + assert "Memory allocation of 512 bytes failed at 40201234" in caplog.text + mock_decode_pc.assert_called_once_with(config, "40201234") + assert state is False diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 52de327bbc..e1abe565b1 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -1,12 +1,15 @@ """Tests for storage_json.py path functions.""" +from datetime import datetime +import json from pathlib import Path import sys -from unittest.mock import patch +from unittest.mock import MagicMock, Mock, patch import pytest from esphome import storage_json +from esphome.const import CONF_DISABLED, CONF_MDNS from esphome.core import CORE @@ -115,7 +118,9 @@ def test_storage_json_firmware_bin_path_property(setup_core: Path) -> None: assert storage.firmware_bin_path == "/path/to/firmware.bin" -def test_storage_json_save_creates_directory(setup_core: Path, tmp_path: Path) -> None: +def test_storage_json_save_creates_directory( + setup_core: Path, tmp_path: Path, mock_write_file_if_changed: Mock +) -> None: """Test StorageJSON.save creates storage directory if it doesn't exist.""" storage_dir = tmp_path / "new_data" / "storage" storage_file = storage_dir / "test.json" @@ -139,11 +144,10 @@ def test_storage_json_save_creates_directory(setup_core: Path, tmp_path: Path) - no_mdns=False, ) - with patch("esphome.storage_json.write_file_if_changed") as mock_write: - storage.save(str(storage_file)) - mock_write.assert_called_once() - call_args = mock_write.call_args[0] - assert call_args[0] == str(storage_file) + storage.save(str(storage_file)) + mock_write_file_if_changed.assert_called_once() + call_args = mock_write_file_if_changed.call_args[0] + assert call_args[0] == str(storage_file) def test_storage_json_from_wizard(setup_core: Path) -> None: @@ -180,3 +184,477 @@ def test_storage_paths_with_ha_addon(mock_is_ha_addon: bool, tmp_path: Path) -> result = storage_json.esphome_storage_path() expected = str(Path("/data") / "esphome.json") assert result == expected + + +def test_storage_json_as_dict() -> None: + """Test StorageJSON.as_dict returns correct dictionary.""" + storage = storage_json.StorageJSON( + storage_version=1, + name="test_device", + friendly_name="Test Device", + comment="Test comment", + esphome_version="2024.1.0", + src_version=1, + address="192.168.1.100", + web_port=80, + target_platform="ESP32", + build_path="/path/to/build", + firmware_bin_path="/path/to/firmware.bin", + loaded_integrations={"wifi", "api", "ota"}, + loaded_platforms={"sensor", "binary_sensor"}, + no_mdns=True, + framework="arduino", + core_platform="esp32", + ) + + result = storage.as_dict() + + assert result["storage_version"] == 1 + assert result["name"] == "test_device" + assert result["friendly_name"] == "Test Device" + assert result["comment"] == "Test comment" + assert result["esphome_version"] == "2024.1.0" + assert result["src_version"] == 1 + assert result["address"] == "192.168.1.100" + assert result["web_port"] == 80 + assert result["esp_platform"] == "ESP32" + assert result["build_path"] == "/path/to/build" + assert result["firmware_bin_path"] == "/path/to/firmware.bin" + assert "api" in result["loaded_integrations"] + assert "wifi" in result["loaded_integrations"] + assert "ota" in result["loaded_integrations"] + assert result["loaded_integrations"] == sorted( + ["wifi", "api", "ota"] + ) # Should be sorted + assert "sensor" in result["loaded_platforms"] + assert result["loaded_platforms"] == sorted( + ["sensor", "binary_sensor"] + ) # Should be sorted + assert result["no_mdns"] is True + assert result["framework"] == "arduino" + assert result["core_platform"] == "esp32" + + +def test_storage_json_to_json() -> None: + """Test StorageJSON.to_json returns valid JSON string.""" + storage = storage_json.StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=None, + address="test.local", + web_port=None, + target_platform="ESP8266", + build_path=None, + firmware_bin_path=None, + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + ) + + json_str = storage.to_json() + + # Should be valid JSON + parsed = json.loads(json_str) + assert parsed["name"] == "test" + assert parsed["storage_version"] == 1 + + # Should end with newline + assert json_str.endswith("\n") + + +def test_storage_json_save(tmp_path: Path) -> None: + """Test StorageJSON.save writes file correctly.""" + storage = storage_json.StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=None, + address="test.local", + web_port=None, + target_platform="ESP32", + build_path=None, + firmware_bin_path=None, + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + ) + + save_path = tmp_path / "test.json" + + with patch("esphome.storage_json.write_file_if_changed") as mock_write: + storage.save(str(save_path)) + mock_write.assert_called_once_with(str(save_path), storage.to_json()) + + +def test_storage_json_from_esphome_core(setup_core: Path) -> None: + """Test StorageJSON.from_esphome_core creates correct storage object.""" + # Mock CORE object + mock_core = MagicMock() + mock_core.name = "my_device" + mock_core.friendly_name = "My Device" + mock_core.comment = "A test device" + mock_core.address = "192.168.1.50" + mock_core.web_port = 8080 + mock_core.target_platform = "esp32" + mock_core.is_esp32 = True + mock_core.build_path = "/build/my_device" + mock_core.firmware_bin = "/build/my_device/firmware.bin" + mock_core.loaded_integrations = {"wifi", "api"} + mock_core.loaded_platforms = {"sensor"} + mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} + mock_core.target_framework = "esp-idf" + + with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: + mock_variant.return_value = "ESP32-C3" + + result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) + + assert result.name == "my_device" + assert result.friendly_name == "My Device" + assert result.comment == "A test device" + assert result.address == "192.168.1.50" + assert result.web_port == 8080 + assert result.target_platform == "ESP32-C3" + assert result.build_path == "/build/my_device" + assert result.firmware_bin_path == "/build/my_device/firmware.bin" + assert result.loaded_integrations == {"wifi", "api"} + assert result.loaded_platforms == {"sensor"} + assert result.no_mdns is True + assert result.framework == "esp-idf" + assert result.core_platform == "esp32" + + +def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: + """Test from_esphome_core with mDNS enabled.""" + mock_core = MagicMock() + mock_core.name = "test" + mock_core.friendly_name = "Test" + mock_core.comment = None + mock_core.address = "test.local" + mock_core.web_port = None + mock_core.target_platform = "esp8266" + mock_core.is_esp32 = False + mock_core.build_path = "/build" + mock_core.firmware_bin = "/build/firmware.bin" + mock_core.loaded_integrations = set() + mock_core.loaded_platforms = set() + mock_core.config = {} # No MDNS config means enabled + mock_core.target_framework = "arduino" + + result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) + + assert result.no_mdns is False + + +def test_storage_json_load_valid_file(tmp_path: Path) -> None: + """Test StorageJSON.load with valid JSON file.""" + storage_data = { + "storage_version": 1, + "name": "loaded_device", + "friendly_name": "Loaded Device", + "comment": "Loaded from file", + "esphome_version": "2024.1.0", + "src_version": 2, + "address": "10.0.0.1", + "web_port": 8080, + "esp_platform": "ESP32", + "build_path": "/loaded/build", + "firmware_bin_path": "/loaded/firmware.bin", + "loaded_integrations": ["wifi", "api"], + "loaded_platforms": ["sensor"], + "no_mdns": True, + "framework": "arduino", + "core_platform": "esp32", + } + + file_path = tmp_path / "storage.json" + file_path.write_text(json.dumps(storage_data)) + + result = storage_json.StorageJSON.load(str(file_path)) + + assert result is not None + assert result.name == "loaded_device" + assert result.friendly_name == "Loaded Device" + assert result.comment == "Loaded from file" + assert result.esphome_version == "2024.1.0" + assert result.src_version == 2 + assert result.address == "10.0.0.1" + assert result.web_port == 8080 + assert result.target_platform == "ESP32" + assert result.build_path == "/loaded/build" + assert result.firmware_bin_path == "/loaded/firmware.bin" + assert result.loaded_integrations == {"wifi", "api"} + assert result.loaded_platforms == {"sensor"} + assert result.no_mdns is True + assert result.framework == "arduino" + assert result.core_platform == "esp32" + + +def test_storage_json_load_invalid_file(tmp_path: Path) -> None: + """Test StorageJSON.load with invalid JSON file.""" + file_path = tmp_path / "invalid.json" + file_path.write_text("not valid json{") + + result = storage_json.StorageJSON.load(str(file_path)) + + assert result is None + + +def test_storage_json_load_nonexistent_file() -> None: + """Test StorageJSON.load with non-existent file.""" + result = storage_json.StorageJSON.load("/nonexistent/file.json") + + assert result is None + + +def test_storage_json_equality() -> None: + """Test StorageJSON equality comparison.""" + storage1 = storage_json.StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="test.local", + web_port=80, + target_platform="ESP32", + build_path="/build", + firmware_bin_path="/firmware.bin", + loaded_integrations={"wifi"}, + loaded_platforms=set(), + no_mdns=False, + ) + + storage2 = storage_json.StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="test.local", + web_port=80, + target_platform="ESP32", + build_path="/build", + firmware_bin_path="/firmware.bin", + loaded_integrations={"wifi"}, + loaded_platforms=set(), + no_mdns=False, + ) + + storage3 = storage_json.StorageJSON( + storage_version=1, + name="different", # Different name + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="test.local", + web_port=80, + target_platform="ESP32", + build_path="/build", + firmware_bin_path="/firmware.bin", + loaded_integrations={"wifi"}, + loaded_platforms=set(), + no_mdns=False, + ) + + assert storage1 == storage2 + assert storage1 != storage3 + assert storage1 != "not a storage object" + + +def test_esphome_storage_json_as_dict() -> None: + """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" + storage = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="secret123", + last_update_check="2024-01-15T10:30:00", + remote_version="2024.1.1", + ) + + result = storage.as_dict() + + assert result["storage_version"] == 1 + assert result["cookie_secret"] == "secret123" + assert result["last_update_check"] == "2024-01-15T10:30:00" + assert result["remote_version"] == "2024.1.1" + + +def test_esphome_storage_json_last_update_check_property() -> None: + """Test EsphomeStorageJSON.last_update_check property.""" + storage = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="secret", + last_update_check="2024-01-15T10:30:00", + remote_version=None, + ) + + # Test getter + result = storage.last_update_check + assert isinstance(result, datetime) + assert result.year == 2024 + assert result.month == 1 + assert result.day == 15 + assert result.hour == 10 + assert result.minute == 30 + + # Test setter + new_date = datetime(2024, 2, 20, 15, 45, 30) + storage.last_update_check = new_date + assert storage.last_update_check_str == "2024-02-20T15:45:30" + + +def test_esphome_storage_json_last_update_check_invalid() -> None: + """Test EsphomeStorageJSON.last_update_check with invalid date.""" + storage = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="secret", + last_update_check="invalid date", + remote_version=None, + ) + + result = storage.last_update_check + assert result is None + + +def test_esphome_storage_json_to_json() -> None: + """Test EsphomeStorageJSON.to_json returns valid JSON string.""" + storage = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="mysecret", + last_update_check="2024-01-15T10:30:00", + remote_version="2024.1.1", + ) + + json_str = storage.to_json() + + # Should be valid JSON + parsed = json.loads(json_str) + assert parsed["cookie_secret"] == "mysecret" + assert parsed["storage_version"] == 1 + + # Should end with newline + assert json_str.endswith("\n") + + +def test_esphome_storage_json_save(tmp_path: Path) -> None: + """Test EsphomeStorageJSON.save writes file correctly.""" + storage = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="secret", + last_update_check=None, + remote_version=None, + ) + + save_path = tmp_path / "esphome.json" + + with patch("esphome.storage_json.write_file_if_changed") as mock_write: + storage.save(str(save_path)) + mock_write.assert_called_once_with(str(save_path), storage.to_json()) + + +def test_esphome_storage_json_load_valid_file(tmp_path: Path) -> None: + """Test EsphomeStorageJSON.load with valid JSON file.""" + storage_data = { + "storage_version": 1, + "cookie_secret": "loaded_secret", + "last_update_check": "2024-01-20T14:30:00", + "remote_version": "2024.1.2", + } + + file_path = tmp_path / "esphome.json" + file_path.write_text(json.dumps(storage_data)) + + result = storage_json.EsphomeStorageJSON.load(str(file_path)) + + assert result is not None + assert result.storage_version == 1 + assert result.cookie_secret == "loaded_secret" + assert result.last_update_check_str == "2024-01-20T14:30:00" + assert result.remote_version == "2024.1.2" + + +def test_esphome_storage_json_load_invalid_file(tmp_path: Path) -> None: + """Test EsphomeStorageJSON.load with invalid JSON file.""" + file_path = tmp_path / "invalid.json" + file_path.write_text("not valid json{") + + result = storage_json.EsphomeStorageJSON.load(str(file_path)) + + assert result is None + + +def test_esphome_storage_json_load_nonexistent_file() -> None: + """Test EsphomeStorageJSON.load with non-existent file.""" + result = storage_json.EsphomeStorageJSON.load("/nonexistent/file.json") + + assert result is None + + +def test_esphome_storage_json_get_default() -> None: + """Test EsphomeStorageJSON.get_default creates default storage.""" + with patch("esphome.storage_json.os.urandom") as mock_urandom: + # Mock urandom to return predictable bytes + mock_urandom.return_value = b"test" * 16 # 64 bytes + + result = storage_json.EsphomeStorageJSON.get_default() + + assert result.storage_version == 1 + assert len(result.cookie_secret) == 128 # 64 bytes hex = 128 chars + assert result.last_update_check is None + assert result.remote_version is None + + +def test_esphome_storage_json_equality() -> None: + """Test EsphomeStorageJSON equality comparison.""" + storage1 = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="secret", + last_update_check="2024-01-15T10:30:00", + remote_version="2024.1.1", + ) + + storage2 = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="secret", + last_update_check="2024-01-15T10:30:00", + remote_version="2024.1.1", + ) + + storage3 = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="different", # Different secret + last_update_check="2024-01-15T10:30:00", + remote_version="2024.1.1", + ) + + assert storage1 == storage2 + assert storage1 != storage3 + assert storage1 != "not a storage object" + + +def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: + """Test loading storage with legacy esphomeyaml_version field.""" + storage_data = { + "storage_version": 1, + "name": "legacy_device", + "friendly_name": "Legacy Device", + "esphomeyaml_version": "1.14.0", # Legacy field name + "address": "legacy.local", + "esp_platform": "ESP8266", + } + + file_path = tmp_path / "legacy.json" + file_path.write_text(json.dumps(storage_data)) + + result = storage_json.StorageJSON.load(str(file_path)) + + assert result is not None + assert result.esphome_version == "1.14.0" # Should map to esphome_version From 10a665b864bbab8aafa40846a6237ed2a7e269b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:45:22 -0500 Subject: [PATCH 1095/1626] [ethernet] Fix permanent component failure from undocumented ESP_FAIL in IPv6 setup (#10708) --- .../ethernet/ethernet_component.cpp | 46 ++++++++++++++++++- .../components/ethernet/ethernet_component.h | 2 + 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 844a30bd8b..cd73333222 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -300,6 +300,7 @@ void EthernetComponent::loop() { this->state_ = EthernetComponentState::CONNECTING; this->start_connect_(); } else { + this->finish_connect_(); // When connected and stable, disable the loop to save CPU cycles this->disable_loop(); } @@ -486,10 +487,35 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_ } #endif /* USE_NETWORK_IPV6 */ +void EthernetComponent::finish_connect_() { +#if USE_NETWORK_IPV6 + // Retry IPv6 link-local setup if it failed during initial connect + // This handles the case where min_ipv6_addr_count is NOT set (or is 0), + // allowing us to reach CONNECTED state with just IPv4. + // If IPv6 setup failed in start_connect_() because the interface wasn't ready: + // - Bootup timing issues (#10281) + // - Cable unplugged/network interruption (#10705) + // We can now retry since we're in CONNECTED state and the interface is definitely up. + if (!this->ipv6_setup_done_) { + esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); + if (err == ESP_OK) { + ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); + } + // Always set the flag to prevent continuous retries + // If IPv6 setup fails here with the interface up and stable, it's + // likely a persistent issue (IPv6 disabled at router, hardware + // limitation, etc.) that won't be resolved by further retries. + // The device continues to work with IPv4. + this->ipv6_setup_done_ = true; + } +#endif /* USE_NETWORK_IPV6 */ +} + void EthernetComponent::start_connect_() { global_eth_component->got_ipv4_address_ = false; #if USE_NETWORK_IPV6 global_eth_component->ipv6_count_ = 0; + this->ipv6_setup_done_ = false; #endif /* USE_NETWORK_IPV6 */ this->connect_begin_ = millis(); this->status_set_warning(LOG_STR("waiting for IP configuration")); @@ -545,9 +571,27 @@ void EthernetComponent::start_connect_() { } } #if USE_NETWORK_IPV6 + // Attempt to create IPv6 link-local address + // We MUST attempt this here, not just in finish_connect_(), because with + // min_ipv6_addr_count set, the component won't reach CONNECTED state without IPv6. + // However, this may fail with ESP_FAIL if the interface is not up yet: + // - At bootup when link isn't ready (#10281) + // - After disconnection/cable unplugged (#10705) + // We'll retry in finish_connect_() if it fails here. err = esp_netif_create_ip6_linklocal(this->eth_netif_); if (err != ESP_OK) { - ESPHL_ERROR_CHECK(err, "Enable IPv6 link local failed"); + if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { + // This is a programming error, not a transient failure + ESPHL_ERROR_CHECK(err, "esp_netif_create_ip6_linklocal invalid parameters"); + } else { + // ESP_FAIL means the interface isn't up yet + // This is expected and non-fatal, happens in multiple scenarios: + // - During reconnection after network interruptions (#10705) + // - At bootup when the link isn't ready yet (#10281) + // We'll retry once we reach CONNECTED state and the interface is up + ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed: %s", esp_err_to_name(err)); + // Don't mark component as failed - this is a transient error + } } #endif /* USE_NETWORK_IPV6 */ diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index bdcda6afb4..3d2713ee5c 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -102,6 +102,7 @@ class EthernetComponent : public Component { #endif /* LWIP_IPV6 */ void start_connect_(); + void finish_connect_(); void dump_connect_params_(); /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. void ksz8081_set_clock_reference_(esp_eth_mac_t *mac); @@ -144,6 +145,7 @@ class EthernetComponent : public Component { bool got_ipv4_address_{false}; #if LWIP_IPV6 uint8_t ipv6_count_{0}; + bool ipv6_setup_done_{false}; #endif /* LWIP_IPV6 */ // Pointers at the end (naturally aligned) From 9c85a7eff34d0268321609dd755528c3a5aeaed7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:50:38 -0500 Subject: [PATCH 1096/1626] [core] Optimize MAC address formatting to eliminate sprintf dependency (#10713) --- .../ethernet/ethernet_component.cpp | 4 ++- esphome/core/helpers.cpp | 15 +++++----- esphome/core/helpers.h | 29 +++++++++++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index cd73333222..a48fd27383 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -682,7 +682,9 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { std::string EthernetComponent::get_eth_mac_address_pretty() { uint8_t mac[6]; get_eth_mac_address_raw(mac); - return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); } eth_duplex_t EthernetComponent::get_duplex_mode() { diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 43d6f1153c..f1560711ef 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -255,23 +255,22 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { } std::string format_mac_address_pretty(const uint8_t *mac) { - return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); } -static char format_hex_char(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } std::string format_hex(const uint8_t *data, size_t length) { std::string ret; ret.resize(length * 2); for (size_t i = 0; i < length; i++) { - ret[2 * i] = format_hex_char((data[i] & 0xF0) >> 4); + ret[2 * i] = format_hex_char(data[i] >> 4); ret[2 * i + 1] = format_hex_char(data[i] & 0x0F); } return ret; } std::string format_hex(const std::vector &data) { return format_hex(data.data(), data.size()); } -static char format_hex_pretty_char(uint8_t v) { return v >= 10 ? 'A' + (v - 10) : '0' + v; } - // Shared implementation for uint8_t and string hex formatting static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, char separator, bool show_length) { if (data == nullptr || length == 0) @@ -280,7 +279,7 @@ static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, c uint8_t multiple = separator ? 3 : 2; // 3 if separator is not \0, 2 otherwise ret.resize(multiple * length - (separator ? 1 : 0)); for (size_t i = 0; i < length; i++) { - ret[multiple * i] = format_hex_pretty_char((data[i] & 0xF0) >> 4); + ret[multiple * i] = format_hex_pretty_char(data[i] >> 4); ret[multiple * i + 1] = format_hex_pretty_char(data[i] & 0x0F); if (separator && i != length - 1) ret[multiple * i + 2] = separator; @@ -591,7 +590,9 @@ bool HighFrequencyLoopRequester::is_high_frequency() { return num_requests > 0; std::string get_mac_address() { uint8_t mac[6]; get_mac_address_raw(mac); - return str_snprintf("%02x%02x%02x%02x%02x%02x", 12, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[13]; + format_mac_addr_lower_no_sep(mac, buf); + return std::string(buf); } std::string get_mac_address_pretty() { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a6741925d0..21aa159b25 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -380,6 +380,35 @@ template::value, int> = 0> optional< return parse_hex(str.c_str(), str.length()); } +/// Convert a nibble (0-15) to lowercase hex char +inline char format_hex_char(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } + +/// Convert a nibble (0-15) to uppercase hex char (used for pretty printing) +/// This always uses uppercase (A-F) for pretty/human-readable output +inline char format_hex_pretty_char(uint8_t v) { return v >= 10 ? 'A' + (v - 10) : '0' + v; } + +/// Format MAC address as XX:XX:XX:XX:XX:XX (uppercase) +inline void format_mac_addr_upper(const uint8_t *mac, char *output) { + for (size_t i = 0; i < 6; i++) { + uint8_t byte = mac[i]; + output[i * 3] = format_hex_pretty_char(byte >> 4); + output[i * 3 + 1] = format_hex_pretty_char(byte & 0x0F); + if (i < 5) + output[i * 3 + 2] = ':'; + } + output[17] = '\0'; +} + +/// Format MAC address as xxxxxxxxxxxxxx (lowercase, no separators) +inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { + for (size_t i = 0; i < 6; i++) { + uint8_t byte = mac[i]; + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); + } + output[12] = '\0'; +} + /// Format the six-byte array \p mac into a MAC address. std::string format_mac_address_pretty(const uint8_t mac[6]); /// Format the byte array \p data of length \p len in lowercased hex. From bb6be9c939ea0a11618b8c704ae13399bf72e18e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:52:19 -0500 Subject: [PATCH 1097/1626] [api] Revert unneeded GetTime bidirectional support added in #9790 (#10702) --- esphome/components/api/api.proto | 7 ++----- esphome/components/api/api_connection.cpp | 6 ------ esphome/components/api/api_connection.h | 1 - esphome/components/api/api_pb2.cpp | 8 -------- esphome/components/api/api_pb2.h | 4 ---- esphome/components/api/api_pb2_dump.cpp | 8 +------- esphome/components/api/api_pb2_service.cpp | 14 -------------- esphome/components/api/api_pb2_service.h | 4 +--- 8 files changed, 4 insertions(+), 48 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 208187d598..471127e93a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -27,9 +27,6 @@ service APIConnection { rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} rpc subscribe_homeassistant_services (SubscribeHomeassistantServicesRequest) returns (void) {} rpc subscribe_home_assistant_states (SubscribeHomeAssistantStatesRequest) returns (void) {} - rpc get_time (GetTimeRequest) returns (GetTimeResponse) { - option (needs_authentication) = false; - } rpc execute_service (ExecuteServiceRequest) returns (void) {} rpc noise_encryption_set_key (NoiseEncryptionSetKeyRequest) returns (NoiseEncryptionSetKeyResponse) {} @@ -809,12 +806,12 @@ message HomeAssistantStateResponse { // ==================== IMPORT TIME ==================== message GetTimeRequest { option (id) = 36; - option (source) = SOURCE_BOTH; + option (source) = SOURCE_SERVER; } message GetTimeResponse { option (id) = 37; - option (source) = SOURCE_BOTH; + option (source) = SOURCE_CLIENT; option (no_delay) = true; fixed32 epoch_seconds = 1; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 99a0bc9044..1fb65f3a2b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1081,12 +1081,6 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { } #endif -bool APIConnection::send_get_time_response(const GetTimeRequest &msg) { - GetTimeResponse resp; - resp.epoch_seconds = ::time(nullptr); - return this->send_message(resp, GetTimeResponse::MESSAGE_TYPE); -} - #ifdef USE_BLUETOOTH_PROXY void APIConnection::subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->subscribe_api_connection(this, msg.flags); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7ee82e0c68..8f93f38203 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -219,7 +219,6 @@ class APIConnection final : public APIServerConnection { #ifdef USE_API_HOMEASSISTANT_STATES void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override; #endif - bool send_get_time_response(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES void execute_service(const ExecuteServiceRequest &msg) override; #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 022ac55cf3..a92fca70d6 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -921,14 +921,6 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { } return true; } -void GetTimeResponse::encode(ProtoWriteBuffer buffer) const { - buffer.encode_fixed32(1, this->epoch_seconds); - buffer.encode_string(2, this->timezone_ref_); -} -void GetTimeResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32(1, this->epoch_seconds); - size.add_length(1, this->timezone_ref_.size()); -} #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index fd124e7bfe..5b6d694e3b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1180,10 +1180,6 @@ class GetTimeResponse final : public ProtoDecodableMessage { #endif uint32_t epoch_seconds{0}; std::string timezone{}; - StringRef timezone_ref_{}; - void set_timezone(const StringRef &ref) { this->timezone_ref_ = ref; } - void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 9795999953..b5e98a9f28 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1113,13 +1113,7 @@ void GetTimeRequest::dump_to(std::string &out) const { out.append("GetTimeReques void GetTimeResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "GetTimeResponse"); dump_field(out, "epoch_seconds", this->epoch_seconds); - out.append(" timezone: "); - if (!this->timezone_ref_.empty()) { - out.append("'").append(this->timezone_ref_.c_str()).append("'"); - } else { - out.append("'").append(this->timezone).append("'"); - } - out.append("\n"); + dump_field(out, "timezone", this->timezone); } #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::dump_to(std::string &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 6b7b8b9ebd..2598e9a0fb 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -160,15 +160,6 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #endif - case GetTimeRequest::MESSAGE_TYPE: { - GetTimeRequest msg; - // Empty message: no decode needed -#ifdef HAS_PROTO_MESSAGE_DUMP - ESP_LOGVV(TAG, "on_get_time_request: %s", msg.dump().c_str()); -#endif - this->on_get_time_request(msg); - break; - } case GetTimeResponse::MESSAGE_TYPE: { GetTimeResponse msg; msg.decode(msg_data, msg_size); @@ -656,11 +647,6 @@ void APIServerConnection::on_subscribe_home_assistant_states_request(const Subsc } } #endif -void APIServerConnection::on_get_time_request(const GetTimeRequest &msg) { - if (this->check_connection_setup_() && !this->send_get_time_response(msg)) { - this->on_fatal_error(); - } -} #ifdef USE_API_SERVICES void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { if (this->check_authenticated_()) { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 6172e33bf6..5b7508e786 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -71,7 +71,7 @@ class APIServerConnectionBase : public ProtoService { #ifdef USE_API_HOMEASSISTANT_STATES virtual void on_home_assistant_state_response(const HomeAssistantStateResponse &value){}; #endif - virtual void on_get_time_request(const GetTimeRequest &value){}; + virtual void on_get_time_response(const GetTimeResponse &value){}; #ifdef USE_API_SERVICES @@ -226,7 +226,6 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_API_HOMEASSISTANT_STATES virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; #endif - virtual bool send_get_time_response(const GetTimeRequest &msg) = 0; #ifdef USE_API_SERVICES virtual void execute_service(const ExecuteServiceRequest &msg) = 0; #endif @@ -348,7 +347,6 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; #endif - void on_get_time_request(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES void on_execute_service_request(const ExecuteServiceRequest &msg) override; #endif From 7ae11de2e44e327dce4e14b229066c288a8c3caf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:54:42 -0500 Subject: [PATCH 1098/1626] [api] Optimize HelloResponse server_info to reduce memory usage (#10701) --- esphome/components/api/api_connection.cpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 1fb65f3a2b..7b7853f040 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -42,6 +42,8 @@ static constexpr uint8_t MAX_PING_RETRIES = 60; static constexpr uint16_t PING_RETRY_INTERVAL = 1000; static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2; +static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); + static const char *const TAG = "api.connection"; #ifdef USE_CAMERA static const int CAMERA_STOP_STREAM = 5000; @@ -1370,9 +1372,8 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; resp.api_version_minor = 12; - // Temporary string for concatenation - will be valid during send_message call - std::string server_info = App.get_name() + " (esphome v" ESPHOME_VERSION ")"; - resp.set_server_info(StringRef(server_info)); + // Send only the version string - the client only logs this for debugging and doesn't use it otherwise + resp.set_server_info(ESPHOME_VERSION_REF); resp.set_name(StringRef(App.get_name())); #ifdef USE_API_PASSWORD @@ -1419,8 +1420,6 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { std::string mac_address = get_mac_address_pretty(); resp.set_mac_address(StringRef(mac_address)); - // Compile-time StringRef constants - static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); resp.set_esphome_version(ESPHOME_VERSION_REF); resp.set_compilation_time(App.get_compilation_time_ref()); From ac07a001412810aef02706e72e59672aa8635761 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 17:05:56 -0500 Subject: [PATCH 1099/1626] [scheduler] Fix timing accumulation in scheduler causing incorrect execution measurements (#10719) --- esphome/core/scheduler.cpp | 8 ++++---- esphome/core/scheduler.h | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 68da0a56ca..71e2a00fbe 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -345,7 +345,7 @@ void HOT Scheduler::call(uint32_t now) { // Execute callback without holding lock to prevent deadlocks // if the callback tries to call defer() again if (!this->should_skip_item_(item.get())) { - this->execute_item_(item.get(), now); + now = this->execute_item_(item.get(), now); } // Recycle the defer item after execution this->recycle_item_(std::move(item)); @@ -483,7 +483,7 @@ void HOT Scheduler::call(uint32_t now) { // Warning: During callback(), a lot of stuff can happen, including: // - timeouts/intervals get added, potentially invalidating vector pointers // - timeouts/intervals get cancelled - this->execute_item_(item.get(), now); + now = this->execute_item_(item.get(), now); LockGuard guard{this->lock_}; @@ -568,11 +568,11 @@ void HOT Scheduler::pop_raw_() { } // Helper to execute a scheduler item -void HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { +uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); - guard.finish(); + return guard.finish(); } // Common implementation for cancel operations diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 301342e8c2..885ee13754 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -254,7 +254,7 @@ class Scheduler { } // Helper to execute a scheduler item - void execute_item_(SchedulerItem *item, uint32_t now); + uint32_t execute_item_(SchedulerItem *item, uint32_t now); // Helper to check if item should be skipped bool should_skip_item_(SchedulerItem *item) const { From f756de276beeb9dad56be28b33804384cba492b7 Mon Sep 17 00:00:00 2001 From: Big Mike Date: Sun, 14 Sep 2025 17:16:01 -0500 Subject: [PATCH 1100/1626] ina2xx should be total increasing for energy sensor (#10711) --- esphome/components/ina2xx_base/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index ff70f217ec..fef88e72e9 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -18,6 +18,7 @@ from esphome.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, UNIT_VOLT, @@ -162,7 +163,7 @@ INA2XX_SCHEMA = cv.Schema( unit_of_measurement=UNIT_WATT_HOURS, accuracy_decimals=8, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), key=CONF_NAME, ), @@ -170,7 +171,8 @@ INA2XX_SCHEMA = cv.Schema( sensor.sensor_schema( unit_of_measurement=UNIT_JOULE, accuracy_decimals=8, - state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), key=CONF_NAME, ), From 1accc409f6ae728baf55226cbc0974d8306f0ce1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Sep 2025 22:33:07 +0000 Subject: [PATCH 1101/1626] Bump aioesphomeapi from 40.2.0 to 40.2.1 (#10721) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4736e2a024..296485bdae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==40.2.0 +aioesphomeapi==40.2.1 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From e0241e9dcdb1b81a6159f38721828797dc7158d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 17:35:18 -0500 Subject: [PATCH 1102/1626] [md5] Optimize MD5::get_hex() to eliminate sprintf dependency (#10710) --- esphome/components/md5/md5.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index 980cb98699..21bd2e1cab 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -1,4 +1,3 @@ -#include #include #include "md5.h" #ifdef USE_MD5 @@ -44,7 +43,9 @@ void MD5Digest::get_bytes(uint8_t *output) { memcpy(output, this->digest_, 16); void MD5Digest::get_hex(char *output) { for (size_t i = 0; i < 16; i++) { - sprintf(output + i * 2, "%02x", this->digest_[i]); + uint8_t byte = this->digest_[i]; + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); } } From b946cb160d686614e5c3dc8d83dff6ff31e525e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 17:35:27 -0500 Subject: [PATCH 1103/1626] [wifi] Optimize WiFi MAC formatting to eliminate sprintf dependency (#10715) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi_info/wifi_info_text_sensor.h | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index e57bf25b8c..43ece636e5 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -593,7 +593,7 @@ void WiFiComponent::check_scanning_finished() { for (auto &res : this->scan_result_) { char bssid_s[18]; auto bssid = res.get_bssid(); - sprintf(bssid_s, "%02X:%02X:%02X:%02X:%02X:%02X", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]); + format_mac_addr_upper(bssid.data(), bssid_s); if (res.get_matches()) { ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 68b5f438e4..2cb96123a0 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/components/wifi/wifi_component.h" #ifdef USE_WIFI @@ -106,8 +107,8 @@ class BSSIDWiFiInfo : public PollingComponent, public text_sensor::TextSensor { wifi::bssid_t bssid = wifi::global_wifi_component->wifi_bssid(); if (memcmp(bssid.data(), last_bssid_.data(), 6) != 0) { std::copy(bssid.begin(), bssid.end(), last_bssid_.begin()); - char buf[30]; - sprintf(buf, "%02X:%02X:%02X:%02X:%02X:%02X", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]); + char buf[18]; + format_mac_addr_upper(bssid.data(), buf); this->publish_state(buf); } } From af3e1788d1bdef290172a8f03433c147aecfc1a5 Mon Sep 17 00:00:00 2001 From: Jimmy Hedman Date: Mon, 15 Sep 2025 00:54:14 +0200 Subject: [PATCH 1104/1626] Unpin libretiny version in network test (#10717) --- tests/components/network/test-ipv6.bk72xx-ard.yaml | 4 ---- tests/components/network/test.bk72xx-ard.yaml | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) create mode 100644 tests/components/network/test.bk72xx-ard.yaml diff --git a/tests/components/network/test-ipv6.bk72xx-ard.yaml b/tests/components/network/test-ipv6.bk72xx-ard.yaml index d0c4bbfcb9..da1324b17e 100644 --- a/tests/components/network/test-ipv6.bk72xx-ard.yaml +++ b/tests/components/network/test-ipv6.bk72xx-ard.yaml @@ -1,8 +1,4 @@ substitutions: network_enable_ipv6: "true" -bk72xx: - framework: - version: 1.7.0 - <<: !include common.yaml diff --git a/tests/components/network/test.bk72xx-ard.yaml b/tests/components/network/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/network/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 1d5a3b647d0ad61ae06ffaf87e50a5b65eaead14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 19:04:45 -0500 Subject: [PATCH 1105/1626] [esp32_ble] Optimize BLE hex formatting to eliminate sprintf dependency (#10714) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../bluetooth_proxy/bluetooth_proxy.h | 4 +- esphome/components/esp32_ble/ble_uuid.cpp | 39 ++++++++++++++----- .../esp32_ble_beacon/esp32_ble_beacon.cpp | 7 ++-- .../esp32_ble_client/ble_client_base.h | 13 ++++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 5 +-- 5 files changed, 47 insertions(+), 21 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 4b262dbe86..1ce2321bee 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -130,7 +130,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ std::string get_bluetooth_mac_address_pretty() { const uint8_t *mac = esp_bt_dev_get_address(); - return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); } protected: diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index be9c6945d7..5f83e2ba0b 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -7,6 +7,7 @@ #include #include #include "esphome/core/log.h" +#include "esphome/core/helpers.h" namespace esphome::esp32_ble { @@ -169,22 +170,42 @@ bool ESPBTUUID::operator==(const ESPBTUUID &uuid) const { } esp_bt_uuid_t ESPBTUUID::get_uuid() const { return this->uuid_; } std::string ESPBTUUID::to_string() const { + char buf[40]; // Enough for 128-bit UUID with dashes + char *pos = buf; + switch (this->uuid_.len) { case ESP_UUID_LEN_16: - return str_snprintf("0x%02X%02X", 6, this->uuid_.uuid.uuid16 >> 8, this->uuid_.uuid.uuid16 & 0xff); + *pos++ = '0'; + *pos++ = 'x'; + *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 >> 12); + *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 8) & 0x0F); + *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 4) & 0x0F); + *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 & 0x0F); + *pos = '\0'; + return std::string(buf); + case ESP_UUID_LEN_32: - return str_snprintf("0x%02" PRIX32 "%02" PRIX32 "%02" PRIX32 "%02" PRIX32, 10, (this->uuid_.uuid.uuid32 >> 24), - (this->uuid_.uuid.uuid32 >> 16 & 0xff), (this->uuid_.uuid.uuid32 >> 8 & 0xff), - this->uuid_.uuid.uuid32 & 0xff); + *pos++ = '0'; + *pos++ = 'x'; + for (int shift = 28; shift >= 0; shift -= 4) { + *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid32 >> shift) & 0x0F); + } + *pos = '\0'; + return std::string(buf); + default: case ESP_UUID_LEN_128: - std::string buf; + // Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX for (int8_t i = 15; i >= 0; i--) { - buf += str_snprintf("%02X", 2, this->uuid_.uuid.uuid128[i]); - if (i == 6 || i == 8 || i == 10 || i == 12) - buf += "-"; + uint8_t byte = this->uuid_.uuid.uuid128[i]; + *pos++ = format_hex_pretty_char(byte >> 4); + *pos++ = format_hex_pretty_char(byte & 0x0F); + if (i == 12 || i == 10 || i == 8 || i == 6) { + *pos++ = '-'; + } } - return buf; + *pos = '\0'; + return std::string(buf); } return ""; } diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 423fe61592..259628e00f 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -31,12 +31,13 @@ void ESP32BLEBeacon::dump_config() { char uuid[37]; char *bpos = uuid; for (int8_t ii = 0; ii < 16; ++ii) { - bpos += sprintf(bpos, "%02X", this->uuid_[ii]); + *bpos++ = format_hex_pretty_char(this->uuid_[ii] >> 4); + *bpos++ = format_hex_pretty_char(this->uuid_[ii] & 0x0F); if (ii == 3 || ii == 5 || ii == 7 || ii == 9) { - bpos += sprintf(bpos, "-"); + *bpos++ = '-'; } } - uuid[36] = '\0'; + *bpos = '\0'; ESP_LOGCONFIG(TAG, " UUID: %s, Major: %u, Minor: %u, Min Interval: %ums, Max Interval: %ums, Measured Power: %d" ", TX Power: %ddBm", diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index acfad9e9b0..f2edd6c2b3 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -60,11 +60,14 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { if (address == 0) { this->address_str_ = ""; } else { - this->address_str_ = - str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, (uint8_t) (this->address_ >> 40) & 0xff, - (uint8_t) (this->address_ >> 32) & 0xff, (uint8_t) (this->address_ >> 24) & 0xff, - (uint8_t) (this->address_ >> 16) & 0xff, (uint8_t) (this->address_ >> 8) & 0xff, - (uint8_t) (this->address_ >> 0) & 0xff); + char buf[18]; + uint8_t mac[6] = { + (uint8_t) ((this->address_ >> 40) & 0xff), (uint8_t) ((this->address_ >> 32) & 0xff), + (uint8_t) ((this->address_ >> 24) & 0xff), (uint8_t) ((this->address_ >> 16) & 0xff), + (uint8_t) ((this->address_ >> 8) & 0xff), (uint8_t) ((this->address_ >> 0) & 0xff), + }; + format_mac_addr_upper(mac, buf); + this->address_str_ = buf; } } const std::string &address_str() const { return this->address_str_; } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index bab1dd7c98..908bb36c9e 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -605,9 +605,8 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) { } std::string ESPBTDevice::address_str() const { - char mac[24]; - snprintf(mac, sizeof(mac), "%02X:%02X:%02X:%02X:%02X:%02X", this->address_[0], this->address_[1], this->address_[2], - this->address_[3], this->address_[4], this->address_[5]); + char mac[18]; + format_mac_addr_upper(this->address_, mac); return mac; } From f857fa1f0dfa634da0fdd89b26d0443d8de86c9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 19:22:33 -0500 Subject: [PATCH 1106/1626] [dashboard] Fix archive handler incorrectly deleting build folders instead of archiving them --- esphome/dashboard/web_server.py | 6 +- tests/dashboard/test_web_server.py | 128 +++++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 12 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 294a180794..e4c0b5b84a 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1039,11 +1039,11 @@ class ArchiveRequestHandler(BaseHandler): storage_json = StorageJSON.load(storage_path) if storage_json is not None: - # Delete build folder (if exists) + # Move build folder to archive (if exists) name = storage_json.name build_folder = os.path.join(settings.config_dir, name) - if build_folder is not None: - shutil.rmtree(build_folder, os.path.join(archive_path, name)) + if os.path.exists(build_folder): + shutil.move(build_folder, os.path.join(archive_path, name)) class UnArchiveRequestHandler(BaseHandler): diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index e206090ac0..ea14309997 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -589,7 +589,7 @@ async def test_archive_request_handler_post( mock_ext_storage_path: MagicMock, tmp_path: Path, ) -> None: - """Test ArchiveRequestHandler.post method.""" + """Test ArchiveRequestHandler.post method without storage_json.""" # Set up temp directories config_dir = Path(get_fixture_path("conf")) @@ -599,14 +599,18 @@ async def test_archive_request_handler_post( test_config = config_dir / "test_archive.yaml" test_config.write_text("esphome:\n name: test_archive\n") - # Archive the configuration - response = await dashboard.fetch( - "/archive", - method="POST", - body="configuration=test_archive.yaml", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 + # Mock storage_json to return None (no storage) + with patch("esphome.dashboard.web_server.StorageJSON.load") as mock_load: + mock_load.return_value = None + + # Archive the configuration + response = await dashboard.fetch( + "/archive", + method="POST", + body="configuration=test_archive.yaml", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 # Verify file was moved to archive assert not test_config.exists() @@ -616,6 +620,112 @@ async def test_archive_request_handler_post( ).read_text() == "esphome:\n name: test_archive\n" +@pytest.mark.asyncio +async def test_archive_handler_with_build_folder( + dashboard: DashboardTestHelper, + mock_archive_storage_path: MagicMock, + mock_ext_storage_path: MagicMock, + mock_dashboard_settings: MagicMock, + tmp_path: Path, +) -> None: + """Test ArchiveRequestHandler.post with storage_json and build folder.""" + # Set up temp directories + config_dir = tmp_path / "config" + config_dir.mkdir() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + + # Create a test configuration file + configuration = "test_device.yaml" + test_config = config_dir / configuration + test_config.write_text("esphome:\n name: test_device\n") + + # Create build folder with content + build_folder = config_dir / "test_device" + build_folder.mkdir() + (build_folder / "firmware.bin").write_text("binary content") + (build_folder / ".pioenvs").mkdir() + + # Mock settings to use our temp directory + mock_dashboard_settings.config_dir = str(config_dir) + mock_dashboard_settings.rel_path.return_value = str(test_config) + mock_archive_storage_path.return_value = str(archive_dir) + + # Mock storage_json with device name + with patch("esphome.dashboard.web_server.StorageJSON.load") as mock_load: + mock_storage = MagicMock() + mock_storage.name = "test_device" + mock_load.return_value = mock_storage + + # Archive the configuration + response = await dashboard.fetch( + "/archive", + method="POST", + body=f"configuration={configuration}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 + + # Verify config file was moved to archive + assert not test_config.exists() + assert (archive_dir / configuration).exists() + + # Verify build folder was moved to archive + assert not build_folder.exists() + assert (archive_dir / "test_device").exists() + assert (archive_dir / "test_device" / "firmware.bin").exists() + + +@pytest.mark.asyncio +async def test_archive_handler_no_build_folder( + dashboard: DashboardTestHelper, + mock_archive_storage_path: MagicMock, + mock_ext_storage_path: MagicMock, + mock_dashboard_settings: MagicMock, + tmp_path: Path, +) -> None: + """Test ArchiveRequestHandler.post with storage_json but no build folder.""" + # Set up temp directories + config_dir = tmp_path / "config" + config_dir.mkdir() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + + # Create a test configuration file + configuration = "test_device.yaml" + test_config = config_dir / configuration + test_config.write_text("esphome:\n name: test_device\n") + + # Note: No build folder created + + # Mock settings to use our temp directory + mock_dashboard_settings.config_dir = str(config_dir) + mock_dashboard_settings.rel_path.return_value = str(test_config) + mock_archive_storage_path.return_value = str(archive_dir) + + # Mock storage_json with device name + with patch("esphome.dashboard.web_server.StorageJSON.load") as mock_load: + mock_storage = MagicMock() + mock_storage.name = "test_device" + mock_load.return_value = mock_storage + + # Archive the configuration (should not fail even without build folder) + response = await dashboard.fetch( + "/archive", + method="POST", + body=f"configuration={configuration}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 + + # Verify config file was moved to archive + assert not test_config.exists() + assert (archive_dir / configuration).exists() + + # Verify no build folder in archive (since it didn't exist) + assert not (archive_dir / "test_device").exists() + + @pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") @pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: From 6f1fa094c2c25c56d7c6c559c48da9e84b33861f Mon Sep 17 00:00:00 2001 From: esphomebot Date: Thu, 11 Sep 2025 03:41:18 +1200 Subject: [PATCH 1107/1626] Update webserver local assets to 20250910-110003 (#10668) --- .../components/captive_portal/captive_index.h | 174 +++++----- .../components/web_server/server_index_v2.h | 298 +++++++++--------- 2 files changed, 226 insertions(+), 246 deletions(-) diff --git a/esphome/components/captive_portal/captive_index.h b/esphome/components/captive_portal/captive_index.h index 8835762fb3..3122f27558 100644 --- a/esphome/components/captive_portal/captive_index.h +++ b/esphome/components/captive_portal/captive_index.h @@ -7,103 +7,83 @@ namespace esphome { namespace captive_portal { const uint8_t INDEX_GZ[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xdd, 0x58, 0x6d, 0x6f, 0xdb, 0x38, 0x12, 0xfe, 0xde, - 0x5f, 0x31, 0xa7, 0x36, 0x6b, 0x6b, 0x1b, 0x51, 0x22, 0xe5, 0xb7, 0xd8, 0x92, 0x16, 0x69, 0xae, 0x8b, 0x5d, 0xa0, - 0xdd, 0x2d, 0x90, 0x6c, 0xef, 0x43, 0x51, 0x20, 0xb4, 0x34, 0xb2, 0xd8, 0x48, 0xa4, 0x4e, 0xa4, 0x5f, 0x52, 0xc3, - 0xf7, 0xdb, 0x0f, 0x94, 0x6c, 0xc7, 0xe9, 0x35, 0x87, 0xeb, 0xe2, 0x0e, 0x87, 0xdd, 0x18, 0x21, 0x86, 0xe4, 0xcc, - 0x70, 0xe6, 0xf1, 0x0c, 0x67, 0xcc, 0xe8, 0x2f, 0x99, 0x4a, 0xcd, 0x7d, 0x8d, 0x50, 0x98, 0xaa, 0x4c, 0x22, 0x3b, - 0x42, 0xc9, 0xe5, 0x22, 0x46, 0x99, 0x44, 0x05, 0xf2, 0x2c, 0x89, 0x2a, 0x34, 0x1c, 0xd2, 0x82, 0x37, 0x1a, 0x4d, - 0xfc, 0xdb, 0xcd, 0x8f, 0xde, 0x04, 0xfc, 0x24, 0x2a, 0x85, 0xbc, 0x83, 0x06, 0xcb, 0x58, 0xa4, 0x4a, 0x42, 0xd1, - 0x60, 0x1e, 0x67, 0xdc, 0xf0, 0xa9, 0xa8, 0xf8, 0x02, 0x2d, 0x43, 0x2b, 0x26, 0x79, 0x85, 0xf1, 0x4a, 0xe0, 0xba, - 0x56, 0x8d, 0x81, 0x54, 0x49, 0x83, 0xd2, 0xc4, 0xce, 0x5a, 0x64, 0xa6, 0x88, 0x33, 0x5c, 0x89, 0x14, 0xbd, 0x76, - 0x72, 0x2e, 0xa4, 0x30, 0x82, 0x97, 0x9e, 0x4e, 0x79, 0x89, 0x31, 0x3d, 0x5f, 0x6a, 0x6c, 0xda, 0x09, 0x9f, 0x97, - 0x18, 0x4b, 0xe5, 0xf8, 0x49, 0xa4, 0xd3, 0x46, 0xd4, 0x06, 0xac, 0xbd, 0x71, 0xa5, 0xb2, 0x65, 0x89, 0x89, 0xef, - 0x73, 0xad, 0xd1, 0x68, 0x5f, 0xc8, 0x0c, 0x37, 0x64, 0x14, 0x86, 0x29, 0xe3, 0xe3, 0x9c, 0x7c, 0xd2, 0xcf, 0x32, - 0x95, 0x2e, 0x2b, 0x94, 0x86, 0x94, 0x2a, 0xe5, 0x46, 0x28, 0x49, 0x34, 0xf2, 0x26, 0x2d, 0xe2, 0x38, 0x76, 0x7e, - 0xd0, 0x7c, 0x85, 0xce, 0x77, 0xdf, 0xf5, 0x8f, 0x4c, 0x0b, 0x34, 0xaf, 0x4b, 0xb4, 0xa4, 0x7e, 0x75, 0x7f, 0xc3, - 0x17, 0xbf, 0xf0, 0x0a, 0xfb, 0x0e, 0xd7, 0x22, 0x43, 0xc7, 0xfd, 0x10, 0x7c, 0x24, 0xda, 0xdc, 0x97, 0x48, 0x32, - 0xa1, 0xeb, 0x92, 0xdf, 0xc7, 0xce, 0xbc, 0x54, 0xe9, 0x9d, 0xe3, 0xce, 0xf2, 0xa5, 0x4c, 0xad, 0x72, 0xd0, 0x7d, - 0x74, 0xb7, 0x25, 0x1a, 0x30, 0xf1, 0x5b, 0x6e, 0x0a, 0x52, 0xf1, 0x4d, 0xbf, 0x23, 0x84, 0xec, 0xb3, 0xef, 0xfb, - 0xf8, 0x92, 0x06, 0x81, 0x7b, 0xde, 0x0e, 0x81, 0xeb, 0xd3, 0x20, 0x98, 0x35, 0x68, 0x96, 0x8d, 0x04, 0xde, 0xbf, - 0x8d, 0x6a, 0x6e, 0x0a, 0xc8, 0x62, 0xa7, 0xa2, 0x8c, 0x04, 0xc1, 0x04, 0xe8, 0x05, 0x61, 0x43, 0x8f, 0x52, 0x12, - 0x7a, 0x74, 0x98, 0x8e, 0xbd, 0x21, 0xd0, 0x81, 0x37, 0x04, 0xc6, 0xc8, 0x10, 0x82, 0xcf, 0x0e, 0xe4, 0xa2, 0x2c, - 0x63, 0x47, 0x2a, 0x89, 0x0e, 0x68, 0xd3, 0xa8, 0x3b, 0x8c, 0x9d, 0x74, 0xd9, 0x34, 0x28, 0xcd, 0x95, 0x2a, 0x55, - 0xe3, 0xf8, 0xc9, 0x33, 0x78, 0xf4, 0xf7, 0xcd, 0x47, 0x98, 0x86, 0x4b, 0x9d, 0xab, 0xa6, 0x8a, 0x9d, 0xf6, 0x4b, - 0xe9, 0xbf, 0xd8, 0x9a, 0x1d, 0xd8, 0xc1, 0x3d, 0xd9, 0xf4, 0x54, 0x23, 0x16, 0x42, 0xc6, 0x0e, 0x65, 0x40, 0x27, - 0x8e, 0x9f, 0xdc, 0xba, 0xbb, 0x23, 0x26, 0xdc, 0x62, 0xb2, 0xf7, 0x52, 0xf5, 0x3f, 0xdc, 0x46, 0x7a, 0xb5, 0x80, - 0x4d, 0x55, 0x4a, 0x1d, 0x3b, 0x85, 0x31, 0xf5, 0xd4, 0xf7, 0xd7, 0xeb, 0x35, 0x59, 0x87, 0x44, 0x35, 0x0b, 0x9f, - 0x05, 0x41, 0xe0, 0xeb, 0xd5, 0xc2, 0x81, 0x2e, 0x3e, 0x1c, 0x36, 0x70, 0xa0, 0x40, 0xb1, 0x28, 0x4c, 0x4b, 0x27, - 0x2f, 0xb6, 0xb8, 0x8b, 0x2c, 0x47, 0x72, 0xfb, 0xf1, 0xe4, 0x14, 0x71, 0x72, 0x0a, 0xfe, 0x70, 0x82, 0x66, 0xef, - 0xad, 0x35, 0x6a, 0xcc, 0x19, 0x30, 0x08, 0xda, 0x0f, 0xf3, 0x2c, 0xbd, 0x9f, 0x79, 0x5f, 0xcc, 0xe0, 0x64, 0x06, - 0x0c, 0x9e, 0x01, 0xb0, 0x6a, 0xe4, 0x5d, 0x1c, 0xc5, 0xa9, 0xdd, 0x5e, 0xd1, 0xe0, 0x61, 0xc1, 0xca, 0xfc, 0x34, - 0x3a, 0x9d, 0x7b, 0xec, 0xbd, 0x65, 0xb0, 0xd8, 0x1f, 0x85, 0x3c, 0x56, 0xd0, 0xf7, 0x23, 0x3e, 0x84, 0xe1, 0x7e, - 0x65, 0xe8, 0x59, 0xfa, 0x38, 0xb3, 0x27, 0xc1, 0x70, 0xc5, 0x0a, 0x5a, 0x79, 0x23, 0x6f, 0xc8, 0x43, 0x08, 0xf7, - 0x26, 0x85, 0x10, 0xae, 0x58, 0x31, 0x7a, 0x3f, 0x3a, 0x5d, 0xf3, 0xc2, 0xcf, 0x3d, 0x0b, 0xf3, 0xd4, 0x71, 0x1e, - 0x30, 0x50, 0xa7, 0x18, 0x90, 0x4f, 0x4a, 0xc8, 0xbe, 0xe3, 0xb8, 0xbb, 0x1c, 0x4d, 0x5a, 0xf4, 0x1d, 0x3f, 0x55, - 0x32, 0x17, 0x0b, 0xf2, 0x49, 0x2b, 0xe9, 0xb8, 0xc4, 0x14, 0x28, 0xfb, 0x07, 0x51, 0x2b, 0x88, 0xed, 0x4e, 0xff, - 0xcb, 0x1d, 0xe3, 0x6e, 0x8f, 0xf9, 0x61, 0x84, 0x29, 0x31, 0x36, 0xc4, 0x66, 0xf4, 0xf9, 0x71, 0x75, 0xae, 0xb2, - 0xfb, 0x27, 0x52, 0xa7, 0xa0, 0x5d, 0xde, 0x08, 0x29, 0xb1, 0xb9, 0xc1, 0x8d, 0x89, 0x9d, 0xb7, 0x97, 0x57, 0x70, - 0x99, 0x65, 0x0d, 0x6a, 0x3d, 0x05, 0xe7, 0xa5, 0x21, 0x15, 0x4f, 0xff, 0x73, 0x5d, 0xf4, 0x91, 0xae, 0xbf, 0x89, - 0x1f, 0x05, 0xfc, 0x82, 0x66, 0xad, 0x9a, 0xbb, 0xbd, 0x36, 0x6b, 0xda, 0xcc, 0x66, 0x60, 0x13, 0x1b, 0xc2, 0x6b, - 0x4d, 0x74, 0x29, 0x52, 0xec, 0x53, 0x97, 0x54, 0xbc, 0x7e, 0xf0, 0x4a, 0x1e, 0x80, 0xba, 0x8d, 0x32, 0xb1, 0x82, - 0xb4, 0xe4, 0x5a, 0xc7, 0x8e, 0xec, 0x54, 0x39, 0xb0, 0x4f, 0x1b, 0x25, 0xd3, 0x52, 0xa4, 0x77, 0xb1, 0xf3, 0x95, - 0x1b, 0xe2, 0xd5, 0xfd, 0xcf, 0x59, 0xbf, 0xa7, 0xb5, 0xc8, 0x7a, 0x2e, 0x59, 0xf1, 0x72, 0x89, 0x10, 0x83, 0x29, - 0x84, 0x7e, 0x30, 0x70, 0xf6, 0xa4, 0x58, 0xad, 0xef, 0x7a, 0x2e, 0xc9, 0x55, 0xba, 0xd4, 0x7d, 0xd7, 0x39, 0x64, - 0x69, 0xc4, 0xbb, 0x3b, 0xd4, 0x79, 0xee, 0x7c, 0x61, 0x91, 0x57, 0x62, 0x6e, 0x9c, 0x87, 0x6c, 0x7e, 0xb1, 0xd5, - 0x7d, 0x49, 0x1a, 0xad, 0x85, 0xbb, 0x3b, 0x2e, 0x46, 0xba, 0xe6, 0xf2, 0x4b, 0x41, 0x6b, 0xa0, 0x4d, 0x1a, 0x49, - 0x2c, 0x65, 0x33, 0xa7, 0xe6, 0xf2, 0x78, 0xa0, 0xcf, 0x0f, 0xe4, 0x8b, 0xad, 0xe8, 0x4b, 0x7b, 0x4b, 0xde, 0x1d, - 0x35, 0x46, 0x7e, 0x26, 0x56, 0xc9, 0xed, 0xce, 0x7d, 0xf0, 0xe3, 0xef, 0x4b, 0x6c, 0xee, 0xaf, 0xb1, 0xc4, 0xd4, - 0xa8, 0xa6, 0xef, 0x3c, 0x97, 0x68, 0x1c, 0xb7, 0x73, 0xf8, 0xa7, 0x9b, 0xb7, 0x6f, 0x62, 0xd5, 0x6f, 0xdc, 0xf3, - 0xa7, 0xb8, 0x6d, 0xb5, 0xf8, 0xd0, 0x60, 0xf9, 0x8f, 0xb8, 0x67, 0xeb, 0x45, 0xef, 0xa3, 0xe3, 0x92, 0xd6, 0xdf, - 0xdb, 0x87, 0xa2, 0x61, 0x13, 0xfb, 0xe5, 0xa6, 0x2a, 0xcf, 0xad, 0x87, 0xde, 0x68, 0xe8, 0xee, 0x6e, 0x77, 0xee, - 0xce, 0x9d, 0x45, 0x7e, 0x77, 0xef, 0x27, 0x51, 0x7b, 0x05, 0x27, 0xdf, 0x6f, 0xe7, 0x6a, 0xe3, 0x69, 0xf1, 0x59, - 0xc8, 0xc5, 0x54, 0xc8, 0x02, 0x1b, 0x61, 0x76, 0x99, 0x58, 0x9d, 0x0b, 0x59, 0x2f, 0xcd, 0xb6, 0xe6, 0x59, 0x66, - 0x77, 0x86, 0xf5, 0x66, 0x96, 0x2b, 0x69, 0x2c, 0x27, 0x4e, 0x29, 0x56, 0xbb, 0x6e, 0xbf, 0xbd, 0x5b, 0xa6, 0x17, - 0xc3, 0xb3, 0x9d, 0x0d, 0xb8, 0xad, 0xc1, 0x8d, 0xf1, 0x78, 0x29, 0x16, 0x72, 0x9a, 0xa2, 0x34, 0xd8, 0x74, 0x42, - 0x39, 0xaf, 0x44, 0x79, 0x3f, 0xd5, 0x5c, 0x6a, 0x4f, 0x63, 0x23, 0xf2, 0xdd, 0x7c, 0x69, 0x8c, 0x92, 0xdb, 0xb9, - 0x6a, 0x32, 0x6c, 0xa6, 0xc1, 0xac, 0x23, 0xbc, 0x86, 0x67, 0x62, 0xa9, 0xa7, 0x24, 0x6c, 0xb0, 0x9a, 0xcd, 0x79, - 0x7a, 0xb7, 0x68, 0xd4, 0x52, 0x66, 0x5e, 0x6a, 0x6f, 0xe1, 0xe9, 0x73, 0x9a, 0xf3, 0x10, 0xd3, 0xd9, 0x7e, 0x96, - 0xe7, 0xf9, 0xac, 0x14, 0x12, 0xbd, 0xee, 0x56, 0x9b, 0x32, 0x32, 0xb0, 0x62, 0x27, 0x66, 0x12, 0x66, 0x17, 0x3a, - 0x1b, 0x69, 0x10, 0x9c, 0xcd, 0x0e, 0xee, 0x04, 0xb3, 0x74, 0xd9, 0x68, 0xd5, 0x4c, 0x6b, 0x25, 0xac, 0x99, 0xbb, - 0x8a, 0x0b, 0x79, 0x6a, 0xbd, 0x0d, 0x93, 0xd9, 0xbe, 0x3c, 0x4d, 0x85, 0x6c, 0x8f, 0x69, 0x8b, 0xd4, 0xac, 0x12, - 0xb2, 0x2b, 0xb2, 0x53, 0x36, 0x0a, 0xea, 0xcd, 0x8e, 0xec, 0x03, 0x64, 0x7b, 0xe0, 0xce, 0x4b, 0xdc, 0xcc, 0x3e, - 0x2d, 0xb5, 0x11, 0xf9, 0xbd, 0xb7, 0x2f, 0xd2, 0x53, 0x5d, 0xf3, 0x14, 0xbd, 0x39, 0x9a, 0x35, 0xa2, 0x9c, 0xb5, - 0x67, 0x78, 0xc2, 0x60, 0xa5, 0xf7, 0x38, 0x1d, 0xd5, 0xb4, 0x01, 0xfa, 0x58, 0xd7, 0xbf, 0xe3, 0xb6, 0xb1, 0xb8, - 0xad, 0x78, 0xb3, 0x10, 0xd2, 0x9b, 0x2b, 0x63, 0x54, 0x35, 0xf5, 0xc6, 0xf5, 0x66, 0xb6, 0x5f, 0xb2, 0xca, 0xa6, - 0xd4, 0x9a, 0xd9, 0xd6, 0xde, 0x03, 0xde, 0xb4, 0xde, 0x80, 0x56, 0xa5, 0xc8, 0xf6, 0x7c, 0x2d, 0x0b, 0x04, 0x47, - 0x78, 0xe8, 0xb0, 0xde, 0x80, 0x5d, 0x3b, 0x40, 0x3d, 0xc8, 0x27, 0x9c, 0x06, 0x5f, 0xf9, 0x46, 0xb2, 0x3c, 0x67, - 0xf3, 0xfc, 0x88, 0x94, 0x2d, 0xa1, 0x3b, 0xb1, 0x8f, 0x0a, 0x36, 0xa8, 0x37, 0xb3, 0xc3, 0x77, 0x33, 0xa8, 0x37, - 0x3b, 0xd1, 0xa6, 0xc5, 0xf6, 0x44, 0x4b, 0x1b, 0xaa, 0xd3, 0x65, 0x53, 0xf6, 0x9d, 0xaf, 0x84, 0xee, 0x59, 0x78, - 0xf5, 0x50, 0xe2, 0x7a, 0x4f, 0x97, 0xb8, 0x1e, 0xd8, 0xa6, 0xe8, 0x95, 0xda, 0xc4, 0xbd, 0xb6, 0xd8, 0x0c, 0x80, - 0x0d, 0x7a, 0x67, 0xe1, 0xeb, 0xb3, 0xf0, 0xea, 0xbf, 0x52, 0xbb, 0x7e, 0x77, 0xe1, 0xfa, 0x86, 0xaa, 0xf5, 0x8d, - 0x15, 0xab, 0xf3, 0xce, 0x3a, 0x7f, 0x16, 0xbe, 0x76, 0xdc, 0x9d, 0x20, 0x5a, 0x2c, 0xe8, 0xff, 0x02, 0xda, 0x7f, - 0xc5, 0x31, 0xbc, 0xa4, 0x13, 0x72, 0x01, 0xed, 0xd0, 0x41, 0x44, 0xc2, 0x09, 0x8c, 0xaf, 0x06, 0x64, 0x40, 0xc1, - 0xb6, 0x43, 0x23, 0x18, 0x93, 0xc9, 0x05, 0xd0, 0x11, 0x09, 0xc7, 0x40, 0x19, 0x30, 0x4a, 0x86, 0x6f, 0x58, 0x48, - 0x46, 0x43, 0x18, 0x5f, 0xb1, 0x80, 0x84, 0x0c, 0x3a, 0xde, 0x11, 0x61, 0x0c, 0x42, 0xcb, 0x12, 0x56, 0x01, 0xb0, - 0x34, 0x24, 0xc1, 0x18, 0x02, 0x18, 0x91, 0xe0, 0x82, 0x4c, 0x46, 0x30, 0x21, 0x63, 0x0a, 0x8c, 0x0c, 0x86, 0xa5, - 0x37, 0x24, 0x14, 0x46, 0x24, 0x1c, 0xf1, 0x09, 0x19, 0x84, 0xd0, 0x0e, 0x1d, 0x1c, 0x63, 0xc2, 0x98, 0x47, 0x02, - 0xfa, 0x26, 0x24, 0x6c, 0x0c, 0x63, 0x32, 0x18, 0x5c, 0xd2, 0x11, 0xb9, 0x18, 0x40, 0x37, 0x76, 0xf0, 0x52, 0x06, - 0xc3, 0xa7, 0x40, 0x63, 0x7f, 0x5e, 0xd0, 0x42, 0xc2, 0x28, 0x84, 0xe4, 0x62, 0xc2, 0x6d, 0x5f, 0xca, 0xa0, 0x1b, - 0x3b, 0xdc, 0x28, 0x85, 0xe0, 0x77, 0x63, 0x16, 0xfe, 0x79, 0x31, 0xa3, 0x16, 0x01, 0x46, 0x06, 0xe1, 0x25, 0x0d, - 0xc9, 0x08, 0xda, 0xa1, 0x3b, 0x9b, 0x32, 0x98, 0x5c, 0x5d, 0xc0, 0x04, 0x46, 0x64, 0x34, 0x81, 0x0b, 0x18, 0x5a, - 0x74, 0x2f, 0xc8, 0x64, 0xd0, 0x09, 0x79, 0x8c, 0x7c, 0x2b, 0x8c, 0x83, 0x3f, 0x30, 0x8c, 0x4f, 0xf9, 0xf4, 0x07, - 0x76, 0xe9, 0xff, 0x71, 0x05, 0x45, 0x7e, 0xd7, 0x86, 0x45, 0x7e, 0xf7, 0x3c, 0x60, 0xbb, 0xa8, 0x24, 0xb2, 0xdd, - 0x48, 0x12, 0x15, 0x14, 0x44, 0x16, 0x57, 0x3c, 0x4d, 0x4e, 0x5a, 0xfd, 0xc8, 0x2f, 0xe8, 0x61, 0xab, 0xa0, 0xc9, - 0xa3, 0xc6, 0xbd, 0xdb, 0x6b, 0x2b, 0x7d, 0x72, 0x53, 0x20, 0xbc, 0xbe, 0x7e, 0x07, 0x6b, 0x51, 0x96, 0x20, 0xd5, - 0x1a, 0x4c, 0x73, 0x0f, 0x46, 0xd9, 0x57, 0x03, 0x89, 0xa9, 0xb1, 0xa4, 0x29, 0x10, 0xf6, 0x7d, 0x04, 0x21, 0x24, - 0x9a, 0x37, 0xc9, 0xbb, 0x12, 0xb9, 0x46, 0x58, 0x88, 0x15, 0x82, 0x30, 0xa0, 0x55, 0x85, 0x60, 0x84, 0x1d, 0x8e, - 0x82, 0x2d, 0x5f, 0xe4, 0x77, 0x87, 0x74, 0x8d, 0xb2, 0xc8, 0x62, 0x89, 0x26, 0xd9, 0x77, 0xc4, 0x51, 0x11, 0x76, - 0x56, 0x5d, 0xa3, 0x31, 0x42, 0x2e, 0xac, 0x55, 0x61, 0x12, 0xd9, 0x5f, 0xb7, 0xc0, 0xdb, 0xdf, 0x0c, 0xb1, 0xbf, - 0x16, 0xb9, 0xb0, 0x6f, 0x06, 0x49, 0xd4, 0x76, 0x91, 0x56, 0x83, 0x6d, 0x64, 0xba, 0x07, 0x8e, 0x96, 0x2a, 0x51, - 0x2e, 0x4c, 0x11, 0x87, 0x0c, 0xea, 0x92, 0xa7, 0x58, 0xa8, 0x32, 0xc3, 0x26, 0xbe, 0xbe, 0xfe, 0xf9, 0xaf, 0xf6, - 0x35, 0xc4, 0x9a, 0x70, 0x94, 0xac, 0xf5, 0x5d, 0x27, 0x68, 0x89, 0xbd, 0xdc, 0x68, 0xd0, 0xbd, 0x6b, 0xd4, 0x5c, - 0xeb, 0xb5, 0x6a, 0xb2, 0x47, 0x5a, 0xde, 0x1d, 0x16, 0xf7, 0x9a, 0xda, 0xff, 0xb6, 0x1f, 0xed, 0x84, 0xf4, 0x72, - 0x5e, 0x09, 0x93, 0x5c, 0xf3, 0x15, 0x46, 0x7e, 0xb7, 0x91, 0x44, 0xbe, 0x75, 0xa0, 0xe3, 0x2d, 0xf6, 0x32, 0x05, - 0x4d, 0x7e, 0xbd, 0xb9, 0x84, 0xdf, 0xea, 0x8c, 0x1b, 0xec, 0xb0, 0x6f, 0xbd, 0xac, 0xd0, 0x14, 0x2a, 0x8b, 0xdf, - 0xfd, 0x7a, 0x7d, 0x73, 0xf4, 0x78, 0xd9, 0x32, 0x01, 0xca, 0xb4, 0x7b, 0x6f, 0x59, 0x96, 0x46, 0xd4, 0xbc, 0x31, - 0xad, 0x5a, 0xcf, 0x66, 0xc7, 0xc1, 0xa3, 0x76, 0x3f, 0x17, 0x25, 0x76, 0x4e, 0xed, 0x05, 0xfd, 0x04, 0xbe, 0x66, - 0xe3, 0xe1, 0xec, 0x2f, 0xac, 0xf4, 0xbb, 0x00, 0xf2, 0xbb, 0x68, 0xf2, 0xdb, 0xd7, 0xa8, 0x7f, 0x02, 0x14, 0xee, - 0xbc, 0x64, 0x9d, 0x12, 0x00, 0x00}; + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7e, + 0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36, + 0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf, + 0x7e, 0xa0, 0x24, 0x7b, 0x9d, 0x45, 0x73, 0xb8, 0xb3, 0x60, 0x61, 0x38, 0xef, 0x19, 0xcd, 0x83, 0xc5, 0xdf, 0x2a, + 0xc5, 0xec, 0xbe, 0x03, 0xd4, 0xd8, 0x56, 0x94, 0x85, 0x7b, 0x23, 0x41, 0xe5, 0x8a, 0x80, 0x2c, 0x8b, 0x06, 0x68, + 0x55, 0x16, 0x2d, 0x58, 0x8a, 0x58, 0x43, 0xb5, 0x01, 0x4b, 0xfe, 0xbc, 0xff, 0x39, 0xb8, 0x2d, 0x0b, 0xc1, 0xe5, + 0x1a, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0x24, 0x22, + 0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0x04, 0x6f, 0x79, 0x65, 0x1b, 0x52, + 0xc1, 0x86, 0x33, 0x08, 0x86, 0xc3, 0x35, 0x97, 0xdc, 0x72, 0x2a, 0x02, 0xc3, 0xa8, 0x00, 0x92, 0x5c, 0xf7, 0x06, + 0xf4, 0x70, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0x5c, 0x16, 0x86, 0x69, 0xde, 0x59, 0xe4, 0x5c, 0x25, 0xad, 0xaa, 0x7a, + 0x01, 0x65, 0x14, 0x51, 0x63, 0xc0, 0x9a, 0x88, 0xcb, 0x0a, 0x76, 0xe1, 0x32, 0x66, 0x2c, 0x86, 0xdb, 0xdb, 0xf0, + 0xb3, 0x79, 0x56, 0x29, 0xd6, 0xb7, 0x20, 0x6d, 0x28, 0x14, 0xa3, 0x96, 0x2b, 0x19, 0x1a, 0xa0, 0x9a, 0x35, 0x84, + 0x10, 0xfc, 0xa3, 0xa1, 0x1b, 0xc0, 0xdf, 0x7f, 0xef, 0x9d, 0x99, 0x56, 0x60, 0xff, 0x21, 0xc0, 0x81, 0xe6, 0xa7, + 0xfd, 0x3d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x30, 0x35, 0xbc, 0x02, 0xec, 0x7f, 0x8c, 0x3f, 0x85, 0xc6, 0xee, 0x05, + 0x84, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x82, 0x97, 0x42, 0xb1, 0x35, 0xf6, 0xf3, 0xba, 0x97, 0xcc, 0x29, 0x47, 0xc6, + 0x03, 0xff, 0x20, 0xc0, 0x22, 0x4b, 0xde, 0x51, 0xdb, 0x84, 0x2d, 0xdd, 0x79, 0x23, 0xc0, 0xa5, 0x97, 0xfe, 0xe0, + 0xc1, 0xcb, 0x24, 0x8e, 0xfd, 0xeb, 0xe1, 0x15, 0xfb, 0x51, 0x12, 0xc7, 0xb9, 0x06, 0xdb, 0x6b, 0x89, 0xa8, 0xf7, + 0x50, 0x74, 0xd4, 0x36, 0xa8, 0x22, 0xf8, 0x5d, 0x92, 0xa2, 0xe4, 0x75, 0x98, 0xce, 0x7f, 0x0b, 0x5f, 0xa1, 0x9b, + 0x30, 0x9d, 0xb3, 0x57, 0xc1, 0x1c, 0x25, 0x37, 0xc1, 0x1c, 0xa5, 0x69, 0x38, 0x47, 0xf1, 0x17, 0x8c, 0x6a, 0x2e, + 0x04, 0xc1, 0x52, 0x49, 0xc0, 0xc8, 0x58, 0xad, 0xd6, 0x40, 0x30, 0xeb, 0xb5, 0x06, 0x69, 0xdf, 0x2a, 0xa1, 0x34, + 0x8e, 0xca, 0x67, 0xff, 0x97, 0x42, 0xab, 0xa9, 0x34, 0xb5, 0xd2, 0x2d, 0xc1, 0x43, 0xf6, 0xbd, 0x17, 0x07, 0x7b, + 0x44, 0xee, 0xe5, 0x5f, 0x10, 0x03, 0xa5, 0xf9, 0x8a, 0x4b, 0x82, 0x9d, 0xc6, 0x5b, 0x1c, 0x95, 0x0f, 0xfe, 0xf1, + 0x1c, 0x3d, 0x75, 0xd1, 0x4f, 0xf1, 0x28, 0xef, 0xe3, 0x43, 0x61, 0x36, 0x2b, 0xb4, 0x6b, 0x85, 0x34, 0x04, 0x37, + 0xd6, 0x76, 0x59, 0x14, 0x6d, 0xb7, 0xdb, 0x70, 0x3b, 0x0b, 0x95, 0x5e, 0x45, 0x69, 0x1c, 0xc7, 0x91, 0xd9, 0xac, + 0x30, 0x1a, 0x0b, 0x01, 0xa7, 0x37, 0x18, 0x35, 0xc0, 0x57, 0x8d, 0x1d, 0xe0, 0xf2, 0xc5, 0x01, 0x8e, 0x85, 0xe3, + 0x28, 0x1f, 0x3e, 0x5d, 0x58, 0xe1, 0x17, 0x56, 0xe0, 0x47, 0xea, 0xe1, 0x53, 0x98, 0x57, 0x43, 0x98, 0xaf, 0x68, + 0x8a, 0x52, 0x14, 0x0f, 0x4f, 0x1a, 0x38, 0x78, 0x3a, 0x05, 0x4f, 0x4e, 0xe8, 0xe2, 0xe4, 0xa0, 0x76, 0x11, 0xbc, + 0x3e, 0xcb, 0x26, 0x0e, 0xb3, 0x49, 0xe2, 0x47, 0x84, 0x13, 0xf8, 0x65, 0x71, 0x79, 0x0e, 0xd2, 0x0f, 0x97, 0x0c, + 0xce, 0x5a, 0x93, 0x7c, 0x58, 0xd0, 0x39, 0x9a, 0x4f, 0x98, 0x79, 0xe0, 0xe0, 0xf3, 0x09, 0xcd, 0x37, 0x69, 0x93, + 0xb4, 0xc1, 0x22, 0x98, 0xd3, 0x19, 0x9a, 0x4d, 0x8e, 0xcc, 0xd0, 0x6c, 0x93, 0x36, 0x8b, 0x0f, 0x8b, 0x4b, 0x5c, + 0x30, 0xfb, 0x72, 0x15, 0x95, 0xd8, 0xcf, 0x30, 0x7e, 0x8c, 0x5c, 0x5d, 0x46, 0x1e, 0x7e, 0x56, 0x5c, 0x7a, 0x18, + 0xfb, 0xc7, 0x1a, 0x2c, 0x6b, 0x3c, 0x1c, 0x31, 0x25, 0x6b, 0xbe, 0x0a, 0x3f, 0x1b, 0x25, 0xb1, 0x1f, 0xda, 0x06, + 0xa4, 0x77, 0x12, 0x75, 0x82, 0x30, 0x50, 0xbc, 0xa7, 0x14, 0xeb, 0x1f, 0xce, 0xf5, 0x6f, 0xb9, 0x15, 0x40, 0x6c, + 0xe8, 0x1a, 0xf6, 0xfa, 0x8c, 0x5d, 0xaa, 0x6a, 0xff, 0x8d, 0xd6, 0x68, 0x92, 0xb1, 0x2f, 0xb8, 0x94, 0xa0, 0xef, + 0x61, 0x67, 0x09, 0x7e, 0xf7, 0xe6, 0x2d, 0x7a, 0x53, 0x55, 0x1a, 0x8c, 0xc9, 0x10, 0x7e, 0x69, 0xc3, 0x96, 0xb2, + 0xff, 0x5d, 0x57, 0xf2, 0x95, 0xae, 0x7f, 0xf2, 0x9f, 0x39, 0xfa, 0x1d, 0xec, 0x56, 0xe9, 0xf5, 0xa4, 0xcd, 0xb9, + 0x96, 0xbb, 0x0e, 0xd3, 0xc4, 0x86, 0xb4, 0x33, 0xa1, 0x11, 0x9c, 0x81, 0x97, 0xf8, 0x61, 0x4b, 0xbb, 0xc7, 0xa8, + 0xe4, 0x29, 0x51, 0x0f, 0x45, 0xc5, 0x37, 0x88, 0x09, 0x6a, 0x0c, 0xc1, 0x72, 0x54, 0x85, 0xd1, 0x33, 0x34, 0xfc, + 0x94, 0x64, 0x82, 0xb3, 0x35, 0xc1, 0x7f, 0x31, 0x01, 0x7e, 0xda, 0xff, 0x5a, 0x79, 0x57, 0xc6, 0xf0, 0xea, 0xca, + 0x0f, 0x37, 0x54, 0xf4, 0x80, 0x08, 0xb2, 0x0d, 0x37, 0x8f, 0x0e, 0xe6, 0xdf, 0x14, 0xeb, 0xcc, 0xfa, 0xca, 0x0f, + 0x6b, 0xc5, 0x7a, 0xe3, 0xf9, 0xb8, 0x9c, 0xcc, 0x15, 0x74, 0x1c, 0x90, 0xf8, 0x39, 0x7e, 0xe2, 0x51, 0x20, 0xa0, + 0xb6, 0x67, 0x3e, 0x84, 0x5e, 0x1c, 0x8c, 0x27, 0x43, 0x6d, 0x0c, 0xf7, 0x8f, 0x67, 0x64, 0x61, 0x3a, 0x2a, 0x9f, + 0x0a, 0x3a, 0x07, 0x5d, 0xab, 0xc8, 0xd0, 0x41, 0xae, 0x5f, 0x3a, 0x2a, 0xcf, 0x06, 0x23, 0x7a, 0x02, 0x5f, 0x1c, + 0xb8, 0x27, 0xdd, 0x14, 0x5c, 0x9f, 0x35, 0x16, 0x51, 0xc5, 0x37, 0xe5, 0xc3, 0xd1, 0x7f, 0x8c, 0xe3, 0x5f, 0x3d, + 0xe8, 0xfd, 0x1d, 0x08, 0x60, 0x56, 0x69, 0x0f, 0x3f, 0x97, 0x60, 0xb1, 0x3f, 0x06, 0xfc, 0xcb, 0xfd, 0xbb, 0xdf, + 0x88, 0xf2, 0xb4, 0x7f, 0xfd, 0x2d, 0x6e, 0xb7, 0x0a, 0x3e, 0x6a, 0x10, 0xff, 0x26, 0x57, 0x6e, 0x19, 0x5c, 0x7d, + 0xc2, 0x7e, 0x38, 0xc4, 0xfb, 0xf0, 0xb8, 0x11, 0x5c, 0x3b, 0xbf, 0xdc, 0xb5, 0xe2, 0xda, 0x45, 0x18, 0x2c, 0xe6, + 0xfe, 0xf1, 0xe1, 0xe8, 0x1f, 0xfd, 0xbc, 0x88, 0xc6, 0xb9, 0x5e, 0x16, 0xc3, 0x88, 0x2d, 0x7f, 0x38, 0x2c, 0xd5, + 0x2e, 0x30, 0xfc, 0x0b, 0x97, 0xab, 0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0xae, 0xb9, 0xec, 0x7a, 0x7b, + 0xe8, 0x68, 0x55, 0x39, 0xca, 0xbc, 0xdb, 0xe5, 0xb5, 0x92, 0xd6, 0x71, 0x42, 0x96, 0x40, 0x7b, 0x1c, 0xe9, 0xc3, + 0x44, 0xc9, 0x5e, 0xcf, 0xbf, 0x3b, 0xba, 0x82, 0x3b, 0x58, 0xd8, 0xd9, 0x80, 0x0a, 0xbe, 0x92, 0x19, 0x03, 0x69, + 0x41, 0x8f, 0x42, 0x35, 0x6d, 0xb9, 0xd8, 0x67, 0x86, 0x4a, 0x13, 0x18, 0xd0, 0xbc, 0x3e, 0x2e, 0x7b, 0x6b, 0x95, + 0x3c, 0x2c, 0x95, 0xae, 0x40, 0x67, 0x71, 0x3e, 0x02, 0x81, 0xa6, 0x15, 0xef, 0x4d, 0x16, 0xce, 0x34, 0xb4, 0xf9, + 0x92, 0xb2, 0xf5, 0x4a, 0xab, 0x5e, 0x56, 0x01, 0x73, 0x93, 0x36, 0x7b, 0x9e, 0xd4, 0x74, 0x06, 0x2c, 0x9f, 0x4e, + 0x75, 0x5d, 0xe7, 0x82, 0x4b, 0x08, 0xc6, 0x59, 0x96, 0xa5, 0xe1, 0x8d, 0x13, 0xbb, 0x70, 0x33, 0x4c, 0x1d, 0x62, + 0xf4, 0x31, 0x89, 0xe3, 0xef, 0xf2, 0x53, 0x38, 0x71, 0xce, 0x7a, 0x6d, 0x94, 0xce, 0x3a, 0xc5, 0x9d, 0x9b, 0xc7, + 0x96, 0x72, 0x79, 0xe9, 0xbd, 0x2b, 0x93, 0x7c, 0x5a, 0x3f, 0x19, 0x97, 0x83, 0x99, 0x61, 0x09, 0xe5, 0x2d, 0x97, + 0xe3, 0x0e, 0xcd, 0xd2, 0x45, 0xdc, 0xed, 0x8e, 0xe1, 0x54, 0x20, 0x87, 0x13, 0x77, 0x2d, 0x60, 0x97, 0x7f, 0xee, + 0x8d, 0xe5, 0xf5, 0x3e, 0x98, 0x76, 0x70, 0x66, 0x3a, 0xca, 0x20, 0x58, 0x82, 0xdd, 0x02, 0xc8, 0x7c, 0xb0, 0x11, + 0x70, 0x0b, 0xad, 0x99, 0xf2, 0x74, 0x56, 0x33, 0x14, 0xe8, 0xd7, 0xba, 0xfe, 0x1b, 0xb7, 0xab, 0xc5, 0x43, 0x4b, + 0xf5, 0x8a, 0xcb, 0x60, 0xa9, 0xac, 0x55, 0x6d, 0x16, 0xbc, 0xea, 0x76, 0xf9, 0x84, 0x72, 0xca, 0xb2, 0xc4, 0xb9, + 0x39, 0xec, 0xd6, 0x53, 0xbe, 0x93, 0x6e, 0x87, 0x8c, 0x12, 0xbc, 0x9a, 0xf8, 0x06, 0x16, 0x14, 0x9f, 0xd3, 0x93, + 0xcc, 0xbb, 0x1d, 0x72, 0xb8, 0x53, 0xaa, 0x6f, 0xea, 0x5b, 0x9a, 0xc4, 0x7f, 0xf1, 0x45, 0xaa, 0xba, 0x4e, 0x97, + 0xf5, 0x39, 0x53, 0x6e, 0x4d, 0xba, 0xd6, 0x18, 0x4a, 0xab, 0x88, 0xc6, 0xdb, 0x8c, 0xab, 0x8c, 0xb2, 0x70, 0x19, + 0x2e, 0x8b, 0x26, 0x41, 0xbc, 0x22, 0x2d, 0x65, 0xe5, 0xc5, 0xf8, 0x2a, 0xa2, 0x26, 0x39, 0x91, 0x9a, 0xa4, 0xfc, + 0x6a, 0x18, 0x8d, 0xb4, 0xc1, 0xfb, 0xf2, 0xad, 0x92, 0x12, 0x98, 0xe5, 0x72, 0x85, 0xac, 0x42, 0x53, 0x0a, 0xc2, + 0x30, 0x2c, 0x96, 0xba, 0x7c, 0x2f, 0x80, 0x1a, 0x40, 0x5b, 0xca, 0x6d, 0x58, 0x44, 0x23, 0xff, 0xd8, 0xc7, 0xbc, + 0x22, 0x12, 0x6c, 0x39, 0x35, 0x6c, 0xd1, 0xcc, 0x46, 0x03, 0x77, 0x60, 0x9d, 0x26, 0x67, 0x60, 0x56, 0x16, 0x6e, + 0xe5, 0x22, 0x3a, 0x8c, 0x34, 0x12, 0x6d, 0x79, 0xcd, 0xdd, 0x95, 0xa5, 0x2c, 0x86, 0x22, 0x77, 0x1a, 0x5c, 0x9e, + 0xc7, 0xeb, 0xd5, 0x00, 0x09, 0x90, 0x2b, 0xdb, 0x90, 0x59, 0x8a, 0x3a, 0x41, 0x19, 0x34, 0x4a, 0x54, 0xa0, 0xc9, + 0xdd, 0xdd, 0xaf, 0x7f, 0x2f, 0x9d, 0x33, 0x8f, 0x72, 0x9d, 0x59, 0x8f, 0x62, 0x0e, 0x98, 0xa4, 0x16, 0x37, 0xe3, + 0xa5, 0xaa, 0xa3, 0xc6, 0x6c, 0x95, 0xae, 0xbe, 0xd2, 0xf1, 0x7e, 0x42, 0x8e, 0x7a, 0x86, 0xff, 0xd0, 0x2a, 0xe5, + 0x1d, 0xdd, 0x40, 0x11, 0x4d, 0x87, 0x22, 0x72, 0x0e, 0x8f, 0xf4, 0x66, 0xe2, 0x6b, 0x92, 0xf2, 0x8f, 0xfb, 0x37, + 0xe8, 0xcf, 0xae, 0xa2, 0x16, 0xc6, 0xb4, 0x0d, 0x51, 0xb5, 0x60, 0x1b, 0x55, 0x91, 0xf7, 0x7f, 0xdc, 0xdd, 0x9f, + 0x23, 0xec, 0x07, 0x26, 0x04, 0x92, 0x8d, 0xd7, 0xbb, 0x5e, 0x58, 0xde, 0x51, 0x6d, 0x07, 0xb5, 0x81, 0x9b, 0x22, + 0xa7, 0x18, 0x06, 0x7a, 0xcd, 0x05, 0x8c, 0x61, 0x8c, 0x82, 0x25, 0x3a, 0x79, 0x75, 0xb2, 0xf6, 0xc4, 0xaf, 0x68, + 0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00}; } // namespace captive_portal } // namespace esphome diff --git a/esphome/components/web_server/server_index_v2.h b/esphome/components/web_server/server_index_v2.h index ec093d3186..e675d81552 100644 --- a/esphome/components/web_server/server_index_v2.h +++ b/esphome/components/web_server/server_index_v2.h @@ -494,155 +494,155 @@ const uint8_t INDEX_GZ[] PROGMEM = { 0x1c, 0x40, 0xc8, 0x12, 0x7c, 0xa6, 0xc1, 0x29, 0x21, 0xa4, 0xd5, 0x9f, 0x05, 0x5f, 0xe2, 0x9b, 0x98, 0xa6, 0xc1, 0xbc, 0xe8, 0x96, 0x04, 0xa0, 0x22, 0xa6, 0x6f, 0x45, 0x79, 0x6f, 0x9c, 0xa4, 0x8a, 0xea, 0xb5, 0x82, 0xb3, 0x59, 0x52, 0xcf, 0x96, 0x58, 0x9a, 0xe5, 0x93, 0x19, 0x25, 0xfc, 0xa6, 0x79, 0xeb, 0xf6, 0x36, 0xc7, 0xd7, 0x60, 0x76, - 0x65, 0x7c, 0x4d, 0x02, 0x5b, 0x3e, 0xbd, 0x0f, 0xc7, 0xe5, 0xef, 0x57, 0x34, 0xcf, 0xc3, 0xb1, 0xae, 0xb9, 0x3d, - 0x9e, 0x26, 0x41, 0xb4, 0x63, 0x69, 0x06, 0x08, 0x88, 0x89, 0x01, 0x46, 0xc0, 0xa7, 0xa1, 0x43, 0x64, 0x30, 0xf5, - 0x7a, 0x74, 0x4d, 0x0e, 0x5f, 0x2f, 0x12, 0xe1, 0xb8, 0x2a, 0x38, 0x99, 0x66, 0x54, 0x96, 0x2a, 0x34, 0x16, 0x27, - 0xfb, 0x50, 0xa0, 0x5e, 0x6f, 0x89, 0xa2, 0x19, 0x07, 0xca, 0xf6, 0x58, 0x9a, 0x63, 0xa2, 0x68, 0x76, 0xa2, 0x52, - 0x99, 0xa5, 0xb4, 0x1e, 0xbb, 0xf9, 0xbc, 0x3d, 0x84, 0x3f, 0x3a, 0x32, 0xf4, 0xf9, 0x68, 0x34, 0xba, 0x37, 0xaa, - 0xf6, 0x79, 0x34, 0xa2, 0x1d, 0x7a, 0xd4, 0x85, 0x24, 0x96, 0xa6, 0x8e, 0xc5, 0xb4, 0x0b, 0x89, 0xbb, 0xc5, 0xc3, - 0x2a, 0x43, 0xd8, 0x46, 0xc4, 0x8b, 0x87, 0x47, 0xd8, 0x8a, 0x69, 0x46, 0x17, 0x93, 0x30, 0x1b, 0xb3, 0x34, 0x68, - 0x15, 0xfe, 0x5c, 0x87, 0xa4, 0x3e, 0x3f, 0x3e, 0x3e, 0x2e, 0xfc, 0xc8, 0x3c, 0xb5, 0xa2, 0xa8, 0xf0, 0x87, 0x8b, - 0x72, 0x1a, 0xad, 0xd6, 0x68, 0x54, 0xf8, 0xcc, 0x14, 0x1c, 0x74, 0x86, 0xd1, 0x41, 0xa7, 0xf0, 0x6f, 0xac, 0x1a, - 0x85, 0x4f, 0xf5, 0x53, 0x46, 0xa3, 0x5a, 0x26, 0xcc, 0xe3, 0x56, 0xab, 0xf0, 0x15, 0xa1, 0x2d, 0xc0, 0x2c, 0x55, - 0x3f, 0x83, 0x70, 0x26, 0x38, 0x30, 0xf7, 0x6e, 0x22, 0xbc, 0xc1, 0xa5, 0xbe, 0x65, 0x44, 0x7d, 0x93, 0xa3, 0x40, - 0x17, 0xf8, 0x67, 0x3b, 0x78, 0x04, 0xc4, 0x2c, 0x83, 0x46, 0x89, 0x89, 0x2d, 0xd5, 0x5e, 0x03, 0x65, 0xc9, 0xd7, - 0x3f, 0x93, 0xa4, 0x8a, 0x29, 0x01, 0x27, 0x83, 0x9a, 0xea, 0x32, 0x3c, 0x4a, 0xb7, 0xc8, 0x0f, 0xf6, 0x69, 0xf9, - 0x71, 0xf7, 0x10, 0xf1, 0xc1, 0xfe, 0x70, 0xf1, 0x41, 0xa9, 0x25, 0x3e, 0x14, 0xf3, 0xb8, 0x13, 0xc4, 0x1d, 0xc6, - 0x74, 0xf8, 0xf1, 0x9a, 0xdf, 0x36, 0x61, 0x4b, 0x64, 0xae, 0x14, 0x2c, 0xbb, 0xbf, 0x35, 0x6b, 0xc6, 0x74, 0x66, - 0x7d, 0xd1, 0x43, 0xaa, 0x0f, 0x6f, 0x52, 0xe2, 0xbe, 0x31, 0xb6, 0xad, 0x2a, 0x19, 0x8d, 0x88, 0xfb, 0x66, 0x34, - 0x72, 0xcd, 0x59, 0xc9, 0x50, 0x50, 0x59, 0xeb, 0x75, 0xad, 0x44, 0xd6, 0xfa, 0xf2, 0x4b, 0xbb, 0xcc, 0x2e, 0xd0, - 0xa1, 0x27, 0x3b, 0xcc, 0xa4, 0xdf, 0x44, 0x2c, 0x87, 0xad, 0x06, 0x1f, 0x1a, 0xa9, 0xdf, 0xd5, 0x98, 0xd6, 0xae, - 0xd5, 0x2e, 0x01, 0xde, 0x70, 0x17, 0xf8, 0xea, 0x45, 0x01, 0x63, 0x6a, 0xf2, 0x16, 0x9f, 0xde, 0x7d, 0x15, 0x79, - 0x77, 0x02, 0x15, 0x2c, 0x7f, 0x93, 0xae, 0x1c, 0x02, 0x52, 0x30, 0x12, 0x62, 0x4f, 0xab, 0x10, 0x7c, 0x3c, 0x4e, - 0xe0, 0x5b, 0x2f, 0x8b, 0xda, 0xfd, 0xb1, 0xaa, 0x79, 0xbf, 0x36, 0xdf, 0xc0, 0x6e, 0xa8, 0x6f, 0x5b, 0x95, 0x9f, - 0x9e, 0x52, 0xc9, 0xe3, 0x73, 0xfd, 0x0d, 0x22, 0x69, 0x16, 0x2f, 0x34, 0x93, 0x5f, 0xa8, 0x94, 0x63, 0x01, 0xe9, - 0x36, 0xaa, 0xe3, 0xa8, 0x28, 0xf4, 0x61, 0x8d, 0x88, 0xe5, 0x53, 0xb8, 0xd7, 0x54, 0xb5, 0xa4, 0x9f, 0x62, 0xe1, - 0xf9, 0x8d, 0x15, 0xdf, 0xa9, 0x2d, 0x57, 0x61, 0x02, 0x3c, 0xca, 0x61, 0x7e, 0x27, 0x0a, 0x57, 0xfb, 0xdd, 0x0d, - 0x12, 0x5d, 0x47, 0xe1, 0x53, 0x45, 0x9e, 0xac, 0x19, 0x82, 0xf3, 0xbb, 0x5c, 0x10, 0xf3, 0xca, 0x14, 0x14, 0x76, - 0xfc, 0x52, 0xbe, 0x51, 0xd8, 0x92, 0xd1, 0x92, 0x7c, 0x1a, 0xa6, 0x8a, 0x8d, 0x12, 0x57, 0xf1, 0x83, 0xdd, 0x45, - 0xb5, 0xf2, 0x85, 0x6b, 0xc0, 0x56, 0xc4, 0xdb, 0x3b, 0xd9, 0x87, 0x06, 0x3d, 0xa7, 0x06, 0x7a, 0xba, 0x16, 0x64, - 0xf9, 0x44, 0xba, 0xc3, 0x95, 0x9f, 0xdf, 0x60, 0x3f, 0xbf, 0x71, 0xfe, 0xbc, 0x68, 0xde, 0xd0, 0xeb, 0x8f, 0x4c, - 0x34, 0x45, 0x38, 0x6d, 0x82, 0xe1, 0x23, 0x9d, 0xa3, 0x9a, 0x3d, 0xcb, 0x2c, 0x3f, 0x75, 0xd5, 0x41, 0x77, 0x96, - 0x43, 0x56, 0x84, 0x54, 0xdf, 0x83, 0x94, 0xa7, 0xb4, 0x5b, 0xcf, 0xe6, 0xb4, 0x83, 0xec, 0x06, 0x5b, 0x17, 0x0b, - 0x0e, 0x59, 0x14, 0xe2, 0x2e, 0x68, 0x69, 0xb6, 0xde, 0x32, 0x11, 0xf4, 0xd6, 0xc6, 0xfa, 0x81, 0x46, 0x6e, 0x43, - 0x4a, 0xaf, 0x6c, 0x3d, 0x93, 0x60, 0x5b, 0x26, 0xc0, 0xa7, 0x72, 0x1b, 0xc1, 0xa5, 0x6a, 0xfe, 0x5a, 0x49, 0xa1, - 0xab, 0xc5, 0x32, 0xb7, 0xf1, 0x21, 0x90, 0x05, 0xe1, 0x48, 0xd0, 0x0c, 0x3f, 0xa4, 0xe6, 0xb5, 0x3c, 0x86, 0xb4, - 0x00, 0x31, 0x13, 0xb4, 0x8f, 0xa7, 0xb7, 0x0f, 0xef, 0xfe, 0xfe, 0xe9, 0x17, 0x1a, 0x47, 0xe6, 0x5a, 0x1e, 0xd7, - 0xed, 0xc2, 0x46, 0x48, 0xc2, 0xbb, 0x80, 0xa5, 0x52, 0xe6, 0x5d, 0x83, 0x5f, 0xb4, 0x3b, 0xe5, 0x3a, 0x49, 0x37, - 0xa3, 0x89, 0xfc, 0x0a, 0x9f, 0x5e, 0x8a, 0x83, 0x47, 0xd3, 0x5b, 0xb3, 0x1a, 0xed, 0x95, 0xe4, 0xdb, 0x3f, 0x34, - 0xc7, 0x76, 0x7b, 0x52, 0x6f, 0x3d, 0x4f, 0xf4, 0x68, 0x7a, 0xdb, 0x55, 0x82, 0xb6, 0x99, 0x29, 0xa8, 0x5a, 0xd3, - 0x5b, 0x3b, 0xcb, 0xb8, 0xea, 0xc8, 0xf1, 0x0f, 0x72, 0x87, 0x86, 0x39, 0xed, 0xc2, 0xbd, 0xe3, 0x6c, 0x18, 0x26, - 0x5a, 0x98, 0x4f, 0x58, 0x14, 0x25, 0xb4, 0x6b, 0xe4, 0xb5, 0xd3, 0x7e, 0x04, 0x49, 0xba, 0xf6, 0x92, 0xd5, 0x57, - 0xc5, 0x42, 0x5e, 0x89, 0xa7, 0xf0, 0x3a, 0xe7, 0x09, 0x7c, 0xf4, 0x63, 0x23, 0x3a, 0x75, 0xf6, 0x6a, 0xab, 0x42, - 0x9e, 0xfc, 0x5d, 0x9f, 0xcb, 0x51, 0xeb, 0x4f, 0x5d, 0xb9, 0xe0, 0xad, 0xae, 0xe0, 0xd3, 0xa0, 0x79, 0x50, 0x9f, - 0x08, 0xbc, 0x2a, 0xa7, 0x80, 0x37, 0x4c, 0x0b, 0x83, 0xb4, 0x52, 0x7c, 0xda, 0xf1, 0xdb, 0xba, 0x4c, 0x76, 0x00, - 0x79, 0x61, 0x65, 0x51, 0x51, 0x9f, 0xcc, 0xbf, 0xcd, 0x6e, 0x79, 0xb2, 0x79, 0xb7, 0x3c, 0x31, 0xbb, 0xe5, 0x7e, - 0x8a, 0xfd, 0x7c, 0xd4, 0x86, 0x3f, 0xdd, 0x6a, 0x42, 0x41, 0xcb, 0x39, 0x98, 0xde, 0x3a, 0xa0, 0xa7, 0x35, 0x3b, - 0xd3, 0x5b, 0x95, 0x63, 0x0d, 0xb1, 0x9b, 0x16, 0x64, 0x1d, 0xe3, 0x96, 0x03, 0x85, 0xf0, 0xb7, 0x55, 0x7b, 0xd5, - 0x3e, 0x84, 0x77, 0xd0, 0xea, 0x68, 0xfd, 0x5d, 0xe7, 0xfe, 0x4d, 0x1b, 0xa4, 0x5c, 0x78, 0x81, 0xe1, 0xc6, 0xc8, - 0x17, 0xe1, 0xf5, 0x35, 0x8d, 0x82, 0x11, 0x1f, 0xce, 0xf2, 0x7f, 0xd2, 0xf0, 0x6b, 0x24, 0xde, 0xbb, 0xa5, 0x57, - 0xfa, 0x31, 0x4d, 0x55, 0xc6, 0xb7, 0xe9, 0x61, 0x51, 0xae, 0x53, 0x90, 0x0f, 0xc3, 0x84, 0x7a, 0x1d, 0xff, 0x70, - 0xc3, 0x26, 0xf8, 0x77, 0x59, 0x9b, 0x8d, 0x93, 0xf9, 0xbd, 0xc8, 0xb8, 0x17, 0x09, 0xbf, 0x0a, 0x07, 0xf6, 0x1a, - 0xb6, 0x8e, 0x37, 0x83, 0x3b, 0x30, 0x23, 0x5d, 0x18, 0xa1, 0xa0, 0xe5, 0x4e, 0x44, 0x47, 0xe1, 0x2c, 0x11, 0xf7, - 0xf7, 0xba, 0x8d, 0x32, 0xd6, 0x7a, 0xbd, 0x87, 0xa1, 0x57, 0x75, 0x1f, 0xc8, 0xa5, 0x3f, 0x7f, 0x72, 0x08, 0x7f, - 0x54, 0xfe, 0xd7, 0x5d, 0xa5, 0xab, 0x2b, 0xbb, 0x17, 0x74, 0xf5, 0xdd, 0x9a, 0x32, 0xae, 0x44, 0xb8, 0xd4, 0xc7, - 0x1f, 0x5a, 0x1b, 0xb4, 0xca, 0x07, 0x55, 0xd7, 0x5a, 0xd6, 0xaf, 0xaa, 0xfd, 0xeb, 0x3a, 0x7f, 0x60, 0xdd, 0xa1, - 0xd2, 0x5c, 0xeb, 0x75, 0xf5, 0x67, 0x08, 0xd7, 0x2a, 0x1b, 0x8c, 0xcb, 0xfa, 0xbb, 0xe4, 0xae, 0x34, 0x51, 0x54, - 0x34, 0x16, 0xac, 0x94, 0x5d, 0x65, 0xa5, 0xe4, 0x94, 0x5c, 0x9d, 0xf4, 0x6f, 0x27, 0x89, 0x33, 0x57, 0xc7, 0x25, - 0x89, 0xdb, 0xf6, 0x5b, 0xae, 0x23, 0xf3, 0x00, 0xe0, 0xd6, 0x76, 0x57, 0x7e, 0xde, 0xd6, 0xed, 0x83, 0xa6, 0x35, - 0x1f, 0x4b, 0xcd, 0xee, 0x65, 0x78, 0x47, 0xb3, 0xcb, 0x8e, 0xeb, 0x80, 0x9f, 0xa6, 0xa9, 0x52, 0x26, 0x64, 0x99, - 0xd3, 0x71, 0x9d, 0xdb, 0x49, 0x92, 0xe6, 0xc4, 0x8d, 0x85, 0x98, 0x06, 0xea, 0xfb, 0xb7, 0x37, 0x07, 0x3e, 0xcf, - 0xc6, 0xfb, 0x9d, 0x56, 0xab, 0x05, 0x17, 0xc0, 0xba, 0xce, 0x9c, 0xd1, 0x9b, 0xa7, 0xfc, 0x96, 0xb8, 0x2d, 0xa7, - 0xe5, 0xb4, 0x3b, 0xc7, 0x4e, 0xbb, 0x73, 0xe8, 0x3f, 0x3a, 0x76, 0x7b, 0x9f, 0x39, 0xce, 0x49, 0x44, 0x47, 0x39, - 0xfc, 0x70, 0x9c, 0x13, 0xa9, 0x78, 0xa9, 0xdf, 0x8e, 0xe3, 0x0f, 0x93, 0xbc, 0xd9, 0x76, 0x16, 0xfa, 0xd1, 0x71, - 0xe0, 0x50, 0x69, 0xe0, 0x7c, 0x3e, 0xea, 0x8c, 0x0e, 0x47, 0x4f, 0xba, 0xba, 0xb8, 0xf8, 0xac, 0x56, 0x1d, 0xab, - 0xff, 0x3b, 0x56, 0xb3, 0x5c, 0x64, 0xfc, 0x23, 0xd5, 0x39, 0x89, 0x0e, 0x88, 0x9e, 0x8d, 0x4d, 0x3b, 0xeb, 0x23, - 0xb5, 0x8f, 0xaf, 0x87, 0xa3, 0x4e, 0x55, 0x5d, 0xc2, 0xb8, 0x5f, 0x02, 0x79, 0xb2, 0x6f, 0x40, 0x3f, 0xb1, 0xd1, - 0xd4, 0x6e, 0x6e, 0x42, 0x54, 0xdb, 0xd5, 0x73, 0x1c, 0x9b, 0xf9, 0x9d, 0xc0, 0x19, 0x06, 0xa3, 0xab, 0x4a, 0x08, - 0x5c, 0x27, 0x22, 0xee, 0xab, 0x76, 0xe7, 0x18, 0xb7, 0xdb, 0x8f, 0xfc, 0x47, 0xc7, 0xc3, 0x16, 0x3e, 0xf4, 0x0f, - 0x9b, 0x07, 0xfe, 0x23, 0x7c, 0xdc, 0x3c, 0xc6, 0xc7, 0x2f, 0x8e, 0x87, 0xcd, 0x43, 0xff, 0x10, 0xb7, 0x9a, 0xc7, - 0x50, 0xd8, 0x3c, 0x6e, 0x1e, 0xcf, 0x9b, 0x87, 0xc7, 0xc3, 0x96, 0x2c, 0xed, 0xf8, 0x47, 0x47, 0xcd, 0x76, 0xcb, - 0x3f, 0x3a, 0xc2, 0x47, 0xfe, 0xa3, 0x47, 0xcd, 0xf6, 0x81, 0xff, 0xe8, 0xd1, 0xcb, 0xa3, 0x63, 0xff, 0x00, 0xde, - 0x1d, 0x1c, 0x0c, 0x0f, 0xfc, 0x76, 0xbb, 0x09, 0xff, 0xe0, 0x63, 0xbf, 0xa3, 0x7e, 0xb4, 0xdb, 0xfe, 0x41, 0x1b, - 0xb7, 0x92, 0xa3, 0x8e, 0xff, 0xe8, 0x09, 0x96, 0xff, 0xca, 0x6a, 0x58, 0xfe, 0x03, 0xdd, 0xe0, 0x27, 0x7e, 0xe7, - 0x91, 0xfa, 0x25, 0x3b, 0x9c, 0x1f, 0x1e, 0xff, 0xe0, 0xee, 0x6f, 0x9d, 0x43, 0x5b, 0xcd, 0xe1, 0xf8, 0xc8, 0x3f, - 0x38, 0xc0, 0x87, 0x6d, 0xff, 0xf8, 0x20, 0x6e, 0x1e, 0x76, 0xfc, 0x47, 0x8f, 0x87, 0xcd, 0xb6, 0xff, 0xf8, 0x31, - 0x6e, 0x35, 0x0f, 0xfc, 0x0e, 0x6e, 0xfb, 0x87, 0x07, 0xf2, 0xc7, 0x81, 0xdf, 0x99, 0x3f, 0x7e, 0xe2, 0x3f, 0x3a, - 0x8a, 0x1f, 0xf9, 0x87, 0xdf, 0x1e, 0x1e, 0xfb, 0x9d, 0x83, 0xf8, 0xe0, 0x91, 0xdf, 0x79, 0x3c, 0x7f, 0xe4, 0x1f, - 0xc6, 0xcd, 0xce, 0xa3, 0x7b, 0x5b, 0xb6, 0x3b, 0x3e, 0xe0, 0x48, 0xbe, 0x86, 0x17, 0x58, 0xbf, 0x80, 0xbf, 0xb1, - 0x6c, 0xfb, 0xef, 0xd8, 0x4d, 0xbe, 0xde, 0xf4, 0x89, 0x7f, 0xfc, 0x78, 0xa8, 0xaa, 0x43, 0x41, 0xd3, 0xd4, 0x80, - 0x26, 0xf3, 0xa6, 0x1a, 0x56, 0x76, 0xd7, 0x34, 0x1d, 0x99, 0xbf, 0x7a, 0xb0, 0x79, 0x13, 0x06, 0x56, 0xe3, 0xfe, - 0x87, 0xf6, 0x53, 0x2e, 0xf9, 0xc9, 0xfe, 0x58, 0x91, 0xfe, 0xb8, 0xf7, 0x99, 0xba, 0xdd, 0xf9, 0xb3, 0x2b, 0x9c, - 0x6e, 0x73, 0x7c, 0x64, 0x9f, 0x76, 0x7c, 0x70, 0xfa, 0x10, 0xcf, 0x47, 0xf6, 0x87, 0x7b, 0x3e, 0x52, 0xba, 0xe2, - 0x38, 0xbf, 0x16, 0x6b, 0x0e, 0x8e, 0x55, 0xab, 0xf8, 0xa9, 0xf0, 0x06, 0x39, 0x7c, 0x47, 0xac, 0xe8, 0x5e, 0x0b, - 0xc2, 0xa9, 0xed, 0x07, 0xe2, 0xc0, 0x62, 0xaf, 0x85, 0xe2, 0xb1, 0xc9, 0x36, 0x84, 0x84, 0x9f, 0x46, 0xc8, 0xb7, - 0x0f, 0xc1, 0x47, 0xf8, 0x87, 0xe3, 0x23, 0xb1, 0xf1, 0x51, 0xf3, 0xe5, 0x4b, 0x4f, 0x83, 0xf4, 0x14, 0x9c, 0xcb, - 0x67, 0x0f, 0x0e, 0x51, 0x35, 0xdc, 0x7d, 0x0a, 0x45, 0xb9, 0xab, 0x22, 0x5f, 0xef, 0x7e, 0x4d, 0xd8, 0x41, 0x9d, - 0x98, 0x24, 0xae, 0x76, 0xcb, 0x4c, 0xa5, 0xd4, 0xd1, 0x0f, 0xa5, 0x50, 0xea, 0xf8, 0x2d, 0xbf, 0x55, 0xba, 0x74, - 0xe0, 0x94, 0x2c, 0x59, 0x70, 0x11, 0xc2, 0x17, 0x6b, 0x13, 0x3e, 0x96, 0xdf, 0xb6, 0x85, 0xaf, 0x09, 0x40, 0xd2, - 0xcf, 0x50, 0x7d, 0xc8, 0x21, 0x70, 0x5d, 0x7d, 0xb7, 0x06, 0x9c, 0xc2, 0xfc, 0x06, 0x4e, 0xaa, 0x9a, 0xa8, 0xc4, - 0x04, 0xbc, 0x1d, 0xaf, 0x68, 0xc4, 0x42, 0xcf, 0xf5, 0xa6, 0x19, 0x1d, 0xd1, 0x2c, 0x6f, 0xd6, 0x8e, 0x6f, 0xca, - 0x93, 0x9b, 0xc8, 0x35, 0x9f, 0x46, 0xcd, 0xe0, 0x76, 0x6c, 0x32, 0xd0, 0xfe, 0x46, 0x57, 0x1b, 0x60, 0x6e, 0x81, - 0x4d, 0x49, 0x06, 0xb2, 0xb6, 0x52, 0xda, 0x5c, 0xa5, 0xb5, 0xb5, 0xfd, 0xce, 0x11, 0x72, 0x64, 0x31, 0xdc, 0x3b, - 0xfc, 0xbd, 0xd7, 0x3c, 0x68, 0xfd, 0x09, 0x59, 0xcd, 0xca, 0x8e, 0x2e, 0xb4, 0xbb, 0x2d, 0xad, 0xbe, 0x29, 0x5d, - 0x3f, 0x5b, 0xeb, 0x2a, 0x8a, 0xf8, 0x5c, 0xcd, 0xdd, 0x45, 0xdd, 0x54, 0x47, 0xb8, 0xd5, 0x0d, 0x11, 0x23, 0x36, - 0xf6, 0xec, 0x2f, 0x06, 0xab, 0x7b, 0x8d, 0xe5, 0x87, 0xc6, 0x51, 0x51, 0x55, 0x49, 0xd1, 0x42, 0xc6, 0x5b, 0x58, - 0xea, 0xa4, 0xcb, 0xa5, 0x97, 0x82, 0x8b, 0x9c, 0x58, 0x38, 0x85, 0x67, 0x54, 0x43, 0x72, 0x8a, 0x4b, 0x80, 0x24, - 0x82, 0x49, 0xaa, 0xfe, 0xaf, 0x8a, 0xcd, 0x0f, 0xed, 0xf8, 0xf2, 0x93, 0x30, 0x1d, 0x03, 0x15, 0x86, 0xe9, 0x78, - 0xcd, 0xad, 0xa6, 0x42, 0x46, 0x2b, 0xa5, 0x55, 0x57, 0x95, 0xfb, 0x2c, 0x7f, 0x7a, 0xf7, 0x5e, 0x5f, 0x80, 0xe6, - 0x82, 0x77, 0x5a, 0x46, 0x38, 0xaa, 0xcb, 0x9a, 0x1b, 0xe4, 0x8b, 0x93, 0x09, 0x15, 0xa1, 0xca, 0xd7, 0x04, 0x7d, - 0x02, 0x4e, 0xcd, 0x3a, 0xda, 0x1a, 0x25, 0xae, 0x94, 0xee, 0x24, 0xa2, 0x73, 0x36, 0xd4, 0xa2, 0x1e, 0x3b, 0xfa, - 0xe6, 0x80, 0xa6, 0x5c, 0x1a, 0xd2, 0xc6, 0xca, 0x1f, 0x33, 0x0c, 0x65, 0x46, 0x3e, 0x49, 0xb9, 0xdb, 0xfb, 0xa2, - 0xfc, 0xfa, 0xe9, 0xb6, 0x45, 0x48, 0x58, 0xfa, 0x71, 0x90, 0xd1, 0xe4, 0x9f, 0xc8, 0x17, 0x6c, 0xc8, 0xd3, 0x2f, - 0x2e, 0xe0, 0xab, 0xf4, 0x7e, 0x9c, 0xd1, 0x11, 0xf9, 0x02, 0x64, 0x7c, 0x20, 0xad, 0x0f, 0x60, 0x84, 0x8d, 0xdb, - 0x49, 0x82, 0xa5, 0xc6, 0xf4, 0x00, 0x85, 0x48, 0x81, 0xeb, 0x76, 0x8e, 0x5c, 0x47, 0xd9, 0xc4, 0xf2, 0x77, 0x4f, - 0x89, 0x53, 0xa9, 0x04, 0x38, 0xed, 0x8e, 0x7f, 0x14, 0x77, 0xfc, 0x27, 0xf3, 0xc7, 0xfe, 0x71, 0xdc, 0x7e, 0x3c, - 0x6f, 0xc2, 0xff, 0x1d, 0xff, 0x49, 0xd2, 0xec, 0xf8, 0x4f, 0xe0, 0xef, 0xb7, 0x87, 0xfe, 0x51, 0xdc, 0x6c, 0xfb, - 0xc7, 0xf3, 0x03, 0xff, 0xe0, 0x65, 0xbb, 0xe3, 0x1f, 0x38, 0x6d, 0x47, 0xb5, 0x03, 0x76, 0xad, 0xb8, 0xf3, 0x17, - 0x2b, 0x1b, 0x62, 0x43, 0x38, 0x4e, 0xe5, 0x9c, 0xba, 0xd8, 0x2b, 0xbf, 0xb1, 0xa8, 0xf7, 0xa7, 0x76, 0xd6, 0x3d, - 0x0b, 0x33, 0xf8, 0xd0, 0x4d, 0x7d, 0xef, 0xd6, 0xde, 0xe1, 0x1a, 0xbf, 0xd8, 0x30, 0x04, 0xec, 0x70, 0x17, 0xdb, - 0x47, 0xef, 0xe1, 0xdc, 0xba, 0xbc, 0x17, 0xdc, 0x5c, 0x8f, 0xb8, 0x9d, 0xb4, 0x55, 0x45, 0x73, 0x05, 0xa3, 0x64, - 0x16, 0x4c, 0x7e, 0x81, 0x41, 0x0e, 0xf2, 0x55, 0x54, 0xac, 0x8e, 0x0f, 0xa9, 0xaf, 0x19, 0xb7, 0x6e, 0x1f, 0xa0, - 0xd5, 0x81, 0x8d, 0x88, 0xc1, 0x7d, 0x11, 0x45, 0x61, 0x40, 0xaf, 0xb9, 0x69, 0x2b, 0x2c, 0x49, 0x7e, 0x41, 0xf3, - 0xbe, 0x0b, 0x45, 0x6e, 0xe0, 0x4a, 0x17, 0x9f, 0x5b, 0x7e, 0xec, 0xa7, 0x24, 0xec, 0xaa, 0x00, 0xcb, 0x43, 0x57, - 0xb0, 0x6b, 0x01, 0x3f, 0x2e, 0xda, 0xdb, 0xdb, 0xba, 0x5f, 0xa4, 0x02, 0x09, 0x73, 0xad, 0xbe, 0x11, 0x62, 0xb3, - 0x22, 0xd7, 0x46, 0x74, 0xd9, 0xaf, 0x44, 0x21, 0xd2, 0x78, 0xba, 0xa6, 0xa1, 0xf0, 0xc3, 0x54, 0x25, 0xd1, 0x58, - 0x0c, 0x0b, 0xb7, 0xe9, 0x01, 0x2a, 0xb8, 0x08, 0xad, 0xef, 0x00, 0xeb, 0x7d, 0xce, 0x45, 0x68, 0xce, 0xd2, 0x5a, - 0xd7, 0x06, 0x81, 0xa3, 0x37, 0xee, 0xf4, 0xde, 0xbc, 0x3f, 0x75, 0xd4, 0xf6, 0x3c, 0xd9, 0x8f, 0x3b, 0xbd, 0x13, - 0xe9, 0x33, 0x51, 0x27, 0xf1, 0x88, 0x3a, 0x89, 0xe7, 0xe8, 0x53, 0x99, 0x10, 0x49, 0x2b, 0xf6, 0xd5, 0xb4, 0xa5, - 0xcd, 0xa0, 0xbc, 0xbd, 0x93, 0x59, 0x22, 0x18, 0xdc, 0x71, 0xbd, 0x2f, 0x8f, 0xe1, 0xc1, 0x82, 0x95, 0x79, 0xd8, - 0x5a, 0x3b, 0xbc, 0x16, 0xa9, 0xf1, 0x0d, 0x8f, 0x58, 0x42, 0x4d, 0xe6, 0xb5, 0xee, 0xaa, 0x3c, 0x29, 0xb0, 0x5e, - 0x3b, 0x9f, 0x5d, 0x4f, 0x98, 0x70, 0xcd, 0x79, 0x86, 0x0f, 0xba, 0xc1, 0x89, 0x1c, 0xaa, 0x77, 0x55, 0x68, 0xe7, - 0xb5, 0xf9, 0x9a, 0x4f, 0x7d, 0x49, 0xf5, 0xec, 0xb5, 0x84, 0x80, 0x13, 0x72, 0xf1, 0x41, 0xaf, 0x74, 0x17, 0xdb, - 0xef, 0x8a, 0x93, 0xfd, 0xf8, 0xa0, 0x77, 0x15, 0x4c, 0x75, 0x7f, 0x2f, 0xf9, 0x78, 0x73, 0x5f, 0x09, 0x1f, 0xf7, - 0xe5, 0x51, 0x10, 0x75, 0x48, 0xd9, 0x28, 0xbf, 0x3c, 0x71, 0x7b, 0x27, 0x5a, 0x19, 0x70, 0x64, 0x60, 0xdd, 0x3d, - 0x6a, 0x99, 0xd3, 0x25, 0x09, 0x1f, 0xc3, 0x86, 0x54, 0x4d, 0xac, 0x41, 0x6a, 0x1e, 0xf7, 0xb8, 0xdd, 0x3b, 0x09, - 0x1d, 0xc9, 0x5b, 0x24, 0xf3, 0xc8, 0x83, 0x7d, 0x68, 0x1c, 0xf3, 0x09, 0xf5, 0x19, 0xdf, 0xbf, 0xa1, 0xd7, 0xcd, - 0x70, 0xca, 0x2a, 0xf7, 0x36, 0x28, 0x1d, 0xe5, 0x90, 0xdc, 0x78, 0xc4, 0xf5, 0xd9, 0xab, 0x4e, 0xe5, 0x6e, 0x3b, - 0x04, 0x9b, 0xc7, 0xb8, 0xe6, 0xa4, 0x4f, 0xce, 0x02, 0x8b, 0xf7, 0x4e, 0xf6, 0xc3, 0x15, 0x8c, 0x48, 0x7e, 0x5f, - 0x68, 0x47, 0x3b, 0x18, 0x36, 0x40, 0x6f, 0xae, 0xa3, 0xc4, 0x81, 0x71, 0xc8, 0x6b, 0x41, 0x5d, 0xb8, 0xbd, 0x7f, - 0xfd, 0x1f, 0xff, 0x4b, 0xfb, 0xd8, 0x4f, 0xf6, 0xe3, 0xb6, 0xe9, 0x6b, 0x65, 0x55, 0x8a, 0x13, 0x38, 0xee, 0x59, - 0x05, 0x85, 0xe9, 0x6d, 0x73, 0x9c, 0xb1, 0xa8, 0x19, 0x87, 0xc9, 0xc8, 0xed, 0x6d, 0xc7, 0xa6, 0x7d, 0x6c, 0x4b, - 0x43, 0x5d, 0x2f, 0x02, 0x7a, 0xfd, 0x4d, 0x07, 0x8f, 0xcc, 0xf9, 0x15, 0xb9, 0xb5, 0xed, 0x63, 0x48, 0xd5, 0xee, - 0xab, 0x1d, 0x45, 0x4a, 0xf5, 0x27, 0xc2, 0x34, 0x07, 0x4c, 0x6b, 0x27, 0x90, 0x0a, 0xd7, 0x29, 0x83, 0x5a, 0xff, - 0xf7, 0x7f, 0xfe, 0x97, 0xff, 0x66, 0x1e, 0x21, 0x56, 0xf5, 0xaf, 0xff, 0xfd, 0x3f, 0xff, 0x9f, 0xff, 0xfd, 0x5f, - 0xe1, 0xd4, 0x8a, 0x8e, 0x67, 0x49, 0xa6, 0xe2, 0x54, 0xc1, 0x2c, 0xc5, 0x5d, 0x1c, 0x48, 0xec, 0x9c, 0xb0, 0x5c, - 0xb0, 0x61, 0xfd, 0x4c, 0xd2, 0xb9, 0x1c, 0x50, 0xee, 0x4c, 0x0d, 0x9d, 0xdc, 0xe1, 0x45, 0x45, 0x50, 0x35, 0x94, - 0x4b, 0xc2, 0x2d, 0x4e, 0xf6, 0x01, 0xdf, 0x0f, 0x3b, 0xc6, 0xe9, 0x97, 0xcb, 0xb1, 0x30, 0x64, 0x02, 0x25, 0x45, - 0x55, 0xee, 0x40, 0x6c, 0x65, 0x01, 0x8f, 0x41, 0xc7, 0x2a, 0x96, 0xab, 0x57, 0x6b, 0xd3, 0xfd, 0x69, 0x96, 0x0b, - 0x36, 0x02, 0x94, 0x2b, 0x3f, 0xb1, 0x0c, 0x63, 0x37, 0x41, 0x57, 0x4c, 0xee, 0x0a, 0xd9, 0x8b, 0x22, 0xd0, 0xc3, - 0xe3, 0x3f, 0x15, 0x7f, 0x99, 0x80, 0x46, 0xe6, 0x78, 0x93, 0xf0, 0x56, 0x9b, 0xe7, 0x8f, 0x5a, 0xad, 0xe9, 0x2d, - 0x5a, 0x54, 0x23, 0xe0, 0x6d, 0x83, 0x49, 0x3a, 0xb6, 0x3b, 0x94, 0xf1, 0xef, 0xd2, 0x8d, 0xdd, 0x72, 0xc0, 0x17, - 0xee, 0xb4, 0x8a, 0xe2, 0xcf, 0x0b, 0xe9, 0x49, 0x65, 0xbf, 0x40, 0x9c, 0x5a, 0x3b, 0x9d, 0xaf, 0xb9, 0x3d, 0xb9, - 0x85, 0xd5, 0xaa, 0xa3, 0x5a, 0xc5, 0xed, 0xf5, 0xd3, 0x89, 0x76, 0x9c, 0xdd, 0x8e, 0x90, 0x1f, 0x42, 0xcc, 0x3b, - 0x6e, 0xe3, 0xb8, 0xb3, 0x28, 0xbb, 0x17, 0x82, 0x4f, 0xec, 0xc0, 0x3a, 0x0d, 0xe9, 0x90, 0x8e, 0x8c, 0xb3, 0x5e, - 0xbf, 0x57, 0x41, 0xf3, 0x22, 0x3e, 0xd8, 0x30, 0x96, 0x06, 0x49, 0x06, 0xd4, 0x9d, 0x56, 0xf1, 0x39, 0xec, 0xc0, - 0xc5, 0x28, 0xe1, 0xa1, 0x08, 0x24, 0xc1, 0x76, 0xed, 0xf0, 0x7c, 0x08, 0x3c, 0x89, 0x2f, 0x2c, 0x78, 0xba, 0xaa, - 0x2a, 0xb8, 0xcd, 0xeb, 0x67, 0x48, 0x0b, 0x5f, 0x36, 0xb7, 0xbb, 0x52, 0x5e, 0xb7, 0x6f, 0x75, 0xd4, 0xfb, 0x5d, - 0xcd, 0x5d, 0xa5, 0x05, 0x52, 0x07, 0x6d, 0x7e, 0xaf, 0xe4, 0xba, 0x7a, 0xfb, 0xb5, 0xf0, 0x5c, 0x09, 0xa6, 0xbb, - 0x5a, 0x4b, 0x16, 0x42, 0xad, 0x77, 0xe4, 0xdb, 0xd2, 0x64, 0x0a, 0xa7, 0x53, 0x59, 0x11, 0x75, 0x4f, 0xf6, 0x95, - 0xa6, 0x0b, 0xdc, 0x43, 0xa6, 0x74, 0xa8, 0x0c, 0x0a, 0x5d, 0x49, 0x6f, 0x05, 0xf5, 0x4b, 0xe7, 0x56, 0xc0, 0xa7, - 0xe3, 0x7a, 0xff, 0x0f, 0x82, 0x7a, 0x0b, 0xa7, 0xcf, 0x89, 0x00, 0x00}; + 0x65, 0x7c, 0xed, 0x25, 0x00, 0x5b, 0x3e, 0xbd, 0x0f, 0xc7, 0xe5, 0xef, 0x57, 0x34, 0xcf, 0xc3, 0xb1, 0xae, 0xb9, + 0x3d, 0x9e, 0x26, 0x41, 0xb4, 0x63, 0x69, 0x06, 0x08, 0x88, 0x89, 0x01, 0x46, 0xc0, 0xa7, 0xa1, 0x43, 0x64, 0x30, + 0xf5, 0x7a, 0x74, 0x4d, 0xe2, 0xaa, 0x5e, 0x24, 0xc2, 0x71, 0x55, 0x70, 0x32, 0xcd, 0xa8, 0x2c, 0x55, 0x68, 0x2c, + 0x4e, 0xf6, 0xa1, 0x40, 0xbd, 0xde, 0x12, 0x45, 0x33, 0x0e, 0x94, 0xed, 0xb1, 0x34, 0xc7, 0x44, 0xd1, 0xec, 0x44, + 0xa5, 0x32, 0x4b, 0x69, 0x3d, 0x76, 0xf3, 0x79, 0x7b, 0x08, 0x7f, 0x74, 0x64, 0xe8, 0xf3, 0xd1, 0x68, 0x74, 0x6f, + 0x54, 0xed, 0xf3, 0x68, 0x44, 0x3b, 0xf4, 0xa8, 0x0b, 0x49, 0x2c, 0x4d, 0x1d, 0x8b, 0x69, 0x17, 0x12, 0x77, 0x8b, + 0x87, 0x55, 0x86, 0xb0, 0x8d, 0x88, 0x17, 0x0f, 0x8f, 0xb0, 0x15, 0xd3, 0x8c, 0x2e, 0x26, 0x61, 0x36, 0x66, 0x69, + 0xd0, 0x2a, 0xfc, 0xb9, 0x0e, 0x49, 0x7d, 0x7e, 0x7c, 0x7c, 0x5c, 0xf8, 0x91, 0x79, 0x6a, 0x45, 0x51, 0xe1, 0x0f, + 0x17, 0xe5, 0x34, 0x5a, 0xad, 0xd1, 0xa8, 0xf0, 0x99, 0x29, 0x38, 0xe8, 0x0c, 0xa3, 0x83, 0x4e, 0xe1, 0xdf, 0x58, + 0x35, 0x0a, 0x9f, 0xea, 0xa7, 0x8c, 0x46, 0xb5, 0x4c, 0x98, 0xc7, 0xad, 0x56, 0xe1, 0x2b, 0x42, 0x5b, 0x80, 0x59, + 0xaa, 0x7e, 0x06, 0xe1, 0x4c, 0x70, 0x60, 0xee, 0xdd, 0x44, 0x78, 0x83, 0x4b, 0x7d, 0xcb, 0x88, 0xfa, 0x26, 0x47, + 0x81, 0x2e, 0xf0, 0xcf, 0x76, 0xf0, 0x08, 0x88, 0x59, 0x06, 0x8d, 0x12, 0x13, 0x5b, 0xaa, 0xbd, 0x06, 0xca, 0x92, + 0xaf, 0x7f, 0x26, 0x49, 0x15, 0x53, 0x02, 0x4e, 0x06, 0x35, 0xd5, 0x65, 0x78, 0x94, 0x6e, 0x91, 0x1f, 0xec, 0xd3, + 0xf2, 0xe3, 0xee, 0x21, 0xe2, 0x83, 0xfd, 0xe1, 0xe2, 0x83, 0x52, 0x4b, 0x7c, 0x28, 0xe6, 0x71, 0x27, 0x88, 0x3b, + 0x8c, 0xe9, 0xf0, 0xe3, 0x35, 0xbf, 0x6d, 0xc2, 0x96, 0xc8, 0x5c, 0x29, 0x58, 0x76, 0x7f, 0x6b, 0xd6, 0x8c, 0xe9, + 0xcc, 0xfa, 0xa2, 0x87, 0x54, 0x1f, 0xde, 0xa4, 0xc4, 0x7d, 0x63, 0x6c, 0x5b, 0x55, 0x32, 0x1a, 0x11, 0xf7, 0xcd, + 0x68, 0xe4, 0x9a, 0xb3, 0x92, 0xa1, 0xa0, 0xb2, 0xd6, 0xeb, 0x5a, 0x89, 0xac, 0xf5, 0xe5, 0x97, 0x76, 0x99, 0x5d, + 0xa0, 0x43, 0x4f, 0x76, 0x98, 0x49, 0xbf, 0x89, 0x58, 0x0e, 0x5b, 0x0d, 0x3e, 0x34, 0x52, 0xbf, 0xab, 0x31, 0xad, + 0x5d, 0xab, 0x5d, 0x02, 0xbc, 0xe1, 0x2e, 0xf0, 0xd5, 0x8b, 0x02, 0xc6, 0xd4, 0xe4, 0x2d, 0x3e, 0xbd, 0xfb, 0x2a, + 0xf2, 0xee, 0x04, 0x2a, 0x58, 0xfe, 0x26, 0x5d, 0x39, 0x04, 0xa4, 0x60, 0x24, 0xc4, 0x9e, 0x56, 0x21, 0xf8, 0x78, + 0x9c, 0xc0, 0xb7, 0x5e, 0x16, 0xb5, 0xfb, 0x63, 0x55, 0xf3, 0x7e, 0x6d, 0xbe, 0x81, 0xdd, 0x50, 0xdf, 0xb6, 0x2a, + 0x3f, 0x3d, 0xa5, 0x92, 0xc7, 0xe7, 0xfa, 0x1b, 0x44, 0xd2, 0x2c, 0x5e, 0x68, 0x26, 0xbf, 0x50, 0x29, 0xc7, 0x02, + 0xd2, 0x6d, 0x54, 0xc7, 0x51, 0x51, 0xe8, 0xc3, 0x1a, 0x11, 0xcb, 0xa7, 0x70, 0xaf, 0xa9, 0x6a, 0x49, 0x3f, 0xc5, + 0xc2, 0xf3, 0x1b, 0x2b, 0xbe, 0x53, 0x5b, 0xae, 0xc2, 0x04, 0x78, 0x94, 0xc3, 0xfc, 0x4e, 0x14, 0xae, 0xf6, 0xbb, + 0x1b, 0x24, 0xba, 0x8e, 0xc2, 0xa7, 0x8a, 0x3c, 0x59, 0x33, 0x04, 0xe7, 0x77, 0xb9, 0x20, 0xe6, 0x95, 0x29, 0x28, + 0xec, 0xf8, 0xa5, 0x7c, 0xa3, 0xb0, 0x25, 0xa3, 0x25, 0xf9, 0x34, 0x4c, 0x15, 0x1b, 0x25, 0xae, 0xe2, 0x07, 0xbb, + 0x8b, 0x6a, 0xe5, 0x0b, 0xd7, 0x80, 0xad, 0x88, 0xb7, 0x77, 0xb2, 0x0f, 0x0d, 0x7a, 0x4e, 0x0d, 0xf4, 0x74, 0x2d, + 0xc8, 0xf2, 0x89, 0x74, 0x87, 0x2b, 0x3f, 0xbf, 0xc1, 0x7e, 0x7e, 0xe3, 0xfc, 0x79, 0xd1, 0xbc, 0xa1, 0xd7, 0x1f, + 0x99, 0x68, 0x8a, 0x70, 0xda, 0x04, 0xc3, 0x47, 0x3a, 0x47, 0x35, 0x7b, 0x96, 0x59, 0x7e, 0xea, 0xaa, 0x83, 0xee, + 0x2c, 0x87, 0xac, 0x08, 0xa9, 0xbe, 0x07, 0x29, 0x4f, 0x69, 0xb7, 0x9e, 0xcd, 0x69, 0x07, 0xd9, 0x0d, 0xb6, 0x2e, + 0x16, 0x1c, 0xb2, 0x28, 0xc4, 0x5d, 0xd0, 0xd2, 0x6c, 0xbd, 0x65, 0x22, 0xe8, 0xad, 0x8d, 0xf5, 0x03, 0x8d, 0xdc, + 0x86, 0x94, 0x5e, 0xd9, 0x7a, 0x26, 0xc1, 0xb6, 0x4c, 0x80, 0x4f, 0xe5, 0x36, 0x82, 0x4b, 0xd5, 0xfc, 0xb5, 0x92, + 0x42, 0x57, 0x8b, 0x65, 0x6e, 0xe3, 0x43, 0x20, 0x0b, 0xc2, 0x91, 0xa0, 0x19, 0x7e, 0x48, 0xcd, 0x6b, 0x79, 0x0c, + 0x69, 0x01, 0x62, 0x26, 0x68, 0x1f, 0x4f, 0x6f, 0x1f, 0xde, 0xfd, 0xfd, 0xd3, 0x2f, 0x34, 0x8e, 0xcc, 0xb5, 0x3c, + 0xae, 0xdb, 0x85, 0x8d, 0x90, 0x84, 0x77, 0x01, 0x4b, 0xa5, 0xcc, 0xbb, 0x06, 0xbf, 0x68, 0x77, 0xca, 0x75, 0x92, + 0x6e, 0x46, 0x13, 0xf9, 0x15, 0x3e, 0xbd, 0x14, 0x07, 0x8f, 0xa6, 0xb7, 0x66, 0x35, 0xda, 0x2b, 0xc9, 0xb7, 0x7f, + 0x68, 0x8e, 0xed, 0xf6, 0xa4, 0xde, 0x7a, 0x9e, 0xe8, 0xd1, 0xf4, 0xb6, 0xab, 0x04, 0x6d, 0x33, 0x53, 0x50, 0xb5, + 0xa6, 0xb7, 0x76, 0x96, 0x71, 0xd5, 0x91, 0xe3, 0x1f, 0xe4, 0x0e, 0x0d, 0x73, 0xda, 0x85, 0x7b, 0xc7, 0xd9, 0x30, + 0x4c, 0xb4, 0x30, 0x9f, 0xb0, 0x28, 0x4a, 0x68, 0xd7, 0xc8, 0x6b, 0xa7, 0xfd, 0x08, 0x92, 0x74, 0xed, 0x25, 0xab, + 0xaf, 0x8a, 0x85, 0xbc, 0x12, 0x4f, 0xe1, 0x75, 0xce, 0x13, 0xf8, 0xe8, 0xc7, 0x46, 0x74, 0xea, 0xec, 0xd5, 0x56, + 0x85, 0x3c, 0xf9, 0xbb, 0x3e, 0x97, 0xa3, 0xd6, 0x9f, 0xba, 0x72, 0xc1, 0x5b, 0x5d, 0xc1, 0xa7, 0x41, 0xf3, 0xa0, + 0x3e, 0x11, 0x78, 0x55, 0x4e, 0x01, 0x6f, 0x98, 0x16, 0x06, 0x69, 0xa5, 0xf8, 0xb4, 0xe3, 0xb7, 0x75, 0x99, 0xec, + 0x00, 0xf2, 0xc2, 0xca, 0xa2, 0xa2, 0x3e, 0x99, 0x7f, 0x9b, 0xdd, 0xf2, 0x64, 0xf3, 0x6e, 0x79, 0x62, 0x76, 0xcb, + 0xfd, 0x14, 0xfb, 0xf9, 0xa8, 0x0d, 0x7f, 0xba, 0xd5, 0x84, 0x82, 0x96, 0x73, 0x30, 0xbd, 0x75, 0x40, 0x4f, 0x6b, + 0x76, 0xa6, 0xb7, 0x2a, 0xc7, 0x1a, 0x62, 0x37, 0x2d, 0xc8, 0x3a, 0xc6, 0x2d, 0x07, 0x0a, 0xe1, 0x6f, 0xab, 0xf6, + 0xaa, 0x7d, 0x08, 0xef, 0xa0, 0xd5, 0xd1, 0xfa, 0xbb, 0xce, 0xfd, 0x9b, 0x36, 0x48, 0xb9, 0xf0, 0x02, 0xc3, 0x8d, + 0x91, 0x2f, 0xc2, 0xeb, 0x6b, 0x1a, 0x05, 0x23, 0x3e, 0x9c, 0xe5, 0xff, 0xa4, 0xe1, 0xd7, 0x48, 0xbc, 0x77, 0x4b, + 0xaf, 0xf4, 0x63, 0x9a, 0xaa, 0x8c, 0x6f, 0xd3, 0xc3, 0xa2, 0x5c, 0xa7, 0x20, 0x1f, 0x86, 0x09, 0xf5, 0x3a, 0xfe, + 0xe1, 0x86, 0x4d, 0xf0, 0xef, 0xb2, 0x36, 0x1b, 0x27, 0xf3, 0x7b, 0x91, 0x71, 0x2f, 0x12, 0x7e, 0x15, 0x0e, 0xec, + 0x35, 0x6c, 0x1d, 0x6f, 0x06, 0x77, 0x60, 0x46, 0xba, 0x30, 0x42, 0x41, 0xcb, 0x9d, 0x88, 0x8e, 0xc2, 0x59, 0x22, + 0xee, 0xef, 0x75, 0x1b, 0x65, 0xac, 0xf5, 0x7a, 0x0f, 0x43, 0xaf, 0xea, 0x3e, 0x90, 0x4b, 0x7f, 0xfe, 0xe4, 0x10, + 0xfe, 0xa8, 0xfc, 0xaf, 0xbb, 0x4a, 0x57, 0x57, 0x76, 0x2f, 0xe8, 0xea, 0xbb, 0x35, 0x65, 0x5c, 0x89, 0x70, 0xa9, + 0x8f, 0x3f, 0xb4, 0x36, 0x68, 0x95, 0x0f, 0xaa, 0xae, 0xb5, 0xac, 0x5f, 0x55, 0xfb, 0xd7, 0x75, 0xfe, 0xc0, 0xba, + 0x43, 0xa5, 0xb9, 0xd6, 0xeb, 0xea, 0xcf, 0x10, 0xae, 0x55, 0x36, 0x18, 0x97, 0xf5, 0x77, 0xc9, 0x5d, 0x69, 0xa2, + 0xa8, 0x68, 0x2c, 0x58, 0x29, 0xbb, 0xca, 0x4a, 0xc9, 0x29, 0xb9, 0x3a, 0xe9, 0xdf, 0x4e, 0x12, 0x67, 0xae, 0x8e, + 0x4b, 0x12, 0xb7, 0xed, 0xb7, 0x5c, 0x47, 0xe6, 0x01, 0xc0, 0xad, 0xed, 0xae, 0xfc, 0xbc, 0xad, 0xdb, 0x07, 0x4d, + 0x6b, 0x3e, 0x96, 0x9a, 0xdd, 0xcb, 0xf0, 0x8e, 0x66, 0x97, 0x1d, 0xd7, 0x01, 0x3f, 0x4d, 0x53, 0xa5, 0x4c, 0xc8, + 0x32, 0xa7, 0xe3, 0x3a, 0xb7, 0x93, 0x24, 0xcd, 0x89, 0x1b, 0x0b, 0x31, 0x0d, 0xd4, 0xf7, 0x6f, 0x6f, 0x0e, 0x7c, + 0x9e, 0x8d, 0xf7, 0x3b, 0xad, 0x56, 0x0b, 0x2e, 0x80, 0x75, 0x9d, 0x39, 0xa3, 0x37, 0x4f, 0xf9, 0x2d, 0x71, 0x5b, + 0x4e, 0xcb, 0x69, 0x77, 0x8e, 0x9d, 0x76, 0xe7, 0xd0, 0x7f, 0x74, 0xec, 0xf6, 0x3e, 0x73, 0x9c, 0x93, 0x88, 0x8e, + 0x72, 0xf8, 0xe1, 0x38, 0x27, 0x52, 0xf1, 0x52, 0xbf, 0x1d, 0xc7, 0x1f, 0x26, 0x79, 0xb3, 0xed, 0x2c, 0xf4, 0xa3, + 0xe3, 0xc0, 0xa1, 0xd2, 0xc0, 0xf9, 0x7c, 0xd4, 0x19, 0x1d, 0x8e, 0x9e, 0x74, 0x75, 0x71, 0xf1, 0x59, 0xad, 0x3a, + 0x56, 0xff, 0x77, 0xac, 0x66, 0xb9, 0xc8, 0xf8, 0x47, 0xaa, 0x73, 0x12, 0x1d, 0x10, 0x3d, 0x1b, 0x9b, 0x76, 0xd6, + 0x47, 0x6a, 0x1f, 0x5f, 0x0f, 0x47, 0x9d, 0xaa, 0xba, 0x84, 0x71, 0xbf, 0x04, 0xf2, 0x64, 0xdf, 0x80, 0x7e, 0x62, + 0xa3, 0xa9, 0xdd, 0xdc, 0x84, 0xa8, 0xb6, 0xab, 0xe7, 0x38, 0x36, 0xf3, 0x3b, 0x81, 0x33, 0x0c, 0x46, 0x57, 0x95, + 0x10, 0xb8, 0x4e, 0x44, 0xdc, 0x57, 0xed, 0xce, 0x31, 0x6e, 0xb7, 0x1f, 0xf9, 0x8f, 0x8e, 0x87, 0x2d, 0x7c, 0xe8, + 0x1f, 0x36, 0x0f, 0xfc, 0x47, 0xf8, 0xb8, 0x79, 0x8c, 0x8f, 0x5f, 0x1c, 0x0f, 0x9b, 0x87, 0xfe, 0x21, 0x6e, 0x35, + 0x8f, 0xa1, 0xb0, 0x79, 0xdc, 0x3c, 0x9e, 0x37, 0x0f, 0x8f, 0x87, 0x2d, 0x59, 0xda, 0xf1, 0x8f, 0x8e, 0x9a, 0xed, + 0x96, 0x7f, 0x74, 0x84, 0x8f, 0xfc, 0x47, 0x8f, 0x9a, 0xed, 0x03, 0xff, 0xd1, 0xa3, 0x97, 0x47, 0xc7, 0xfe, 0x01, + 0xbc, 0x3b, 0x38, 0x18, 0x1e, 0xf8, 0xed, 0x76, 0x13, 0xfe, 0xc1, 0xc7, 0x7e, 0x47, 0xfd, 0x68, 0xb7, 0xfd, 0x83, + 0x36, 0x6e, 0x25, 0x47, 0x1d, 0xff, 0xd1, 0x13, 0x2c, 0xff, 0x95, 0xd5, 0xb0, 0xfc, 0x07, 0xba, 0xc1, 0x4f, 0xfc, + 0xce, 0x23, 0xf5, 0x4b, 0x76, 0x38, 0x3f, 0x3c, 0xfe, 0xc1, 0xdd, 0xdf, 0x3a, 0x87, 0xb6, 0x9a, 0xc3, 0xf1, 0x91, + 0x7f, 0x70, 0x80, 0x0f, 0xdb, 0xfe, 0xf1, 0x41, 0xdc, 0x3c, 0xec, 0xf8, 0x8f, 0x1e, 0x0f, 0x9b, 0x6d, 0xff, 0xf1, + 0x63, 0xdc, 0x6a, 0x1e, 0xf8, 0x1d, 0xdc, 0xf6, 0x0f, 0x0f, 0xe4, 0x8f, 0x03, 0xbf, 0x33, 0x7f, 0xfc, 0xc4, 0x7f, + 0x74, 0x14, 0x3f, 0xf2, 0x0f, 0xbf, 0x3d, 0x3c, 0xf6, 0x3b, 0x07, 0xf1, 0xc1, 0x23, 0xbf, 0xf3, 0x78, 0xfe, 0xc8, + 0x3f, 0x8c, 0x9b, 0x9d, 0x47, 0xf7, 0xb6, 0x6c, 0x77, 0x7c, 0xc0, 0x91, 0x7c, 0x0d, 0x2f, 0xb0, 0x7e, 0x01, 0x7f, + 0x63, 0xd9, 0xf6, 0xdf, 0xb1, 0x9b, 0x7c, 0xbd, 0xe9, 0x13, 0xff, 0xf8, 0xf1, 0x50, 0x55, 0x87, 0x82, 0xa6, 0xa9, + 0x01, 0x4d, 0xe6, 0x4d, 0x35, 0xac, 0xec, 0xae, 0x69, 0x3a, 0x32, 0x7f, 0xf5, 0x60, 0xf3, 0x26, 0x0c, 0xac, 0xc6, + 0xfd, 0x0f, 0xed, 0xa7, 0x5c, 0xf2, 0x93, 0xfd, 0xb1, 0x22, 0xfd, 0x71, 0xef, 0x33, 0x75, 0xbb, 0xf3, 0x67, 0x57, + 0x38, 0xdd, 0xe6, 0xf8, 0xc8, 0x3e, 0xed, 0xf8, 0xe0, 0xf4, 0x21, 0x9e, 0x8f, 0xec, 0x0f, 0xf7, 0x7c, 0xa4, 0x74, + 0xc5, 0x71, 0x7e, 0x2d, 0xd6, 0x1c, 0x1c, 0xab, 0x56, 0xf1, 0x53, 0xe1, 0x0d, 0x72, 0xf8, 0x8e, 0x58, 0xd1, 0xbd, + 0x16, 0x84, 0x53, 0xdb, 0x0f, 0xc4, 0x81, 0xc5, 0x5e, 0x0b, 0xc5, 0x63, 0x93, 0x6d, 0x08, 0x09, 0x3f, 0x8d, 0x90, + 0x6f, 0x1f, 0x82, 0x8f, 0xf0, 0x0f, 0xc7, 0x47, 0x62, 0xe3, 0xa3, 0xe6, 0xcb, 0x97, 0x9e, 0x06, 0xe9, 0x29, 0x38, + 0x97, 0xcf, 0x1e, 0x1c, 0xa2, 0x6a, 0xb8, 0xfb, 0x14, 0x8a, 0x72, 0x57, 0x45, 0xbe, 0xde, 0xfd, 0x9a, 0xb0, 0x83, + 0x3a, 0x31, 0x49, 0x5c, 0xed, 0x96, 0x99, 0x4a, 0xa9, 0xa3, 0x1f, 0x4a, 0xa1, 0xd4, 0xf1, 0x5b, 0x7e, 0xab, 0x74, + 0xe9, 0xc0, 0x29, 0x59, 0xb2, 0xe0, 0x22, 0x84, 0x2f, 0xd6, 0x26, 0x7c, 0x2c, 0xbf, 0x6d, 0x0b, 0x5f, 0x13, 0x80, + 0xa4, 0x9f, 0xa1, 0xfa, 0x90, 0x43, 0xe0, 0xba, 0xfa, 0x6e, 0x0d, 0x38, 0x85, 0xf9, 0x0d, 0x9c, 0x54, 0x35, 0x51, + 0x89, 0x09, 0x78, 0x3b, 0x5e, 0xd1, 0x88, 0x85, 0x9e, 0xeb, 0x4d, 0x33, 0x3a, 0xa2, 0x59, 0xde, 0xac, 0x1d, 0xdf, + 0x94, 0x27, 0x37, 0x91, 0x6b, 0x3e, 0x8d, 0x9a, 0xc1, 0xed, 0xd8, 0x64, 0xa0, 0xfd, 0x8d, 0xae, 0x36, 0xc0, 0xdc, + 0x02, 0x9b, 0x92, 0x0c, 0x64, 0x6d, 0xa5, 0xb4, 0xb9, 0x4a, 0x6b, 0x6b, 0xfb, 0x9d, 0x23, 0xe4, 0xc8, 0x62, 0xb8, + 0x77, 0xf8, 0x7b, 0xaf, 0x79, 0xd0, 0xfa, 0x13, 0xb2, 0x9a, 0x95, 0x1d, 0x5d, 0x68, 0x77, 0x5b, 0x5a, 0x7d, 0x53, + 0xba, 0x7e, 0xb6, 0xd6, 0x55, 0x14, 0xf1, 0xb9, 0x9a, 0xbb, 0x8b, 0xba, 0xa9, 0x8e, 0x70, 0xab, 0x1b, 0x22, 0x46, + 0x6c, 0xec, 0xd9, 0x5f, 0x0c, 0x56, 0xf7, 0x1a, 0xcb, 0x0f, 0x8d, 0xa3, 0xa2, 0xaa, 0x92, 0xa2, 0x85, 0x8c, 0xb7, + 0xb0, 0xd4, 0x49, 0x97, 0x4b, 0x2f, 0x05, 0x17, 0x39, 0xb1, 0x70, 0x0a, 0xcf, 0xa8, 0x86, 0xe4, 0x14, 0x97, 0x00, + 0x49, 0x04, 0x93, 0x54, 0xfd, 0x5f, 0x15, 0x9b, 0x1f, 0xda, 0xf1, 0xe5, 0x27, 0x61, 0x3a, 0x06, 0x2a, 0x0c, 0xd3, + 0xf1, 0x9a, 0x5b, 0x4d, 0x85, 0x8c, 0x56, 0x4a, 0xab, 0xae, 0x2a, 0xf7, 0x59, 0xfe, 0xf4, 0xee, 0xbd, 0xbe, 0x00, + 0xcd, 0x05, 0xef, 0xb4, 0x8c, 0x70, 0x54, 0x97, 0x35, 0x37, 0xc8, 0x17, 0x27, 0x13, 0x2a, 0x42, 0x95, 0xaf, 0x09, + 0xfa, 0x04, 0x9c, 0x9a, 0x75, 0xb4, 0x35, 0x4a, 0x5c, 0x29, 0xdd, 0x49, 0x44, 0xe7, 0x6c, 0xa8, 0x45, 0x3d, 0x76, + 0xf4, 0xcd, 0x01, 0x4d, 0xb9, 0x34, 0xa4, 0x8d, 0x95, 0x3f, 0x66, 0x18, 0xca, 0x8c, 0x7c, 0x92, 0x72, 0xb7, 0xf7, + 0x45, 0xf9, 0xf5, 0xd3, 0x6d, 0x8b, 0x90, 0xb0, 0xf4, 0xe3, 0x20, 0xa3, 0xc9, 0x3f, 0x91, 0x2f, 0xd8, 0x90, 0xa7, + 0x5f, 0x5c, 0xc0, 0x57, 0xe9, 0xfd, 0x38, 0xa3, 0x23, 0xf2, 0x05, 0xc8, 0xf8, 0x40, 0x5a, 0x1f, 0xc0, 0x08, 0x1b, + 0xb7, 0x93, 0x04, 0x4b, 0x8d, 0xe9, 0x01, 0x0a, 0x91, 0x02, 0xd7, 0xed, 0x1c, 0xb9, 0x8e, 0xb2, 0x89, 0xe5, 0xef, + 0x9e, 0x12, 0xa7, 0x52, 0x09, 0x70, 0xda, 0x1d, 0xff, 0x28, 0xee, 0xf8, 0x4f, 0xe6, 0x8f, 0xfd, 0xe3, 0xb8, 0xfd, + 0x78, 0xde, 0x84, 0xff, 0x3b, 0xfe, 0x93, 0xa4, 0xd9, 0xf1, 0x9f, 0xc0, 0xdf, 0x6f, 0x0f, 0xfd, 0xa3, 0xb8, 0xd9, + 0xf6, 0x8f, 0xe7, 0x07, 0xfe, 0xc1, 0xcb, 0x76, 0xc7, 0x3f, 0x70, 0xda, 0x8e, 0x6a, 0x07, 0xec, 0x5a, 0x71, 0xe7, + 0x2f, 0x56, 0x36, 0xc4, 0x86, 0x70, 0x9c, 0xca, 0x39, 0x75, 0xb1, 0x57, 0x7e, 0x63, 0x51, 0xef, 0x4f, 0xed, 0xac, + 0x7b, 0x16, 0x66, 0xf0, 0xa1, 0x9b, 0xfa, 0xde, 0xad, 0xbd, 0xc3, 0x35, 0x7e, 0xb1, 0x61, 0x08, 0xd8, 0xe1, 0x2e, + 0xb6, 0x8f, 0xde, 0xc3, 0xb9, 0x75, 0x79, 0x2f, 0xb8, 0xb9, 0x1e, 0x71, 0x3b, 0x69, 0xab, 0x8a, 0xe6, 0x0a, 0x46, + 0xc9, 0x2c, 0x98, 0xfc, 0x02, 0x83, 0x1c, 0xe4, 0xab, 0xa8, 0x58, 0x1d, 0x1f, 0x52, 0x5f, 0x33, 0x6e, 0xdd, 0x3e, + 0x40, 0xab, 0x03, 0x1b, 0x11, 0x83, 0xfb, 0x22, 0x8a, 0xc2, 0x80, 0x5e, 0x73, 0xd3, 0x56, 0x58, 0x92, 0xfc, 0x82, + 0xe6, 0x7d, 0x17, 0x8a, 0xdc, 0xc0, 0x95, 0x2e, 0x3e, 0xb7, 0xfc, 0xd8, 0x4f, 0x49, 0xd8, 0x55, 0x01, 0x96, 0x87, + 0xae, 0x60, 0xd7, 0x02, 0x7e, 0x5c, 0xb4, 0xb7, 0xb7, 0x75, 0xbf, 0x48, 0x05, 0x12, 0xe6, 0x5a, 0x7d, 0x23, 0xc4, + 0x66, 0x45, 0xae, 0x8d, 0xe8, 0xb2, 0x5f, 0x89, 0x42, 0xa4, 0xf1, 0x74, 0x4d, 0x43, 0xe1, 0x87, 0xa9, 0x4a, 0xa2, + 0xb1, 0x18, 0x16, 0x6e, 0xd3, 0x03, 0x54, 0x70, 0x11, 0x5a, 0xdf, 0x01, 0xd6, 0xfb, 0x9c, 0x8b, 0xd0, 0x9c, 0xa5, + 0xb5, 0xae, 0x0d, 0x02, 0x47, 0x6f, 0xdc, 0xe9, 0xbd, 0x79, 0x7f, 0xea, 0xa8, 0xed, 0x79, 0xb2, 0x1f, 0x77, 0x7a, + 0x27, 0xd2, 0x67, 0xa2, 0x4e, 0xe2, 0x11, 0x75, 0x12, 0xcf, 0xd1, 0xa7, 0x32, 0x21, 0x92, 0x56, 0xec, 0xab, 0x69, + 0x4b, 0x9b, 0x41, 0x79, 0x7b, 0x27, 0xb3, 0x44, 0x30, 0xb8, 0xe3, 0x7a, 0x5f, 0x1e, 0xc3, 0x83, 0x05, 0x2b, 0xf3, + 0xb0, 0xb5, 0x76, 0x78, 0x2d, 0x52, 0xe3, 0x1b, 0x1e, 0xb1, 0x84, 0x9a, 0xcc, 0x6b, 0xdd, 0x55, 0x79, 0x52, 0x60, + 0xbd, 0x76, 0x3e, 0xbb, 0x9e, 0x30, 0xe1, 0x9a, 0xf3, 0x0c, 0x1f, 0x74, 0x83, 0x13, 0x39, 0x54, 0xef, 0xaa, 0xd0, + 0xce, 0x6b, 0xf3, 0x35, 0x9f, 0xfa, 0x92, 0xea, 0xd9, 0x6b, 0x09, 0x01, 0x27, 0xe4, 0xe2, 0x83, 0x5e, 0xe9, 0x2e, + 0xb6, 0xdf, 0x15, 0x27, 0xfb, 0xf1, 0x41, 0xef, 0x2a, 0x98, 0xea, 0xfe, 0x5e, 0xf2, 0xf1, 0xe6, 0xbe, 0x12, 0x3e, + 0xee, 0xcb, 0xa3, 0x20, 0xea, 0x90, 0xb2, 0x51, 0x7e, 0x79, 0xe2, 0xf6, 0x4e, 0xb4, 0x32, 0xe0, 0xc8, 0xc0, 0xba, + 0x7b, 0xd4, 0x32, 0xa7, 0x4b, 0x12, 0x3e, 0x86, 0x0d, 0xa9, 0x9a, 0x58, 0x83, 0xd4, 0x3c, 0xee, 0x71, 0xbb, 0x77, + 0x12, 0x3a, 0x92, 0xb7, 0x48, 0xe6, 0x91, 0x07, 0xfb, 0xd0, 0x38, 0xe6, 0x13, 0xea, 0x33, 0xbe, 0x7f, 0x43, 0xaf, + 0x9b, 0xe1, 0x94, 0x55, 0xee, 0x6d, 0x50, 0x3a, 0xca, 0x21, 0xb9, 0xf1, 0x88, 0xeb, 0xb3, 0x57, 0x9d, 0xca, 0xdd, + 0x76, 0x08, 0x36, 0x8f, 0x71, 0xcd, 0x49, 0x9f, 0x9c, 0x05, 0x16, 0xef, 0x9d, 0xec, 0x87, 0x2b, 0x18, 0x91, 0xfc, + 0xbe, 0xd0, 0x8e, 0x76, 0x30, 0x6c, 0x80, 0xde, 0x5c, 0x47, 0x89, 0x03, 0xe3, 0x90, 0xd7, 0x82, 0xba, 0x70, 0x7b, + 0xff, 0xfa, 0x3f, 0xfe, 0x97, 0xf6, 0xb1, 0x9f, 0xec, 0xc7, 0x6d, 0xd3, 0xd7, 0xca, 0xaa, 0x14, 0x27, 0x70, 0xdc, + 0xb3, 0x0a, 0x0a, 0xd3, 0xdb, 0xe6, 0x38, 0x63, 0x51, 0x33, 0x0e, 0x93, 0x91, 0xdb, 0xdb, 0x8e, 0x4d, 0xfb, 0xd8, + 0x96, 0x86, 0xba, 0x5e, 0x04, 0xf4, 0xfa, 0x9b, 0x0e, 0x1e, 0x99, 0xf3, 0x2b, 0x72, 0x6b, 0xdb, 0xc7, 0x90, 0xaa, + 0xdd, 0x57, 0x3b, 0x8a, 0x94, 0xea, 0x4f, 0x84, 0x69, 0x0e, 0x98, 0xd6, 0x4e, 0x20, 0x15, 0xae, 0x53, 0x06, 0xb5, + 0xfe, 0xef, 0xff, 0xfc, 0x2f, 0xff, 0xcd, 0x3c, 0x42, 0xac, 0xea, 0x5f, 0xff, 0xfb, 0x7f, 0xfe, 0x3f, 0xff, 0xfb, + 0xbf, 0xc2, 0xa9, 0x15, 0x1d, 0xcf, 0x92, 0x4c, 0xc5, 0xa9, 0x82, 0x59, 0x8a, 0xbb, 0x38, 0x90, 0xd8, 0x39, 0x61, + 0xb9, 0x60, 0xc3, 0xfa, 0x99, 0xa4, 0x73, 0x39, 0xa0, 0xdc, 0x99, 0x1a, 0x3a, 0xb9, 0xc3, 0x8b, 0x8a, 0xa0, 0x6a, + 0x28, 0x97, 0x84, 0x5b, 0x9c, 0xec, 0x03, 0xbe, 0x1f, 0x76, 0x8c, 0xd3, 0x2f, 0x97, 0x63, 0x61, 0xc8, 0x04, 0x4a, + 0x8a, 0xaa, 0xdc, 0x81, 0xd8, 0xca, 0x02, 0x1e, 0x83, 0x8e, 0x55, 0x2c, 0x57, 0xaf, 0xd6, 0xa6, 0xfb, 0xd3, 0x2c, + 0x17, 0x6c, 0x04, 0x28, 0x57, 0x7e, 0x62, 0x19, 0xc6, 0x6e, 0x82, 0xae, 0x98, 0xdc, 0x15, 0xb2, 0x17, 0x45, 0xa0, + 0x87, 0xc7, 0x7f, 0x2a, 0xfe, 0x32, 0x01, 0x8d, 0xcc, 0xf1, 0x26, 0xe1, 0xad, 0x36, 0xcf, 0x1f, 0xb5, 0x5a, 0xd3, + 0x5b, 0xb4, 0xa8, 0x46, 0xc0, 0xdb, 0x06, 0x93, 0x74, 0x6c, 0x77, 0x28, 0xe3, 0xdf, 0xa5, 0x1b, 0xbb, 0xe5, 0x80, + 0x2f, 0xdc, 0x69, 0x15, 0xc5, 0x9f, 0x17, 0xd2, 0x93, 0xca, 0x7e, 0x81, 0x38, 0xb5, 0x76, 0x3a, 0x5f, 0x73, 0x7b, + 0x72, 0x0b, 0xab, 0x55, 0x47, 0xb5, 0x8a, 0xdb, 0xeb, 0xa7, 0x13, 0xed, 0x38, 0xbb, 0x1d, 0x21, 0x3f, 0x84, 0x98, + 0x77, 0xdc, 0xc6, 0x71, 0x67, 0x51, 0x76, 0x2f, 0x04, 0x9f, 0xd8, 0x81, 0x75, 0x1a, 0xd2, 0x21, 0x1d, 0x19, 0x67, + 0xbd, 0x7e, 0xaf, 0x82, 0xe6, 0x45, 0x7c, 0xb0, 0x61, 0x2c, 0x0d, 0x92, 0x0c, 0xa8, 0x3b, 0xad, 0xe2, 0x73, 0xd8, + 0x81, 0x8b, 0x51, 0xc2, 0x43, 0x11, 0x48, 0x82, 0xed, 0xda, 0xe1, 0xf9, 0x10, 0x78, 0x12, 0x5f, 0x58, 0xf0, 0x74, + 0x55, 0x55, 0x70, 0x9b, 0xd7, 0xcf, 0x90, 0x16, 0xbe, 0x6c, 0x6e, 0x77, 0xa5, 0xbc, 0x6e, 0xdf, 0xea, 0xa8, 0xf7, + 0xbb, 0x9a, 0xbb, 0x4a, 0x0b, 0xa4, 0x0e, 0xda, 0xfc, 0x5e, 0xc9, 0x75, 0xf5, 0xf6, 0x6b, 0xe1, 0xb9, 0x12, 0x4c, + 0x77, 0xb5, 0x96, 0x2c, 0x84, 0x5a, 0xef, 0xc8, 0xb7, 0xa5, 0xc9, 0x14, 0x4e, 0xa7, 0xb2, 0x22, 0xea, 0x9e, 0xec, + 0x2b, 0x4d, 0x17, 0xb8, 0x87, 0x4c, 0xe9, 0x50, 0x19, 0x14, 0xba, 0x92, 0xde, 0x0a, 0xea, 0x97, 0xce, 0xad, 0x80, + 0x4f, 0xc7, 0xf5, 0xfe, 0x1f, 0xe7, 0xe0, 0x1c, 0x12, 0xcf, 0x89, 0x00, 0x00}; } // namespace web_server } // namespace esphome From c6a039a72f0be181987b4343fd7c89dba1dbb348 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:32:04 +1200 Subject: [PATCH 1108/1626] [adc] Fix `FILTER_SOURCE_FILES` location (#10673) --- esphome/components/adc/__init__.py | 27 +-------------------------- esphome/components/adc/sensor.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index f260e13242..15dc447b6c 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -11,15 +11,8 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) -from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.const import ( - CONF_ANALOG, - CONF_INPUT, - CONF_NUMBER, - PLATFORM_ESP8266, - PlatformFramework, -) +from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266 from esphome.core import CORE CODEOWNERS = ["@esphome/core"] @@ -273,21 +266,3 @@ def validate_adc_pin(value): )(value) raise NotImplementedError - - -FILTER_SOURCE_FILES = filter_source_files_from_platform( - { - "adc_sensor_esp32.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, - "adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, - "adc_sensor_libretiny.cpp": { - PlatformFramework.BK72XX_ARDUINO, - PlatformFramework.RTL87XX_ARDUINO, - PlatformFramework.LN882X_ARDUINO, - }, - "adc_sensor_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, - } -) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 49970c5e3d..607609bbc7 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -9,6 +9,7 @@ from esphome.components.zephyr import ( zephyr_add_prj_conf, zephyr_add_user, ) +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_ATTENUATION, @@ -20,6 +21,7 @@ from esphome.const import ( PLATFORM_NRF52, STATE_CLASS_MEASUREMENT, UNIT_VOLT, + PlatformFramework, ) from esphome.core import CORE @@ -174,3 +176,21 @@ async def to_code(config): }}; """ ) + + +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "adc_sensor_esp32.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, + "adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "adc_sensor_libretiny.cpp": { + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, + "adc_sensor_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + } +) From 79b0025fe6492e57bf15fba4f2ce1a05ea14a16d Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Thu, 11 Sep 2025 01:23:58 -0400 Subject: [PATCH 1109/1626] Openthread Fix Factory Reset (#9281) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../button/factory_reset_button.cpp | 19 +++++++- .../button/factory_reset_button.h | 7 ++- .../switch/factory_reset_switch.cpp | 19 +++++++- .../switch/factory_reset_switch.h | 6 ++- esphome/components/openthread/openthread.cpp | 48 ++++++++++++++++--- esphome/components/openthread/openthread.h | 11 +++++ 6 files changed, 99 insertions(+), 11 deletions(-) diff --git a/esphome/components/factory_reset/button/factory_reset_button.cpp b/esphome/components/factory_reset/button/factory_reset_button.cpp index 585975c043..d582317767 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.cpp +++ b/esphome/components/factory_reset/button/factory_reset_button.cpp @@ -1,7 +1,13 @@ #include "factory_reset_button.h" + +#include "esphome/core/defines.h" + +#ifdef USE_OPENTHREAD +#include "esphome/components/openthread/openthread.h" +#endif +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" -#include "esphome/core/application.h" namespace esphome { namespace factory_reset { @@ -13,9 +19,20 @@ void FactoryResetButton::press_action() { ESP_LOGI(TAG, "Resetting"); // Let MQTT settle a bit delay(100); // NOLINT +#ifdef USE_OPENTHREAD + openthread::global_openthread_component->on_factory_reset(FactoryResetButton::factory_reset_callback); +#else + global_preferences->reset(); + App.safe_reboot(); +#endif +} + +#ifdef USE_OPENTHREAD +void FactoryResetButton::factory_reset_callback() { global_preferences->reset(); App.safe_reboot(); } +#endif } // namespace factory_reset } // namespace esphome diff --git a/esphome/components/factory_reset/button/factory_reset_button.h b/esphome/components/factory_reset/button/factory_reset_button.h index 9996a860d9..c68da2ca74 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.h +++ b/esphome/components/factory_reset/button/factory_reset_button.h @@ -1,7 +1,9 @@ #pragma once -#include "esphome/core/component.h" +#include "esphome/core/defines.h" + #include "esphome/components/button/button.h" +#include "esphome/core/component.h" namespace esphome { namespace factory_reset { @@ -9,6 +11,9 @@ namespace factory_reset { class FactoryResetButton : public button::Button, public Component { public: void dump_config() override; +#ifdef USE_OPENTHREAD + static void factory_reset_callback(); +#endif protected: void press_action() override; diff --git a/esphome/components/factory_reset/switch/factory_reset_switch.cpp b/esphome/components/factory_reset/switch/factory_reset_switch.cpp index 1282c73f4e..75449aa526 100644 --- a/esphome/components/factory_reset/switch/factory_reset_switch.cpp +++ b/esphome/components/factory_reset/switch/factory_reset_switch.cpp @@ -1,7 +1,13 @@ #include "factory_reset_switch.h" + +#include "esphome/core/defines.h" + +#ifdef USE_OPENTHREAD +#include "esphome/components/openthread/openthread.h" +#endif +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" -#include "esphome/core/application.h" namespace esphome { namespace factory_reset { @@ -17,10 +23,21 @@ void FactoryResetSwitch::write_state(bool state) { ESP_LOGI(TAG, "Resetting"); // Let MQTT settle a bit delay(100); // NOLINT +#ifdef USE_OPENTHREAD + openthread::global_openthread_component->on_factory_reset(FactoryResetSwitch::factory_reset_callback); +#else global_preferences->reset(); App.safe_reboot(); +#endif } } +#ifdef USE_OPENTHREAD +void FactoryResetSwitch::factory_reset_callback() { + global_preferences->reset(); + App.safe_reboot(); +} +#endif + } // namespace factory_reset } // namespace esphome diff --git a/esphome/components/factory_reset/switch/factory_reset_switch.h b/esphome/components/factory_reset/switch/factory_reset_switch.h index 2c914ea76d..8ea0c79108 100644 --- a/esphome/components/factory_reset/switch/factory_reset_switch.h +++ b/esphome/components/factory_reset/switch/factory_reset_switch.h @@ -1,7 +1,8 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/components/switch/switch.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" namespace esphome { namespace factory_reset { @@ -9,6 +10,9 @@ namespace factory_reset { class FactoryResetSwitch : public switch_::Switch, public Component { public: void dump_config() override; +#ifdef USE_OPENTHREAD + static void factory_reset_callback(); +#endif protected: void write_state(bool state) override; diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 322ff43238..5b5c113f83 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -11,8 +11,6 @@ #include #include #include -#include -#include #include #include @@ -77,8 +75,14 @@ std::optional OpenThreadComponent::get_omr_address_(InstanceLock & return {}; } -void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrpClientService *services, - const otSrpClientService *removed_services, void *context) { +void OpenThreadComponent::defer_factory_reset_external_callback() { + ESP_LOGD(TAG, "Defer factory_reset_external_callback_"); + this->defer([this]() { this->factory_reset_external_callback_(); }); +} + +void OpenThreadSrpComponent::srp_callback(otError err, const otSrpClientHostInfo *host_info, + const otSrpClientService *services, + const otSrpClientService *removed_services, void *context) { if (err != 0) { ESP_LOGW(TAG, "SRP client reported an error: %s", otThreadErrorToString(err)); for (const otSrpClientHostInfo *host = host_info; host; host = nullptr) { @@ -90,16 +94,30 @@ void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrp } } -void srp_start_callback(const otSockAddr *server_socket_address, void *context) { +void OpenThreadSrpComponent::srp_start_callback(const otSockAddr *server_socket_address, void *context) { ESP_LOGI(TAG, "SRP client has started"); } +void OpenThreadSrpComponent::srp_factory_reset_callback(otError err, const otSrpClientHostInfo *host_info, + const otSrpClientService *services, + const otSrpClientService *removed_services, void *context) { + OpenThreadComponent *obj = (OpenThreadComponent *) context; + if (err == OT_ERROR_NONE && removed_services != NULL && host_info != NULL && + host_info->mState == OT_SRP_CLIENT_ITEM_STATE_REMOVED) { + ESP_LOGD(TAG, "Successful Removal SRP Host and Services"); + } else if (err != OT_ERROR_NONE) { + // Handle other SRP client events or errors + ESP_LOGW(TAG, "SRP client event/error: %s", otThreadErrorToString(err)); + } + obj->defer_factory_reset_external_callback(); +} + void OpenThreadSrpComponent::setup() { otError error; InstanceLock lock = InstanceLock::acquire(); otInstance *instance = lock.get_instance(); - otSrpClientSetCallback(instance, srp_callback, nullptr); + otSrpClientSetCallback(instance, OpenThreadSrpComponent::srp_callback, nullptr); // set the host name uint16_t size; @@ -179,7 +197,8 @@ void OpenThreadSrpComponent::setup() { ESP_LOGD(TAG, "Added service: %s", full_service.c_str()); } - otSrpClientEnableAutoStartMode(instance, srp_start_callback, nullptr); + otSrpClientEnableAutoStartMode(instance, OpenThreadSrpComponent::srp_start_callback, nullptr); + ESP_LOGD(TAG, "Finished SRP setup"); } void *OpenThreadSrpComponent::pool_alloc_(size_t size) { @@ -217,6 +236,21 @@ bool OpenThreadComponent::teardown() { return this->teardown_complete_; } +void OpenThreadComponent::on_factory_reset(std::function callback) { + factory_reset_external_callback_ = callback; + ESP_LOGD(TAG, "Start Removal SRP Host and Services"); + otError error; + InstanceLock lock = InstanceLock::acquire(); + otInstance *instance = lock.get_instance(); + otSrpClientSetCallback(instance, OpenThreadSrpComponent::srp_factory_reset_callback, this); + error = otSrpClientRemoveHostAndServices(instance, true, true); + if (error != OT_ERROR_NONE) { + ESP_LOGW(TAG, "Failed to Remove SRP Host and Services"); + return; + } + ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); +} + } // namespace openthread } // namespace esphome diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index a0ea1b3f3a..a9aff78e56 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -6,6 +6,8 @@ #include "esphome/components/network/ip_address.h" #include "esphome/core/component.h" +#include +#include #include #include @@ -28,11 +30,14 @@ class OpenThreadComponent : public Component { network::IPAddresses get_ip_addresses(); std::optional get_omr_address(); void ot_main(); + void on_factory_reset(std::function callback); + void defer_factory_reset_external_callback(); protected: std::optional get_omr_address_(InstanceLock &lock); bool teardown_started_{false}; bool teardown_complete_{false}; + std::function factory_reset_external_callback_; }; extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -43,6 +48,12 @@ class OpenThreadSrpComponent : public Component { // This has to run after the mdns component or else no services are available to advertise float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0; } void setup() override; + static void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrpClientService *services, + const otSrpClientService *removed_services, void *context); + static void srp_start_callback(const otSockAddr *server_socket_address, void *context); + static void srp_factory_reset_callback(otError err, const otSrpClientHostInfo *host_info, + const otSrpClientService *services, const otSrpClientService *removed_services, + void *context); protected: esphome::mdns::MDNSComponent *mdns_{nullptr}; From f43fb3c3a3d331273bb7f953022f94a3c64e4333 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 14:25:55 -0500 Subject: [PATCH 1110/1626] [core] Add millisecond precision to logging timestamps (#10677) --- esphome/__main__.py | 4 +++- esphome/components/api/client.py | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 280f491924..bba254436e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -226,7 +226,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: .replace(b"\n", b"") .decode("utf8", "backslashreplace") ) - time_str = datetime.now().time().strftime("[%H:%M:%S]") + time_ = datetime.now() + nanoseconds = time_.microsecond // 1000 + time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" safe_print(parser.parse_line(line, time_str)) backtrace_state = platformio_api.process_stacktrace( diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index ce018b3b98..ca1fc089fa 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -62,9 +62,11 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: time_ = datetime.now() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") - for parsed_msg in parse_log_message( - text, f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}]" - ): + nanoseconds = time_.microsecond // 1000 + timestamp = ( + f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" + ) + for parsed_msg in parse_log_message(text, timestamp): print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) stop = await async_run(cli, on_log, name=name) From eee64cc3a62f5c260ceb03d25f1baaa47b03d016 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 15:51:58 -0500 Subject: [PATCH 1111/1626] Add comprehensive tests for choose_upload_log_host to prevent regressions (#10679) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- tests/unit_tests/test_main.py | 512 ++++++++++++++++++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 tests/unit_tests/test_main.py diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py new file mode 100644 index 0000000000..2c7236c7f8 --- /dev/null +++ b/tests/unit_tests/test_main.py @@ -0,0 +1,512 @@ +"""Unit tests for esphome.__main__ module.""" + +from __future__ import annotations + +from collections.abc import Generator +from dataclasses import dataclass +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from esphome.__main__ import choose_upload_log_host +from esphome.const import CONF_BROKER, CONF_MQTT, CONF_USE_ADDRESS, CONF_WIFI +from esphome.core import CORE + + +@dataclass +class MockSerialPort: + """Mock serial port for testing. + + Attributes: + path (str): The device path of the mock serial port (e.g., '/dev/ttyUSB0'). + description (str): A human-readable description of the mock serial port. + """ + + path: str + description: str + + +def setup_core( + config: dict[str, Any] | None = None, address: str | None = None +) -> None: + """ + Helper to set up CORE configuration with optional address. + + Args: + config (dict[str, Any] | None): The configuration dictionary to set for CORE. If None, an empty dict is used. + address (str | None): Optional network address to set in the configuration. If provided, it is set under the wifi config. + """ + if config is None: + config = {} + + if address is not None: + # Set address via wifi config (could also use ethernet) + config[CONF_WIFI] = {CONF_USE_ADDRESS: address} + + CORE.config = config + + +@pytest.fixture +def mock_no_serial_ports() -> Generator[Mock]: + """Mock get_serial_ports to return no ports.""" + with patch("esphome.__main__.get_serial_ports", return_value=[]) as mock: + yield mock + + +@pytest.fixture +def mock_serial_ports() -> Generator[Mock]: + """Mock get_serial_ports to return test ports.""" + mock_ports = [ + MockSerialPort("/dev/ttyUSB0", "USB Serial"), + MockSerialPort("/dev/ttyUSB1", "Another USB Serial"), + ] + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports) as mock: + yield mock + + +@pytest.fixture +def mock_choose_prompt() -> Generator[Mock]: + """Mock choose_prompt to return default selection.""" + with patch("esphome.__main__.choose_prompt", return_value="/dev/ttyUSB0") as mock: + yield mock + + +@pytest.fixture +def mock_no_mqtt_logging() -> Generator[Mock]: + """Mock has_mqtt_logging to return False.""" + with patch("esphome.__main__.has_mqtt_logging", return_value=False) as mock: + yield mock + + +@pytest.fixture +def mock_has_mqtt_logging() -> Generator[Mock]: + """Mock has_mqtt_logging to return True.""" + with patch("esphome.__main__.has_mqtt_logging", return_value=True) as mock: + yield mock + + +def test_choose_upload_log_host_with_string_default() -> None: + """Test with a single string default device.""" + result = choose_upload_log_host( + default="192.168.1.100", + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + + +def test_choose_upload_log_host_with_list_default() -> None: + """Test with a list of default devices.""" + result = choose_upload_log_host( + default=["192.168.1.100", "192.168.1.101"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100", "192.168.1.101"] + + +def test_choose_upload_log_host_with_multiple_ip_addresses() -> None: + """Test with multiple IP addresses as defaults.""" + result = choose_upload_log_host( + default=["1.2.3.4", "4.5.5.6"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["1.2.3.4", "4.5.5.6"] + + +def test_choose_upload_log_host_with_mixed_hostnames_and_ips() -> None: + """Test with a mix of hostnames and IP addresses.""" + result = choose_upload_log_host( + default=["host.one", "host.one.local", "1.2.3.4"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["host.one", "host.one.local", "1.2.3.4"] + + +def test_choose_upload_log_host_with_ota_list() -> None: + """Test with OTA as the only item in the list.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default=["OTA"], + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + + +@pytest.mark.usefixtures("mock_has_mqtt_logging") +def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None: + """Test with OTA list falling back to MQTT when no address.""" + setup_core() + + result = choose_upload_log_host( + default=["OTA"], + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=False, + ) + assert result == ["MQTT"] + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_with_serial_device_no_ports( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test SERIAL device when no serial ports are found.""" + result = choose_upload_log_host( + default="SERIAL", + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == [] + assert "No serial ports found, skipping SERIAL device" in caplog.text + + +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_with_serial_device_with_ports( + mock_choose_prompt: Mock, +) -> None: + """Test SERIAL device when serial ports are available.""" + result = choose_upload_log_host( + default="SERIAL", + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + purpose="testing", + ) + assert result == ["/dev/ttyUSB0"] + mock_choose_prompt.assert_called_once_with( + [ + ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), + ("/dev/ttyUSB1 (Another USB Serial)", "/dev/ttyUSB1"), + ], + purpose="testing", + ) + + +def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: + """Test OTA device when OTA is configured.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + + +def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: + """Test OTA device when API is configured.""" + setup_core(config={"api": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=True, + ) + assert result == ["192.168.1.100"] + + +@pytest.mark.usefixtures("mock_has_mqtt_logging") +def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: + """Test OTA device fallback to MQTT when no OTA/API config.""" + setup_core() + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=False, + ) + assert result == ["MQTT"] + + +@pytest.mark.usefixtures("mock_no_mqtt_logging") +def test_choose_upload_log_host_with_ota_device_no_fallback() -> None: + """Test OTA device with no valid fallback options.""" + setup_core() + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=True, + show_mqtt=True, + show_api=False, + ) + assert result == [] + + +@pytest.mark.usefixtures("mock_choose_prompt") +def test_choose_upload_log_host_multiple_devices() -> None: + """Test with multiple devices including special identifiers.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] + + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): + result = choose_upload_log_host( + default=["192.168.1.50", "OTA", "SERIAL"], + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.50", "192.168.1.100", "/dev/ttyUSB0"] + + +def test_choose_upload_log_host_no_defaults_with_serial_ports( + mock_choose_prompt: Mock, +) -> None: + """Test interactive mode with serial ports available.""" + mock_ports = [ + MockSerialPort("/dev/ttyUSB0", "USB Serial"), + ] + + setup_core() + + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + purpose="uploading", + ) + assert result == ["/dev/ttyUSB0"] + mock_choose_prompt.assert_called_once_with( + [("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0")], + purpose="uploading", + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_no_defaults_with_ota() -> None: + """Test interactive mode with OTA option.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + with patch( + "esphome.__main__.choose_prompt", return_value="192.168.1.100" + ) as mock_prompt: + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + mock_prompt.assert_called_once_with( + [("Over The Air (192.168.1.100)", "192.168.1.100")], + purpose=None, + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_no_defaults_with_api() -> None: + """Test interactive mode with API option.""" + setup_core(config={"api": {}}, address="192.168.1.100") + + with patch( + "esphome.__main__.choose_prompt", return_value="192.168.1.100" + ) as mock_prompt: + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=True, + ) + assert result == ["192.168.1.100"] + mock_prompt.assert_called_once_with( + [("Over The Air (192.168.1.100)", "192.168.1.100")], + purpose=None, + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports", "mock_has_mqtt_logging") +def test_choose_upload_log_host_no_defaults_with_mqtt() -> None: + """Test interactive mode with MQTT option.""" + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) + + with patch("esphome.__main__.choose_prompt", return_value="MQTT") as mock_prompt: + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=False, + ) + assert result == ["MQTT"] + mock_prompt.assert_called_once_with( + [("MQTT (mqtt.local)", "MQTT")], + purpose=None, + ) + + +@pytest.mark.usefixtures("mock_has_mqtt_logging") +def test_choose_upload_log_host_no_defaults_with_all_options( + mock_choose_prompt: Mock, +) -> None: + """Test interactive mode with all options available.""" + setup_core( + config={"ota": {}, "api": {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, + address="192.168.1.100", + ) + + mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] + + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=True, + show_api=True, + purpose="testing", + ) + assert result == ["/dev/ttyUSB0"] + + expected_options = [ + ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), + ("Over The Air (192.168.1.100)", "192.168.1.100"), + ("MQTT (mqtt.local)", "MQTT"), + ] + mock_choose_prompt.assert_called_once_with(expected_options, purpose="testing") + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_check_default_matches() -> None: + """Test when check_default matches an available option.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default=None, + check_default="192.168.1.100", + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_check_default_no_match() -> None: + """Test when check_default doesn't match any available option.""" + setup_core() + + with patch( + "esphome.__main__.choose_prompt", return_value="fallback" + ) as mock_prompt: + result = choose_upload_log_host( + default=None, + check_default="192.168.1.100", + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["fallback"] + mock_prompt.assert_called_once() + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_empty_defaults_list() -> None: + """Test with an empty list as default.""" + with patch("esphome.__main__.choose_prompt", return_value="chosen") as mock_prompt: + result = choose_upload_log_host( + default=[], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["chosen"] + mock_prompt.assert_called_once() + + +@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging") +def test_choose_upload_log_host_all_devices_unresolved( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test when all specified devices cannot be resolved.""" + setup_core() + + result = choose_upload_log_host( + default=["SERIAL", "OTA"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == [] + assert ( + "All specified devices: ['SERIAL', 'OTA'] could not be resolved." in caplog.text + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging") +def test_choose_upload_log_host_mixed_resolved_unresolved() -> None: + """Test with a mix of resolved and unresolved devices.""" + setup_core() + + result = choose_upload_log_host( + default=["192.168.1.50", "SERIAL", "OTA"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.50"] + + +def test_choose_upload_log_host_ota_both_conditions() -> None: + """Test OTA device when both OTA and API are configured and enabled.""" + setup_core(config={"ota": {}, "api": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=True, + ) + assert result == ["192.168.1.100"] + + +@pytest.mark.usefixtures("mock_no_mqtt_logging") +def test_choose_upload_log_host_no_address_with_ota_config() -> None: + """Test OTA device when OTA is configured but no address is set.""" + setup_core(config={"ota": {}}) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == [] From 65f15a706f5bcb5ad344bfe39287dd3b381e4ec5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 15:52:46 -0500 Subject: [PATCH 1112/1626] Add some more coverage for dashboard web_server (#10682) --- tests/dashboard/test_web_server.py | 503 ++++++++++++++++++++++++++++- 1 file changed, 501 insertions(+), 2 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index b77ab7a7a3..a22f4a8b2a 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1,13 +1,16 @@ from __future__ import annotations import asyncio +from collections.abc import Generator +import gzip import json import os -from unittest.mock import Mock +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch import pytest import pytest_asyncio -from tornado.httpclient import AsyncHTTPClient, HTTPResponse +from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse from tornado.httpserver import HTTPServer from tornado.ioloop import IOLoop from tornado.testing import bind_unused_port @@ -34,6 +37,66 @@ class DashboardTestHelper: return await future +@pytest.fixture +def mock_async_run_system_command() -> Generator[MagicMock]: + """Fixture to mock async_run_system_command.""" + with patch("esphome.dashboard.web_server.async_run_system_command") as mock: + yield mock + + +@pytest.fixture +def mock_trash_storage_path(tmp_path: Path) -> Generator[MagicMock]: + """Fixture to mock trash_storage_path.""" + trash_dir = tmp_path / "trash" + with patch( + "esphome.dashboard.web_server.trash_storage_path", return_value=str(trash_dir) + ) as mock: + yield mock + + +@pytest.fixture +def mock_archive_storage_path(tmp_path: Path) -> Generator[MagicMock]: + """Fixture to mock archive_storage_path.""" + archive_dir = tmp_path / "archive" + with patch( + "esphome.dashboard.web_server.archive_storage_path", + return_value=str(archive_dir), + ) as mock: + yield mock + + +@pytest.fixture +def mock_dashboard_settings() -> Generator[MagicMock]: + """Fixture to mock dashboard settings.""" + with patch("esphome.dashboard.web_server.settings") as mock_settings: + # Set default auth settings to avoid authentication issues + mock_settings.using_auth = False + mock_settings.on_ha_addon = False + yield mock_settings + + +@pytest.fixture +def mock_ext_storage_path(tmp_path: Path) -> Generator[MagicMock]: + """Fixture to mock ext_storage_path.""" + with patch("esphome.dashboard.web_server.ext_storage_path") as mock: + mock.return_value = str(tmp_path / "storage.json") + yield mock + + +@pytest.fixture +def mock_storage_json() -> Generator[MagicMock]: + """Fixture to mock StorageJSON.""" + with patch("esphome.dashboard.web_server.StorageJSON") as mock: + yield mock + + +@pytest.fixture +def mock_idedata() -> Generator[MagicMock]: + """Fixture to mock platformio_api.IDEData.""" + with patch("esphome.dashboard.web_server.platformio_api.IDEData") as mock: + yield mock + + @pytest_asyncio.fixture() async def dashboard() -> DashboardTestHelper: sock, port = bind_unused_port() @@ -80,3 +143,439 @@ async def test_devices_page(dashboard: DashboardTestHelper) -> None: first_device = configured_devices[0] assert first_device["name"] == "pico" assert first_device["configuration"] == "pico.yaml" + + +@pytest.mark.asyncio +async def test_wizard_handler_invalid_input(dashboard: DashboardTestHelper) -> None: + """Test the WizardRequestHandler.post method with invalid inputs.""" + # Test with missing name (should fail with 422) + body_no_name = json.dumps( + { + "name": "", # Empty name + "platform": "ESP32", + "board": "esp32dev", + } + ) + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/wizard", + method="POST", + body=body_no_name, + headers={"Content-Type": "application/json"}, + ) + assert exc_info.value.code == 422 + + # Test with invalid wizard type (should fail with 422) + body_invalid_type = json.dumps( + { + "name": "test_device", + "type": "invalid_type", + "platform": "ESP32", + "board": "esp32dev", + } + ) + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/wizard", + method="POST", + body=body_invalid_type, + headers={"Content-Type": "application/json"}, + ) + assert exc_info.value.code == 422 + + +@pytest.mark.asyncio +async def test_wizard_handler_conflict(dashboard: DashboardTestHelper) -> None: + """Test the WizardRequestHandler.post when config already exists.""" + # Try to create a wizard for existing pico.yaml (should conflict) + body = json.dumps( + { + "name": "pico", # This already exists in fixtures + "platform": "ESP32", + "board": "esp32dev", + } + ) + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/wizard", + method="POST", + body=body, + headers={"Content-Type": "application/json"}, + ) + assert exc_info.value.code == 409 + + +@pytest.mark.asyncio +async def test_download_binary_handler_not_found( + dashboard: DashboardTestHelper, +) -> None: + """Test the DownloadBinaryRequestHandler.get with non-existent config.""" + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/download.bin?configuration=nonexistent.yaml", + method="GET", + ) + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_no_file_param( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get without file parameter.""" + # Mock storage to exist, but still should fail without file param + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(tmp_path / "firmware.bin") + mock_storage_json.load.return_value = mock_storage + + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/download.bin?configuration=pico.yaml", + method="GET", + ) + assert exc_info.value.code == 400 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_with_file( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with existing binary file.""" + # Create a fake binary file + build_dir = tmp_path / ".esphome" / "build" / "test" + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"fake firmware content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(firmware_file) + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=firmware.bin", + method="GET", + ) + assert response.code == 200 + assert response.body == b"fake firmware content" + assert response.headers["Content-Type"] == "application/octet-stream" + assert "attachment" in response.headers["Content-Disposition"] + assert "test_device-firmware.bin" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_compressed( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with compression.""" + # Create a fake binary file + build_dir = tmp_path / ".esphome" / "build" / "test" + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + original_content = b"fake firmware content for compression test" + firmware_file.write_bytes(original_content) + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(firmware_file) + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=firmware.bin&compressed=1", + method="GET", + ) + assert response.code == 200 + # Decompress and verify content + decompressed = gzip.decompress(response.body) + assert decompressed == original_content + assert response.headers["Content-Type"] == "application/octet-stream" + assert "firmware.bin.gz" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_custom_download_name( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with custom download name.""" + # Create a fake binary file + build_dir = tmp_path / ".esphome" / "build" / "test" + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(firmware_file) + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=firmware.bin&download=custom_name.bin", + method="GET", + ) + assert response.code == 200 + assert "custom_name.bin" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_idedata_fallback( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_async_run_system_command: MagicMock, + mock_storage_json: MagicMock, + mock_idedata: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get falling back to idedata for extra images.""" + # Create build directory but no bootloader file initially + build_dir = tmp_path / ".esphome" / "build" / "test" + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"firmware") + + # Create bootloader file that idedata will find + bootloader_file = tmp_path / "bootloader.bin" + bootloader_file.write_bytes(b"bootloader content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(firmware_file) + mock_storage_json.load.return_value = mock_storage + + # Mock idedata response + mock_image = Mock() + mock_image.path = str(bootloader_file) + mock_idedata_instance = Mock() + mock_idedata_instance.extra_flash_images = [mock_image] + mock_idedata.return_value = mock_idedata_instance + + # Mock async_run_system_command to return idedata JSON + mock_async_run_system_command.return_value = (0, '{"extra_flash_images": []}', "") + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=bootloader.bin", + method="GET", + ) + assert response.code == 200 + assert response.body == b"bootloader content" + + +@pytest.mark.asyncio +async def test_edit_request_handler_post_invalid_file( + dashboard: DashboardTestHelper, +) -> None: + """Test the EditRequestHandler.post with non-yaml file.""" + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/edit?configuration=test.txt", + method="POST", + body=b"content", + ) + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +async def test_edit_request_handler_post_existing( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_dashboard_settings: MagicMock, +) -> None: + """Test the EditRequestHandler.post with existing yaml file.""" + # Create a temporary yaml file to edit (don't modify fixtures) + test_file = tmp_path / "test_edit.yaml" + test_file.write_text("esphome:\n name: original\n") + + # Configure the mock settings + mock_dashboard_settings.rel_path.return_value = str(test_file) + mock_dashboard_settings.absolute_config_dir = test_file.parent + + new_content = "esphome:\n name: modified\n" + response = await dashboard.fetch( + "/edit?configuration=test_edit.yaml", + method="POST", + body=new_content.encode(), + ) + assert response.code == 200 + + # Verify the file was actually modified + assert test_file.read_text() == new_content + + +@pytest.mark.asyncio +async def test_unarchive_request_handler( + dashboard: DashboardTestHelper, + mock_archive_storage_path: MagicMock, + mock_dashboard_settings: MagicMock, + tmp_path: Path, +) -> None: + """Test the UnArchiveRequestHandler.post method.""" + # Set up an archived file + archive_dir = Path(mock_archive_storage_path.return_value) + archive_dir.mkdir(parents=True, exist_ok=True) + archived_file = archive_dir / "archived.yaml" + archived_file.write_text("test content") + + # Set up the destination path where the file should be moved + config_dir = tmp_path / "config" + config_dir.mkdir(parents=True, exist_ok=True) + destination_file = config_dir / "archived.yaml" + mock_dashboard_settings.rel_path.return_value = str(destination_file) + + response = await dashboard.fetch( + "/unarchive?configuration=archived.yaml", + method="POST", + body=b"", + ) + assert response.code == 200 + + # Verify the file was actually moved from archive to config + assert not archived_file.exists() # File should be gone from archive + assert destination_file.exists() # File should now be in config + assert destination_file.read_text() == "test content" # Content preserved + + +@pytest.mark.asyncio +async def test_secret_keys_handler_no_file(dashboard: DashboardTestHelper) -> None: + """Test the SecretKeysRequestHandler.get when no secrets file exists.""" + # By default, there's no secrets file in the test fixtures + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/secret_keys", method="GET") + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +async def test_secret_keys_handler_with_file( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_dashboard_settings: MagicMock, +) -> None: + """Test the SecretKeysRequestHandler.get when secrets file exists.""" + # Create a secrets file in temp directory + secrets_file = tmp_path / "secrets.yaml" + secrets_file.write_text( + "wifi_ssid: TestNetwork\nwifi_password: TestPass123\napi_key: test_key\n" + ) + + # Configure mock to return our temp secrets file + # Since the file actually exists, os.path.isfile will return True naturally + mock_dashboard_settings.rel_path.return_value = str(secrets_file) + + response = await dashboard.fetch("/secret_keys", method="GET") + assert response.code == 200 + data = json.loads(response.body.decode()) + assert "wifi_ssid" in data + assert "wifi_password" in data + assert "api_key" in data + + +@pytest.mark.asyncio +async def test_json_config_handler( + dashboard: DashboardTestHelper, + mock_async_run_system_command: MagicMock, +) -> None: + """Test the JsonConfigRequestHandler.get method.""" + # This will actually run the esphome config command on pico.yaml + mock_output = json.dumps( + { + "esphome": {"name": "pico"}, + "esp32": {"board": "esp32dev"}, + } + ) + mock_async_run_system_command.return_value = (0, mock_output, "") + + response = await dashboard.fetch( + "/json-config?configuration=pico.yaml", method="GET" + ) + assert response.code == 200 + data = json.loads(response.body.decode()) + assert data["esphome"]["name"] == "pico" + + +@pytest.mark.asyncio +async def test_json_config_handler_invalid_config( + dashboard: DashboardTestHelper, + mock_async_run_system_command: MagicMock, +) -> None: + """Test the JsonConfigRequestHandler.get with invalid config.""" + # Simulate esphome config command failure + mock_async_run_system_command.return_value = (1, "", "Error: Invalid configuration") + + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/json-config?configuration=pico.yaml", method="GET") + assert exc_info.value.code == 422 + + +@pytest.mark.asyncio +async def test_json_config_handler_not_found(dashboard: DashboardTestHelper) -> None: + """Test the JsonConfigRequestHandler.get with non-existent file.""" + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/json-config?configuration=nonexistent.yaml", method="GET" + ) + assert exc_info.value.code == 404 + + +def test_start_web_server_with_address_port( + tmp_path: Path, + mock_trash_storage_path: MagicMock, + mock_archive_storage_path: MagicMock, +) -> None: + """Test the start_web_server function with address and port.""" + app = Mock() + trash_dir = Path(mock_trash_storage_path.return_value) + archive_dir = Path(mock_archive_storage_path.return_value) + + # Create trash dir to test migration + trash_dir.mkdir() + (trash_dir / "old.yaml").write_text("old") + + web_server.start_web_server(app, None, "127.0.0.1", 6052, str(tmp_path / "config")) + + # The function calls app.listen directly for non-socket mode + app.listen.assert_called_once_with(6052, "127.0.0.1") + + # Verify trash was moved to archive + assert not trash_dir.exists() + assert archive_dir.exists() + assert (archive_dir / "old.yaml").exists() + + +@pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") +@pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") +def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: + """Test the start_web_server function with unix socket.""" + app = Mock() + socket_path = tmp_path / "test.sock" + + # Don't create trash_dir - it doesn't exist, so no migration needed + with ( + patch("tornado.httpserver.HTTPServer") as mock_server_class, + patch("tornado.netutil.bind_unix_socket") as mock_bind, + ): + server = Mock() + mock_server_class.return_value = server + mock_bind.return_value = Mock() + + web_server.start_web_server( + app, str(socket_path), None, None, str(tmp_path / "config") + ) + + mock_server_class.assert_called_once_with(app) + mock_bind.assert_called_once_with(str(socket_path), mode=0o666) + server.add_socket.assert_called_once() From 56e9fd2e38549f39968b5a288b39ae190575772c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Sep 2025 00:04:22 -0500 Subject: [PATCH 1113/1626] [tests] Add upload_program and show_logs test coverage to prevent regressions (#10684) --- tests/unit_tests/test_main.py | 553 +++++++++++++++++++++++++++++++++- 1 file changed, 548 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 2c7236c7f8..41b62ec196 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -4,14 +4,33 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass +from pathlib import Path from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest -from esphome.__main__ import choose_upload_log_host -from esphome.const import CONF_BROKER, CONF_MQTT, CONF_USE_ADDRESS, CONF_WIFI -from esphome.core import CORE +from esphome.__main__ import choose_upload_log_host, show_logs, upload_program +from esphome.const import ( + CONF_BROKER, + CONF_DISABLED, + CONF_ESPHOME, + CONF_MDNS, + CONF_MQTT, + CONF_OTA, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_PORT, + CONF_USE_ADDRESS, + CONF_WIFI, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2040, +) +from esphome.core import CORE, EsphomeError @dataclass @@ -28,7 +47,11 @@ class MockSerialPort: def setup_core( - config: dict[str, Any] | None = None, address: str | None = None + config: dict[str, Any] | None = None, + address: str | None = None, + platform: str | None = None, + tmp_path: Path | None = None, + name: str = "test", ) -> None: """ Helper to set up CORE configuration with optional address. @@ -36,6 +59,9 @@ def setup_core( Args: config (dict[str, Any] | None): The configuration dictionary to set for CORE. If None, an empty dict is used. address (str | None): Optional network address to set in the configuration. If provided, it is set under the wifi config. + platform (str | None): Optional target platform to set in CORE.data. + tmp_path (Path | None): Optional temp path for setting up build paths. + name (str): The name of the device (defaults to "test"). """ if config is None: config = {} @@ -46,6 +72,15 @@ def setup_core( CORE.config = config + if platform is not None: + CORE.data[KEY_CORE] = {} + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform + + if tmp_path is not None: + CORE.config_path = str(tmp_path / f"{name}.yaml") + CORE.name = name + CORE.build_path = str(tmp_path / ".esphome" / "build" / name) + @pytest.fixture def mock_no_serial_ports() -> Generator[Mock]: @@ -54,6 +89,55 @@ def mock_no_serial_ports() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_get_port_type() -> Generator[Mock]: + """Mock get_port_type for testing.""" + with patch("esphome.__main__.get_port_type") as mock: + yield mock + + +@pytest.fixture +def mock_check_permissions() -> Generator[Mock]: + """Mock check_permissions for testing.""" + with patch("esphome.__main__.check_permissions") as mock: + yield mock + + +@pytest.fixture +def mock_run_miniterm() -> Generator[Mock]: + """Mock run_miniterm for testing.""" + with patch("esphome.__main__.run_miniterm") as mock: + yield mock + + +@pytest.fixture +def mock_upload_using_esptool() -> Generator[Mock]: + """Mock upload_using_esptool for testing.""" + with patch("esphome.__main__.upload_using_esptool") as mock: + yield mock + + +@pytest.fixture +def mock_upload_using_platformio() -> Generator[Mock]: + """Mock upload_using_platformio for testing.""" + with patch("esphome.__main__.upload_using_platformio") as mock: + yield mock + + +@pytest.fixture +def mock_run_ota() -> Generator[Mock]: + """Mock espota2.run_ota for testing.""" + with patch("esphome.espota2.run_ota") as mock: + yield mock + + +@pytest.fixture +def mock_is_ip_address() -> Generator[Mock]: + """Mock is_ip_address for testing.""" + with patch("esphome.__main__.is_ip_address") as mock: + yield mock + + @pytest.fixture def mock_serial_ports() -> Generator[Mock]: """Mock get_serial_ports to return test ports.""" @@ -510,3 +594,462 @@ def test_choose_upload_log_host_no_address_with_ota_config() -> None: show_api=False, ) assert result == [] + + +@dataclass +class MockArgs: + """Mock args for testing.""" + + file: str | None = None + upload_speed: int = 460800 + username: str | None = None + password: str | None = None + client_id: str | None = None + topic: str | None = None + + +def test_upload_program_serial_esp32( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Test upload_program with serial port for ESP32.""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_esptool.return_value = 0 + + config = {} + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "/dev/ttyUSB0" + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + mock_upload_using_esptool.assert_called_once() + + +def test_upload_program_serial_esp8266_with_file( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Test upload_program with serial port for ESP8266 with custom file.""" + setup_core(platform=PLATFORM_ESP8266) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_esptool.return_value = 0 + + config = {} + args = MockArgs(file="firmware.bin") + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "/dev/ttyUSB0" + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + mock_upload_using_esptool.assert_called_once_with( + config, "/dev/ttyUSB0", "firmware.bin", 460800 + ) + + +@pytest.mark.parametrize( + "platform,device", + [ + (PLATFORM_RP2040, "/dev/ttyACM0"), + (PLATFORM_BK72XX, "/dev/ttyUSB0"), # LibreTiny platform + ], +) +def test_upload_program_serial_platformio_platforms( + mock_upload_using_platformio: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, + platform: str, + device: str, +) -> None: + """Test upload_program with serial port for platformio platforms (RP2040/LibreTiny).""" + setup_core(platform=platform) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_platformio.return_value = 0 + + config = {} + args = MockArgs() + devices = [device] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == device + mock_check_permissions.assert_called_once_with(device) + mock_upload_using_platformio.assert_called_once_with(config, device) + + +def test_upload_program_serial_upload_failed( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Test upload_program when serial upload fails.""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_esptool.return_value = 1 # Failed + + config = {} + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 1 + assert host is None + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + mock_upload_using_esptool.assert_called_once() + + +def test_upload_program_ota_success( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_PASSWORD: "secret", + } + ] + } + args = MockArgs() + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + expected_firmware = str( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], 3232, "secret", expected_firmware + ) + + +def test_upload_program_ota_with_file_arg( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA and custom file.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ] + } + args = MockArgs(file="custom.bin") + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", "custom.bin") + + +def test_upload_program_ota_no_config( + mock_get_port_type: Mock, +) -> None: + """Test upload_program with OTA but no OTA config.""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "NETWORK" + + config = {} # No OTA config + args = MockArgs() + devices = ["192.168.1.100"] + + with pytest.raises(EsphomeError, match="Cannot upload Over the Air"): + upload_program(config, args, devices) + + +@patch("esphome.mqtt.get_esphome_device_ip") +def test_upload_program_ota_with_mqtt_resolution( + mock_mqtt_get_ip: Mock, + mock_is_ip_address: Mock, + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA using MQTT for address resolution.""" + setup_core(address="device.local", platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.side_effect = ["MQTT", "NETWORK"] + mock_is_ip_address.return_value = False + mock_mqtt_get_ip.return_value = ["192.168.1.100"] + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ], + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + } + args = MockArgs(username="user", password="pass", client_id="client") + devices = ["MQTT"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + expected_firmware = str( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_ota.assert_called_once_with( + [["192.168.1.100"]], 3232, "", expected_firmware + ) + + +@patch("esphome.__main__.importlib.import_module") +def test_upload_program_platform_specific_handler( + mock_import: Mock, + mock_get_port_type: Mock, +) -> None: + """Test upload_program with platform-specific upload handler.""" + setup_core(platform="custom_platform") + mock_get_port_type.return_value = "CUSTOM" + + mock_module = MagicMock() + mock_module.upload_program.return_value = True + mock_import.return_value = mock_module + + config = {} + args = MockArgs() + devices = ["custom_device"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "custom_device" + mock_import.assert_called_once_with("esphome.components.custom_platform") + mock_module.upload_program.assert_called_once_with(config, args, "custom_device") + + +def test_show_logs_serial( + mock_get_port_type: Mock, + mock_check_permissions: Mock, + mock_run_miniterm: Mock, +) -> None: + """Test show_logs with serial port.""" + setup_core(config={"logger": {}}, platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "SERIAL" + mock_run_miniterm.return_value = 0 + + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + mock_run_miniterm.assert_called_once_with(CORE.config, "/dev/ttyUSB0", args) + + +def test_show_logs_no_logger() -> None: + """Test show_logs when logger is not configured.""" + setup_core(config={}, platform=PLATFORM_ESP32) # No logger config + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + with pytest.raises(EsphomeError, match="Logger is not configured"): + show_logs(CORE.config, args, devices) + + +@patch("esphome.components.api.client.run_logs") +def test_show_logs_api( + mock_run_logs: Mock, + mock_get_port_type: Mock, +) -> None: + """Test show_logs with API.""" + setup_core( + config={ + "logger": {}, + "api": {}, + CONF_MDNS: {CONF_DISABLED: False}, + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "NETWORK" + mock_run_logs.return_value = 0 + + args = MockArgs() + devices = ["192.168.1.100", "192.168.1.101"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_run_logs.assert_called_once_with( + CORE.config, ["192.168.1.100", "192.168.1.101"] + ) + + +@patch("esphome.mqtt.get_esphome_device_ip") +@patch("esphome.components.api.client.run_logs") +def test_show_logs_api_with_mqtt_fallback( + mock_run_logs: Mock, + mock_mqtt_get_ip: Mock, + mock_get_port_type: Mock, +) -> None: + """Test show_logs with API using MQTT for address resolution.""" + setup_core( + config={ + "logger": {}, + "api": {}, + CONF_MDNS: {CONF_DISABLED: True}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "NETWORK" + mock_run_logs.return_value = 0 + mock_mqtt_get_ip.return_value = ["192.168.1.200"] + + args = MockArgs(username="user", password="pass", client_id="client") + devices = ["device.local"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.200"]) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_mqtt( + mock_mqtt_show_logs: Mock, + mock_get_port_type: Mock, +) -> None: + """Test show_logs with MQTT.""" + setup_core( + config={ + "logger": {}, + "mqtt": {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "MQTT" + mock_mqtt_show_logs.return_value = 0 + + args = MockArgs( + topic="esphome/logs", + username="user", + password="pass", + client_id="client", + ) + devices = ["MQTT"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" + ) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_network_with_mqtt_only( + mock_mqtt_show_logs: Mock, + mock_get_port_type: Mock, +) -> None: + """Test show_logs with network port but only MQTT configured.""" + setup_core( + config={ + "logger": {}, + "mqtt": {CONF_BROKER: "mqtt.local"}, + # No API configured + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "NETWORK" + mock_mqtt_show_logs.return_value = 0 + + args = MockArgs( + topic="esphome/logs", + username="user", + password="pass", + client_id="client", + ) + devices = ["192.168.1.100"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" + ) + + +def test_show_logs_no_method_configured( + mock_get_port_type: Mock, +) -> None: + """Test show_logs when no remote logging method is configured.""" + setup_core( + config={ + "logger": {}, + # No API or MQTT configured + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "NETWORK" + + args = MockArgs() + devices = ["192.168.1.100"] + + with pytest.raises( + EsphomeError, match="No remote or local logging method configured" + ): + show_logs(CORE.config, args, devices) + + +@patch("esphome.__main__.importlib.import_module") +def test_show_logs_platform_specific_handler( + mock_import: Mock, +) -> None: + """Test show_logs with platform-specific logs handler.""" + setup_core(platform="custom_platform", config={"logger": {}}) + + mock_module = MagicMock() + mock_module.show_logs.return_value = True + mock_import.return_value = mock_module + + config = {"logger": {}} + args = MockArgs() + devices = ["custom_device"] + + result = show_logs(config, args, devices) + + assert result == 0 + mock_import.assert_called_once_with("esphome.components.custom_platform") + mock_module.show_logs.assert_called_once_with(config, args, devices) From 5b702a1efaeef42cad71ff4acf52e0b798aefa90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Sep 2025 16:04:56 -0500 Subject: [PATCH 1114/1626] Add additional dashboard and main tests (#10688) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/dashboard/test_web_server.py | 60 +++++++++ tests/unit_tests/test_main.py | 197 ++++++++++++++++++++++++++++- 2 files changed, 256 insertions(+), 1 deletion(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index a22f4a8b2a..e206090ac0 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -556,6 +556,66 @@ def test_start_web_server_with_address_port( assert (archive_dir / "old.yaml").exists() +@pytest.mark.asyncio +async def test_edit_request_handler_get(dashboard: DashboardTestHelper) -> None: + """Test EditRequestHandler.get method.""" + # Test getting a valid yaml file + response = await dashboard.fetch("/edit?configuration=pico.yaml") + assert response.code == 200 + assert response.headers["content-type"] == "application/yaml" + content = response.body.decode() + assert "esphome:" in content # Verify it's a valid ESPHome config + + # Test getting a non-existent file + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/edit?configuration=nonexistent.yaml") + assert exc_info.value.code == 404 + + # Test getting a non-yaml file + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/edit?configuration=test.txt") + assert exc_info.value.code == 404 + + # Test path traversal attempt + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/edit?configuration=../../../etc/passwd") + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +async def test_archive_request_handler_post( + dashboard: DashboardTestHelper, + mock_archive_storage_path: MagicMock, + mock_ext_storage_path: MagicMock, + tmp_path: Path, +) -> None: + """Test ArchiveRequestHandler.post method.""" + + # Set up temp directories + config_dir = Path(get_fixture_path("conf")) + archive_dir = tmp_path / "archive" + + # Create a test configuration file + test_config = config_dir / "test_archive.yaml" + test_config.write_text("esphome:\n name: test_archive\n") + + # Archive the configuration + response = await dashboard.fetch( + "/archive", + method="POST", + body="configuration=test_archive.yaml", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 + + # Verify file was moved to archive + assert not test_config.exists() + assert (archive_dir / "test_archive.yaml").exists() + assert ( + archive_dir / "test_archive.yaml" + ).read_text() == "esphome:\n name: test_archive\n" + + @pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") @pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 41b62ec196..96ee43a55f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -9,18 +9,27 @@ from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest +from pytest import CaptureFixture -from esphome.__main__ import choose_upload_log_host, show_logs, upload_program +from esphome.__main__ import ( + choose_upload_log_host, + command_rename, + command_wizard, + show_logs, + upload_program, +) from esphome.const import ( CONF_BROKER, CONF_DISABLED, CONF_ESPHOME, CONF_MDNS, CONF_MQTT, + CONF_NAME, CONF_OTA, CONF_PASSWORD, CONF_PLATFORM, CONF_PORT, + CONF_SUBSTITUTIONS, CONF_USE_ADDRESS, CONF_WIFI, KEY_CORE, @@ -170,6 +179,14 @@ def mock_has_mqtt_logging() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_run_external_process() -> Generator[Mock]: + """Mock run_external_process for testing.""" + with patch("esphome.__main__.run_external_process") as mock: + mock.return_value = 0 # Default to success + yield mock + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" result = choose_upload_log_host( @@ -606,6 +623,9 @@ class MockArgs: password: str | None = None client_id: str | None = None topic: str | None = None + configuration: str | None = None + name: str | None = None + dashboard: bool = False def test_upload_program_serial_esp32( @@ -1053,3 +1073,178 @@ def test_show_logs_platform_specific_handler( assert result == 0 mock_import.assert_called_once_with("esphome.components.custom_platform") mock_module.show_logs.assert_called_once_with(config, args, devices) + + +def test_command_wizard(tmp_path: Path) -> None: + """Test command_wizard function.""" + config_file = tmp_path / "test.yaml" + + # Mock wizard.wizard to avoid interactive prompts + with patch("esphome.wizard.wizard") as mock_wizard: + mock_wizard.return_value = 0 + + args = MockArgs(configuration=str(config_file)) + result = command_wizard(args) + + assert result == 0 + mock_wizard.assert_called_once_with(str(config_file)) + + +def test_command_rename_invalid_characters( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """Test command_rename with invalid characters in name.""" + setup_core(tmp_path=tmp_path) + + # Test with invalid character (space) + args = MockArgs(name="invalid name") + result = command_rename(args, {}) + + assert result == 1 + captured = capfd.readouterr() + assert "invalid character" in captured.out.lower() + + +def test_command_rename_complex_yaml( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """Test command_rename with complex YAML that cannot be renamed.""" + config_file = tmp_path / "test.yaml" + config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = str(config_file) + + args = MockArgs(name="newname") + result = command_rename(args, {}) + + assert result == 1 + captured = capfd.readouterr() + assert "complex yaml" in captured.out.lower() + + +def test_command_rename_success( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test successful rename of a simple configuration.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s + +wifi: + ssid: "test" + password: "test1234" +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = str(config_file) + + # Set up CORE.config to avoid ValueError when accessing CORE.address + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + + # Simulate successful validation and upload + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + # Verify new file was created + new_file = tmp_path / "newname.yaml" + assert new_file.exists() + + # Verify old file was removed + assert not config_file.exists() + + # Verify content was updated + content = new_file.read_text() + assert ( + 'name: "newname"' in content + or "name: 'newname'" in content + or "name: newname" in content + ) + + captured = capfd.readouterr() + assert "SUCCESS" in captured.out + + +def test_command_rename_with_substitutions( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename with substitutions in YAML.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +substitutions: + device_name: oldname + +esphome: + name: ${device_name} + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = str(config_file) + + # Set up CORE.config to avoid ValueError when accessing CORE.address + CORE.config = { + CONF_ESPHOME: {CONF_NAME: "oldname"}, + CONF_SUBSTITUTIONS: {"device_name": "oldname"}, + } + + args = MockArgs(name="newname", dashboard=False) + + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + # Verify substitution was updated + new_file = tmp_path / "newname.yaml" + content = new_file.read_text() + assert 'device_name: "newname"' in content + + +def test_command_rename_validation_failure( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename when validation fails.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = str(config_file) + + args = MockArgs(name="newname", dashboard=False) + + # First call for validation fails + mock_run_external_process.return_value = 1 + + result = command_rename(args, {}) + + assert result == 1 + + # Verify new file was created but then removed due to failure + new_file = tmp_path / "newname.yaml" + assert not new_file.exists() + + # Verify old file still exists (not removed on failure) + assert config_file.exists() + + captured = capfd.readouterr() + assert "Rename failed" in captured.out From 46235684b14c176e36b0ab1ad77d6c8d2cd7fdc8 Mon Sep 17 00:00:00 2001 From: Markus <974709+Links2004@users.noreply.github.com> Date: Fri, 12 Sep 2025 23:31:53 +0200 Subject: [PATCH 1115/1626] [core] fix upload to device via MQTT IP lookup (e.g. when mDNS is disable) (#10632) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/__main__.py | 210 +++++++++----- esphome/const.py | 1 + tests/unit_tests/test_main.py | 523 ++++++++++++++++++++++++++-------- 3 files changed, 547 insertions(+), 187 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index bba254436e..0147a82530 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -15,9 +15,11 @@ import argcomplete from esphome import const, writer, yaml_util import esphome.codegen as cg +from esphome.components.mqtt import CONF_DISCOVER_IP from esphome.config import iter_component_configs, read_config, strip_default_ids from esphome.const import ( ALLOWED_NAME_CHARS, + CONF_API, CONF_BAUD_RATE, CONF_BROKER, CONF_DEASSERT_RTS_DTR, @@ -43,6 +45,7 @@ from esphome.const import ( SECRETS_FILES, ) from esphome.core import CORE, EsphomeError, coroutine +from esphome.enum import StrEnum from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log from esphome.types import ConfigType @@ -106,13 +109,15 @@ def choose_prompt(options, purpose: str = None): return options[opt - 1][1] +class Purpose(StrEnum): + UPLOADING = "uploading" + LOGGING = "logging" + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, - show_ota: bool, - show_mqtt: bool, - show_api: bool, - purpose: str | None = None, + purpose: Purpose, ) -> list[str]: # Convert to list for uniform handling defaults = [default] if isinstance(default, str) else default or [] @@ -132,13 +137,30 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": - if CORE.address and ( - (show_ota and "ota" in CORE.config) - or (show_api and "api" in CORE.config) + # ensure IP adresses are used first + if is_ip_address(CORE.address) and ( + (purpose == Purpose.LOGGING and has_api()) + or (purpose == Purpose.UPLOADING and has_ota()) ): resolved.append(CORE.address) - elif show_mqtt and has_mqtt_logging(): - resolved.append("MQTT") + + if purpose == Purpose.LOGGING: + if has_api() and has_mqtt_ip_lookup(): + resolved.append("MQTTIP") + + if has_mqtt_logging(): + resolved.append("MQTT") + + if has_api() and has_non_ip_address(): + resolved.append(CORE.address) + + elif purpose == Purpose.UPLOADING: + if has_ota() and has_mqtt_ip_lookup(): + resolved.append("MQTTIP") + + if has_ota() and has_non_ip_address(): + resolved.append(CORE.address) + else: resolved.append(device) if not resolved: @@ -149,39 +171,111 @@ def choose_upload_log_host( options = [ (f"{port.path} ({port.description})", port.path) for port in get_serial_ports() ] - if (show_ota and "ota" in CORE.config) or (show_api and "api" in CORE.config): - options.append((f"Over The Air ({CORE.address})", CORE.address)) - if show_mqtt and has_mqtt_logging(): - mqtt_config = CORE.config[CONF_MQTT] - options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) + + if purpose == Purpose.LOGGING: + if has_mqtt_logging(): + mqtt_config = CORE.config[CONF_MQTT] + options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) + + if has_api(): + if has_resolvable_address(): + options.append((f"Over The Air ({CORE.address})", CORE.address)) + if has_mqtt_ip_lookup(): + options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + + elif purpose == Purpose.UPLOADING and has_ota(): + if has_resolvable_address(): + options.append((f"Over The Air ({CORE.address})", CORE.address)) + if has_mqtt_ip_lookup(): + options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) if check_default is not None and check_default in [opt[1] for opt in options]: return [check_default] return [choose_prompt(options, purpose=purpose)] -def mqtt_logging_enabled(mqtt_config): +def has_mqtt_logging() -> bool: + """Check if MQTT logging is available.""" + if CONF_MQTT not in CORE.config: + return False + + mqtt_config = CORE.config[CONF_MQTT] + + # enabled by default + if CONF_LOG_TOPIC not in mqtt_config: + return True + log_topic = mqtt_config[CONF_LOG_TOPIC] if log_topic is None: return False + if CONF_TOPIC not in log_topic: return False - return log_topic.get(CONF_LEVEL, None) != "NONE" + + return log_topic[CONF_LEVEL] != "NONE" -def has_mqtt_logging() -> bool: - """Check if MQTT logging is available.""" - return (mqtt_config := CORE.config.get(CONF_MQTT)) and mqtt_logging_enabled( - mqtt_config - ) +def has_mqtt() -> bool: + """Check if MQTT is available.""" + return CONF_MQTT in CORE.config + + +def has_api() -> bool: + """Check if API is available.""" + return CONF_API in CORE.config + + +def has_ota() -> bool: + """Check if OTA is available.""" + return CONF_OTA in CORE.config + + +def has_mqtt_ip_lookup() -> bool: + """Check if MQTT is available and IP lookup is supported.""" + if CONF_MQTT not in CORE.config: + return False + # Default Enabled + if CONF_DISCOVER_IP not in CORE.config[CONF_MQTT]: + return True + return CORE.config[CONF_MQTT][CONF_DISCOVER_IP] + + +def has_mdns() -> bool: + """Check if MDNS is available.""" + return CONF_MDNS not in CORE.config or not CORE.config[CONF_MDNS][CONF_DISABLED] + + +def has_non_ip_address() -> bool: + """Check if CORE.address is set and is not an IP address.""" + return CORE.address is not None and not is_ip_address(CORE.address) + + +def has_ip_address() -> bool: + """Check if CORE.address is a valid IP address.""" + return CORE.address is not None and is_ip_address(CORE.address) + + +def has_resolvable_address() -> bool: + """Check if CORE.address is resolvable (via mDNS or is an IP address).""" + return has_mdns() or has_ip_address() + + +def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str): + from esphome import mqtt + + return mqtt.get_esphome_device_ip(config, username, password, client_id) + + +_PORT_TO_PORT_TYPE = { + "MQTT": "MQTT", + "MQTTIP": "MQTTIP", +} def get_port_type(port: str) -> str: if port.startswith("/") or port.startswith("COM"): return "SERIAL" - if port == "MQTT": - return "MQTT" - return "NETWORK" + return _PORT_TO_PORT_TYPE.get(port, "NETWORK") def run_miniterm(config: ConfigType, port: str, args) -> int: @@ -439,23 +533,9 @@ def upload_program( password = ota_conf.get(CONF_PASSWORD, "") binary = args.file if getattr(args, "file", None) is not None else CORE.firmware_bin - # Check if we should use MQTT for address resolution - # This happens when no device was specified, or the current host is "MQTT"/"OTA" - if ( - CONF_MQTT in config # pylint: disable=too-many-boolean-expressions - and (not devices or host in ("MQTT", "OTA")) - and ( - ((config[CONF_MDNS][CONF_DISABLED]) and not is_ip_address(CORE.address)) - or get_port_type(host) == "MQTT" - ) - ): - from esphome import mqtt - - devices = [ - mqtt.get_esphome_device_ip( - config, args.username, args.password, args.client_id - ) - ] + # MQTT address resolution + if get_port_type(host) in ("MQTT", "MQTTIP"): + devices = mqtt_get_ip(config, args.username, args.password, args.client_id) return espota2.run_ota(devices, remote_port, password, binary) @@ -476,20 +556,28 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int if get_port_type(port) == "SERIAL": check_permissions(port) return run_miniterm(config, port, args) - if get_port_type(port) == "NETWORK" and "api" in config: - addresses_to_use = devices - if config[CONF_MDNS][CONF_DISABLED] and CONF_MQTT in config: - from esphome import mqtt - mqtt_address = mqtt.get_esphome_device_ip( + port_type = get_port_type(port) + + # Check if we should use API for logging + if has_api(): + addresses_to_use: list[str] | None = None + + if port_type == "NETWORK" and (has_mdns() or is_ip_address(port)): + addresses_to_use = devices + elif port_type in ("NETWORK", "MQTT", "MQTTIP") and has_mqtt_ip_lookup(): + # Only use MQTT IP lookup if the first condition didn't match + # (for MQTT/MQTTIP types, or for NETWORK when mdns/ip check fails) + addresses_to_use = mqtt_get_ip( config, args.username, args.password, args.client_id - )[0] - addresses_to_use = [mqtt_address] + ) - from esphome.components.api.client import run_logs + if addresses_to_use is not None: + from esphome.components.api.client import run_logs - return run_logs(config, addresses_to_use) - if get_port_type(port) in ("NETWORK", "MQTT") and "mqtt" in config: + return run_logs(config, addresses_to_use) + + if port_type in ("NETWORK", "MQTT") and has_mqtt_logging(): from esphome import mqtt return mqtt.show_logs( @@ -555,10 +643,7 @@ def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: devices = choose_upload_log_host( default=args.device, check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, - purpose="uploading", + purpose=Purpose.UPLOADING, ) exit_code, _ = upload_program(config, args, devices) @@ -583,10 +668,7 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: devices = choose_upload_log_host( default=args.device, check_default=None, - show_ota=False, - show_mqtt=True, - show_api=True, - purpose="logging", + purpose=Purpose.LOGGING, ) return show_logs(config, args, devices) @@ -612,10 +694,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: devices = choose_upload_log_host( default=args.device, check_default=None, - show_ota=True, - show_mqtt=False, - show_api=True, - purpose="uploading", + purpose=Purpose.UPLOADING, ) exit_code, successful_device = upload_program(config, args, devices) @@ -632,10 +711,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: devices = choose_upload_log_host( default=successful_device, check_default=successful_device, - show_ota=False, - show_mqtt=True, - show_api=True, - purpose="logging", + purpose=Purpose.LOGGING, ) return show_logs(config, args, devices) diff --git a/esphome/const.py b/esphome/const.py index a77f98c292..4655931823 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -114,6 +114,7 @@ CONF_AND = "and" CONF_ANGLE = "angle" CONF_ANY = "any" CONF_AP = "ap" +CONF_API = "api" CONF_APPARENT_POWER = "apparent_power" CONF_ARDUINO_VERSION = "arduino_version" CONF_AREA = "area" diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 96ee43a55f..bfebb44545 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -12,16 +12,28 @@ import pytest from pytest import CaptureFixture from esphome.__main__ import ( + Purpose, choose_upload_log_host, command_rename, command_wizard, + get_port_type, + has_ip_address, + has_mqtt, + has_mqtt_ip_lookup, + has_mqtt_logging, + has_non_ip_address, + has_resolvable_address, + mqtt_get_ip, show_logs, upload_program, ) from esphome.const import ( + CONF_API, CONF_BROKER, CONF_DISABLED, CONF_ESPHOME, + CONF_LEVEL, + CONF_LOG_TOPIC, CONF_MDNS, CONF_MQTT, CONF_NAME, @@ -30,6 +42,7 @@ from esphome.const import ( CONF_PLATFORM, CONF_PORT, CONF_SUBSTITUTIONS, + CONF_TOPIC, CONF_USE_ADDRESS, CONF_WIFI, KEY_CORE, @@ -147,6 +160,13 @@ def mock_is_ip_address() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_mqtt_get_ip() -> Generator[Mock]: + """Mock mqtt_get_ip for testing.""" + with patch("esphome.__main__.mqtt_get_ip") as mock: + yield mock + + @pytest.fixture def mock_serial_ports() -> Generator[Mock]: """Mock get_serial_ports to return test ports.""" @@ -189,62 +209,56 @@ def mock_run_external_process() -> Generator[Mock]: def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" + setup_core() result = choose_upload_log_host( default="192.168.1.100", check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] def test_choose_upload_log_host_with_list_default() -> None: """Test with a list of default devices.""" + setup_core() result = choose_upload_log_host( default=["192.168.1.100", "192.168.1.101"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100", "192.168.1.101"] def test_choose_upload_log_host_with_multiple_ip_addresses() -> None: """Test with multiple IP addresses as defaults.""" + setup_core() result = choose_upload_log_host( default=["1.2.3.4", "4.5.5.6"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.LOGGING, ) assert result == ["1.2.3.4", "4.5.5.6"] def test_choose_upload_log_host_with_mixed_hostnames_and_ips() -> None: """Test with a mix of hostnames and IP addresses.""" + setup_core() result = choose_upload_log_host( default=["host.one", "host.one.local", "1.2.3.4"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["host.one", "host.one.local", "1.2.3.4"] def test_choose_upload_log_host_with_ota_list() -> None: """Test with OTA as the only item in the list.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") result = choose_upload_log_host( default=["OTA"], check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] @@ -252,16 +266,27 @@ def test_choose_upload_log_host_with_ota_list() -> None: @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None: """Test with OTA list falling back to MQTT when no address.""" - setup_core() + setup_core(config={CONF_OTA: {}, "mqtt": {}}) result = choose_upload_log_host( default=["OTA"], check_default=None, - show_ota=False, - show_mqtt=True, - show_api=False, + purpose=Purpose.UPLOADING, ) - assert result == ["MQTT"] + assert result == ["MQTTIP"] + + +@pytest.mark.usefixtures("mock_has_mqtt_logging") +def test_choose_upload_log_host_with_ota_list_mqtt_fallback_logging() -> None: + """Test with OTA list with API and MQTT when no address.""" + setup_core(config={CONF_API: {}, "mqtt": {}}) + + result = choose_upload_log_host( + default=["OTA"], + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["MQTTIP", "MQTT"] @pytest.mark.usefixtures("mock_no_serial_ports") @@ -269,12 +294,11 @@ def test_choose_upload_log_host_with_serial_device_no_ports( caplog: pytest.LogCaptureFixture, ) -> None: """Test SERIAL device when no serial ports are found.""" + setup_core() result = choose_upload_log_host( default="SERIAL", check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == [] assert "No serial ports found, skipping SERIAL device" in caplog.text @@ -285,13 +309,11 @@ def test_choose_upload_log_host_with_serial_device_with_ports( mock_choose_prompt: Mock, ) -> None: """Test SERIAL device when serial ports are available.""" + setup_core() result = choose_upload_log_host( default="SERIAL", check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, - purpose="testing", + purpose=Purpose.UPLOADING, ) assert result == ["/dev/ttyUSB0"] mock_choose_prompt.assert_called_once_with( @@ -299,34 +321,42 @@ def test_choose_upload_log_host_with_serial_device_with_ports( ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), ("/dev/ttyUSB1 (Another USB Serial)", "/dev/ttyUSB1"), ], - purpose="testing", + purpose=Purpose.UPLOADING, ) def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: """Test OTA device when OTA is configured.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: - """Test OTA device when API is configured.""" - setup_core(config={"api": {}}, address="192.168.1.100") + """Test OTA device when API is configured (no upload without OTA in config).""" + setup_core(config={CONF_API: {}}, address="192.168.1.100") result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=False, - show_mqtt=False, - show_api=True, + purpose=Purpose.UPLOADING, + ) + assert result == [] + + +def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> None: + """Test OTA device when API is configured.""" + setup_core(config={CONF_API: {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, ) assert result == ["192.168.1.100"] @@ -334,14 +364,12 @@ def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: """Test OTA device fallback to MQTT when no OTA/API config.""" - setup_core() + setup_core(config={"mqtt": {}}) result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=False, - show_mqtt=True, - show_api=False, + purpose=Purpose.LOGGING, ) assert result == ["MQTT"] @@ -354,9 +382,7 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None: result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=True, - show_mqtt=True, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == [] @@ -364,7 +390,7 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None: @pytest.mark.usefixtures("mock_choose_prompt") def test_choose_upload_log_host_multiple_devices() -> None: """Test with multiple devices including special identifiers.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] @@ -372,9 +398,7 @@ def test_choose_upload_log_host_multiple_devices() -> None: result = choose_upload_log_host( default=["192.168.1.50", "OTA", "SERIAL"], check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.50", "192.168.1.100", "/dev/ttyUSB0"] @@ -393,22 +417,19 @@ def test_choose_upload_log_host_no_defaults_with_serial_ports( result = choose_upload_log_host( default=None, check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, - purpose="uploading", + purpose=Purpose.UPLOADING, ) assert result == ["/dev/ttyUSB0"] mock_choose_prompt.assert_called_once_with( [("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0")], - purpose="uploading", + purpose=Purpose.UPLOADING, ) @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_no_defaults_with_ota() -> None: """Test interactive mode with OTA option.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") with patch( "esphome.__main__.choose_prompt", return_value="192.168.1.100" @@ -416,21 +437,19 @@ def test_choose_upload_log_host_no_defaults_with_ota() -> None: result = choose_upload_log_host( default=None, check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] mock_prompt.assert_called_once_with( [("Over The Air (192.168.1.100)", "192.168.1.100")], - purpose=None, + purpose=Purpose.UPLOADING, ) @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_no_defaults_with_api() -> None: """Test interactive mode with API option.""" - setup_core(config={"api": {}}, address="192.168.1.100") + setup_core(config={CONF_API: {}}, address="192.168.1.100") with patch( "esphome.__main__.choose_prompt", return_value="192.168.1.100" @@ -438,14 +457,12 @@ def test_choose_upload_log_host_no_defaults_with_api() -> None: result = choose_upload_log_host( default=None, check_default=None, - show_ota=False, - show_mqtt=False, - show_api=True, + purpose=Purpose.LOGGING, ) assert result == ["192.168.1.100"] mock_prompt.assert_called_once_with( [("Over The Air (192.168.1.100)", "192.168.1.100")], - purpose=None, + purpose=Purpose.LOGGING, ) @@ -458,14 +475,12 @@ def test_choose_upload_log_host_no_defaults_with_mqtt() -> None: result = choose_upload_log_host( default=None, check_default=None, - show_ota=False, - show_mqtt=True, - show_api=False, + purpose=Purpose.LOGGING, ) assert result == ["MQTT"] mock_prompt.assert_called_once_with( [("MQTT (mqtt.local)", "MQTT")], - purpose=None, + purpose=Purpose.LOGGING, ) @@ -475,7 +490,7 @@ def test_choose_upload_log_host_no_defaults_with_all_options( ) -> None: """Test interactive mode with all options available.""" setup_core( - config={"ota": {}, "api": {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, + config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, address="192.168.1.100", ) @@ -485,32 +500,59 @@ def test_choose_upload_log_host_no_defaults_with_all_options( result = choose_upload_log_host( default=None, check_default=None, - show_ota=True, - show_mqtt=True, - show_api=True, - purpose="testing", + purpose=Purpose.UPLOADING, ) assert result == ["/dev/ttyUSB0"] expected_options = [ ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), ("Over The Air (192.168.1.100)", "192.168.1.100"), - ("MQTT (mqtt.local)", "MQTT"), + ("Over The Air (MQTT IP lookup)", "MQTTIP"), ] - mock_choose_prompt.assert_called_once_with(expected_options, purpose="testing") + mock_choose_prompt.assert_called_once_with( + expected_options, purpose=Purpose.UPLOADING + ) + + +def test_choose_upload_log_host_no_defaults_with_all_options_logging( + mock_choose_prompt: Mock, +) -> None: + """Test interactive mode with all options available.""" + setup_core( + config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, + address="192.168.1.100", + ) + + mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] + + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): + result = choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["/dev/ttyUSB0"] + + expected_options = [ + ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), + ("MQTT (mqtt.local)", "MQTT"), + ("Over The Air (192.168.1.100)", "192.168.1.100"), + ("Over The Air (MQTT IP lookup)", "MQTTIP"), + ] + mock_choose_prompt.assert_called_once_with( + expected_options, purpose=Purpose.LOGGING + ) @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_check_default_matches() -> None: """Test when check_default matches an available option.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") result = choose_upload_log_host( default=None, check_default="192.168.1.100", - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] @@ -526,9 +568,7 @@ def test_choose_upload_log_host_check_default_no_match() -> None: result = choose_upload_log_host( default=None, check_default="192.168.1.100", - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["fallback"] mock_prompt.assert_called_once() @@ -537,13 +577,12 @@ def test_choose_upload_log_host_check_default_no_match() -> None: @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_empty_defaults_list() -> None: """Test with an empty list as default.""" + setup_core() with patch("esphome.__main__.choose_prompt", return_value="chosen") as mock_prompt: result = choose_upload_log_host( default=[], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["chosen"] mock_prompt.assert_called_once() @@ -559,9 +598,7 @@ def test_choose_upload_log_host_all_devices_unresolved( result = choose_upload_log_host( default=["SERIAL", "OTA"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == [] assert ( @@ -577,38 +614,132 @@ def test_choose_upload_log_host_mixed_resolved_unresolved() -> None: result = choose_upload_log_host( default=["192.168.1.50", "SERIAL", "OTA"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.50"] def test_choose_upload_log_host_ota_both_conditions() -> None: """Test OTA device when both OTA and API are configured and enabled.""" - setup_core(config={"ota": {}, "api": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}, CONF_API: {}}, address="192.168.1.100") result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=True, - show_mqtt=False, - show_api=True, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_ota_ip_all_options() -> None: + """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" + setup_core( + config={ + CONF_OTA: {}, + CONF_API: {}, + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + }, + address="192.168.1.100", + ) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) + assert result == ["192.168.1.100", "MQTTIP"] + + +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_ota_local_all_options() -> None: + """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" + setup_core( + config={ + CONF_OTA: {}, + CONF_API: {}, + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + }, + address="test.local", + ) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) + assert result == ["MQTTIP", "test.local"] + + +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_ota_ip_all_options_logging() -> None: + """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" + setup_core( + config={ + CONF_OTA: {}, + CONF_API: {}, + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + }, + address="192.168.1.100", + ) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["192.168.1.100", "MQTTIP", "MQTT"] + + +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_ota_local_all_options_logging() -> None: + """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" + setup_core( + config={ + CONF_OTA: {}, + CONF_API: {}, + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + }, + address="test.local", + ) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["MQTTIP", "MQTT", "test.local"] + + @pytest.mark.usefixtures("mock_no_mqtt_logging") def test_choose_upload_log_host_no_address_with_ota_config() -> None: """Test OTA device when OTA is configured but no address is set.""" - setup_core(config={"ota": {}}) + setup_core(config={CONF_OTA: {}}) result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == [] @@ -806,18 +937,15 @@ def test_upload_program_ota_no_config( upload_program(config, args, devices) -@patch("esphome.mqtt.get_esphome_device_ip") def test_upload_program_ota_with_mqtt_resolution( mock_mqtt_get_ip: Mock, mock_is_ip_address: Mock, mock_run_ota: Mock, - mock_get_port_type: Mock, tmp_path: Path, ) -> None: """Test upload_program with OTA using MQTT for address resolution.""" setup_core(address="device.local", platform=PLATFORM_ESP32, tmp_path=tmp_path) - mock_get_port_type.side_effect = ["MQTT", "NETWORK"] mock_is_ip_address.return_value = False mock_mqtt_get_ip.return_value = ["192.168.1.100"] mock_run_ota.return_value = (0, "192.168.1.100") @@ -847,9 +975,7 @@ def test_upload_program_ota_with_mqtt_resolution( expected_firmware = str( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) - mock_run_ota.assert_called_once_with( - [["192.168.1.100"]], 3232, "", expected_firmware - ) + mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", expected_firmware) @patch("esphome.__main__.importlib.import_module") @@ -910,18 +1036,16 @@ def test_show_logs_no_logger() -> None: @patch("esphome.components.api.client.run_logs") def test_show_logs_api( mock_run_logs: Mock, - mock_get_port_type: Mock, ) -> None: """Test show_logs with API.""" setup_core( config={ "logger": {}, - "api": {}, + CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}, }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "NETWORK" mock_run_logs.return_value = 0 args = MockArgs() @@ -935,24 +1059,21 @@ def test_show_logs_api( ) -@patch("esphome.mqtt.get_esphome_device_ip") @patch("esphome.components.api.client.run_logs") def test_show_logs_api_with_mqtt_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, - mock_get_port_type: Mock, ) -> None: """Test show_logs with API using MQTT for address resolution.""" setup_core( config={ "logger": {}, - "api": {}, + CONF_API: {}, CONF_MDNS: {CONF_DISABLED: True}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}, }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "NETWORK" mock_run_logs.return_value = 0 mock_mqtt_get_ip.return_value = ["192.168.1.200"] @@ -969,7 +1090,6 @@ def test_show_logs_api_with_mqtt_fallback( @patch("esphome.mqtt.show_logs") def test_show_logs_mqtt( mock_mqtt_show_logs: Mock, - mock_get_port_type: Mock, ) -> None: """Test show_logs with MQTT.""" setup_core( @@ -979,7 +1099,6 @@ def test_show_logs_mqtt( }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "MQTT" mock_mqtt_show_logs.return_value = 0 args = MockArgs( @@ -1001,7 +1120,6 @@ def test_show_logs_mqtt( @patch("esphome.mqtt.show_logs") def test_show_logs_network_with_mqtt_only( mock_mqtt_show_logs: Mock, - mock_get_port_type: Mock, ) -> None: """Test show_logs with network port but only MQTT configured.""" setup_core( @@ -1012,7 +1130,6 @@ def test_show_logs_network_with_mqtt_only( }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "NETWORK" mock_mqtt_show_logs.return_value = 0 args = MockArgs( @@ -1031,9 +1148,7 @@ def test_show_logs_network_with_mqtt_only( ) -def test_show_logs_no_method_configured( - mock_get_port_type: Mock, -) -> None: +def test_show_logs_no_method_configured() -> None: """Test show_logs when no remote logging method is configured.""" setup_core( config={ @@ -1042,7 +1157,6 @@ def test_show_logs_no_method_configured( }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "NETWORK" args = MockArgs() devices = ["192.168.1.100"] @@ -1075,6 +1189,175 @@ def test_show_logs_platform_specific_handler( mock_module.show_logs.assert_called_once_with(config, args, devices) +def test_has_mqtt_logging_no_log_topic() -> None: + """Test has_mqtt_logging returns True when CONF_LOG_TOPIC is not in mqtt_config.""" + + # Setup MQTT config without CONF_LOG_TOPIC (defaults to enabled - this is the missing test case) + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) + assert has_mqtt_logging() is True + + # Setup MQTT config with CONF_LOG_TOPIC set to None (explicitly disabled) + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local", CONF_LOG_TOPIC: None}}) + assert has_mqtt_logging() is False + + # Setup MQTT config with CONF_LOG_TOPIC set with topic and level (explicitly enabled) + setup_core( + config={ + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + CONF_LOG_TOPIC: {CONF_TOPIC: "esphome/logs", CONF_LEVEL: "DEBUG"}, + } + } + ) + assert has_mqtt_logging() is True + + # Setup MQTT config with CONF_LOG_TOPIC set but level is NONE (disabled) + setup_core( + config={ + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + CONF_LOG_TOPIC: {CONF_TOPIC: "esphome/logs", CONF_LEVEL: "NONE"}, + } + } + ) + assert has_mqtt_logging() is False + + # Setup without MQTT config at all + setup_core(config={}) + assert has_mqtt_logging() is False + + +def test_has_mqtt() -> None: + """Test has_mqtt function.""" + + # Test with MQTT configured + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) + assert has_mqtt() is True + + # Test without MQTT configured + setup_core(config={}) + assert has_mqtt() is False + + # Test with other components but no MQTT + setup_core(config={CONF_API: {}, CONF_OTA: {}}) + assert has_mqtt() is False + + +def test_get_port_type() -> None: + """Test get_port_type function.""" + + assert get_port_type("/dev/ttyUSB0") == "SERIAL" + assert get_port_type("/dev/ttyACM0") == "SERIAL" + assert get_port_type("COM1") == "SERIAL" + assert get_port_type("COM10") == "SERIAL" + + assert get_port_type("MQTT") == "MQTT" + assert get_port_type("MQTTIP") == "MQTTIP" + + assert get_port_type("192.168.1.100") == "NETWORK" + assert get_port_type("esphome-device.local") == "NETWORK" + assert get_port_type("10.0.0.1") == "NETWORK" + + +def test_has_mqtt_ip_lookup() -> None: + """Test has_mqtt_ip_lookup function.""" + + CONF_DISCOVER_IP = "discover_ip" + + setup_core(config={}) + assert has_mqtt_ip_lookup() is False + + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) + assert has_mqtt_ip_lookup() is True + + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local", CONF_DISCOVER_IP: True}}) + assert has_mqtt_ip_lookup() is True + + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local", CONF_DISCOVER_IP: False}}) + assert has_mqtt_ip_lookup() is False + + +def test_has_non_ip_address() -> None: + """Test has_non_ip_address function.""" + + setup_core(address=None) + assert has_non_ip_address() is False + + setup_core(address="192.168.1.100") + assert has_non_ip_address() is False + + setup_core(address="10.0.0.1") + assert has_non_ip_address() is False + + setup_core(address="esphome-device.local") + assert has_non_ip_address() is True + + setup_core(address="my-device") + assert has_non_ip_address() is True + + +def test_has_ip_address() -> None: + """Test has_ip_address function.""" + + setup_core(address=None) + assert has_ip_address() is False + + setup_core(address="192.168.1.100") + assert has_ip_address() is True + + setup_core(address="10.0.0.1") + assert has_ip_address() is True + + setup_core(address="esphome-device.local") + assert has_ip_address() is False + + setup_core(address="my-device") + assert has_ip_address() is False + + +def test_mqtt_get_ip() -> None: + """Test mqtt_get_ip function.""" + config = {CONF_MQTT: {CONF_BROKER: "mqtt.local"}} + + with patch("esphome.mqtt.get_esphome_device_ip") as mock_get_ip: + mock_get_ip.return_value = ["192.168.1.100", "192.168.1.101"] + + result = mqtt_get_ip(config, "user", "pass", "client-id") + + assert result == ["192.168.1.100", "192.168.1.101"] + mock_get_ip.assert_called_once_with(config, "user", "pass", "client-id") + + +def test_has_resolvable_address() -> None: + """Test has_resolvable_address function.""" + + # Test with mDNS enabled and hostname address + setup_core(config={}, address="esphome-device.local") + assert has_resolvable_address() is True + + # Test with mDNS disabled and hostname address + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + assert has_resolvable_address() is False + + # Test with IP address (mDNS doesn't matter) + setup_core(config={}, address="192.168.1.100") + assert has_resolvable_address() is True + + # Test with IP address and mDNS disabled + setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address="192.168.1.100") + assert has_resolvable_address() is True + + # Test with no address but mDNS enabled (can still resolve mDNS names) + setup_core(config={}, address=None) + assert has_resolvable_address() is True + + # Test with no address and mDNS disabled + setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None) + assert has_resolvable_address() is False + + def test_command_wizard(tmp_path: Path) -> None: """Test command_wizard function.""" config_file = tmp_path / "test.yaml" From 5b5e5c213ce1b467919a7a4705a075331da40b56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 23:25:19 +0000 Subject: [PATCH 1116/1626] Bump aioesphomeapi from 40.1.0 to 40.2.0 (#10703) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0b9a37005d..4736e2a024 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==40.1.0 +aioesphomeapi==40.2.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 646f4e66bea1e85c2e06267fe68bb8e8dea979c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:45:22 -0500 Subject: [PATCH 1117/1626] [ethernet] Fix permanent component failure from undocumented ESP_FAIL in IPv6 setup (#10708) --- .../ethernet/ethernet_component.cpp | 46 ++++++++++++++++++- .../components/ethernet/ethernet_component.h | 2 + 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 844a30bd8b..cd73333222 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -300,6 +300,7 @@ void EthernetComponent::loop() { this->state_ = EthernetComponentState::CONNECTING; this->start_connect_(); } else { + this->finish_connect_(); // When connected and stable, disable the loop to save CPU cycles this->disable_loop(); } @@ -486,10 +487,35 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_ } #endif /* USE_NETWORK_IPV6 */ +void EthernetComponent::finish_connect_() { +#if USE_NETWORK_IPV6 + // Retry IPv6 link-local setup if it failed during initial connect + // This handles the case where min_ipv6_addr_count is NOT set (or is 0), + // allowing us to reach CONNECTED state with just IPv4. + // If IPv6 setup failed in start_connect_() because the interface wasn't ready: + // - Bootup timing issues (#10281) + // - Cable unplugged/network interruption (#10705) + // We can now retry since we're in CONNECTED state and the interface is definitely up. + if (!this->ipv6_setup_done_) { + esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); + if (err == ESP_OK) { + ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); + } + // Always set the flag to prevent continuous retries + // If IPv6 setup fails here with the interface up and stable, it's + // likely a persistent issue (IPv6 disabled at router, hardware + // limitation, etc.) that won't be resolved by further retries. + // The device continues to work with IPv4. + this->ipv6_setup_done_ = true; + } +#endif /* USE_NETWORK_IPV6 */ +} + void EthernetComponent::start_connect_() { global_eth_component->got_ipv4_address_ = false; #if USE_NETWORK_IPV6 global_eth_component->ipv6_count_ = 0; + this->ipv6_setup_done_ = false; #endif /* USE_NETWORK_IPV6 */ this->connect_begin_ = millis(); this->status_set_warning(LOG_STR("waiting for IP configuration")); @@ -545,9 +571,27 @@ void EthernetComponent::start_connect_() { } } #if USE_NETWORK_IPV6 + // Attempt to create IPv6 link-local address + // We MUST attempt this here, not just in finish_connect_(), because with + // min_ipv6_addr_count set, the component won't reach CONNECTED state without IPv6. + // However, this may fail with ESP_FAIL if the interface is not up yet: + // - At bootup when link isn't ready (#10281) + // - After disconnection/cable unplugged (#10705) + // We'll retry in finish_connect_() if it fails here. err = esp_netif_create_ip6_linklocal(this->eth_netif_); if (err != ESP_OK) { - ESPHL_ERROR_CHECK(err, "Enable IPv6 link local failed"); + if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { + // This is a programming error, not a transient failure + ESPHL_ERROR_CHECK(err, "esp_netif_create_ip6_linklocal invalid parameters"); + } else { + // ESP_FAIL means the interface isn't up yet + // This is expected and non-fatal, happens in multiple scenarios: + // - During reconnection after network interruptions (#10705) + // - At bootup when the link isn't ready yet (#10281) + // We'll retry once we reach CONNECTED state and the interface is up + ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed: %s", esp_err_to_name(err)); + // Don't mark component as failed - this is a transient error + } } #endif /* USE_NETWORK_IPV6 */ diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index bdcda6afb4..3d2713ee5c 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -102,6 +102,7 @@ class EthernetComponent : public Component { #endif /* LWIP_IPV6 */ void start_connect_(); + void finish_connect_(); void dump_connect_params_(); /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. void ksz8081_set_clock_reference_(esp_eth_mac_t *mac); @@ -144,6 +145,7 @@ class EthernetComponent : public Component { bool got_ipv4_address_{false}; #if LWIP_IPV6 uint8_t ipv6_count_{0}; + bool ipv6_setup_done_{false}; #endif /* LWIP_IPV6 */ // Pointers at the end (naturally aligned) From c60149477969e5581f81927966f1dbd601cc70db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:50:38 -0500 Subject: [PATCH 1118/1626] [core] Optimize MAC address formatting to eliminate sprintf dependency (#10713) --- .../ethernet/ethernet_component.cpp | 4 ++- esphome/core/helpers.cpp | 15 +++++----- esphome/core/helpers.h | 29 +++++++++++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index cd73333222..a48fd27383 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -682,7 +682,9 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { std::string EthernetComponent::get_eth_mac_address_pretty() { uint8_t mac[6]; get_eth_mac_address_raw(mac); - return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); } eth_duplex_t EthernetComponent::get_duplex_mode() { diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 43d6f1153c..f1560711ef 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -255,23 +255,22 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { } std::string format_mac_address_pretty(const uint8_t *mac) { - return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); } -static char format_hex_char(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } std::string format_hex(const uint8_t *data, size_t length) { std::string ret; ret.resize(length * 2); for (size_t i = 0; i < length; i++) { - ret[2 * i] = format_hex_char((data[i] & 0xF0) >> 4); + ret[2 * i] = format_hex_char(data[i] >> 4); ret[2 * i + 1] = format_hex_char(data[i] & 0x0F); } return ret; } std::string format_hex(const std::vector &data) { return format_hex(data.data(), data.size()); } -static char format_hex_pretty_char(uint8_t v) { return v >= 10 ? 'A' + (v - 10) : '0' + v; } - // Shared implementation for uint8_t and string hex formatting static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, char separator, bool show_length) { if (data == nullptr || length == 0) @@ -280,7 +279,7 @@ static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, c uint8_t multiple = separator ? 3 : 2; // 3 if separator is not \0, 2 otherwise ret.resize(multiple * length - (separator ? 1 : 0)); for (size_t i = 0; i < length; i++) { - ret[multiple * i] = format_hex_pretty_char((data[i] & 0xF0) >> 4); + ret[multiple * i] = format_hex_pretty_char(data[i] >> 4); ret[multiple * i + 1] = format_hex_pretty_char(data[i] & 0x0F); if (separator && i != length - 1) ret[multiple * i + 2] = separator; @@ -591,7 +590,9 @@ bool HighFrequencyLoopRequester::is_high_frequency() { return num_requests > 0; std::string get_mac_address() { uint8_t mac[6]; get_mac_address_raw(mac); - return str_snprintf("%02x%02x%02x%02x%02x%02x", 12, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[13]; + format_mac_addr_lower_no_sep(mac, buf); + return std::string(buf); } std::string get_mac_address_pretty() { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a6741925d0..21aa159b25 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -380,6 +380,35 @@ template::value, int> = 0> optional< return parse_hex(str.c_str(), str.length()); } +/// Convert a nibble (0-15) to lowercase hex char +inline char format_hex_char(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } + +/// Convert a nibble (0-15) to uppercase hex char (used for pretty printing) +/// This always uses uppercase (A-F) for pretty/human-readable output +inline char format_hex_pretty_char(uint8_t v) { return v >= 10 ? 'A' + (v - 10) : '0' + v; } + +/// Format MAC address as XX:XX:XX:XX:XX:XX (uppercase) +inline void format_mac_addr_upper(const uint8_t *mac, char *output) { + for (size_t i = 0; i < 6; i++) { + uint8_t byte = mac[i]; + output[i * 3] = format_hex_pretty_char(byte >> 4); + output[i * 3 + 1] = format_hex_pretty_char(byte & 0x0F); + if (i < 5) + output[i * 3 + 2] = ':'; + } + output[17] = '\0'; +} + +/// Format MAC address as xxxxxxxxxxxxxx (lowercase, no separators) +inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { + for (size_t i = 0; i < 6; i++) { + uint8_t byte = mac[i]; + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); + } + output[12] = '\0'; +} + /// Format the six-byte array \p mac into a MAC address. std::string format_mac_address_pretty(const uint8_t mac[6]); /// Format the byte array \p data of length \p len in lowercased hex. From ae158179bd161127070a48fd0cc0bc759518efed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:52:19 -0500 Subject: [PATCH 1119/1626] [api] Revert unneeded GetTime bidirectional support added in #9790 (#10702) --- esphome/components/api/api.proto | 7 ++----- esphome/components/api/api_connection.cpp | 6 ------ esphome/components/api/api_connection.h | 1 - esphome/components/api/api_pb2.cpp | 8 -------- esphome/components/api/api_pb2.h | 4 ---- esphome/components/api/api_pb2_dump.cpp | 8 +------- esphome/components/api/api_pb2_service.cpp | 14 -------------- esphome/components/api/api_pb2_service.h | 4 +--- 8 files changed, 4 insertions(+), 48 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 208187d598..471127e93a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -27,9 +27,6 @@ service APIConnection { rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} rpc subscribe_homeassistant_services (SubscribeHomeassistantServicesRequest) returns (void) {} rpc subscribe_home_assistant_states (SubscribeHomeAssistantStatesRequest) returns (void) {} - rpc get_time (GetTimeRequest) returns (GetTimeResponse) { - option (needs_authentication) = false; - } rpc execute_service (ExecuteServiceRequest) returns (void) {} rpc noise_encryption_set_key (NoiseEncryptionSetKeyRequest) returns (NoiseEncryptionSetKeyResponse) {} @@ -809,12 +806,12 @@ message HomeAssistantStateResponse { // ==================== IMPORT TIME ==================== message GetTimeRequest { option (id) = 36; - option (source) = SOURCE_BOTH; + option (source) = SOURCE_SERVER; } message GetTimeResponse { option (id) = 37; - option (source) = SOURCE_BOTH; + option (source) = SOURCE_CLIENT; option (no_delay) = true; fixed32 epoch_seconds = 1; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 99a0bc9044..1fb65f3a2b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1081,12 +1081,6 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { } #endif -bool APIConnection::send_get_time_response(const GetTimeRequest &msg) { - GetTimeResponse resp; - resp.epoch_seconds = ::time(nullptr); - return this->send_message(resp, GetTimeResponse::MESSAGE_TYPE); -} - #ifdef USE_BLUETOOTH_PROXY void APIConnection::subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->subscribe_api_connection(this, msg.flags); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7ee82e0c68..8f93f38203 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -219,7 +219,6 @@ class APIConnection final : public APIServerConnection { #ifdef USE_API_HOMEASSISTANT_STATES void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override; #endif - bool send_get_time_response(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES void execute_service(const ExecuteServiceRequest &msg) override; #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 022ac55cf3..a92fca70d6 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -921,14 +921,6 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { } return true; } -void GetTimeResponse::encode(ProtoWriteBuffer buffer) const { - buffer.encode_fixed32(1, this->epoch_seconds); - buffer.encode_string(2, this->timezone_ref_); -} -void GetTimeResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32(1, this->epoch_seconds); - size.add_length(1, this->timezone_ref_.size()); -} #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index fd124e7bfe..5b6d694e3b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1180,10 +1180,6 @@ class GetTimeResponse final : public ProtoDecodableMessage { #endif uint32_t epoch_seconds{0}; std::string timezone{}; - StringRef timezone_ref_{}; - void set_timezone(const StringRef &ref) { this->timezone_ref_ = ref; } - void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 9795999953..b5e98a9f28 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1113,13 +1113,7 @@ void GetTimeRequest::dump_to(std::string &out) const { out.append("GetTimeReques void GetTimeResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "GetTimeResponse"); dump_field(out, "epoch_seconds", this->epoch_seconds); - out.append(" timezone: "); - if (!this->timezone_ref_.empty()) { - out.append("'").append(this->timezone_ref_.c_str()).append("'"); - } else { - out.append("'").append(this->timezone).append("'"); - } - out.append("\n"); + dump_field(out, "timezone", this->timezone); } #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::dump_to(std::string &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 6b7b8b9ebd..2598e9a0fb 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -160,15 +160,6 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #endif - case GetTimeRequest::MESSAGE_TYPE: { - GetTimeRequest msg; - // Empty message: no decode needed -#ifdef HAS_PROTO_MESSAGE_DUMP - ESP_LOGVV(TAG, "on_get_time_request: %s", msg.dump().c_str()); -#endif - this->on_get_time_request(msg); - break; - } case GetTimeResponse::MESSAGE_TYPE: { GetTimeResponse msg; msg.decode(msg_data, msg_size); @@ -656,11 +647,6 @@ void APIServerConnection::on_subscribe_home_assistant_states_request(const Subsc } } #endif -void APIServerConnection::on_get_time_request(const GetTimeRequest &msg) { - if (this->check_connection_setup_() && !this->send_get_time_response(msg)) { - this->on_fatal_error(); - } -} #ifdef USE_API_SERVICES void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { if (this->check_authenticated_()) { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 6172e33bf6..5b7508e786 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -71,7 +71,7 @@ class APIServerConnectionBase : public ProtoService { #ifdef USE_API_HOMEASSISTANT_STATES virtual void on_home_assistant_state_response(const HomeAssistantStateResponse &value){}; #endif - virtual void on_get_time_request(const GetTimeRequest &value){}; + virtual void on_get_time_response(const GetTimeResponse &value){}; #ifdef USE_API_SERVICES @@ -226,7 +226,6 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_API_HOMEASSISTANT_STATES virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; #endif - virtual bool send_get_time_response(const GetTimeRequest &msg) = 0; #ifdef USE_API_SERVICES virtual void execute_service(const ExecuteServiceRequest &msg) = 0; #endif @@ -348,7 +347,6 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; #endif - void on_get_time_request(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES void on_execute_service_request(const ExecuteServiceRequest &msg) override; #endif From 1750f02ef3a31f7fdb36a5992194bc9e48650fdc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:54:42 -0500 Subject: [PATCH 1120/1626] [api] Optimize HelloResponse server_info to reduce memory usage (#10701) --- esphome/components/api/api_connection.cpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 1fb65f3a2b..7b7853f040 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -42,6 +42,8 @@ static constexpr uint8_t MAX_PING_RETRIES = 60; static constexpr uint16_t PING_RETRY_INTERVAL = 1000; static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2; +static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); + static const char *const TAG = "api.connection"; #ifdef USE_CAMERA static const int CAMERA_STOP_STREAM = 5000; @@ -1370,9 +1372,8 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; resp.api_version_minor = 12; - // Temporary string for concatenation - will be valid during send_message call - std::string server_info = App.get_name() + " (esphome v" ESPHOME_VERSION ")"; - resp.set_server_info(StringRef(server_info)); + // Send only the version string - the client only logs this for debugging and doesn't use it otherwise + resp.set_server_info(ESPHOME_VERSION_REF); resp.set_name(StringRef(App.get_name())); #ifdef USE_API_PASSWORD @@ -1419,8 +1420,6 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { std::string mac_address = get_mac_address_pretty(); resp.set_mac_address(StringRef(mac_address)); - // Compile-time StringRef constants - static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); resp.set_esphome_version(ESPHOME_VERSION_REF); resp.set_compilation_time(App.get_compilation_time_ref()); From 4e17d14acca044d452b0ec0516659a47c907b121 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 17:05:56 -0500 Subject: [PATCH 1121/1626] [scheduler] Fix timing accumulation in scheduler causing incorrect execution measurements (#10719) --- esphome/core/scheduler.cpp | 8 ++++---- esphome/core/scheduler.h | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 68da0a56ca..71e2a00fbe 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -345,7 +345,7 @@ void HOT Scheduler::call(uint32_t now) { // Execute callback without holding lock to prevent deadlocks // if the callback tries to call defer() again if (!this->should_skip_item_(item.get())) { - this->execute_item_(item.get(), now); + now = this->execute_item_(item.get(), now); } // Recycle the defer item after execution this->recycle_item_(std::move(item)); @@ -483,7 +483,7 @@ void HOT Scheduler::call(uint32_t now) { // Warning: During callback(), a lot of stuff can happen, including: // - timeouts/intervals get added, potentially invalidating vector pointers // - timeouts/intervals get cancelled - this->execute_item_(item.get(), now); + now = this->execute_item_(item.get(), now); LockGuard guard{this->lock_}; @@ -568,11 +568,11 @@ void HOT Scheduler::pop_raw_() { } // Helper to execute a scheduler item -void HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { +uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); - guard.finish(); + return guard.finish(); } // Common implementation for cancel operations diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 301342e8c2..885ee13754 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -254,7 +254,7 @@ class Scheduler { } // Helper to execute a scheduler item - void execute_item_(SchedulerItem *item, uint32_t now); + uint32_t execute_item_(SchedulerItem *item, uint32_t now); // Helper to check if item should be skipped bool should_skip_item_(SchedulerItem *item) const { From 3427aaab8c8912799f3c5af3737b29582154433f Mon Sep 17 00:00:00 2001 From: Big Mike Date: Sun, 14 Sep 2025 17:16:01 -0500 Subject: [PATCH 1122/1626] ina2xx should be total increasing for energy sensor (#10711) --- esphome/components/ina2xx_base/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index ff70f217ec..fef88e72e9 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -18,6 +18,7 @@ from esphome.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, UNIT_VOLT, @@ -162,7 +163,7 @@ INA2XX_SCHEMA = cv.Schema( unit_of_measurement=UNIT_WATT_HOURS, accuracy_decimals=8, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), key=CONF_NAME, ), @@ -170,7 +171,8 @@ INA2XX_SCHEMA = cv.Schema( sensor.sensor_schema( unit_of_measurement=UNIT_JOULE, accuracy_decimals=8, - state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), key=CONF_NAME, ), From 24f9550ce5df391f00706727a7814998c5a0ea7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Sep 2025 22:33:07 +0000 Subject: [PATCH 1123/1626] Bump aioesphomeapi from 40.2.0 to 40.2.1 (#10721) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4736e2a024..296485bdae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==40.2.0 +aioesphomeapi==40.2.1 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 2d9152d9b9f035f5bdad3a3160509dae8fd46d9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 17:35:18 -0500 Subject: [PATCH 1124/1626] [md5] Optimize MD5::get_hex() to eliminate sprintf dependency (#10710) --- esphome/components/md5/md5.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index 980cb98699..21bd2e1cab 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -1,4 +1,3 @@ -#include #include #include "md5.h" #ifdef USE_MD5 @@ -44,7 +43,9 @@ void MD5Digest::get_bytes(uint8_t *output) { memcpy(output, this->digest_, 16); void MD5Digest::get_hex(char *output) { for (size_t i = 0; i < 16; i++) { - sprintf(output + i * 2, "%02x", this->digest_[i]); + uint8_t byte = this->digest_[i]; + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); } } From 6b147312cdec2421b8983c97c36d5d5df55f960b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 17:35:27 -0500 Subject: [PATCH 1125/1626] [wifi] Optimize WiFi MAC formatting to eliminate sprintf dependency (#10715) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi_info/wifi_info_text_sensor.h | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index e57bf25b8c..43ece636e5 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -593,7 +593,7 @@ void WiFiComponent::check_scanning_finished() { for (auto &res : this->scan_result_) { char bssid_s[18]; auto bssid = res.get_bssid(); - sprintf(bssid_s, "%02X:%02X:%02X:%02X:%02X:%02X", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]); + format_mac_addr_upper(bssid.data(), bssid_s); if (res.get_matches()) { ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 68b5f438e4..2cb96123a0 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/components/wifi/wifi_component.h" #ifdef USE_WIFI @@ -106,8 +107,8 @@ class BSSIDWiFiInfo : public PollingComponent, public text_sensor::TextSensor { wifi::bssid_t bssid = wifi::global_wifi_component->wifi_bssid(); if (memcmp(bssid.data(), last_bssid_.data(), 6) != 0) { std::copy(bssid.begin(), bssid.end(), last_bssid_.begin()); - char buf[30]; - sprintf(buf, "%02X:%02X:%02X:%02X:%02X:%02X", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]); + char buf[18]; + format_mac_addr_upper(bssid.data(), buf); this->publish_state(buf); } } From 926fdcbecd9b05790f178fe5ead826d2cf7c0065 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 19:04:45 -0500 Subject: [PATCH 1126/1626] [esp32_ble] Optimize BLE hex formatting to eliminate sprintf dependency (#10714) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../bluetooth_proxy/bluetooth_proxy.h | 4 +- esphome/components/esp32_ble/ble_uuid.cpp | 39 ++++++++++++++----- .../esp32_ble_beacon/esp32_ble_beacon.cpp | 7 ++-- .../esp32_ble_client/ble_client_base.h | 13 ++++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 5 +-- 5 files changed, 47 insertions(+), 21 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 4b262dbe86..1ce2321bee 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -130,7 +130,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ std::string get_bluetooth_mac_address_pretty() { const uint8_t *mac = esp_bt_dev_get_address(); - return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); } protected: diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index be9c6945d7..5f83e2ba0b 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -7,6 +7,7 @@ #include #include #include "esphome/core/log.h" +#include "esphome/core/helpers.h" namespace esphome::esp32_ble { @@ -169,22 +170,42 @@ bool ESPBTUUID::operator==(const ESPBTUUID &uuid) const { } esp_bt_uuid_t ESPBTUUID::get_uuid() const { return this->uuid_; } std::string ESPBTUUID::to_string() const { + char buf[40]; // Enough for 128-bit UUID with dashes + char *pos = buf; + switch (this->uuid_.len) { case ESP_UUID_LEN_16: - return str_snprintf("0x%02X%02X", 6, this->uuid_.uuid.uuid16 >> 8, this->uuid_.uuid.uuid16 & 0xff); + *pos++ = '0'; + *pos++ = 'x'; + *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 >> 12); + *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 8) & 0x0F); + *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 4) & 0x0F); + *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 & 0x0F); + *pos = '\0'; + return std::string(buf); + case ESP_UUID_LEN_32: - return str_snprintf("0x%02" PRIX32 "%02" PRIX32 "%02" PRIX32 "%02" PRIX32, 10, (this->uuid_.uuid.uuid32 >> 24), - (this->uuid_.uuid.uuid32 >> 16 & 0xff), (this->uuid_.uuid.uuid32 >> 8 & 0xff), - this->uuid_.uuid.uuid32 & 0xff); + *pos++ = '0'; + *pos++ = 'x'; + for (int shift = 28; shift >= 0; shift -= 4) { + *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid32 >> shift) & 0x0F); + } + *pos = '\0'; + return std::string(buf); + default: case ESP_UUID_LEN_128: - std::string buf; + // Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX for (int8_t i = 15; i >= 0; i--) { - buf += str_snprintf("%02X", 2, this->uuid_.uuid.uuid128[i]); - if (i == 6 || i == 8 || i == 10 || i == 12) - buf += "-"; + uint8_t byte = this->uuid_.uuid.uuid128[i]; + *pos++ = format_hex_pretty_char(byte >> 4); + *pos++ = format_hex_pretty_char(byte & 0x0F); + if (i == 12 || i == 10 || i == 8 || i == 6) { + *pos++ = '-'; + } } - return buf; + *pos = '\0'; + return std::string(buf); } return ""; } diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 423fe61592..259628e00f 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -31,12 +31,13 @@ void ESP32BLEBeacon::dump_config() { char uuid[37]; char *bpos = uuid; for (int8_t ii = 0; ii < 16; ++ii) { - bpos += sprintf(bpos, "%02X", this->uuid_[ii]); + *bpos++ = format_hex_pretty_char(this->uuid_[ii] >> 4); + *bpos++ = format_hex_pretty_char(this->uuid_[ii] & 0x0F); if (ii == 3 || ii == 5 || ii == 7 || ii == 9) { - bpos += sprintf(bpos, "-"); + *bpos++ = '-'; } } - uuid[36] = '\0'; + *bpos = '\0'; ESP_LOGCONFIG(TAG, " UUID: %s, Major: %u, Minor: %u, Min Interval: %ums, Max Interval: %ums, Measured Power: %d" ", TX Power: %ddBm", diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index acfad9e9b0..f2edd6c2b3 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -60,11 +60,14 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { if (address == 0) { this->address_str_ = ""; } else { - this->address_str_ = - str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, (uint8_t) (this->address_ >> 40) & 0xff, - (uint8_t) (this->address_ >> 32) & 0xff, (uint8_t) (this->address_ >> 24) & 0xff, - (uint8_t) (this->address_ >> 16) & 0xff, (uint8_t) (this->address_ >> 8) & 0xff, - (uint8_t) (this->address_ >> 0) & 0xff); + char buf[18]; + uint8_t mac[6] = { + (uint8_t) ((this->address_ >> 40) & 0xff), (uint8_t) ((this->address_ >> 32) & 0xff), + (uint8_t) ((this->address_ >> 24) & 0xff), (uint8_t) ((this->address_ >> 16) & 0xff), + (uint8_t) ((this->address_ >> 8) & 0xff), (uint8_t) ((this->address_ >> 0) & 0xff), + }; + format_mac_addr_upper(mac, buf); + this->address_str_ = buf; } } const std::string &address_str() const { return this->address_str_; } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 0edde169eb..63fb3b8b32 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -607,9 +607,8 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) { } std::string ESPBTDevice::address_str() const { - char mac[24]; - snprintf(mac, sizeof(mac), "%02X:%02X:%02X:%02X:%02X:%02X", this->address_[0], this->address_[1], this->address_[2], - this->address_[3], this->address_[4], this->address_[5]); + char mac[18]; + format_mac_addr_upper(this->address_, mac); return mac; } From 971de64494ad949a471e017ae03d54b4c9527867 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:34:56 +1200 Subject: [PATCH 1127/1626] Bump version to 2025.9.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a7f591cbf5..d35c01b144 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.9.0b1 +PROJECT_NUMBER = 2025.9.0b2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 4655931823..03dc33df89 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.9.0b1" +__version__ = "2025.9.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From bd9dc43e592df61cd1e1e01d741858f5b76a0af2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 20:33:15 -0500 Subject: [PATCH 1128/1626] Add additional coverage ahead of Path conversion (#10723) --- tests/unit_tests/build_gen/test_platformio.py | 188 ++++++++++++ tests/unit_tests/core/test_config.py | 275 ++++++++++++++++++ tests/unit_tests/test_core.py | 83 ++++++ tests/unit_tests/test_helpers.py | 236 +++++++++++++++ tests/unit_tests/test_util.py | 84 ++++++ 5 files changed, 866 insertions(+) create mode 100644 tests/unit_tests/build_gen/test_platformio.py diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py new file mode 100644 index 0000000000..04b7381141 --- /dev/null +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -0,0 +1,188 @@ +"""Tests for esphome.build_gen.platformio module.""" + +from __future__ import annotations + +from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.build_gen import platformio +from esphome.core import CORE + + +@pytest.fixture +def mock_update_storage_json() -> Generator[MagicMock]: + """Mock update_storage_json for all tests.""" + with patch("esphome.build_gen.platformio.update_storage_json") as mock: + yield mock + + +@pytest.fixture +def mock_write_file_if_changed() -> Generator[MagicMock]: + """Mock write_file_if_changed for tests.""" + with patch("esphome.build_gen.platformio.write_file_if_changed") as mock: + yield mock + + +def test_write_ini_creates_new_file( + tmp_path: Path, mock_update_storage_json: MagicMock +) -> None: + """Test write_ini creates a new platformio.ini file.""" + CORE.build_path = str(tmp_path) + + content = """ +[env:test] +platform = espressif32 +board = esp32dev +framework = arduino +""" + + platformio.write_ini(content) + + ini_file = tmp_path / "platformio.ini" + assert ini_file.exists() + + file_content = ini_file.read_text() + assert content in file_content + assert platformio.INI_AUTO_GENERATE_BEGIN in file_content + assert platformio.INI_AUTO_GENERATE_END in file_content + + +def test_write_ini_updates_existing_file( + tmp_path: Path, mock_update_storage_json: MagicMock +) -> None: + """Test write_ini updates existing platformio.ini file.""" + CORE.build_path = str(tmp_path) + + # Create existing file with custom content + ini_file = tmp_path / "platformio.ini" + existing_content = f""" +; Custom header +[platformio] +default_envs = test + +{platformio.INI_AUTO_GENERATE_BEGIN} +; Old auto-generated content +[env:old] +platform = old +{platformio.INI_AUTO_GENERATE_END} + +; Custom footer +""" + ini_file.write_text(existing_content) + + # New content to write + new_content = """ +[env:test] +platform = espressif32 +board = esp32dev +framework = arduino +""" + + platformio.write_ini(new_content) + + file_content = ini_file.read_text() + + # Check that custom parts are preserved + assert "; Custom header" in file_content + assert "[platformio]" in file_content + assert "default_envs = test" in file_content + assert "; Custom footer" in file_content + + # Check that new content replaced old auto-generated content + assert new_content in file_content + assert "[env:old]" not in file_content + assert "platform = old" not in file_content + + +def test_write_ini_preserves_custom_sections( + tmp_path: Path, mock_update_storage_json: MagicMock +) -> None: + """Test write_ini preserves custom sections outside auto-generate markers.""" + CORE.build_path = str(tmp_path) + + # Create existing file with multiple custom sections + ini_file = tmp_path / "platformio.ini" + existing_content = f""" +[platformio] +src_dir = . +include_dir = . + +[common] +lib_deps = + Wire + SPI + +{platformio.INI_AUTO_GENERATE_BEGIN} +[env:old] +platform = old +{platformio.INI_AUTO_GENERATE_END} + +[env:custom] +upload_speed = 921600 +monitor_speed = 115200 +""" + ini_file.write_text(existing_content) + + new_content = "[env:auto]\nplatform = new" + + platformio.write_ini(new_content) + + file_content = ini_file.read_text() + + # All custom sections should be preserved + assert "[platformio]" in file_content + assert "src_dir = ." in file_content + assert "[common]" in file_content + assert "lib_deps" in file_content + assert "[env:custom]" in file_content + assert "upload_speed = 921600" in file_content + + # New auto-generated content should replace old + assert "[env:auto]" in file_content + assert "platform = new" in file_content + assert "[env:old]" not in file_content + + +def test_write_ini_no_change_when_content_same( + tmp_path: Path, + mock_update_storage_json: MagicMock, + mock_write_file_if_changed: MagicMock, +) -> None: + """Test write_ini doesn't rewrite file when content is unchanged.""" + CORE.build_path = str(tmp_path) + + content = "[env:test]\nplatform = esp32" + full_content = ( + f"{platformio.INI_BASE_FORMAT[0]}" + f"{platformio.INI_AUTO_GENERATE_BEGIN}\n" + f"{content}" + f"{platformio.INI_AUTO_GENERATE_END}" + f"{platformio.INI_BASE_FORMAT[1]}" + ) + + ini_file = tmp_path / "platformio.ini" + ini_file.write_text(full_content) + + mock_write_file_if_changed.return_value = False # Indicate no change + platformio.write_ini(content) + + # write_file_if_changed should be called with the same content + mock_write_file_if_changed.assert_called_once() + call_args = mock_write_file_if_changed.call_args[0] + assert call_args[0] == str(ini_file) + assert content in call_args[1] + + +def test_write_ini_calls_update_storage_json( + tmp_path: Path, mock_update_storage_json: MagicMock +) -> None: + """Test write_ini calls update_storage_json.""" + CORE.build_path = str(tmp_path) + + content = "[env:test]\nplatform = esp32" + + platformio.write_ini(content) + mock_update_storage_json.assert_called_once() diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 484c30d59e..e520db9e33 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -35,6 +35,22 @@ from .common import load_config_from_fixture FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config" +@pytest.fixture +def mock_cg_with_include_capture() -> tuple[Mock, list[str]]: + """Mock code generation with include capture.""" + includes_added: list[str] = [] + + with patch("esphome.core.config.cg") as mock_cg: + mock_raw_statement = MagicMock() + + def capture_include(text: str) -> MagicMock: + includes_added.append(text) + return mock_raw_statement + + mock_cg.RawStatement.side_effect = capture_include + yield mock_cg, includes_added + + def test_validate_area_config_with_string() -> None: """Test that string area config is converted to structured format.""" result = validate_area_config("Living Room") @@ -568,3 +584,262 @@ def test_is_target_platform() -> None: assert config._is_target_platform("rp2040") is True assert config._is_target_platform("invalid_platform") is False assert config._is_target_platform("api") is False # Component but not platform + + +@pytest.mark.asyncio +async def test_add_includes_with_single_file( + tmp_path: Path, + mock_copy_file_if_changed: Mock, + mock_cg_with_include_capture: tuple[Mock, list[str]], +) -> None: + """Test add_includes copies a single header file to build directory.""" + CORE.config_path = str(tmp_path / "config.yaml") + CORE.build_path = str(tmp_path / "build") + os.makedirs(CORE.build_path, exist_ok=True) + + # Create include file + include_file = tmp_path / "my_header.h" + include_file.write_text("#define MY_CONSTANT 42") + + mock_cg, includes_added = mock_cg_with_include_capture + + await config.add_includes([str(include_file)]) + + # Verify copy_file_if_changed was called to copy the file + # Note: add_includes adds files to a src/ subdirectory + mock_copy_file_if_changed.assert_called_once_with( + str(include_file), str(Path(CORE.build_path) / "src" / "my_header.h") + ) + + # Verify include statement was added + assert any('#include "my_header.h"' in inc for inc in includes_added) + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") +async def test_add_includes_with_directory_unix( + tmp_path: Path, + mock_copy_file_if_changed: Mock, + mock_cg_with_include_capture: tuple[Mock, list[str]], +) -> None: + """Test add_includes copies all files from a directory on Unix.""" + CORE.config_path = str(tmp_path / "config.yaml") + CORE.build_path = str(tmp_path / "build") + os.makedirs(CORE.build_path, exist_ok=True) + + # Create include directory with files + include_dir = tmp_path / "includes" + include_dir.mkdir() + (include_dir / "header1.h").write_text("#define HEADER1") + (include_dir / "header2.hpp").write_text("#define HEADER2") + (include_dir / "source.cpp").write_text("// Implementation") + (include_dir / "README.md").write_text( + "# Documentation" + ) # Should be copied but not included + + # Create subdirectory with files + subdir = include_dir / "subdir" + subdir.mkdir() + (subdir / "nested.h").write_text("#define NESTED") + + mock_cg, includes_added = mock_cg_with_include_capture + + await config.add_includes([str(include_dir)]) + + # Verify copy_file_if_changed was called for all files + assert mock_copy_file_if_changed.call_count == 5 # 4 code files + 1 README + + # Verify include statements were added for valid extensions + include_strings = " ".join(includes_added) + assert "includes/header1.h" in include_strings + assert "includes/header2.hpp" in include_strings + assert "includes/subdir/nested.h" in include_strings + # CPP files are copied but not included + assert "source.cpp" not in include_strings or "#include" not in include_strings + # README.md should not have an include statement + assert "README.md" not in include_strings + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") +async def test_add_includes_with_directory_windows( + tmp_path: Path, + mock_copy_file_if_changed: Mock, + mock_cg_with_include_capture: tuple[Mock, list[str]], +) -> None: + """Test add_includes copies all files from a directory on Windows.""" + CORE.config_path = str(tmp_path / "config.yaml") + CORE.build_path = str(tmp_path / "build") + os.makedirs(CORE.build_path, exist_ok=True) + + # Create include directory with files + include_dir = tmp_path / "includes" + include_dir.mkdir() + (include_dir / "header1.h").write_text("#define HEADER1") + (include_dir / "header2.hpp").write_text("#define HEADER2") + (include_dir / "source.cpp").write_text("// Implementation") + (include_dir / "README.md").write_text( + "# Documentation" + ) # Should be copied but not included + + # Create subdirectory with files + subdir = include_dir / "subdir" + subdir.mkdir() + (subdir / "nested.h").write_text("#define NESTED") + + mock_cg, includes_added = mock_cg_with_include_capture + + await config.add_includes([str(include_dir)]) + + # Verify copy_file_if_changed was called for all files + assert mock_copy_file_if_changed.call_count == 5 # 4 code files + 1 README + + # Verify include statements were added for valid extensions + include_strings = " ".join(includes_added) + assert "includes\\header1.h" in include_strings + assert "includes\\header2.hpp" in include_strings + assert "includes\\subdir\\nested.h" in include_strings + # CPP files are copied but not included + assert "source.cpp" not in include_strings or "#include" not in include_strings + # README.md should not have an include statement + assert "README.md" not in include_strings + + +@pytest.mark.asyncio +async def test_add_includes_with_multiple_sources( + tmp_path: Path, mock_copy_file_if_changed: Mock +) -> None: + """Test add_includes with multiple files and directories.""" + CORE.config_path = str(tmp_path / "config.yaml") + CORE.build_path = str(tmp_path / "build") + os.makedirs(CORE.build_path, exist_ok=True) + + # Create various include sources + single_file = tmp_path / "single.h" + single_file.write_text("#define SINGLE") + + dir1 = tmp_path / "dir1" + dir1.mkdir() + (dir1 / "file1.h").write_text("#define FILE1") + + dir2 = tmp_path / "dir2" + dir2.mkdir() + (dir2 / "file2.cpp").write_text("// File2") + + with patch("esphome.core.config.cg"): + await config.add_includes([str(single_file), str(dir1), str(dir2)]) + + # Verify copy_file_if_changed was called for all files + assert mock_copy_file_if_changed.call_count == 3 # 3 files total + + +@pytest.mark.asyncio +async def test_add_includes_empty_directory( + tmp_path: Path, mock_copy_file_if_changed: Mock +) -> None: + """Test add_includes with an empty directory doesn't fail.""" + CORE.config_path = str(tmp_path / "config.yaml") + CORE.build_path = str(tmp_path / "build") + os.makedirs(CORE.build_path, exist_ok=True) + + # Create empty directory + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + + with patch("esphome.core.config.cg"): + # Should not raise any errors + await config.add_includes([str(empty_dir)]) + + # No files to copy from empty directory + mock_copy_file_if_changed.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") +async def test_add_includes_preserves_directory_structure_unix( + tmp_path: Path, mock_copy_file_if_changed: Mock +) -> None: + """Test that add_includes preserves relative directory structure on Unix.""" + CORE.config_path = str(tmp_path / "config.yaml") + CORE.build_path = str(tmp_path / "build") + os.makedirs(CORE.build_path, exist_ok=True) + + # Create nested directory structure + lib_dir = tmp_path / "lib" + lib_dir.mkdir() + + src_dir = lib_dir / "src" + src_dir.mkdir() + (src_dir / "core.h").write_text("#define CORE") + + utils_dir = lib_dir / "utils" + utils_dir.mkdir() + (utils_dir / "helper.h").write_text("#define HELPER") + + with patch("esphome.core.config.cg"): + await config.add_includes([str(lib_dir)]) + + # Verify copy_file_if_changed was called with correct paths + calls = mock_copy_file_if_changed.call_args_list + dest_paths = [call[0][1] for call in calls] + + # Check that relative paths are preserved + assert any("lib/src/core.h" in path for path in dest_paths) + assert any("lib/utils/helper.h" in path for path in dest_paths) + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") +async def test_add_includes_preserves_directory_structure_windows( + tmp_path: Path, mock_copy_file_if_changed: Mock +) -> None: + """Test that add_includes preserves relative directory structure on Windows.""" + CORE.config_path = str(tmp_path / "config.yaml") + CORE.build_path = str(tmp_path / "build") + os.makedirs(CORE.build_path, exist_ok=True) + + # Create nested directory structure + lib_dir = tmp_path / "lib" + lib_dir.mkdir() + + src_dir = lib_dir / "src" + src_dir.mkdir() + (src_dir / "core.h").write_text("#define CORE") + + utils_dir = lib_dir / "utils" + utils_dir.mkdir() + (utils_dir / "helper.h").write_text("#define HELPER") + + with patch("esphome.core.config.cg"): + await config.add_includes([str(lib_dir)]) + + # Verify copy_file_if_changed was called with correct paths + calls = mock_copy_file_if_changed.call_args_list + dest_paths = [call[0][1] for call in calls] + + # Check that relative paths are preserved + assert any("lib\\src\\core.h" in path for path in dest_paths) + assert any("lib\\utils\\helper.h" in path for path in dest_paths) + + +@pytest.mark.asyncio +async def test_add_includes_overwrites_existing_files( + tmp_path: Path, mock_copy_file_if_changed: Mock +) -> None: + """Test that add_includes overwrites existing files in build directory.""" + CORE.config_path = str(tmp_path / "config.yaml") + CORE.build_path = str(tmp_path / "build") + os.makedirs(CORE.build_path, exist_ok=True) + + # Create include file + include_file = tmp_path / "header.h" + include_file.write_text("#define NEW_VALUE 42") + + with patch("esphome.core.config.cg"): + await config.add_includes([str(include_file)]) + + # Verify copy_file_if_changed was called (it handles overwriting) + # Note: add_includes adds files to a src/ subdirectory + mock_copy_file_if_changed.assert_called_once_with( + str(include_file), str(Path(CORE.build_path) / "src" / "header.h") + ) diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index f7dda9fb95..9a69329e80 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -1,3 +1,6 @@ +import os +from unittest.mock import patch + from hypothesis import given import pytest from strategies import mac_addr_strings @@ -577,3 +580,83 @@ class TestEsphomeCore: assert target.is_esp32 is False assert target.is_esp8266 is True + + @pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") + def test_data_dir_default_unix(self, target): + """Test data_dir returns .esphome in config directory by default on Unix.""" + target.config_path = "/home/user/config.yaml" + assert target.data_dir == "/home/user/.esphome" + + @pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") + def test_data_dir_default_windows(self, target): + """Test data_dir returns .esphome in config directory by default on Windows.""" + target.config_path = "D:\\home\\user\\config.yaml" + assert target.data_dir == "D:\\home\\user\\.esphome" + + def test_data_dir_ha_addon(self, target): + """Test data_dir returns /data when running as Home Assistant addon.""" + target.config_path = "/config/test.yaml" + + with patch.dict(os.environ, {"ESPHOME_IS_HA_ADDON": "true"}): + assert target.data_dir == "/data" + + def test_data_dir_env_override(self, target): + """Test data_dir uses ESPHOME_DATA_DIR environment variable when set.""" + target.config_path = "/home/user/config.yaml" + + with patch.dict(os.environ, {"ESPHOME_DATA_DIR": "/custom/data/path"}): + assert target.data_dir == "/custom/data/path" + + @pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") + def test_data_dir_priority_unix(self, target): + """Test data_dir priority on Unix: HA addon > env var > default.""" + target.config_path = "/config/test.yaml" + expected_default = "/config/.esphome" + + # Test HA addon takes priority over env var + with patch.dict( + os.environ, + {"ESPHOME_IS_HA_ADDON": "true", "ESPHOME_DATA_DIR": "/custom/path"}, + ): + assert target.data_dir == "/data" + + # Test env var is used when not HA addon + with patch.dict( + os.environ, + {"ESPHOME_IS_HA_ADDON": "false", "ESPHOME_DATA_DIR": "/custom/path"}, + ): + assert target.data_dir == "/custom/path" + + # Test default when neither is set + with patch.dict(os.environ, {}, clear=True): + # Ensure these env vars are not set + os.environ.pop("ESPHOME_IS_HA_ADDON", None) + os.environ.pop("ESPHOME_DATA_DIR", None) + assert target.data_dir == expected_default + + @pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") + def test_data_dir_priority_windows(self, target): + """Test data_dir priority on Windows: HA addon > env var > default.""" + target.config_path = "D:\\config\\test.yaml" + expected_default = "D:\\config\\.esphome" + + # Test HA addon takes priority over env var + with patch.dict( + os.environ, + {"ESPHOME_IS_HA_ADDON": "true", "ESPHOME_DATA_DIR": "/custom/path"}, + ): + assert target.data_dir == "/data" + + # Test env var is used when not HA addon + with patch.dict( + os.environ, + {"ESPHOME_IS_HA_ADDON": "false", "ESPHOME_DATA_DIR": "/custom/path"}, + ): + assert target.data_dir == "/custom/path" + + # Test default when neither is set + with patch.dict(os.environ, {}, clear=True): + # Ensure these env vars are not set + os.environ.pop("ESPHOME_IS_HA_ADDON", None) + os.environ.pop("ESPHOME_DATA_DIR", None) + assert target.data_dir == expected_default diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 9f51206ff9..cc65d9747e 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1,5 +1,8 @@ import logging +import os +from pathlib import Path import socket +import stat from unittest.mock import patch from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr @@ -554,6 +557,239 @@ def test_addr_preference_ipv6_link_local_with_scope() -> None: assert helpers.addr_preference_(addr_info) == 1 # Has scope, so it's usable +def test_mkdir_p(tmp_path: Path) -> None: + """Test mkdir_p creates directories recursively.""" + # Test creating nested directories + nested_path = tmp_path / "level1" / "level2" / "level3" + helpers.mkdir_p(nested_path) + assert nested_path.exists() + assert nested_path.is_dir() + + # Test that mkdir_p is idempotent (doesn't fail if directory exists) + helpers.mkdir_p(nested_path) + assert nested_path.exists() + + # Test with empty path (should do nothing) + helpers.mkdir_p("") + + # Test with existing directory + existing_dir = tmp_path / "existing" + existing_dir.mkdir() + helpers.mkdir_p(existing_dir) + assert existing_dir.exists() + + +def test_mkdir_p_file_exists_error(tmp_path: Path) -> None: + """Test mkdir_p raises error when path is a file.""" + # Create a file + file_path = tmp_path / "test_file.txt" + file_path.write_text("test content") + + # Try to create directory with same name as existing file + with pytest.raises(EsphomeError, match=r"Error creating directories"): + helpers.mkdir_p(file_path) + + +def test_mkdir_p_with_existing_file_raises_error(tmp_path: Path) -> None: + """Test mkdir_p raises error when trying to create dir over existing file.""" + # Create a file where we want to create a directory + file_path = tmp_path / "existing_file" + file_path.write_text("content") + + # Try to create a directory with a path that goes through the file + dir_path = file_path / "subdir" + + with pytest.raises(EsphomeError, match=r"Error creating directories"): + helpers.mkdir_p(dir_path) + + +@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") +def test_read_file_unix(tmp_path: Path) -> None: + """Test read_file reads file content correctly on Unix.""" + # Test reading regular file + test_file = tmp_path / "test.txt" + expected_content = "Test content\nLine 2\n" + test_file.write_text(expected_content) + + content = helpers.read_file(test_file) + assert content == expected_content + + # Test reading file with UTF-8 characters + utf8_file = tmp_path / "utf8.txt" + utf8_content = "Hello 世界 🌍" + utf8_file.write_text(utf8_content, encoding="utf-8") + + content = helpers.read_file(utf8_file) + assert content == utf8_content + + +@pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") +def test_read_file_windows(tmp_path: Path) -> None: + """Test read_file reads file content correctly on Windows.""" + # Test reading regular file + test_file = tmp_path / "test.txt" + expected_content = "Test content\nLine 2\n" + test_file.write_text(expected_content) + + content = helpers.read_file(test_file) + # On Windows, text mode reading converts \n to \r\n + assert content == expected_content.replace("\n", "\r\n") + + # Test reading file with UTF-8 characters + utf8_file = tmp_path / "utf8.txt" + utf8_content = "Hello 世界 🌍" + utf8_file.write_text(utf8_content, encoding="utf-8") + + content = helpers.read_file(utf8_file) + assert content == utf8_content + + +def test_read_file_not_found() -> None: + """Test read_file raises error for non-existent file.""" + with pytest.raises(EsphomeError, match=r"Error reading file"): + helpers.read_file("/nonexistent/file.txt") + + +def test_read_file_unicode_decode_error(tmp_path: Path) -> None: + """Test read_file raises error for invalid UTF-8.""" + test_file = tmp_path / "invalid.txt" + # Write invalid UTF-8 bytes + test_file.write_bytes(b"\xff\xfe") + + with pytest.raises(EsphomeError, match=r"Error reading file"): + helpers.read_file(test_file) + + +@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") +def test_write_file_unix(tmp_path: Path) -> None: + """Test write_file writes content correctly on Unix.""" + # Test writing string content + test_file = tmp_path / "test.txt" + content = "Test content\nLine 2" + helpers.write_file(test_file, content) + + assert test_file.read_text() == content + # Check file permissions + assert oct(test_file.stat().st_mode)[-3:] == "644" + + # Test overwriting existing file + new_content = "New content" + helpers.write_file(test_file, new_content) + assert test_file.read_text() == new_content + + # Test writing to nested directories (should create them) + nested_file = tmp_path / "dir1" / "dir2" / "file.txt" + helpers.write_file(nested_file, content) + assert nested_file.read_text() == content + + +@pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") +def test_write_file_windows(tmp_path: Path) -> None: + """Test write_file writes content correctly on Windows.""" + # Test writing string content + test_file = tmp_path / "test.txt" + content = "Test content\nLine 2" + helpers.write_file(test_file, content) + + assert test_file.read_text() == content + # Windows doesn't have Unix-style 644 permissions + + # Test overwriting existing file + new_content = "New content" + helpers.write_file(test_file, new_content) + assert test_file.read_text() == new_content + + # Test writing to nested directories (should create them) + nested_file = tmp_path / "dir1" / "dir2" / "file.txt" + helpers.write_file(nested_file, content) + assert nested_file.read_text() == content + + +@pytest.mark.skipif(os.name == "nt", reason="Unix-specific permission test") +def test_write_file_to_non_writable_directory_unix(tmp_path: Path) -> None: + """Test write_file raises error when directory is not writable on Unix.""" + # Create a directory and make it read-only + read_only_dir = tmp_path / "readonly" + read_only_dir.mkdir() + test_file = read_only_dir / "test.txt" + + # Make directory read-only (no write permission) + read_only_dir.chmod(0o555) + + try: + with pytest.raises(EsphomeError, match=r"Could not write file"): + helpers.write_file(test_file, "content") + finally: + # Restore write permissions for cleanup + read_only_dir.chmod(0o755) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") +def test_write_file_to_non_writable_directory_windows(tmp_path: Path) -> None: + """Test write_file error handling on Windows.""" + # Windows handles permissions differently - test a different error case + # Try to write to a file path that contains an existing file as a directory component + existing_file = tmp_path / "file.txt" + existing_file.write_text("content") + + # Try to write to a path that treats the file as a directory + invalid_path = existing_file / "subdir" / "test.txt" + + with pytest.raises(EsphomeError, match=r"Could not write file"): + helpers.write_file(invalid_path, "content") + + +@pytest.mark.skipif(os.name == "nt", reason="Unix-specific permission test") +def test_write_file_with_permission_bits_unix(tmp_path: Path) -> None: + """Test that write_file sets correct permissions on Unix.""" + test_file = tmp_path / "test.txt" + helpers.write_file(test_file, "content") + + # Check that file has 644 permissions + file_mode = test_file.stat().st_mode + assert stat.S_IMODE(file_mode) == 0o644 + + +@pytest.mark.skipif(os.name == "nt", reason="Unix-specific permission test") +def test_copy_file_if_changed_permission_recovery_unix(tmp_path: Path) -> None: + """Test copy_file_if_changed handles permission errors correctly on Unix.""" + # Test with read-only destination file + src = tmp_path / "source.txt" + dst = tmp_path / "dest.txt" + src.write_text("new content") + dst.write_text("old content") + dst.chmod(0o444) # Make destination read-only + + try: + # Should handle permission error by deleting and retrying + helpers.copy_file_if_changed(src, dst) + assert dst.read_text() == "new content" + finally: + # Restore write permissions for cleanup + if dst.exists(): + dst.chmod(0o644) + + +def test_copy_file_if_changed_creates_directories(tmp_path: Path) -> None: + """Test copy_file_if_changed creates missing directories.""" + src = tmp_path / "source.txt" + dst = tmp_path / "subdir" / "nested" / "dest.txt" + src.write_text("content") + + helpers.copy_file_if_changed(src, dst) + assert dst.exists() + assert dst.read_text() == "content" + + +def test_copy_file_if_changed_nonexistent_source(tmp_path: Path) -> None: + """Test copy_file_if_changed with non-existent source.""" + src = tmp_path / "nonexistent.txt" + dst = tmp_path / "dest.txt" + + with pytest.raises(EsphomeError, match=r"Error copying file"): + helpers.copy_file_if_changed(src, dst) + + def test_resolve_ip_address_sorting() -> None: """Test that results are sorted by preference.""" # Create multiple address infos with different preferences diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 34f40a651f..6feda854b7 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -1,5 +1,7 @@ """Tests for esphome.util module.""" +from __future__ import annotations + from pathlib import Path import pytest @@ -308,3 +310,85 @@ def test_filter_yaml_files_case_sensitive() -> None: assert "/path/to/config.YAML" not in result assert "/path/to/config.YML" not in result assert "/path/to/config.Yaml" not in result + + +@pytest.mark.parametrize( + ("input_str", "expected"), + [ + # Empty string + ("", "''"), + # Simple strings that don't need quoting + ("hello", "hello"), + ("test123", "test123"), + ("file.txt", "file.txt"), + ("/path/to/file", "/path/to/file"), + ("user@host", "user@host"), + ("value:123", "value:123"), + ("item,list", "item,list"), + ("path-with-dash", "path-with-dash"), + # Strings that need quoting + ("hello world", "'hello world'"), + ("test\ttab", "'test\ttab'"), + ("line\nbreak", "'line\nbreak'"), + ("semicolon;here", "'semicolon;here'"), + ("pipe|symbol", "'pipe|symbol'"), + ("redirect>file", "'redirect>file'"), + ("redirect None: + """Test shlex_quote properly escapes shell arguments.""" + assert util.shlex_quote(input_str) == expected + + +def test_shlex_quote_safe_characters() -> None: + """Test that safe characters are not quoted.""" + # These characters are considered safe and shouldn't be quoted + safe_chars = ( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@%+=:,./-_" + ) + for char in safe_chars: + assert util.shlex_quote(char) == char + assert util.shlex_quote(f"test{char}test") == f"test{char}test" + + +def test_shlex_quote_unsafe_characters() -> None: + """Test that unsafe characters trigger quoting.""" + # These characters should trigger quoting + unsafe_chars = ' \t\n;|>&<$`"\\?*[](){}!#~^' + for char in unsafe_chars: + result = util.shlex_quote(f"test{char}test") + assert result.startswith("'") + assert result.endswith("'") + + +def test_shlex_quote_edge_cases() -> None: + """Test edge cases for shlex_quote.""" + # Multiple single quotes + assert util.shlex_quote("'''") == "''\"'\"''\"'\"''\"'\"''" + + # Mixed quotes + assert util.shlex_quote('"\'"') == "'\"'\"'\"'\"'" + + # Only whitespace + assert util.shlex_quote(" ") == "' '" + assert util.shlex_quote("\t") == "'\t'" + assert util.shlex_quote("\n") == "'\n'" + assert util.shlex_quote(" ") == "' '" From 459ef7f262ef79e3b2fde08c0474ba37b00c7aad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 20:45:28 -0500 Subject: [PATCH 1129/1626] [api] Exclude ConnectRequest/Response when password is disabled (#10704) --- esphome/components/api/api.proto | 2 ++ esphome/components/api/api_connection.cpp | 11 ++++------- esphome/components/api/api_connection.h | 2 ++ esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 2 ++ esphome/components/api/api_pb2_dump.cpp | 2 ++ esphome/components/api/api_pb2_service.cpp | 4 ++++ esphome/components/api/api_pb2_service.h | 6 ++++++ 8 files changed, 24 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 471127e93a..f82e762345 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -133,6 +133,7 @@ message ConnectRequest { option (id) = 3; option (source) = SOURCE_CLIENT; option (no_delay) = true; + option (ifdef) = "USE_API_PASSWORD"; // The password to log in with string password = 1; @@ -144,6 +145,7 @@ message ConnectResponse { option (id) = 4; option (source) = SOURCE_SERVER; option (no_delay) = true; + option (ifdef) = "USE_API_PASSWORD"; bool invalid_password = 1; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7b7853f040..79f9fe9a08 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1386,20 +1386,17 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { return this->send_message(resp, HelloResponse::MESSAGE_TYPE); } -bool APIConnection::send_connect_response(const ConnectRequest &msg) { - bool correct = true; #ifdef USE_API_PASSWORD - correct = this->parent_->check_password(msg.password); -#endif - +bool APIConnection::send_connect_response(const ConnectRequest &msg) { ConnectResponse resp; // bool invalid_password = 1; - resp.invalid_password = !correct; - if (correct) { + resp.invalid_password = !this->parent_->check_password(msg.password); + if (!resp.invalid_password) { this->complete_authentication_(); } return this->send_message(resp, ConnectResponse::MESSAGE_TYPE); } +#endif // USE_API_PASSWORD bool APIConnection::send_ping_response(const PingRequest &msg) { PingResponse resp; diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 8f93f38203..70fc881a82 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -197,7 +197,9 @@ class APIConnection final : public APIServerConnection { void on_get_time_response(const GetTimeResponse &value) override; #endif bool send_hello_response(const HelloRequest &msg) override; +#ifdef USE_API_PASSWORD bool send_connect_response(const ConnectRequest &msg) override; +#endif 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; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index a92fca70d6..7b1601515e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -42,6 +42,7 @@ void HelloResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->server_info_ref_.size()); size.add_length(1, this->name_ref_.size()); } +#ifdef USE_API_PASSWORD bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: @@ -54,6 +55,7 @@ bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value } void ConnectResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->invalid_password); } void ConnectResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->invalid_password); } +#endif #ifdef USE_AREAS void AreaInfo::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(1, this->area_id); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5b6d694e3b..421789a770 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -360,6 +360,7 @@ class HelloResponse final : public ProtoMessage { protected: }; +#ifdef USE_API_PASSWORD class ConnectRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 3; @@ -391,6 +392,7 @@ class ConnectResponse final : public ProtoMessage { protected: }; +#endif class DisconnectRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 5; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index b5e98a9f28..53af7eefef 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -669,8 +669,10 @@ void HelloResponse::dump_to(std::string &out) const { dump_field(out, "server_info", this->server_info_ref_); dump_field(out, "name", this->name_ref_); } +#ifdef USE_API_PASSWORD void ConnectRequest::dump_to(std::string &out) const { dump_field(out, "password", this->password); } void ConnectResponse::dump_to(std::string &out) const { dump_field(out, "invalid_password", this->invalid_password); } +#endif void DisconnectRequest::dump_to(std::string &out) const { out.append("DisconnectRequest {}"); } void DisconnectResponse::dump_to(std::string &out) const { out.append("DisconnectResponse {}"); } void PingRequest::dump_to(std::string &out) const { out.append("PingRequest {}"); } diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 2598e9a0fb..1bcbfe1ba2 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -24,6 +24,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_hello_request(msg); break; } +#ifdef USE_API_PASSWORD case ConnectRequest::MESSAGE_TYPE: { ConnectRequest msg; msg.decode(msg_data, msg_size); @@ -33,6 +34,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_connect_request(msg); break; } +#endif case DisconnectRequest::MESSAGE_TYPE: { DisconnectRequest msg; // Empty message: no decode needed @@ -597,11 +599,13 @@ void APIServerConnection::on_hello_request(const HelloRequest &msg) { this->on_fatal_error(); } } +#ifdef USE_API_PASSWORD void APIServerConnection::on_connect_request(const ConnectRequest &msg) { if (!this->send_connect_response(msg)) { this->on_fatal_error(); } } +#endif void APIServerConnection::on_disconnect_request(const DisconnectRequest &msg) { if (!this->send_disconnect_response(msg)) { this->on_fatal_error(); diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 5b7508e786..cc7604c083 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -26,7 +26,9 @@ class APIServerConnectionBase : public ProtoService { virtual void on_hello_request(const HelloRequest &value){}; +#ifdef USE_API_PASSWORD virtual void on_connect_request(const ConnectRequest &value){}; +#endif virtual void on_disconnect_request(const DisconnectRequest &value){}; virtual void on_disconnect_response(const DisconnectResponse &value){}; @@ -213,7 +215,9 @@ class APIServerConnectionBase : public ProtoService { class APIServerConnection : public APIServerConnectionBase { public: virtual bool send_hello_response(const HelloRequest &msg) = 0; +#ifdef USE_API_PASSWORD virtual bool send_connect_response(const ConnectRequest &msg) = 0; +#endif 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; @@ -334,7 +338,9 @@ class APIServerConnection : public APIServerConnectionBase { #endif protected: void on_hello_request(const HelloRequest &msg) override; +#ifdef USE_API_PASSWORD void on_connect_request(const ConnectRequest &msg) override; +#endif 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; From 73773ed5c6208a23d7cfdf311c56989b031b4000 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 21:22:17 -0500 Subject: [PATCH 1130/1626] [api] Rename ConnectRequest/Response to AuthenticationRequest/Response in API --- esphome/components/api/api.proto | 6 +++--- esphome/components/api/api_connection.cpp | 6 +++--- esphome/components/api/api_connection.h | 2 +- esphome/components/api/api_pb2.cpp | 6 +++--- esphome/components/api/api_pb2.h | 8 ++++---- esphome/components/api/api_pb2_dump.cpp | 7 +++++-- esphome/components/api/api_pb2_service.cpp | 12 ++++++------ esphome/components/api/api_pb2_service.h | 6 +++--- 8 files changed, 28 insertions(+), 25 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f82e762345..37e4c16bfc 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -7,7 +7,7 @@ service APIConnection { option (needs_setup_connection) = false; option (needs_authentication) = false; } - rpc connect (ConnectRequest) returns (ConnectResponse) { + rpc authenticate (AuthenticationRequest) returns (AuthenticationResponse) { option (needs_setup_connection) = false; option (needs_authentication) = false; } @@ -129,7 +129,7 @@ message HelloResponse { // Message sent at the beginning of each connection to authenticate the client // Can only be sent by the client and only at the beginning of the connection -message ConnectRequest { +message AuthenticationRequest { option (id) = 3; option (source) = SOURCE_CLIENT; option (no_delay) = true; @@ -141,7 +141,7 @@ message ConnectRequest { // Confirmation of successful connection. After this the connection is available for all traffic. // Can only be sent by the server and only at the beginning of the connection -message ConnectResponse { +message AuthenticationResponse { option (id) = 4; option (source) = SOURCE_SERVER; option (no_delay) = true; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 79f9fe9a08..dfcab4bda6 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1387,14 +1387,14 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { return this->send_message(resp, HelloResponse::MESSAGE_TYPE); } #ifdef USE_API_PASSWORD -bool APIConnection::send_connect_response(const ConnectRequest &msg) { - ConnectResponse resp; +bool APIConnection::send_authenticate_response(const AuthenticationRequest &msg) { + AuthenticationResponse resp; // bool invalid_password = 1; resp.invalid_password = !this->parent_->check_password(msg.password); if (!resp.invalid_password) { this->complete_authentication_(); } - return this->send_message(resp, ConnectResponse::MESSAGE_TYPE); + return this->send_message(resp, AuthenticationResponse::MESSAGE_TYPE); } #endif // USE_API_PASSWORD diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 70fc881a82..7d50aa4591 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -198,7 +198,7 @@ class APIConnection final : public APIServerConnection { #endif bool send_hello_response(const HelloRequest &msg) override; #ifdef USE_API_PASSWORD - bool send_connect_response(const ConnectRequest &msg) override; + bool send_authenticate_response(const AuthenticationRequest &msg) override; #endif bool send_disconnect_response(const DisconnectRequest &msg) override; bool send_ping_response(const PingRequest &msg) override; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 7b1601515e..4f2130466a 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -43,7 +43,7 @@ void HelloResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->name_ref_.size()); } #ifdef USE_API_PASSWORD -bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { +bool AuthenticationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: this->password = value.as_string(); @@ -53,8 +53,8 @@ bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value } return true; } -void ConnectResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->invalid_password); } -void ConnectResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->invalid_password); } +void AuthenticationResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->invalid_password); } +void AuthenticationResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->invalid_password); } #endif #ifdef USE_AREAS void AreaInfo::encode(ProtoWriteBuffer buffer) const { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 421789a770..e68fce75aa 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -361,12 +361,12 @@ class HelloResponse final : public ProtoMessage { protected: }; #ifdef USE_API_PASSWORD -class ConnectRequest final : public ProtoDecodableMessage { +class AuthenticationRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 3; static constexpr uint8_t ESTIMATED_SIZE = 9; #ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "connect_request"; } + const char *message_name() const override { return "authentication_request"; } #endif std::string password{}; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -376,12 +376,12 @@ class ConnectRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class ConnectResponse final : public ProtoMessage { +class AuthenticationResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 4; static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "connect_response"; } + const char *message_name() const override { return "authentication_response"; } #endif bool invalid_password{false}; void encode(ProtoWriteBuffer buffer) const override; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 53af7eefef..222aa2b603 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -670,8 +670,11 @@ void HelloResponse::dump_to(std::string &out) const { dump_field(out, "name", this->name_ref_); } #ifdef USE_API_PASSWORD -void ConnectRequest::dump_to(std::string &out) const { dump_field(out, "password", this->password); } -void ConnectResponse::dump_to(std::string &out) const { dump_field(out, "invalid_password", this->invalid_password); } +void AuthenticationRequest::dump_to(std::string &out) const { dump_field(out, "password", this->password); } +void AuthenticationResponse::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "AuthenticationResponse"); + dump_field(out, "invalid_password", this->invalid_password); +} #endif void DisconnectRequest::dump_to(std::string &out) const { out.append("DisconnectRequest {}"); } void DisconnectResponse::dump_to(std::string &out) const { out.append("DisconnectResponse {}"); } diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 1bcbfe1ba2..ef7acbc6b2 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -25,13 +25,13 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #ifdef USE_API_PASSWORD - case ConnectRequest::MESSAGE_TYPE: { - ConnectRequest msg; + case AuthenticationRequest::MESSAGE_TYPE: { + AuthenticationRequest msg; msg.decode(msg_data, msg_size); #ifdef HAS_PROTO_MESSAGE_DUMP - ESP_LOGVV(TAG, "on_connect_request: %s", msg.dump().c_str()); + ESP_LOGVV(TAG, "on_authentication_request: %s", msg.dump().c_str()); #endif - this->on_connect_request(msg); + this->on_authentication_request(msg); break; } #endif @@ -600,8 +600,8 @@ void APIServerConnection::on_hello_request(const HelloRequest &msg) { } } #ifdef USE_API_PASSWORD -void APIServerConnection::on_connect_request(const ConnectRequest &msg) { - if (!this->send_connect_response(msg)) { +void APIServerConnection::on_authentication_request(const AuthenticationRequest &msg) { + if (!this->send_authenticate_response(msg)) { this->on_fatal_error(); } } diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index cc7604c083..f81ac1a337 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -27,7 +27,7 @@ class APIServerConnectionBase : public ProtoService { virtual void on_hello_request(const HelloRequest &value){}; #ifdef USE_API_PASSWORD - virtual void on_connect_request(const ConnectRequest &value){}; + virtual void on_authentication_request(const AuthenticationRequest &value){}; #endif virtual void on_disconnect_request(const DisconnectRequest &value){}; @@ -216,7 +216,7 @@ class APIServerConnection : public APIServerConnectionBase { public: virtual bool send_hello_response(const HelloRequest &msg) = 0; #ifdef USE_API_PASSWORD - virtual bool send_connect_response(const ConnectRequest &msg) = 0; + virtual bool send_authenticate_response(const AuthenticationRequest &msg) = 0; #endif virtual bool send_disconnect_response(const DisconnectRequest &msg) = 0; virtual bool send_ping_response(const PingRequest &msg) = 0; @@ -339,7 +339,7 @@ class APIServerConnection : public APIServerConnectionBase { protected: void on_hello_request(const HelloRequest &msg) override; #ifdef USE_API_PASSWORD - void on_connect_request(const ConnectRequest &msg) override; + void on_authentication_request(const AuthenticationRequest &msg) override; #endif void on_disconnect_request(const DisconnectRequest &msg) override; void on_ping_request(const PingRequest &msg) override; From 913a088c3376c1b58b2dfdf9f58cd4d68def14aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 02:46:11 +0000 Subject: [PATCH 1131/1626] Bump aioesphomeapi from 40.2.1 to 41.0.0 Bumps [aioesphomeapi](https://github.com/esphome/aioesphomeapi) from 40.2.1 to 41.0.0. - [Release notes](https://github.com/esphome/aioesphomeapi/releases) - [Commits](https://github.com/esphome/aioesphomeapi/compare/v40.2.1...v41.0.0) --- updated-dependencies: - dependency-name: aioesphomeapi dependency-version: 41.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 296485bdae..e8bd1ba7c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==40.2.1 +aioesphomeapi==41.0.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 43a2f20ea7afaf7eb3fe588019d0e0327922ce4a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 22:18:44 -0500 Subject: [PATCH 1132/1626] [json] Only compile SpiRamAllocator when PSRAM is enabled --- esphome/components/json/json_util.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 94c531222a..842b5e283a 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -8,7 +8,9 @@ namespace json { static const char *const TAG = "json"; +#ifdef USE_PSRAM // Build an allocator for the JSON Library using the RAMAllocator class +// This is only compiled when PSRAM is enabled struct SpiRamAllocator : ArduinoJson::Allocator { void *allocate(size_t size) override { return this->allocator_.allocate(size); } @@ -29,11 +31,16 @@ struct SpiRamAllocator : ArduinoJson::Allocator { protected: RAMAllocator allocator_{RAMAllocator(RAMAllocator::NONE)}; }; +#endif std::string build_json(const json_build_t &f) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson +#ifdef USE_PSRAM auto doc_allocator = SpiRamAllocator(); JsonDocument json_document(&doc_allocator); +#else + JsonDocument json_document; +#endif if (json_document.overflowed()) { ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); return "{}"; @@ -52,8 +59,12 @@ std::string build_json(const json_build_t &f) { bool parse_json(const std::string &data, const json_parse_t &f) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson +#ifdef USE_PSRAM auto doc_allocator = SpiRamAllocator(); JsonDocument json_document(&doc_allocator); +#else + JsonDocument json_document; +#endif if (json_document.overflowed()) { ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); return false; From c7ec5c820aec5ec26e8c8776595495e69519c6f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 22:35:26 -0500 Subject: [PATCH 1133/1626] [esp32] Optimize NVS preferences memory usage by replacing vector with unique_ptr --- esphome/components/esp32/preferences.cpp | 32 ++++++++++++++---------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index c5b07b497c..6fe2a2dec0 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -17,7 +17,14 @@ static const char *const TAG = "esp32.preferences"; struct NVSData { std::string key; - std::vector data; + std::unique_ptr data; + size_t len; + + void set_data(const uint8_t *src, size_t size) { + data = std::make_unique(size); + memcpy(data.get(), src, size); + len = size; + } }; static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -30,14 +37,14 @@ class ESP32PreferenceBackend : public ESPPreferenceBackend { // try find in pending saves and update that for (auto &obj : s_pending_save) { if (obj.key == key) { - obj.data.assign(data, data + len); + obj.set_data(data, len); return true; } } NVSData save{}; save.key = key; - save.data.assign(data, data + len); - s_pending_save.emplace_back(save); + save.set_data(data, len); + s_pending_save.emplace_back(std::move(save)); ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %d", key.c_str(), len); return true; } @@ -45,11 +52,11 @@ class ESP32PreferenceBackend : public ESPPreferenceBackend { // try find in pending saves and load from that for (auto &obj : s_pending_save) { if (obj.key == key) { - if (obj.data.size() != len) { + if (obj.len != len) { // size mismatch return false; } - memcpy(data, obj.data.data(), len); + memcpy(data, obj.data.get(), len); return true; } } @@ -123,11 +130,10 @@ class ESP32Preferences : public ESPPreferences { const auto &save = s_pending_save[i]; ESP_LOGVV(TAG, "Checking if NVS data %s has changed", save.key.c_str()); if (is_changed(nvs_handle, save)) { - esp_err_t err = nvs_set_blob(nvs_handle, save.key.c_str(), save.data.data(), save.data.size()); - ESP_LOGV(TAG, "sync: key: %s, len: %d", save.key.c_str(), save.data.size()); + esp_err_t err = nvs_set_blob(nvs_handle, save.key.c_str(), save.data.get(), save.len); + ESP_LOGV(TAG, "sync: key: %s, len: %d", save.key.c_str(), save.len); if (err != 0) { - ESP_LOGV(TAG, "nvs_set_blob('%s', len=%u) failed: %s", save.key.c_str(), save.data.size(), - esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_set_blob('%s', len=%u) failed: %s", save.key.c_str(), save.len, esp_err_to_name(err)); failed++; last_err = err; last_key = save.key; @@ -135,7 +141,7 @@ class ESP32Preferences : public ESPPreferences { } written++; } else { - ESP_LOGV(TAG, "NVS data not changed skipping %s len=%u", save.key.c_str(), save.data.size()); + ESP_LOGV(TAG, "NVS data not changed skipping %s len=%u", save.key.c_str(), save.len); cached++; } s_pending_save.erase(s_pending_save.begin() + i); @@ -164,7 +170,7 @@ class ESP32Preferences : public ESPPreferences { return true; } // Check size first before allocating memory - if (actual_len != to_save.data.size()) { + if (actual_len != to_save.len) { return true; } auto stored_data = std::make_unique(actual_len); @@ -173,7 +179,7 @@ class ESP32Preferences : public ESPPreferences { ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", to_save.key.c_str(), esp_err_to_name(err)); return true; } - return memcmp(to_save.data.data(), stored_data.get(), to_save.data.size()) != 0; + return memcmp(to_save.data.get(), stored_data.get(), to_save.len) != 0; } bool reset() override { From 2df57e622cdbdfe1a908c90c81ab1e60c5878a1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 22:47:43 -0500 Subject: [PATCH 1134/1626] zu --- esphome/components/esp32/preferences.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 6fe2a2dec0..7bdbb265ca 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -45,7 +45,7 @@ class ESP32PreferenceBackend : public ESPPreferenceBackend { save.key = key; save.set_data(data, len); s_pending_save.emplace_back(std::move(save)); - ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %d", key.c_str(), len); + ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %zu", key.c_str(), len); return true; } bool load(uint8_t *data, size_t len) override { @@ -68,7 +68,7 @@ class ESP32PreferenceBackend : public ESPPreferenceBackend { return false; } if (actual_len != len) { - ESP_LOGVV(TAG, "NVS length does not match (%u!=%u)", actual_len, len); + ESP_LOGVV(TAG, "NVS length does not match (%zu!=%zu)", actual_len, len); return false; } err = nvs_get_blob(nvs_handle, key.c_str(), data, &len); @@ -76,7 +76,7 @@ class ESP32PreferenceBackend : public ESPPreferenceBackend { ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", key.c_str(), esp_err_to_name(err)); return false; } else { - ESP_LOGVV(TAG, "nvs_get_blob: key: %s, len: %d", key.c_str(), len); + ESP_LOGVV(TAG, "nvs_get_blob: key: %s, len: %zu", key.c_str(), len); } return true; } @@ -119,7 +119,7 @@ class ESP32Preferences : public ESPPreferences { if (s_pending_save.empty()) return true; - ESP_LOGV(TAG, "Saving %d items...", s_pending_save.size()); + ESP_LOGV(TAG, "Saving %zu items...", s_pending_save.size()); // goal try write all pending saves even if one fails int cached = 0, written = 0, failed = 0; esp_err_t last_err = ESP_OK; @@ -131,9 +131,9 @@ class ESP32Preferences : public ESPPreferences { ESP_LOGVV(TAG, "Checking if NVS data %s has changed", save.key.c_str()); if (is_changed(nvs_handle, save)) { esp_err_t err = nvs_set_blob(nvs_handle, save.key.c_str(), save.data.get(), save.len); - ESP_LOGV(TAG, "sync: key: %s, len: %d", save.key.c_str(), save.len); + ESP_LOGV(TAG, "sync: key: %s, len: %zu", save.key.c_str(), save.len); if (err != 0) { - ESP_LOGV(TAG, "nvs_set_blob('%s', len=%u) failed: %s", save.key.c_str(), save.len, esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_set_blob('%s', len=%zu) failed: %s", save.key.c_str(), save.len, esp_err_to_name(err)); failed++; last_err = err; last_key = save.key; @@ -141,7 +141,7 @@ class ESP32Preferences : public ESPPreferences { } written++; } else { - ESP_LOGV(TAG, "NVS data not changed skipping %s len=%u", save.key.c_str(), save.len); + ESP_LOGV(TAG, "NVS data not changed skipping %s len=%zu", save.key.c_str(), save.len); cached++; } s_pending_save.erase(s_pending_save.begin() + i); From f8ff00af06a832ec2e070e07bbc0a0c068378204 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 23:15:33 -0500 Subject: [PATCH 1135/1626] [libretiny] Optimize preferences memory usage by replacing vector with unique_ptr --- esphome/components/libretiny/preferences.cpp | 39 ++++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index fc535c99b4..871b186d8e 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -15,7 +15,14 @@ static const char *const TAG = "lt.preferences"; struct NVSData { std::string key; - std::vector data; + std::unique_ptr data; + size_t len; + + void set_data(const uint8_t *src, size_t size) { + data = std::make_unique(size); + memcpy(data.get(), src, size); + len = size; + } }; static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -30,15 +37,15 @@ class LibreTinyPreferenceBackend : public ESPPreferenceBackend { // try find in pending saves and update that for (auto &obj : s_pending_save) { if (obj.key == key) { - obj.data.assign(data, data + len); + obj.set_data(data, len); return true; } } NVSData save{}; save.key = key; - save.data.assign(data, data + len); - s_pending_save.emplace_back(save); - ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %d", key.c_str(), len); + save.set_data(data, len); + s_pending_save.emplace_back(std::move(save)); + ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %zu", key.c_str(), len); return true; } @@ -46,11 +53,11 @@ class LibreTinyPreferenceBackend : public ESPPreferenceBackend { // try find in pending saves and load from that for (auto &obj : s_pending_save) { if (obj.key == key) { - if (obj.data.size() != len) { + if (obj.len != len) { // size mismatch return false; } - memcpy(data, obj.data.data(), len); + memcpy(data, obj.data.get(), len); return true; } } @@ -58,10 +65,10 @@ class LibreTinyPreferenceBackend : public ESPPreferenceBackend { fdb_blob_make(blob, data, len); size_t actual_len = fdb_kv_get_blob(db, key.c_str(), blob); if (actual_len != len) { - ESP_LOGVV(TAG, "NVS length does not match (%u!=%u)", actual_len, len); + ESP_LOGVV(TAG, "NVS length does not match (%zu!=%zu)", actual_len, len); return false; } else { - ESP_LOGVV(TAG, "fdb_kv_get_blob: key: %s, len: %d", key.c_str(), len); + ESP_LOGVV(TAG, "fdb_kv_get_blob: key: %s, len: %zu", key.c_str(), len); } return true; } @@ -101,7 +108,7 @@ class LibreTinyPreferences : public ESPPreferences { if (s_pending_save.empty()) return true; - ESP_LOGV(TAG, "Saving %d items...", s_pending_save.size()); + ESP_LOGV(TAG, "Saving %zu items...", s_pending_save.size()); // goal try write all pending saves even if one fails int cached = 0, written = 0, failed = 0; fdb_err_t last_err = FDB_NO_ERR; @@ -112,11 +119,11 @@ class LibreTinyPreferences : public ESPPreferences { const auto &save = s_pending_save[i]; ESP_LOGVV(TAG, "Checking if FDB data %s has changed", save.key.c_str()); if (is_changed(&db, save)) { - ESP_LOGV(TAG, "sync: key: %s, len: %d", save.key.c_str(), save.data.size()); - fdb_blob_make(&blob, save.data.data(), save.data.size()); + ESP_LOGV(TAG, "sync: key: %s, len: %zu", save.key.c_str(), save.len); + fdb_blob_make(&blob, save.data.get(), save.len); fdb_err_t err = fdb_kv_set_blob(&db, save.key.c_str(), &blob); if (err != FDB_NO_ERR) { - ESP_LOGV(TAG, "fdb_kv_set_blob('%s', len=%u) failed: %d", save.key.c_str(), save.data.size(), err); + ESP_LOGV(TAG, "fdb_kv_set_blob('%s', len=%zu) failed: %d", save.key.c_str(), save.len, err); failed++; last_err = err; last_key = save.key; @@ -124,7 +131,7 @@ class LibreTinyPreferences : public ESPPreferences { } written++; } else { - ESP_LOGD(TAG, "FDB data not changed; skipping %s len=%u", save.key.c_str(), save.data.size()); + ESP_LOGD(TAG, "FDB data not changed; skipping %s len=%zu", save.key.c_str(), save.len); cached++; } s_pending_save.erase(s_pending_save.begin() + i); @@ -147,7 +154,7 @@ class LibreTinyPreferences : public ESPPreferences { } // Check size first - if different, data has changed - if (kv.value_len != to_save.data.size()) { + if (kv.value_len != to_save.len) { return true; } @@ -161,7 +168,7 @@ class LibreTinyPreferences : public ESPPreferences { } // Compare the actual data - return memcmp(to_save.data.data(), stored_data.get(), kv.value_len) != 0; + return memcmp(to_save.data.get(), stored_data.get(), kv.value_len) != 0; } bool reset() override { From 5fac67d19572edae522057df87b6265900819a51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 11:50:11 -0500 Subject: [PATCH 1136/1626] [json] Only compile SpiRamAllocator when PSRAM is enabled (#10728) --- esphome/components/json/json_util.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 94c531222a..842b5e283a 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -8,7 +8,9 @@ namespace json { static const char *const TAG = "json"; +#ifdef USE_PSRAM // Build an allocator for the JSON Library using the RAMAllocator class +// This is only compiled when PSRAM is enabled struct SpiRamAllocator : ArduinoJson::Allocator { void *allocate(size_t size) override { return this->allocator_.allocate(size); } @@ -29,11 +31,16 @@ struct SpiRamAllocator : ArduinoJson::Allocator { protected: RAMAllocator allocator_{RAMAllocator(RAMAllocator::NONE)}; }; +#endif std::string build_json(const json_build_t &f) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson +#ifdef USE_PSRAM auto doc_allocator = SpiRamAllocator(); JsonDocument json_document(&doc_allocator); +#else + JsonDocument json_document; +#endif if (json_document.overflowed()) { ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); return "{}"; @@ -52,8 +59,12 @@ std::string build_json(const json_build_t &f) { bool parse_json(const std::string &data, const json_parse_t &f) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson +#ifdef USE_PSRAM auto doc_allocator = SpiRamAllocator(); JsonDocument json_document(&doc_allocator); +#else + JsonDocument json_document; +#endif if (json_document.overflowed()) { ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); return false; From d8385780f1de4e60a5a77ecf20d814a0d17a1d1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 12:15:29 -0500 Subject: [PATCH 1137/1626] [select] Use const references to avoid unnecessary vector copies --- esphome/components/select/select.cpp | 6 +++--- esphome/components/select/select_call.cpp | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 37887da27c..beb72aa320 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -28,12 +28,12 @@ bool Select::has_option(const std::string &option) const { return this->index_of bool Select::has_index(size_t index) const { return index < this->size(); } size_t Select::size() const { - auto options = traits.get_options(); + const auto &options = traits.get_options(); return options.size(); } optional Select::index_of(const std::string &option) const { - auto options = traits.get_options(); + const auto &options = traits.get_options(); auto it = std::find(options.begin(), options.end(), option); if (it == options.end()) { return {}; @@ -51,7 +51,7 @@ optional Select::active_index() const { optional Select::at(size_t index) const { if (this->has_index(index)) { - auto options = traits.get_options(); + const auto &options = traits.get_options(); return options.at(index); } else { return {}; diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index 85f755645c..a8272f8622 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -45,7 +45,7 @@ void SelectCall::perform() { auto *parent = this->parent_; const auto *name = parent->get_name().c_str(); const auto &traits = parent->traits; - auto options = traits.get_options(); + const auto &options = traits.get_options(); if (this->operation_ == SELECT_OP_NONE) { ESP_LOGW(TAG, "'%s' - SelectCall performed without selecting an operation", name); From 612fb4cc3c6b9e304febca2683e448f23aada45b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Sep 2025 08:03:02 +1200 Subject: [PATCH 1138/1626] [CI] Check esp32 boards file is up to date (#10730) --- .github/workflows/ci.yml | 1 + esphome/components/esp32/boards.py | 81 ++++++++++++++++++++++++++++++ script/generate-esp32-boards.py | 77 +++++++++++++++++----------- 3 files changed, 130 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07fd91b1c8..a89a12e2e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,7 @@ jobs: script/ci-custom.py script/build_codeowners.py --check script/build_language_schema.py --check + script/generate-esp32-boards.py --check pytest: name: Run pytest diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index cf6cf8cbe5..5f039492c8 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1504,6 +1504,10 @@ BOARDS = { "name": "BPI-Bit", "variant": VARIANT_ESP32, }, + "bpi-centi-s3": { + "name": "BPI-Centi-S3", + "variant": VARIANT_ESP32S3, + }, "bpi_leaf_s3": { "name": "BPI-Leaf-S3", "variant": VARIANT_ESP32S3, @@ -1664,10 +1668,46 @@ BOARDS = { "name": "Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)", "variant": VARIANT_ESP32S3, }, + "esp32-s3-devkitc-1-n32r8v": { + "name": "Espressif ESP32-S3-DevKitC-1-N32R8V (32 MB Flash Octal, 8 MB PSRAM Octal)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n16r16": { + "name": "Espressif ESP32-S3-DevKitC-1-N16R16V (16 MB Flash Quad, 16 MB PSRAM Octal)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n16r2": { + "name": "Espressif ESP32-S3-DevKitC-1-N16R2 (16 MB Flash Quad, 2 MB PSRAM Quad)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n16r8": { + "name": "Espressif ESP32-S3-DevKitC-1-N16R8V (16 MB Flash Quad, 8 MB PSRAM Octal)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n4r2": { + "name": "Espressif ESP32-S3-DevKitC-1-N4R2 (4 MB Flash Quad, 2 MB PSRAM Quad)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n4r8": { + "name": "Espressif ESP32-S3-DevKitC-1-N4R8 (4 MB Flash Quad, 8 MB PSRAM Octal)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n8r2": { + "name": "Espressif ESP32-S3-DevKitC-1-N8R2 (8 MB Flash Quad, 2 MB PSRAM quad)", + "variant": VARIANT_ESP32S3, + }, + "esp32-s3-devkitc1-n8r8": { + "name": "Espressif ESP32-S3-DevKitC-1-N8R8 (8 MB Flash Quad, 8 MB PSRAM Octal)", + "variant": VARIANT_ESP32S3, + }, "esp32-s3-devkitm-1": { "name": "Espressif ESP32-S3-DevKitM-1", "variant": VARIANT_ESP32S3, }, + "esp32-s3-fh4r2": { + "name": "Espressif ESP32-S3-FH4R2 (4 MB QD, 2MB PSRAM)", + "variant": VARIANT_ESP32S3, + }, "esp32-solo1": { "name": "Espressif Generic ESP32-solo1 4M Flash", "variant": VARIANT_ESP32, @@ -1764,6 +1804,10 @@ BOARDS = { "name": "Franzininho WiFi MSC", "variant": VARIANT_ESP32S2, }, + "freenove-esp32-s3-n8r8": { + "name": "Freenove ESP32-S3 WROOM N8R8 (8MB Flash / 8MB PSRAM)", + "variant": VARIANT_ESP32S3, + }, "freenove_esp32_s3_wroom": { "name": "Freenove ESP32-S3 WROOM N8R8 (8MB Flash / 8MB PSRAM)", "variant": VARIANT_ESP32S3, @@ -1964,6 +2008,10 @@ BOARDS = { "name": "M5Stack AtomS3", "variant": VARIANT_ESP32S3, }, + "m5stack-atoms3u": { + "name": "M5Stack AtomS3U", + "variant": VARIANT_ESP32S3, + }, "m5stack-core-esp32": { "name": "M5Stack Core ESP32", "variant": VARIANT_ESP32, @@ -2084,6 +2132,10 @@ BOARDS = { "name": "Ai-Thinker NodeMCU-32S2 (ESP-12K)", "variant": VARIANT_ESP32S2, }, + "nologo_esp32c3_super_mini": { + "name": "Nologo ESP32C3 SuperMini", + "variant": VARIANT_ESP32C3, + }, "nscreen-32": { "name": "YeaCreate NSCREEN-32", "variant": VARIANT_ESP32, @@ -2192,6 +2244,10 @@ BOARDS = { "name": "SparkFun LoRa Gateway 1-Channel", "variant": VARIANT_ESP32, }, + "sparkfun_pro_micro_esp32c3": { + "name": "SparkFun Pro Micro ESP32-C3", + "variant": VARIANT_ESP32C3, + }, "sparkfun_qwiic_pocket_esp32c6": { "name": "SparkFun ESP32-C6 Qwiic Pocket", "variant": VARIANT_ESP32C6, @@ -2256,6 +2312,14 @@ BOARDS = { "name": "Turta IoT Node", "variant": VARIANT_ESP32, }, + "um_bling": { + "name": "Unexpected Maker BLING!", + "variant": VARIANT_ESP32S3, + }, + "um_edges3_d": { + "name": "Unexpected Maker EDGES3[D]", + "variant": VARIANT_ESP32S3, + }, "um_feathers2": { "name": "Unexpected Maker FeatherS2", "variant": VARIANT_ESP32S2, @@ -2268,10 +2332,18 @@ BOARDS = { "name": "Unexpected Maker FeatherS3", "variant": VARIANT_ESP32S3, }, + "um_feathers3_neo": { + "name": "Unexpected Maker FeatherS3 Neo", + "variant": VARIANT_ESP32S3, + }, "um_nanos3": { "name": "Unexpected Maker NanoS3", "variant": VARIANT_ESP32S3, }, + "um_omgs3": { + "name": "Unexpected Maker OMGS3", + "variant": VARIANT_ESP32S3, + }, "um_pros3": { "name": "Unexpected Maker PROS3", "variant": VARIANT_ESP32S3, @@ -2280,6 +2352,14 @@ BOARDS = { "name": "Unexpected Maker RMP", "variant": VARIANT_ESP32S2, }, + "um_squixl": { + "name": "Unexpected Maker SQUiXL", + "variant": VARIANT_ESP32S3, + }, + "um_tinyc6": { + "name": "Unexpected Maker TinyC6", + "variant": VARIANT_ESP32C6, + }, "um_tinys2": { "name": "Unexpected Maker TinyS2", "variant": VARIANT_ESP32S2, @@ -2401,3 +2481,4 @@ BOARDS = { "variant": VARIANT_ESP32S3, }, } +# DO NOT ADD ANYTHING BELOW THIS LINE diff --git a/script/generate-esp32-boards.py b/script/generate-esp32-boards.py index 3f444ed455..152a480d23 100755 --- a/script/generate-esp32-boards.py +++ b/script/generate-esp32-boards.py @@ -1,14 +1,18 @@ #!/usr/bin/env python3 +import argparse import json -import os +from pathlib import Path import subprocess +import sys import tempfile from esphome.components.esp32 import ESP_IDF_PLATFORM_VERSION as ver +from esphome.helpers import write_file_if_changed version_str = f"{ver.major}.{ver.minor:02d}.{ver.patch:02d}" -print(f"ESP32 Platform Version: {version_str}") +root = Path(__file__).parent.parent +boards_file_path = root / "esphome" / "components" / "esp32" / "boards.py" def get_boards(): @@ -17,6 +21,9 @@ def get_boards(): [ "git", "clone", + "-q", + "-c", + "advice.detachedHead=false", "--depth", "1", "--branch", @@ -26,16 +33,14 @@ def get_boards(): ], check=True, ) - boards_file = os.path.join(tempdir, "boards") + boards_directory = Path(tempdir) / "boards" boards = {} - for fname in os.listdir(boards_file): - if not fname.endswith(".json"): - continue - with open(os.path.join(boards_file, fname), encoding="utf-8") as f: + for fname in boards_directory.glob("*.json"): + with fname.open(encoding="utf-8") as f: board_info = json.load(f) mcu = board_info["build"]["mcu"] name = board_info["name"] - board = fname[:-5] + board = fname.stem variant = mcu.upper() boards[board] = { "name": name, @@ -47,33 +52,47 @@ def get_boards(): TEMPLATE = """ "%s": { "name": "%s", "variant": %s, - }, -""" + },""" -def main(): +def main(check: bool): boards = get_boards() # open boards.py, delete existing BOARDS variable and write the new boards dict - boards_file_path = os.path.join( - os.path.dirname(__file__), "..", "esphome", "components", "esp32", "boards.py" - ) - with open(boards_file_path, encoding="UTF-8") as f: - lines = f.readlines() + existing_content = boards_file_path.read_text(encoding="UTF-8") - with open(boards_file_path, "w", encoding="UTF-8") as f: - for line in lines: - if line.startswith("BOARDS = {"): - f.write("BOARDS = {\n") - f.writelines( - TEMPLATE % (board, info["name"], info["variant"]) - for board, info in sorted(boards.items()) - ) - f.write("}\n") - break + parts: list[str] = [] + for line in existing_content.splitlines(): + if line == "BOARDS = {": + parts.append(line) + parts.extend( + TEMPLATE % (board, info["name"], info["variant"]) + for board, info in sorted(boards.items()) + ) + parts.append("}") + parts.append("# DO NOT ADD ANYTHING BELOW THIS LINE") + break - f.write(line) + parts.append(line) + + parts.append("") + content = "\n".join(parts) + + if check: + if existing_content != content: + print("boards.py file is not up to date.") + print("Please run `script/generate-esp32-boards.py`") + sys.exit(1) + print("boards.py file is up to date") + elif write_file_if_changed(boards_file_path, content): + print("ESP32 boards updated successfully.") if __name__ == "__main__": - main() - print("ESP32 boards updated successfully.") + parser = argparse.ArgumentParser() + parser.add_argument( + "--check", + help="Check if the boards.py file is up to date.", + action="store_true", + ) + args = parser.parse_args() + main(args.check) From a723673dcc9d40895df074797bffd02c7069e786 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 16:16:26 -0500 Subject: [PATCH 1139/1626] [select] Use const references to avoid unnecessary vector copies (#10741) --- esphome/components/select/select.cpp | 6 +++--- esphome/components/select/select_call.cpp | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 37887da27c..beb72aa320 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -28,12 +28,12 @@ bool Select::has_option(const std::string &option) const { return this->index_of bool Select::has_index(size_t index) const { return index < this->size(); } size_t Select::size() const { - auto options = traits.get_options(); + const auto &options = traits.get_options(); return options.size(); } optional Select::index_of(const std::string &option) const { - auto options = traits.get_options(); + const auto &options = traits.get_options(); auto it = std::find(options.begin(), options.end(), option); if (it == options.end()) { return {}; @@ -51,7 +51,7 @@ optional Select::active_index() const { optional Select::at(size_t index) const { if (this->has_index(index)) { - auto options = traits.get_options(); + const auto &options = traits.get_options(); return options.at(index); } else { return {}; diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index 85f755645c..a8272f8622 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -45,7 +45,7 @@ void SelectCall::perform() { auto *parent = this->parent_; const auto *name = parent->get_name().c_str(); const auto &traits = parent->traits; - auto options = traits.get_options(); + const auto &options = traits.get_options(); if (this->operation_ == SELECT_OP_NONE) { ESP_LOGW(TAG, "'%s' - SelectCall performed without selecting an operation", name); From fa00e07e1032163e037bd0d4ab1f04cf3b50adc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 17:19:28 -0500 Subject: [PATCH 1140/1626] fix --- esphome/dashboard/web_server.py | 9 ++++----- tests/dashboard/test_web_server.py | 18 +++++++++++------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index e4c0b5b84a..ef6ec061cd 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1038,12 +1038,11 @@ class ArchiveRequestHandler(BaseHandler): shutil.move(config_file, os.path.join(archive_path, configuration)) storage_json = StorageJSON.load(storage_path) - if storage_json is not None: - # Move build folder to archive (if exists) - name = storage_json.name - build_folder = os.path.join(settings.config_dir, name) + if storage_json is not None and storage_json.build_path: + # Delete build folder (if exists) + build_folder = storage_json.build_path if os.path.exists(build_folder): - shutil.move(build_folder, os.path.join(archive_path, name)) + shutil.rmtree(build_folder, ignore_errors=True) class UnArchiveRequestHandler(BaseHandler): diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index ea14309997..6bb9a04f6c 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -634,14 +634,16 @@ async def test_archive_handler_with_build_folder( config_dir.mkdir() archive_dir = tmp_path / "archive" archive_dir.mkdir() + build_dir = tmp_path / "build" + build_dir.mkdir() # Create a test configuration file configuration = "test_device.yaml" test_config = config_dir / configuration test_config.write_text("esphome:\n name: test_device\n") - # Create build folder with content - build_folder = config_dir / "test_device" + # Create build folder with content (in proper location) + build_folder = build_dir / "test_device" build_folder.mkdir() (build_folder / "firmware.bin").write_text("binary content") (build_folder / ".pioenvs").mkdir() @@ -651,10 +653,11 @@ async def test_archive_handler_with_build_folder( mock_dashboard_settings.rel_path.return_value = str(test_config) mock_archive_storage_path.return_value = str(archive_dir) - # Mock storage_json with device name + # Mock storage_json with device name and build_path with patch("esphome.dashboard.web_server.StorageJSON.load") as mock_load: mock_storage = MagicMock() mock_storage.name = "test_device" + mock_storage.build_path = str(build_folder) mock_load.return_value = mock_storage # Archive the configuration @@ -670,10 +673,10 @@ async def test_archive_handler_with_build_folder( assert not test_config.exists() assert (archive_dir / configuration).exists() - # Verify build folder was moved to archive + # Verify build folder was deleted (not archived) assert not build_folder.exists() - assert (archive_dir / "test_device").exists() - assert (archive_dir / "test_device" / "firmware.bin").exists() + # Build folder should NOT be in archive + assert not (archive_dir / "test_device").exists() @pytest.mark.asyncio @@ -703,10 +706,11 @@ async def test_archive_handler_no_build_folder( mock_dashboard_settings.rel_path.return_value = str(test_config) mock_archive_storage_path.return_value = str(archive_dir) - # Mock storage_json with device name + # Mock storage_json with device name but no build_path with patch("esphome.dashboard.web_server.StorageJSON.load") as mock_load: mock_storage = MagicMock() mock_storage.name = "test_device" + mock_storage.build_path = None mock_load.return_value = mock_storage # Archive the configuration (should not fail even without build folder) From 47d24edd0eb1324b91f6b383b5a27697647239e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 17:23:01 -0500 Subject: [PATCH 1141/1626] cleanup --- tests/dashboard/test_web_server.py | 72 ++++++++++++++---------------- 1 file changed, 34 insertions(+), 38 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 6bb9a04f6c..6b5b2e7e6a 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -599,18 +599,14 @@ async def test_archive_request_handler_post( test_config = config_dir / "test_archive.yaml" test_config.write_text("esphome:\n name: test_archive\n") - # Mock storage_json to return None (no storage) - with patch("esphome.dashboard.web_server.StorageJSON.load") as mock_load: - mock_load.return_value = None - - # Archive the configuration - response = await dashboard.fetch( - "/archive", - method="POST", - body="configuration=test_archive.yaml", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 + # Archive the configuration + response = await dashboard.fetch( + "/archive", + method="POST", + body="configuration=test_archive.yaml", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 # Verify file was moved to archive assert not test_config.exists() @@ -626,6 +622,7 @@ async def test_archive_handler_with_build_folder( mock_archive_storage_path: MagicMock, mock_ext_storage_path: MagicMock, mock_dashboard_settings: MagicMock, + mock_storage_json: MagicMock, tmp_path: Path, ) -> None: """Test ArchiveRequestHandler.post with storage_json and build folder.""" @@ -654,20 +651,19 @@ async def test_archive_handler_with_build_folder( mock_archive_storage_path.return_value = str(archive_dir) # Mock storage_json with device name and build_path - with patch("esphome.dashboard.web_server.StorageJSON.load") as mock_load: - mock_storage = MagicMock() - mock_storage.name = "test_device" - mock_storage.build_path = str(build_folder) - mock_load.return_value = mock_storage + mock_storage = MagicMock() + mock_storage.name = "test_device" + mock_storage.build_path = str(build_folder) + mock_storage_json.load.return_value = mock_storage - # Archive the configuration - response = await dashboard.fetch( - "/archive", - method="POST", - body=f"configuration={configuration}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 + # Archive the configuration + response = await dashboard.fetch( + "/archive", + method="POST", + body=f"configuration={configuration}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 # Verify config file was moved to archive assert not test_config.exists() @@ -685,6 +681,7 @@ async def test_archive_handler_no_build_folder( mock_archive_storage_path: MagicMock, mock_ext_storage_path: MagicMock, mock_dashboard_settings: MagicMock, + mock_storage_json: MagicMock, tmp_path: Path, ) -> None: """Test ArchiveRequestHandler.post with storage_json but no build folder.""" @@ -707,20 +704,19 @@ async def test_archive_handler_no_build_folder( mock_archive_storage_path.return_value = str(archive_dir) # Mock storage_json with device name but no build_path - with patch("esphome.dashboard.web_server.StorageJSON.load") as mock_load: - mock_storage = MagicMock() - mock_storage.name = "test_device" - mock_storage.build_path = None - mock_load.return_value = mock_storage + mock_storage = MagicMock() + mock_storage.name = "test_device" + mock_storage.build_path = None + mock_storage_json.load.return_value = mock_storage - # Archive the configuration (should not fail even without build folder) - response = await dashboard.fetch( - "/archive", - method="POST", - body=f"configuration={configuration}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 + # Archive the configuration (should not fail even without build folder) + response = await dashboard.fetch( + "/archive", + method="POST", + body=f"configuration={configuration}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 # Verify config file was moved to archive assert not test_config.exists() From f7bfbb619d5eb9acd746259e1de33b421c0fa935 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 17:24:45 -0500 Subject: [PATCH 1142/1626] cleanup --- tests/dashboard/test_web_server.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 6b5b2e7e6a..1ca7478fe8 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -590,16 +590,12 @@ async def test_archive_request_handler_post( tmp_path: Path, ) -> None: """Test ArchiveRequestHandler.post method without storage_json.""" - - # Set up temp directories config_dir = Path(get_fixture_path("conf")) archive_dir = tmp_path / "archive" - # Create a test configuration file test_config = config_dir / "test_archive.yaml" test_config.write_text("esphome:\n name: test_archive\n") - # Archive the configuration response = await dashboard.fetch( "/archive", method="POST", @@ -608,7 +604,6 @@ async def test_archive_request_handler_post( ) assert response.code == 200 - # Verify file was moved to archive assert not test_config.exists() assert (archive_dir / "test_archive.yaml").exists() assert ( From 55684d079e692c42a737db39557a474a2a0ee760 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 17:24:58 -0500 Subject: [PATCH 1143/1626] cleanup --- tests/dashboard/test_web_server.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 1ca7478fe8..d3f5ba8edb 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -621,7 +621,6 @@ async def test_archive_handler_with_build_folder( tmp_path: Path, ) -> None: """Test ArchiveRequestHandler.post with storage_json and build folder.""" - # Set up temp directories config_dir = tmp_path / "config" config_dir.mkdir() archive_dir = tmp_path / "archive" @@ -629,29 +628,24 @@ async def test_archive_handler_with_build_folder( build_dir = tmp_path / "build" build_dir.mkdir() - # Create a test configuration file configuration = "test_device.yaml" test_config = config_dir / configuration test_config.write_text("esphome:\n name: test_device\n") - # Create build folder with content (in proper location) build_folder = build_dir / "test_device" build_folder.mkdir() (build_folder / "firmware.bin").write_text("binary content") (build_folder / ".pioenvs").mkdir() - # Mock settings to use our temp directory mock_dashboard_settings.config_dir = str(config_dir) mock_dashboard_settings.rel_path.return_value = str(test_config) mock_archive_storage_path.return_value = str(archive_dir) - # Mock storage_json with device name and build_path mock_storage = MagicMock() mock_storage.name = "test_device" mock_storage.build_path = str(build_folder) mock_storage_json.load.return_value = mock_storage - # Archive the configuration response = await dashboard.fetch( "/archive", method="POST", @@ -660,13 +654,10 @@ async def test_archive_handler_with_build_folder( ) assert response.code == 200 - # Verify config file was moved to archive assert not test_config.exists() assert (archive_dir / configuration).exists() - # Verify build folder was deleted (not archived) assert not build_folder.exists() - # Build folder should NOT be in archive assert not (archive_dir / "test_device").exists() From 62b713a04cb7ec2ffc14f95dc3dee11e061f59f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 17:25:23 -0500 Subject: [PATCH 1144/1626] cleanup --- tests/dashboard/test_web_server.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index d3f5ba8edb..f434647cec 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -671,31 +671,24 @@ async def test_archive_handler_no_build_folder( tmp_path: Path, ) -> None: """Test ArchiveRequestHandler.post with storage_json but no build folder.""" - # Set up temp directories config_dir = tmp_path / "config" config_dir.mkdir() archive_dir = tmp_path / "archive" archive_dir.mkdir() - # Create a test configuration file configuration = "test_device.yaml" test_config = config_dir / configuration test_config.write_text("esphome:\n name: test_device\n") - # Note: No build folder created - - # Mock settings to use our temp directory mock_dashboard_settings.config_dir = str(config_dir) mock_dashboard_settings.rel_path.return_value = str(test_config) mock_archive_storage_path.return_value = str(archive_dir) - # Mock storage_json with device name but no build_path mock_storage = MagicMock() mock_storage.name = "test_device" mock_storage.build_path = None mock_storage_json.load.return_value = mock_storage - # Archive the configuration (should not fail even without build folder) response = await dashboard.fetch( "/archive", method="POST", @@ -704,11 +697,8 @@ async def test_archive_handler_no_build_folder( ) assert response.code == 200 - # Verify config file was moved to archive assert not test_config.exists() assert (archive_dir / configuration).exists() - - # Verify no build folder in archive (since it didn't exist) assert not (archive_dir / "test_device").exists() From 601c7929131a44daaede2c9c11c1b9113790fd8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 17:25:56 -0500 Subject: [PATCH 1145/1626] cleanup --- esphome/dashboard/web_server.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index ef6ec061cd..e6c5fd3d84 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1040,9 +1040,7 @@ class ArchiveRequestHandler(BaseHandler): storage_json = StorageJSON.load(storage_path) if storage_json is not None and storage_json.build_path: # Delete build folder (if exists) - build_folder = storage_json.build_path - if os.path.exists(build_folder): - shutil.rmtree(build_folder, ignore_errors=True) + shutil.rmtree(storage_json.build_path, ignore_errors=True) class UnArchiveRequestHandler(BaseHandler): From 50f22a362ffb67dd032a45209829a51ba8f86ee0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 17:28:00 -0500 Subject: [PATCH 1146/1626] cleanup --- tests/dashboard/test_web_server.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index f434647cec..1938617f20 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -590,12 +590,16 @@ async def test_archive_request_handler_post( tmp_path: Path, ) -> None: """Test ArchiveRequestHandler.post method without storage_json.""" + + # Set up temp directories config_dir = Path(get_fixture_path("conf")) archive_dir = tmp_path / "archive" + # Create a test configuration file test_config = config_dir / "test_archive.yaml" test_config.write_text("esphome:\n name: test_archive\n") + # Archive the configuration response = await dashboard.fetch( "/archive", method="POST", @@ -604,6 +608,7 @@ async def test_archive_request_handler_post( ) assert response.code == 200 + # Verify file was moved to archive assert not test_config.exists() assert (archive_dir / "test_archive.yaml").exists() assert ( From f3c156ca578a3771e27426c5ad9731602a7cdd61 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 17:29:51 -0500 Subject: [PATCH 1147/1626] add more coverage to make sure we are more careful about deletes --- tests/unit_tests/core/test_config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index e520db9e33..4c543bff9c 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -384,6 +384,8 @@ def test_preload_core_config_basic(setup_core: Path) -> None: assert platform == "esp32" assert KEY_CORE in CORE.data assert CONF_BUILD_PATH in config[CONF_ESPHOME] + # Verify default build path is "build/" + assert config[CONF_ESPHOME][CONF_BUILD_PATH].endswith("build/test_device") def test_preload_core_config_with_build_path(setup_core: Path) -> None: @@ -418,6 +420,8 @@ def test_preload_core_config_env_build_path(setup_core: Path) -> None: assert CONF_BUILD_PATH in config[CONF_ESPHOME] assert "test_device" in config[CONF_ESPHOME][CONF_BUILD_PATH] + # Verify it uses the env var path with device name appended + assert config[CONF_ESPHOME][CONF_BUILD_PATH].endswith("/env/build/test_device") assert platform == "rp2040" From f91a6979b4a02f15d7ef3e1601f27030208bba36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 17:37:35 -0500 Subject: [PATCH 1148/1626] add more coverage to make sure we are more careful about deletes --- tests/unit_tests/core/test_config.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 4c543bff9c..7d3b90794b 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -385,7 +385,8 @@ def test_preload_core_config_basic(setup_core: Path) -> None: assert KEY_CORE in CORE.data assert CONF_BUILD_PATH in config[CONF_ESPHOME] # Verify default build path is "build/" - assert config[CONF_ESPHOME][CONF_BUILD_PATH].endswith("build/test_device") + build_path = config[CONF_ESPHOME][CONF_BUILD_PATH] + assert build_path.endswith(os.path.join("build", "test_device")) def test_preload_core_config_with_build_path(setup_core: Path) -> None: @@ -421,7 +422,11 @@ def test_preload_core_config_env_build_path(setup_core: Path) -> None: assert CONF_BUILD_PATH in config[CONF_ESPHOME] assert "test_device" in config[CONF_ESPHOME][CONF_BUILD_PATH] # Verify it uses the env var path with device name appended - assert config[CONF_ESPHOME][CONF_BUILD_PATH].endswith("/env/build/test_device") + build_path = config[CONF_ESPHOME][CONF_BUILD_PATH] + expected_path = os.path.join("/env/build", "test_device") + assert build_path == expected_path or build_path == expected_path.replace( + "/", os.sep + ) assert platform == "rp2040" From 877ba13f4f36624ca37ff2263be7d34b4b62def2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 17:56:58 -0500 Subject: [PATCH 1149/1626] [ethernet] Conditionally compile PHY-specific code to reduce flash usage --- esphome/components/ethernet/__init__.py | 11 +++++++++++ esphome/components/ethernet/ethernet_component.cpp | 4 ++++ esphome/components/ethernet/ethernet_component.h | 2 ++ esphome/core/defines.h | 1 + 4 files changed, 18 insertions(+) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index a26238553c..151da7d0e5 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -77,6 +77,13 @@ ETHERNET_TYPES = { "DM9051": EthernetType.ETHERNET_TYPE_DM9051, } +# PHY types that need compile-time defines for conditional compilation +_PHY_TYPE_TO_DEFINE = { + "KSZ8081": "USE_ETHERNET_KSZ8081", + "KSZ8081RNA": "USE_ETHERNET_KSZ8081", + # Add other PHY types here only if they need conditional compilation +} + SPI_ETHERNET_TYPES = ["W5500", "DM9051"] SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10) @@ -345,6 +352,10 @@ async def to_code(config): if CONF_MANUAL_IP in config: cg.add(var.set_manual_ip(manual_ip(config[CONF_MANUAL_IP]))) + # Add compile-time define for PHY types with specific code + if phy_define := _PHY_TYPE_TO_DEFINE.get(config[CONF_TYPE]): + cg.add_define(phy_define) + cg.add_define("USE_ETHERNET") # Disable WiFi when using Ethernet to save memory diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index a48fd27383..ff14d19427 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -229,10 +229,12 @@ void EthernetComponent::setup() { ESPHL_ERROR_CHECK(err, "ETH driver install error"); #ifndef USE_ETHERNET_SPI +#ifdef USE_ETHERNET_KSZ8081 if (this->type_ == ETHERNET_TYPE_KSZ8081RNA && this->clk_mode_ == EMAC_CLK_OUT) { // KSZ8081RNA default is incorrect. It expects a 25MHz clock instead of the 50MHz we provide. this->ksz8081_set_clock_reference_(mac); } +#endif // USE_ETHERNET_KSZ8081 for (const auto &phy_register : this->phy_registers_) { this->write_phy_register_(mac, phy_register); @@ -721,6 +723,7 @@ bool EthernetComponent::powerdown() { #ifndef USE_ETHERNET_SPI +#ifdef USE_ETHERNET_KSZ8081 constexpr uint8_t KSZ80XX_PC2R_REG_ADDR = 0x1F; void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { @@ -749,6 +752,7 @@ void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { ESP_LOGVV(TAG, "KSZ8081 PHY Control 2: %s", format_hex_pretty((u_int8_t *) &phy_control_2, 2).c_str()); } } +#endif // USE_ETHERNET_KSZ8081 void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) { esp_err_t err; diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 3d2713ee5c..bbb9d7fb60 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -104,8 +104,10 @@ class EthernetComponent : public Component { void start_connect_(); void finish_connect_(); void dump_connect_params_(); +#ifdef USE_ETHERNET_KSZ8081 /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. void ksz8081_set_clock_reference_(esp_eth_mac_t *mac); +#endif /// @brief Set arbitratry PHY registers from config. void write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9a7e090b83..6e8d5ed74c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -175,6 +175,7 @@ #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 2, 1) #define USE_ETHERNET +#define USE_ETHERNET_KSZ8081 #endif #ifdef USE_ESP_IDF From 4859fe67eb1d3f0f478ffda168e3b13a3423d463 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 18:04:35 -0500 Subject: [PATCH 1150/1626] [dashboard] Fix archive handler to properly delete build folders using correct path (#10724) --- esphome/dashboard/web_server.py | 7 +-- tests/dashboard/test_web_server.py | 93 +++++++++++++++++++++++++++- tests/unit_tests/core/test_config.py | 9 +++ 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 294a180794..e6c5fd3d84 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1038,12 +1038,9 @@ class ArchiveRequestHandler(BaseHandler): shutil.move(config_file, os.path.join(archive_path, configuration)) storage_json = StorageJSON.load(storage_path) - if storage_json is not None: + if storage_json is not None and storage_json.build_path: # Delete build folder (if exists) - name = storage_json.name - build_folder = os.path.join(settings.config_dir, name) - if build_folder is not None: - shutil.rmtree(build_folder, os.path.join(archive_path, name)) + shutil.rmtree(storage_json.build_path, ignore_errors=True) class UnArchiveRequestHandler(BaseHandler): diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index e206090ac0..1938617f20 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -589,7 +589,7 @@ async def test_archive_request_handler_post( mock_ext_storage_path: MagicMock, tmp_path: Path, ) -> None: - """Test ArchiveRequestHandler.post method.""" + """Test ArchiveRequestHandler.post method without storage_json.""" # Set up temp directories config_dir = Path(get_fixture_path("conf")) @@ -616,6 +616,97 @@ async def test_archive_request_handler_post( ).read_text() == "esphome:\n name: test_archive\n" +@pytest.mark.asyncio +async def test_archive_handler_with_build_folder( + dashboard: DashboardTestHelper, + mock_archive_storage_path: MagicMock, + mock_ext_storage_path: MagicMock, + mock_dashboard_settings: MagicMock, + mock_storage_json: MagicMock, + tmp_path: Path, +) -> None: + """Test ArchiveRequestHandler.post with storage_json and build folder.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + build_dir = tmp_path / "build" + build_dir.mkdir() + + configuration = "test_device.yaml" + test_config = config_dir / configuration + test_config.write_text("esphome:\n name: test_device\n") + + build_folder = build_dir / "test_device" + build_folder.mkdir() + (build_folder / "firmware.bin").write_text("binary content") + (build_folder / ".pioenvs").mkdir() + + mock_dashboard_settings.config_dir = str(config_dir) + mock_dashboard_settings.rel_path.return_value = str(test_config) + mock_archive_storage_path.return_value = str(archive_dir) + + mock_storage = MagicMock() + mock_storage.name = "test_device" + mock_storage.build_path = str(build_folder) + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/archive", + method="POST", + body=f"configuration={configuration}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 + + assert not test_config.exists() + assert (archive_dir / configuration).exists() + + assert not build_folder.exists() + assert not (archive_dir / "test_device").exists() + + +@pytest.mark.asyncio +async def test_archive_handler_no_build_folder( + dashboard: DashboardTestHelper, + mock_archive_storage_path: MagicMock, + mock_ext_storage_path: MagicMock, + mock_dashboard_settings: MagicMock, + mock_storage_json: MagicMock, + tmp_path: Path, +) -> None: + """Test ArchiveRequestHandler.post with storage_json but no build folder.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + + configuration = "test_device.yaml" + test_config = config_dir / configuration + test_config.write_text("esphome:\n name: test_device\n") + + mock_dashboard_settings.config_dir = str(config_dir) + mock_dashboard_settings.rel_path.return_value = str(test_config) + mock_archive_storage_path.return_value = str(archive_dir) + + mock_storage = MagicMock() + mock_storage.name = "test_device" + mock_storage.build_path = None + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/archive", + method="POST", + body=f"configuration={configuration}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 + + assert not test_config.exists() + assert (archive_dir / configuration).exists() + assert not (archive_dir / "test_device").exists() + + @pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") @pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index e520db9e33..7d3b90794b 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -384,6 +384,9 @@ def test_preload_core_config_basic(setup_core: Path) -> None: assert platform == "esp32" assert KEY_CORE in CORE.data assert CONF_BUILD_PATH in config[CONF_ESPHOME] + # Verify default build path is "build/" + build_path = config[CONF_ESPHOME][CONF_BUILD_PATH] + assert build_path.endswith(os.path.join("build", "test_device")) def test_preload_core_config_with_build_path(setup_core: Path) -> None: @@ -418,6 +421,12 @@ def test_preload_core_config_env_build_path(setup_core: Path) -> None: assert CONF_BUILD_PATH in config[CONF_ESPHOME] assert "test_device" in config[CONF_ESPHOME][CONF_BUILD_PATH] + # Verify it uses the env var path with device name appended + build_path = config[CONF_ESPHOME][CONF_BUILD_PATH] + expected_path = os.path.join("/env/build", "test_device") + assert build_path == expected_path or build_path == expected_path.replace( + "/", os.sep + ) assert platform == "rp2040" From c1a90dad9e80648f3c09921177e176e2186a3076 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 18:37:40 -0500 Subject: [PATCH 1151/1626] get rid of lambdas --- esphome/components/json/json_util.cpp | 48 ++ esphome/components/json/json_util.h | 15 + esphome/components/web_server/web_server.cpp | 655 ++++++++++-------- .../web_server_idf/web_server_idf.cpp | 9 +- 4 files changed, 427 insertions(+), 300 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 842b5e283a..a15f0db081 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -84,5 +84,53 @@ bool parse_json(const std::string &data, const json_parse_t &f) { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } +// JsonBuilder implementation +class JsonBuilder::Impl { + public: + Impl() { +#ifdef USE_PSRAM + allocator_ = std::make_unique(); + doc_ = std::make_unique(allocator_.get()); +#else + doc_ = std::make_unique(); +#endif + } + + JsonObject root() { + if (!root_created_) { + root_ = doc_->to(); + root_created_ = true; + } + return root_; + } + + bool overflowed() const { return doc_->overflowed(); } + + void serialize_to(std::string &output) { serializeJson(*doc_, output); } + + private: +#ifdef USE_PSRAM + std::unique_ptr allocator_; +#endif + std::unique_ptr doc_; + JsonObject root_; + bool root_created_{false}; +}; + +JsonBuilder::JsonBuilder() : impl_(std::make_unique()) {} +JsonBuilder::~JsonBuilder() = default; + +JsonObject JsonBuilder::root() { return impl_->root(); } + +std::string JsonBuilder::serialize() { + if (impl_->overflowed()) { + ESP_LOGE(TAG, "JSON document overflow"); + return "{}"; + } + std::string output; + impl_->serialize_to(output); + return output; +} + } // namespace json } // namespace esphome diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 72d31c8afe..5004bb6a21 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -25,5 +25,20 @@ std::string build_json(const json_build_t &f); /// Parse a JSON string and run the provided json parse function if it's valid. bool parse_json(const std::string &data, const json_parse_t &f); +/// Builder class for creating JSON documents without lambdas +class JsonBuilder { + public: + JsonBuilder(); + ~JsonBuilder(); + + JsonObject root(); + std::string serialize(); + + private: + // Use opaque pointer to hide implementation details + class Impl; + std::unique_ptr impl_; +}; + } // namespace json } // namespace esphome diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 290992b096..951153af6b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -228,10 +228,11 @@ void DeferredUpdateEventSourceList::on_client_connect_(WebServer *ws, DeferredUp #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { - message = json::build_json([group](JsonObject root) { - root["name"] = group.second.name; - root["sorting_weight"] = group.second.weight; - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + root["name"] = group.second.name; + root["sorting_weight"] = group.second.weight; + message = builder.serialize(); // up to 31 groups should be able to be queued initially without defer source->try_send_nodefer(message.c_str(), "sorting_group"); @@ -265,17 +266,20 @@ void WebServer::set_js_include(const char *js_include) { this->js_include_ = js_ #endif std::string WebServer::get_config_json() { - return json::build_json([this](JsonObject root) { - root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); - root["comment"] = App.get_comment(); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); + root["comment"] = App.get_comment(); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) - root["ota"] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal + root["ota"] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else - root["ota"] = true; + root["ota"] = true; #endif - root["log"] = this->expose_log_; - root["lang"] = "en"; - }); + root["log"] = this->expose_log_; + root["lang"] = "en"; + + return builder.serialize(); } void WebServer::setup() { @@ -435,22 +439,26 @@ std::string WebServer::sensor_all_json_generator(WebServer *web_server, void *so return web_server->sensor_json((sensor::Sensor *) (source), ((sensor::Sensor *) (source))->state, DETAIL_ALL); } std::string WebServer::sensor_json(sensor::Sensor *obj, float value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - std::string state; - if (std::isnan(value)) { - state = "NA"; - } else { - state = value_accuracy_to_string(value, obj->get_accuracy_decimals()); - if (!obj->get_unit_of_measurement().empty()) - state += " " + obj->get_unit_of_measurement(); - } - set_json_icon_state_value(root, obj, "sensor-" + obj->get_object_id(), state, value, start_config); - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - if (!obj->get_unit_of_measurement().empty()) - root["uom"] = obj->get_unit_of_measurement(); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + // Build JSON directly inline + std::string state; + if (std::isnan(value)) { + state = "NA"; + } else { + state = value_accuracy_to_string(value, obj->get_accuracy_decimals()); + if (!obj->get_unit_of_measurement().empty()) + state += " " + obj->get_unit_of_measurement(); + } + set_json_icon_state_value(root, obj, "sensor-" + obj->get_object_id(), state, value, start_config); + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + if (!obj->get_unit_of_measurement().empty()) + root["uom"] = obj->get_unit_of_measurement(); + } + + return builder.serialize(); } #endif @@ -483,12 +491,15 @@ std::string WebServer::text_sensor_all_json_generator(WebServer *web_server, voi } std::string WebServer::text_sensor_json(text_sensor::TextSensor *obj, const std::string &value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "text_sensor-" + obj->get_object_id(), value, value, start_config); - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "text_sensor-" + obj->get_object_id(), value, value, start_config); + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -553,13 +564,16 @@ std::string WebServer::switch_all_json_generator(WebServer *web_server, void *so return web_server->switch_json((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_ALL); } std::string WebServer::switch_json(switch_::Switch *obj, bool value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "switch-" + obj->get_object_id(), value ? "ON" : "OFF", value, start_config); - if (start_config == DETAIL_ALL) { - root["assumed_state"] = obj->assumed_state(); - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "switch-" + obj->get_object_id(), value ? "ON" : "OFF", value, start_config); + if (start_config == DETAIL_ALL) { + root["assumed_state"] = obj->assumed_state(); + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -590,12 +604,15 @@ std::string WebServer::button_all_json_generator(WebServer *web_server, void *so return web_server->button_json((button::Button *) (source), DETAIL_ALL); } std::string WebServer::button_json(button::Button *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "button-" + obj->get_object_id(), start_config); - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "button-" + obj->get_object_id(), start_config); + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -627,13 +644,16 @@ std::string WebServer::binary_sensor_all_json_generator(WebServer *web_server, v ((binary_sensor::BinarySensor *) (source))->state, DETAIL_ALL); } std::string WebServer::binary_sensor_json(binary_sensor::BinarySensor *obj, bool value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "binary_sensor-" + obj->get_object_id(), value ? "ON" : "OFF", value, - start_config); - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "binary_sensor-" + obj->get_object_id(), value ? "ON" : "OFF", value, + start_config); + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -694,20 +714,23 @@ std::string WebServer::fan_all_json_generator(WebServer *web_server, void *sourc return web_server->fan_json((fan::Fan *) (source), DETAIL_ALL); } std::string WebServer::fan_json(fan::Fan *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "fan-" + obj->get_object_id(), obj->state ? "ON" : "OFF", obj->state, - start_config); - const auto traits = obj->get_traits(); - if (traits.supports_speed()) { - root["speed_level"] = obj->speed; - root["speed_count"] = traits.supported_speed_count(); - } - if (obj->get_traits().supports_oscillation()) - root["oscillation"] = obj->oscillating; - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "fan-" + obj->get_object_id(), obj->state ? "ON" : "OFF", obj->state, + start_config); + const auto traits = obj->get_traits(); + if (traits.supports_speed()) { + root["speed_level"] = obj->speed; + root["speed_count"] = traits.supported_speed_count(); + } + if (obj->get_traits().supports_oscillation()) + root["oscillation"] = obj->oscillating; + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -767,20 +790,23 @@ std::string WebServer::light_all_json_generator(WebServer *web_server, void *sou return web_server->light_json((light::LightState *) (source), DETAIL_ALL); } std::string WebServer::light_json(light::LightState *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "light-" + obj->get_object_id(), start_config); - root["state"] = obj->remote_values.is_on() ? "ON" : "OFF"; + json::JsonBuilder builder; + JsonObject root = builder.root(); - light::LightJSONSchema::dump_json(*obj, root); - if (start_config == DETAIL_ALL) { - JsonArray opt = root["effects"].to(); - opt.add("None"); - for (auto const &option : obj->get_effects()) { - opt.add(option->get_name()); - } - this->add_sorting_info_(root, obj); + set_json_id(root, obj, "light-" + obj->get_object_id(), start_config); + root["state"] = obj->remote_values.is_on() ? "ON" : "OFF"; + + light::LightJSONSchema::dump_json(*obj, root); + if (start_config == DETAIL_ALL) { + JsonArray opt = root["effects"].to(); + opt.add("None"); + for (auto const &option : obj->get_effects()) { + opt.add(option->get_name()); } - }); + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -839,19 +865,22 @@ std::string WebServer::cover_all_json_generator(WebServer *web_server, void *sou return web_server->cover_json((cover::Cover *) (source), DETAIL_ALL); } std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "cover-" + obj->get_object_id(), obj->is_fully_closed() ? "CLOSED" : "OPEN", - obj->position, start_config); - root["current_operation"] = cover::cover_operation_to_str(obj->current_operation); + json::JsonBuilder builder; + JsonObject root = builder.root(); - if (obj->get_traits().get_supports_position()) - root["position"] = obj->position; - if (obj->get_traits().get_supports_tilt()) - root["tilt"] = obj->tilt; - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + set_json_icon_state_value(root, obj, "cover-" + obj->get_object_id(), obj->is_fully_closed() ? "CLOSED" : "OPEN", + obj->position, start_config); + root["current_operation"] = cover::cover_operation_to_str(obj->current_operation); + + if (obj->get_traits().get_supports_position()) + root["position"] = obj->position; + if (obj->get_traits().get_supports_tilt()) + root["tilt"] = obj->tilt; + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -894,31 +923,33 @@ std::string WebServer::number_all_json_generator(WebServer *web_server, void *so return web_server->number_json((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_ALL); } std::string WebServer::number_json(number::Number *obj, float value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_id(root, obj, "number-" + obj->get_object_id(), start_config); - if (start_config == DETAIL_ALL) { - root["min_value"] = - value_accuracy_to_string(obj->traits.get_min_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root["max_value"] = - value_accuracy_to_string(obj->traits.get_max_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root["step"] = - value_accuracy_to_string(obj->traits.get_step(), step_to_accuracy_decimals(obj->traits.get_step())); - root["mode"] = (int) obj->traits.get_mode(); - if (!obj->traits.get_unit_of_measurement().empty()) - root["uom"] = obj->traits.get_unit_of_measurement(); - this->add_sorting_info_(root, obj); - } - if (std::isnan(value)) { - root["value"] = "\"NaN\""; - root["state"] = "NA"; - } else { - root["value"] = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); - std::string state = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); - if (!obj->traits.get_unit_of_measurement().empty()) - state += " " + obj->traits.get_unit_of_measurement(); - root["state"] = state; - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "number-" + obj->get_object_id(), start_config); + if (start_config == DETAIL_ALL) { + root["min_value"] = + value_accuracy_to_string(obj->traits.get_min_value(), step_to_accuracy_decimals(obj->traits.get_step())); + root["max_value"] = + value_accuracy_to_string(obj->traits.get_max_value(), step_to_accuracy_decimals(obj->traits.get_step())); + root["step"] = value_accuracy_to_string(obj->traits.get_step(), step_to_accuracy_decimals(obj->traits.get_step())); + root["mode"] = (int) obj->traits.get_mode(); + if (!obj->traits.get_unit_of_measurement().empty()) + root["uom"] = obj->traits.get_unit_of_measurement(); + this->add_sorting_info_(root, obj); + } + if (std::isnan(value)) { + root["value"] = "\"NaN\""; + root["state"] = "NA"; + } else { + root["value"] = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); + std::string state = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); + if (!obj->traits.get_unit_of_measurement().empty()) + state += " " + obj->traits.get_unit_of_measurement(); + root["state"] = state; + } + + return builder.serialize(); } #endif @@ -966,15 +997,18 @@ std::string WebServer::date_all_json_generator(WebServer *web_server, void *sour return web_server->date_json((datetime::DateEntity *) (source), DETAIL_ALL); } std::string WebServer::date_json(datetime::DateEntity *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "date-" + obj->get_object_id(), start_config); - std::string value = str_sprintf("%d-%02d-%02d", obj->year, obj->month, obj->day); - root["value"] = value; - root["state"] = value; - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "date-" + obj->get_object_id(), start_config); + std::string value = str_sprintf("%d-%02d-%02d", obj->year, obj->month, obj->day); + root["value"] = value; + root["state"] = value; + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif // USE_DATETIME_DATE @@ -1021,15 +1055,18 @@ std::string WebServer::time_all_json_generator(WebServer *web_server, void *sour return web_server->time_json((datetime::TimeEntity *) (source), DETAIL_ALL); } std::string WebServer::time_json(datetime::TimeEntity *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "time-" + obj->get_object_id(), start_config); - std::string value = str_sprintf("%02d:%02d:%02d", obj->hour, obj->minute, obj->second); - root["value"] = value; - root["state"] = value; - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "time-" + obj->get_object_id(), start_config); + std::string value = str_sprintf("%02d:%02d:%02d", obj->hour, obj->minute, obj->second); + root["value"] = value; + root["state"] = value; + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif // USE_DATETIME_TIME @@ -1076,16 +1113,19 @@ std::string WebServer::datetime_all_json_generator(WebServer *web_server, void * return web_server->datetime_json((datetime::DateTimeEntity *) (source), DETAIL_ALL); } std::string WebServer::datetime_json(datetime::DateTimeEntity *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "datetime-" + obj->get_object_id(), start_config); - std::string value = str_sprintf("%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, - obj->minute, obj->second); - root["value"] = value; - root["state"] = value; - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "datetime-" + obj->get_object_id(), start_config); + std::string value = + str_sprintf("%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second); + root["value"] = value; + root["state"] = value; + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif // USE_DATETIME_DATETIME @@ -1128,22 +1168,25 @@ std::string WebServer::text_all_json_generator(WebServer *web_server, void *sour return web_server->text_json((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_ALL); } std::string WebServer::text_json(text::Text *obj, const std::string &value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_id(root, obj, "text-" + obj->get_object_id(), start_config); - root["min_length"] = obj->traits.get_min_length(); - root["max_length"] = obj->traits.get_max_length(); - root["pattern"] = obj->traits.get_pattern(); - if (obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD) { - root["state"] = "********"; - } else { - root["state"] = value; - } - root["value"] = value; - if (start_config == DETAIL_ALL) { - root["mode"] = (int) obj->traits.get_mode(); - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "text-" + obj->get_object_id(), start_config); + root["min_length"] = obj->traits.get_min_length(); + root["max_length"] = obj->traits.get_max_length(); + root["pattern"] = obj->traits.get_pattern(); + if (obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD) { + root["state"] = "********"; + } else { + root["state"] = value; + } + root["value"] = value; + if (start_config == DETAIL_ALL) { + root["mode"] = (int) obj->traits.get_mode(); + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -1186,16 +1229,19 @@ std::string WebServer::select_all_json_generator(WebServer *web_server, void *so return web_server->select_json((select::Select *) (source), ((select::Select *) (source))->state, DETAIL_ALL); } std::string WebServer::select_json(select::Select *obj, const std::string &value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "select-" + obj->get_object_id(), value, value, start_config); - if (start_config == DETAIL_ALL) { - JsonArray opt = root["option"].to(); - for (auto &option : obj->traits.get_options()) { - opt.add(option); - } - this->add_sorting_info_(root, obj); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "select-" + obj->get_object_id(), value, value, start_config); + if (start_config == DETAIL_ALL) { + JsonArray opt = root["option"].to(); + for (auto &option : obj->traits.get_options()) { + opt.add(option); } - }); + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -1251,91 +1297,93 @@ std::string WebServer::climate_all_json_generator(WebServer *web_server, void *s } std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "climate-" + obj->get_object_id(), start_config); - const auto traits = obj->get_traits(); - int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); - int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); - char buf[16]; + json::JsonBuilder builder; + JsonObject root = builder.root(); + set_json_id(root, obj, "climate-" + obj->get_object_id(), start_config); + const auto traits = obj->get_traits(); + int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); + int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); + char buf[16]; - if (start_config == DETAIL_ALL) { - JsonArray opt = root["modes"].to(); - for (climate::ClimateMode m : traits.get_supported_modes()) - opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); - if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root["fan_modes"].to(); - for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) - opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); - } - - if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root["custom_fan_modes"].to(); - for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) - opt.add(custom_fan_mode); - } - if (traits.get_supports_swing_modes()) { - JsonArray opt = root["swing_modes"].to(); - for (auto swing_mode : traits.get_supported_swing_modes()) - opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); - } - if (traits.get_supports_presets() && obj->preset.has_value()) { - JsonArray opt = root["presets"].to(); - for (climate::ClimatePreset m : traits.get_supported_presets()) - opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); - } - if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { - JsonArray opt = root["custom_presets"].to(); - for (auto const &custom_preset : traits.get_supported_custom_presets()) - opt.add(custom_preset); - } - this->add_sorting_info_(root, obj); + if (start_config == DETAIL_ALL) { + JsonArray opt = root["modes"].to(); + for (climate::ClimateMode m : traits.get_supported_modes()) + opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); + if (!traits.get_supported_custom_fan_modes().empty()) { + JsonArray opt = root["fan_modes"].to(); + for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) + opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); } - bool has_state = false; - root["mode"] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); - root["max_temp"] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); - root["min_temp"] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); - root["step"] = traits.get_visual_target_temperature_step(); - if (traits.get_supports_action()) { - root["action"] = PSTR_LOCAL(climate_action_to_string(obj->action)); - root["state"] = root["action"]; - has_state = true; - } - if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root["fan_mode"] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); - } - if (!traits.get_supported_custom_fan_modes().empty() && obj->custom_fan_mode.has_value()) { - root["custom_fan_mode"] = obj->custom_fan_mode.value().c_str(); - } - if (traits.get_supports_presets() && obj->preset.has_value()) { - root["preset"] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); - } - if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { - root["custom_preset"] = obj->custom_preset.value().c_str(); + if (!traits.get_supported_custom_fan_modes().empty()) { + JsonArray opt = root["custom_fan_modes"].to(); + for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) + opt.add(custom_fan_mode); } if (traits.get_supports_swing_modes()) { - root["swing_mode"] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + JsonArray opt = root["swing_modes"].to(); + for (auto swing_mode : traits.get_supported_swing_modes()) + opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); } - if (traits.get_supports_current_temperature()) { - if (!std::isnan(obj->current_temperature)) { - root["current_temperature"] = value_accuracy_to_string(obj->current_temperature, current_accuracy); - } else { - root["current_temperature"] = "NA"; - } + if (traits.get_supports_presets() && obj->preset.has_value()) { + JsonArray opt = root["presets"].to(); + for (climate::ClimatePreset m : traits.get_supported_presets()) + opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); } - if (traits.get_supports_two_point_target_temperature()) { - root["target_temperature_low"] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); - root["target_temperature_high"] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy); - if (!has_state) { - root["state"] = value_accuracy_to_string((obj->target_temperature_high + obj->target_temperature_low) / 2.0f, - target_accuracy); - } + if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { + JsonArray opt = root["custom_presets"].to(); + for (auto const &custom_preset : traits.get_supported_custom_presets()) + opt.add(custom_preset); + } + this->add_sorting_info_(root, obj); + } + + bool has_state = false; + root["mode"] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); + root["max_temp"] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); + root["min_temp"] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); + root["step"] = traits.get_visual_target_temperature_step(); + if (traits.get_supports_action()) { + root["action"] = PSTR_LOCAL(climate_action_to_string(obj->action)); + root["state"] = root["action"]; + has_state = true; + } + if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { + root["fan_mode"] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + } + if (!traits.get_supported_custom_fan_modes().empty() && obj->custom_fan_mode.has_value()) { + root["custom_fan_mode"] = obj->custom_fan_mode.value().c_str(); + } + if (traits.get_supports_presets() && obj->preset.has_value()) { + root["preset"] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + } + if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { + root["custom_preset"] = obj->custom_preset.value().c_str(); + } + if (traits.get_supports_swing_modes()) { + root["swing_mode"] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + } + if (traits.get_supports_current_temperature()) { + if (!std::isnan(obj->current_temperature)) { + root["current_temperature"] = value_accuracy_to_string(obj->current_temperature, current_accuracy); } else { - root["target_temperature"] = value_accuracy_to_string(obj->target_temperature, target_accuracy); - if (!has_state) - root["state"] = root["target_temperature"]; + root["current_temperature"] = "NA"; } - }); + } + if (traits.get_supports_two_point_target_temperature()) { + root["target_temperature_low"] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); + root["target_temperature_high"] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy); + if (!has_state) { + root["state"] = value_accuracy_to_string((obj->target_temperature_high + obj->target_temperature_low) / 2.0f, + target_accuracy); + } + } else { + root["target_temperature"] = value_accuracy_to_string(obj->target_temperature, target_accuracy); + if (!has_state) + root["state"] = root["target_temperature"]; + } + + return builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } #endif @@ -1401,13 +1449,16 @@ std::string WebServer::lock_all_json_generator(WebServer *web_server, void *sour return web_server->lock_json((lock::Lock *) (source), ((lock::Lock *) (source))->state, DETAIL_ALL); } std::string WebServer::lock_json(lock::Lock *obj, lock::LockState value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "lock-" + obj->get_object_id(), lock::lock_state_to_string(value), value, - start_config); - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "lock-" + obj->get_object_id(), lock::lock_state_to_string(value), value, + start_config); + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -1464,17 +1515,20 @@ std::string WebServer::valve_all_json_generator(WebServer *web_server, void *sou return web_server->valve_json((valve::Valve *) (source), DETAIL_ALL); } std::string WebServer::valve_json(valve::Valve *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "valve-" + obj->get_object_id(), obj->is_fully_closed() ? "CLOSED" : "OPEN", - obj->position, start_config); - root["current_operation"] = valve::valve_operation_to_str(obj->current_operation); + json::JsonBuilder builder; + JsonObject root = builder.root(); - if (obj->get_traits().get_supports_position()) - root["position"] = obj->position; - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + set_json_icon_state_value(root, obj, "valve-" + obj->get_object_id(), obj->is_fully_closed() ? "CLOSED" : "OPEN", + obj->position, start_config); + root["current_operation"] = valve::valve_operation_to_str(obj->current_operation); + + if (obj->get_traits().get_supports_position()) + root["position"] = obj->position; + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -1533,14 +1587,17 @@ std::string WebServer::alarm_control_panel_all_json_generator(WebServer *web_ser std::string WebServer::alarm_control_panel_json(alarm_control_panel::AlarmControlPanel *obj, alarm_control_panel::AlarmControlPanelState value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - char buf[16]; - set_json_icon_state_value(root, obj, "alarm-control-panel-" + obj->get_object_id(), - PSTR_LOCAL(alarm_control_panel_state_to_string(value)), value, start_config); - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + char buf[16]; + set_json_icon_state_value(root, obj, "alarm-control-panel-" + obj->get_object_id(), + PSTR_LOCAL(alarm_control_panel_state_to_string(value)), value, start_config); + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -1577,20 +1634,23 @@ std::string WebServer::event_all_json_generator(WebServer *web_server, void *sou return web_server->event_json(event, get_event_type(event), DETAIL_ALL); } std::string WebServer::event_json(event::Event *obj, const std::string &event_type, JsonDetail start_config) { - return json::build_json([this, obj, event_type, start_config](JsonObject root) { - set_json_id(root, obj, "event-" + obj->get_object_id(), start_config); - if (!event_type.empty()) { - root["event_type"] = event_type; + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "event-" + obj->get_object_id(), start_config); + if (!event_type.empty()) { + root["event_type"] = event_type; + } + if (start_config == DETAIL_ALL) { + JsonArray event_types = root["event_types"].to(); + for (auto const &event_type : obj->get_event_types()) { + event_types.add(event_type); } - if (start_config == DETAIL_ALL) { - JsonArray event_types = root["event_types"].to(); - for (auto const &event_type : obj->get_event_types()) { - event_types.add(event_type); - } - root["device_class"] = obj->get_device_class(); - this->add_sorting_info_(root, obj); - } - }); + root["device_class"] = obj->get_device_class(); + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -1644,18 +1704,21 @@ std::string WebServer::update_all_json_generator(WebServer *web_server, void *so } std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "update-" + obj->get_object_id(), start_config); - root["value"] = obj->update_info.latest_version; - root["state"] = update_state_to_string(obj->state); - if (start_config == DETAIL_ALL) { - root["current_version"] = obj->update_info.current_version; - root["title"] = obj->update_info.title; - root["summary"] = obj->update_info.summary; - root["release_url"] = obj->update_info.release_url; - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "update-" + obj->get_object_id(), start_config); + root["value"] = obj->update_info.latest_version; + root["state"] = update_state_to_string(obj->state); + if (start_config == DETAIL_ALL) { + root["current_version"] = obj->update_info.current_version; + root["title"] = obj->update_info.title; + root["summary"] = obj->update_info.summary; + root["release_url"] = obj->update_info.release_url; + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } #endif diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 55b07c0f5e..7c16b4062c 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -392,10 +392,11 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - message = json::build_json([group](JsonObject root) { - root["name"] = group.second.name; - root["sorting_weight"] = group.second.weight; - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + root["name"] = group.second.name; + root["sorting_weight"] = group.second.weight; + message = builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) // a (very) large number of these should be able to be queued initially without defer From 044aeaa0636cbfb5261b068ef3bcc99522474c2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 18:39:14 -0500 Subject: [PATCH 1152/1626] preen --- esphome/components/json/json_util.cpp | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index a15f0db081..13647af11c 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -35,25 +35,10 @@ struct SpiRamAllocator : ArduinoJson::Allocator { std::string build_json(const json_build_t &f) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson -#ifdef USE_PSRAM - auto doc_allocator = SpiRamAllocator(); - JsonDocument json_document(&doc_allocator); -#else - JsonDocument json_document; -#endif - if (json_document.overflowed()) { - ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); - return "{}"; - } - JsonObject root = json_document.to(); + JsonBuilder builder; + JsonObject root = builder.root(); f(root); - if (json_document.overflowed()) { - ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); - return "{}"; - } - std::string output; - serializeJson(json_document, output); - return output; + return builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } From f3ac21b3b494bfbdac99b8b9827b8e8c03c9ec67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 18:46:07 -0500 Subject: [PATCH 1153/1626] [ethernet] Conditionally compile PHY-specific code to reduce flash usage (#10747) --- esphome/components/ethernet/__init__.py | 11 +++++++++++ esphome/components/ethernet/ethernet_component.cpp | 4 ++++ esphome/components/ethernet/ethernet_component.h | 2 ++ esphome/core/defines.h | 1 + 4 files changed, 18 insertions(+) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index a26238553c..151da7d0e5 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -77,6 +77,13 @@ ETHERNET_TYPES = { "DM9051": EthernetType.ETHERNET_TYPE_DM9051, } +# PHY types that need compile-time defines for conditional compilation +_PHY_TYPE_TO_DEFINE = { + "KSZ8081": "USE_ETHERNET_KSZ8081", + "KSZ8081RNA": "USE_ETHERNET_KSZ8081", + # Add other PHY types here only if they need conditional compilation +} + SPI_ETHERNET_TYPES = ["W5500", "DM9051"] SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10) @@ -345,6 +352,10 @@ async def to_code(config): if CONF_MANUAL_IP in config: cg.add(var.set_manual_ip(manual_ip(config[CONF_MANUAL_IP]))) + # Add compile-time define for PHY types with specific code + if phy_define := _PHY_TYPE_TO_DEFINE.get(config[CONF_TYPE]): + cg.add_define(phy_define) + cg.add_define("USE_ETHERNET") # Disable WiFi when using Ethernet to save memory diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index a48fd27383..ff14d19427 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -229,10 +229,12 @@ void EthernetComponent::setup() { ESPHL_ERROR_CHECK(err, "ETH driver install error"); #ifndef USE_ETHERNET_SPI +#ifdef USE_ETHERNET_KSZ8081 if (this->type_ == ETHERNET_TYPE_KSZ8081RNA && this->clk_mode_ == EMAC_CLK_OUT) { // KSZ8081RNA default is incorrect. It expects a 25MHz clock instead of the 50MHz we provide. this->ksz8081_set_clock_reference_(mac); } +#endif // USE_ETHERNET_KSZ8081 for (const auto &phy_register : this->phy_registers_) { this->write_phy_register_(mac, phy_register); @@ -721,6 +723,7 @@ bool EthernetComponent::powerdown() { #ifndef USE_ETHERNET_SPI +#ifdef USE_ETHERNET_KSZ8081 constexpr uint8_t KSZ80XX_PC2R_REG_ADDR = 0x1F; void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { @@ -749,6 +752,7 @@ void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { ESP_LOGVV(TAG, "KSZ8081 PHY Control 2: %s", format_hex_pretty((u_int8_t *) &phy_control_2, 2).c_str()); } } +#endif // USE_ETHERNET_KSZ8081 void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) { esp_err_t err; diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 3d2713ee5c..bbb9d7fb60 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -104,8 +104,10 @@ class EthernetComponent : public Component { void start_connect_(); void finish_connect_(); void dump_connect_params_(); +#ifdef USE_ETHERNET_KSZ8081 /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. void ksz8081_set_clock_reference_(esp_eth_mac_t *mac); +#endif /// @brief Set arbitratry PHY registers from config. void write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9a7e090b83..6e8d5ed74c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -175,6 +175,7 @@ #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 2, 1) #define USE_ETHERNET +#define USE_ETHERNET_KSZ8081 #endif #ifdef USE_ESP_IDF From c203f61e6b2cca6a9827806e8ee05a914cbde20c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 18:58:07 -0500 Subject: [PATCH 1154/1626] more ArduinoJson false positives --- esphome/components/web_server/web_server.cpp | 4 ++++ esphome/components/web_server_idf/web_server_idf.cpp | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 951153af6b..0a97e542c3 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1290,9 +1290,11 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url request->send(404); } std::string WebServer::climate_state_json_generator(WebServer *web_server, void *source) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return web_server->climate_json((climate::Climate *) (source), DETAIL_STATE); } std::string WebServer::climate_all_json_generator(WebServer *web_server, void *source) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return web_server->climate_json((climate::Climate *) (source), DETAIL_ALL); } std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_config) { @@ -1697,9 +1699,11 @@ void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlM request->send(404); } std::string WebServer::update_state_json_generator(WebServer *web_server, void *source) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return web_server->update_json((update::UpdateEntity *) (source), DETAIL_STATE); } std::string WebServer::update_all_json_generator(WebServer *web_server, void *source) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return web_server->update_json((update::UpdateEntity *) (source), DETAIL_STATE); } std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_config) { diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 7c16b4062c..51d763c508 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -317,8 +317,8 @@ AsyncEventSource::~AsyncEventSource() { } void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) { - auto *rsp = // NOLINT(cppcoreguidelines-owning-memory) - new AsyncEventSourceResponse(request, this, this->web_server_); + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory,clang-analyzer-cplusplus.NewDeleteLeaks) + auto *rsp = new AsyncEventSourceResponse(request, this, this->web_server_); if (this->on_connect_) { this->on_connect_(rsp); } From 90e33306f1138663b1144076719abf5dbafea9b7 Mon Sep 17 00:00:00 2001 From: DT-art1 <81360462+DT-art1@users.noreply.github.com> Date: Tue, 16 Sep 2025 03:24:23 +0200 Subject: [PATCH 1155/1626] [const] Move CONF_CLEAR to const.py (#10742) --- esphome/components/as7341/sensor.py | 2 +- esphome/const.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index 2832b7c3df..fa51a1cdfa 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( + CONF_CLEAR, CONF_GAIN, CONF_ID, DEVICE_CLASS_ILLUMINANCE, @@ -29,7 +30,6 @@ CONF_F5 = "f5" CONF_F6 = "f6" CONF_F7 = "f7" CONF_F8 = "f8" -CONF_CLEAR = "clear" CONF_NIR = "nir" UNIT_COUNTS = "#" diff --git a/esphome/const.py b/esphome/const.py index 677b9173ec..c91c81f484 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -186,6 +186,7 @@ CONF_CHARACTERISTIC_UUID = "characteristic_uuid" CONF_CHECK = "check" CONF_CHIPSET = "chipset" CONF_CLEAN_SESSION = "clean_session" +CONF_CLEAR = "clear" CONF_CLEAR_IMPEDANCE = "clear_impedance" CONF_CLIENT_CERTIFICATE = "client_certificate" CONF_CLIENT_CERTIFICATE_KEY = "client_certificate_key" From 290c2e17f5b2c0474207d252e75ab297655d0ab7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 20:49:22 -0500 Subject: [PATCH 1156/1626] simplier --- esphome/components/json/json_util.cpp | 43 +++++---------------------- esphome/components/json/json_util.h | 19 ++++++++---- 2 files changed, 22 insertions(+), 40 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 13647af11c..6d1d258c13 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -70,50 +70,23 @@ bool parse_json(const std::string &data, const json_parse_t &f) { } // JsonBuilder implementation -class JsonBuilder::Impl { - public: - Impl() { +JsonBuilder::JsonBuilder() + : doc_( #ifdef USE_PSRAM - allocator_ = std::make_unique(); - doc_ = std::make_unique(allocator_.get()); + (allocator_ = std::make_unique(), allocator_.get()) #else - doc_ = std::make_unique(); + nullptr #endif - } - - JsonObject root() { - if (!root_created_) { - root_ = doc_->to(); - root_created_ = true; - } - return root_; - } - - bool overflowed() const { return doc_->overflowed(); } - - void serialize_to(std::string &output) { serializeJson(*doc_, output); } - - private: -#ifdef USE_PSRAM - std::unique_ptr allocator_; -#endif - std::unique_ptr doc_; - JsonObject root_; - bool root_created_{false}; -}; - -JsonBuilder::JsonBuilder() : impl_(std::make_unique()) {} -JsonBuilder::~JsonBuilder() = default; - -JsonObject JsonBuilder::root() { return impl_->root(); } + ) { +} std::string JsonBuilder::serialize() { - if (impl_->overflowed()) { + if (doc_.overflowed()) { ESP_LOGE(TAG, "JSON document overflow"); return "{}"; } std::string output; - impl_->serialize_to(output); + serializeJson(doc_, output); return output; } diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 5004bb6a21..64658aa194 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -29,15 +29,24 @@ bool parse_json(const std::string &data, const json_parse_t &f); class JsonBuilder { public: JsonBuilder(); - ~JsonBuilder(); - JsonObject root(); + JsonObject root() { + if (!root_created_) { + root_ = doc_.to(); + root_created_ = true; + } + return root_; + } + std::string serialize(); private: - // Use opaque pointer to hide implementation details - class Impl; - std::unique_ptr impl_; +#ifdef USE_PSRAM + std::unique_ptr allocator_; +#endif + JsonDocument doc_; + JsonObject root_; + bool root_created_{false}; }; } // namespace json From 703bb0c9c6d0e3741e9c0603c1e131f6b275fa81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 21:18:52 -0500 Subject: [PATCH 1157/1626] cleanup --- esphome/components/json/json_util.cpp | 12 +++++++++++- esphome/components/json/json_util.h | 8 +++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 6d1d258c13..40a3496981 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -73,13 +73,23 @@ bool parse_json(const std::string &data, const json_parse_t &f) { JsonBuilder::JsonBuilder() : doc_( #ifdef USE_PSRAM - (allocator_ = std::make_unique(), allocator_.get()) + [this]() { + auto *alloc = new SpiRamAllocator(); // NOLINT(cppcoreguidelines-owning-memory) + allocator_ = alloc; + return alloc; + }() #else nullptr #endif ) { } +JsonBuilder::~JsonBuilder() { +#ifdef USE_PSRAM + delete static_cast(allocator_); // NOLINT(cppcoreguidelines-owning-memory) +#endif +} + std::string JsonBuilder::serialize() { if (doc_.overflowed()) { ESP_LOGE(TAG, "JSON document overflow"); diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 64658aa194..8eac87b10a 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -29,6 +29,7 @@ bool parse_json(const std::string &data, const json_parse_t &f); class JsonBuilder { public: JsonBuilder(); + ~JsonBuilder(); JsonObject root() { if (!root_created_) { @@ -41,12 +42,13 @@ class JsonBuilder { std::string serialize(); private: -#ifdef USE_PSRAM - std::unique_ptr allocator_; -#endif JsonDocument doc_; JsonObject root_; bool root_created_{false}; + // Allocator must be last member to ensure it's destroyed after doc_ +#ifdef USE_PSRAM + void *allocator_{nullptr}; // Will store SpiRamAllocator*, managed in cpp file +#endif }; } // namespace json From 7fe92085b4ab3ce3c4b67e5488ee85bddb7ffded Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 21:28:42 -0500 Subject: [PATCH 1158/1626] preen --- esphome/components/json/json_util.cpp | 13 +++---------- esphome/components/json/json_util.h | 10 ++++++---- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 40a3496981..e03f95fe7c 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -31,6 +31,7 @@ struct SpiRamAllocator : ArduinoJson::Allocator { protected: RAMAllocator allocator_{RAMAllocator(RAMAllocator::NONE)}; }; + #endif std::string build_json(const json_build_t &f) { @@ -73,22 +74,14 @@ bool parse_json(const std::string &data, const json_parse_t &f) { JsonBuilder::JsonBuilder() : doc_( #ifdef USE_PSRAM - [this]() { - auto *alloc = new SpiRamAllocator(); // NOLINT(cppcoreguidelines-owning-memory) - allocator_ = alloc; - return alloc; - }() + (allocator_ = std::make_unique(), allocator_.get()) #else nullptr #endif ) { } -JsonBuilder::~JsonBuilder() { -#ifdef USE_PSRAM - delete static_cast(allocator_); // NOLINT(cppcoreguidelines-owning-memory) -#endif -} +JsonBuilder::~JsonBuilder() = default; std::string JsonBuilder::serialize() { if (doc_.overflowed()) { diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 8eac87b10a..633e8182fb 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -25,6 +25,9 @@ std::string build_json(const json_build_t &f); /// Parse a JSON string and run the provided json parse function if it's valid. bool parse_json(const std::string &data, const json_parse_t &f); +// Forward declaration to avoid exposing implementation details +struct SpiRamAllocator; + /// Builder class for creating JSON documents without lambdas class JsonBuilder { public: @@ -42,13 +45,12 @@ class JsonBuilder { std::string serialize(); private: +#ifdef USE_PSRAM + std::unique_ptr allocator_; // One heap allocation, but keeps code clean +#endif JsonDocument doc_; JsonObject root_; bool root_created_{false}; - // Allocator must be last member to ensure it's destroyed after doc_ -#ifdef USE_PSRAM - void *allocator_{nullptr}; // Will store SpiRamAllocator*, managed in cpp file -#endif }; } // namespace json From b0b207eddbfd3a5be36e7ede8a41d7bb4947701c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 21:34:19 -0500 Subject: [PATCH 1159/1626] cleanup --- esphome/components/json/json_util.cpp | 20 ++++++++++++++------ esphome/components/json/json_util.h | 7 +++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index e03f95fe7c..a9cf383a18 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -71,17 +71,25 @@ bool parse_json(const std::string &data, const json_parse_t &f) { } // JsonBuilder implementation -JsonBuilder::JsonBuilder() - : doc_( +JsonBuilder::JsonBuilder() { #ifdef USE_PSRAM - (allocator_ = std::make_unique(), allocator_.get()) + // Verify our storage is large enough (and log the actual size for reference) + static_assert(sizeof(SpiRamAllocator) <= sizeof(allocator_storage_), "allocator_storage_ too small"); + // Note: sizeof(SpiRamAllocator) is typically around 24-32 bytes on ESP32 + // Use placement new to construct SpiRamAllocator in the pre-allocated storage + auto *allocator = new (allocator_storage_) SpiRamAllocator(); + doc_ = JsonDocument(allocator); #else - nullptr + doc_ = JsonDocument(); #endif - ) { } -JsonBuilder::~JsonBuilder() = default; +JsonBuilder::~JsonBuilder() { +#ifdef USE_PSRAM + // Explicitly call destructor for placement-new allocated object + reinterpret_cast(allocator_storage_)->~SpiRamAllocator(); +#endif +} std::string JsonBuilder::serialize() { if (doc_.overflowed()) { diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 633e8182fb..de76ca53da 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -25,9 +25,6 @@ std::string build_json(const json_build_t &f); /// Parse a JSON string and run the provided json parse function if it's valid. bool parse_json(const std::string &data, const json_parse_t &f); -// Forward declaration to avoid exposing implementation details -struct SpiRamAllocator; - /// Builder class for creating JSON documents without lambdas class JsonBuilder { public: @@ -46,7 +43,9 @@ class JsonBuilder { private: #ifdef USE_PSRAM - std::unique_ptr allocator_; // One heap allocation, but keeps code clean + // Storage for SpiRamAllocator - typically around 24-32 bytes on ESP32 + // Static assert in .cpp file ensures this is large enough + std::aligned_storage<32, alignof(void *)>::type allocator_storage_; #endif JsonDocument doc_; JsonObject root_; From 7549d031fdd3ccb52f1cef8c5457fbba8ccafcdf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 21:40:30 -0500 Subject: [PATCH 1160/1626] cleanup --- esphome/components/json/json_util.cpp | 46 +++------------------------ esphome/components/json/json_util.h | 23 +++++++++++--- 2 files changed, 24 insertions(+), 45 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index a9cf383a18..166fbcd167 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -8,32 +8,6 @@ namespace json { static const char *const TAG = "json"; -#ifdef USE_PSRAM -// Build an allocator for the JSON Library using the RAMAllocator class -// This is only compiled when PSRAM is enabled -struct SpiRamAllocator : ArduinoJson::Allocator { - void *allocate(size_t size) override { return this->allocator_.allocate(size); } - - void deallocate(void *pointer) override { - // ArduinoJson's Allocator interface doesn't provide the size parameter in deallocate. - // RAMAllocator::deallocate() requires the size, which we don't have access to here. - // RAMAllocator::deallocate implementation just calls free() regardless of whether - // the memory was allocated with heap_caps_malloc or malloc. - // This is safe because ESP-IDF's heap implementation internally tracks the memory region - // and routes free() to the appropriate heap. - free(pointer); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) - } - - void *reallocate(void *ptr, size_t new_size) override { - return this->allocator_.reallocate(static_cast(ptr), new_size); - } - - protected: - RAMAllocator allocator_{RAMAllocator(RAMAllocator::NONE)}; -}; - -#endif - std::string build_json(const json_build_t &f) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson JsonBuilder builder; @@ -71,24 +45,14 @@ bool parse_json(const std::string &data, const json_parse_t &f) { } // JsonBuilder implementation -JsonBuilder::JsonBuilder() { +JsonBuilder::JsonBuilder() + : doc_( #ifdef USE_PSRAM - // Verify our storage is large enough (and log the actual size for reference) - static_assert(sizeof(SpiRamAllocator) <= sizeof(allocator_storage_), "allocator_storage_ too small"); - // Note: sizeof(SpiRamAllocator) is typically around 24-32 bytes on ESP32 - // Use placement new to construct SpiRamAllocator in the pre-allocated storage - auto *allocator = new (allocator_storage_) SpiRamAllocator(); - doc_ = JsonDocument(allocator); + &allocator_ #else - doc_ = JsonDocument(); -#endif -} - -JsonBuilder::~JsonBuilder() { -#ifdef USE_PSRAM - // Explicitly call destructor for placement-new allocated object - reinterpret_cast(allocator_storage_)->~SpiRamAllocator(); + nullptr #endif + ) { } std::string JsonBuilder::serialize() { diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index de76ca53da..9699955133 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -13,6 +13,24 @@ namespace esphome { namespace json { +#ifdef USE_PSRAM +// Allocator for JSON that uses PSRAM on supported devices +struct SpiRamAllocator : ArduinoJson::Allocator { + void *allocate(size_t size) override { return allocator_.allocate(size); } + + void deallocate(void *ptr) override { + free(ptr); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) + } + + void *reallocate(void *ptr, size_t new_size) override { + return allocator_.reallocate(static_cast(ptr), new_size); + } + + protected: + RAMAllocator allocator_{RAMAllocator::NONE}; +}; +#endif + /// Callback function typedef for parsing JsonObjects. using json_parse_t = std::function; @@ -29,7 +47,6 @@ bool parse_json(const std::string &data, const json_parse_t &f); class JsonBuilder { public: JsonBuilder(); - ~JsonBuilder(); JsonObject root() { if (!root_created_) { @@ -43,9 +60,7 @@ class JsonBuilder { private: #ifdef USE_PSRAM - // Storage for SpiRamAllocator - typically around 24-32 bytes on ESP32 - // Static assert in .cpp file ensures this is large enough - std::aligned_storage<32, alignof(void *)>::type allocator_storage_; + SpiRamAllocator allocator_; // Just a regular member on the stack! #endif JsonDocument doc_; JsonObject root_; From 7aae946678161c63a0648935f7cbf55df4022120 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 21:44:50 -0500 Subject: [PATCH 1161/1626] cleanup --- esphome/components/json/json_util.cpp | 11 ----------- esphome/components/json/json_util.h | 8 ++++---- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 166fbcd167..51c0fcf9cb 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -44,17 +44,6 @@ bool parse_json(const std::string &data, const json_parse_t &f) { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } -// JsonBuilder implementation -JsonBuilder::JsonBuilder() - : doc_( -#ifdef USE_PSRAM - &allocator_ -#else - nullptr -#endif - ) { -} - std::string JsonBuilder::serialize() { if (doc_.overflowed()) { ESP_LOGE(TAG, "JSON document overflow"); diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 9699955133..d85e7eefe0 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -46,8 +46,6 @@ bool parse_json(const std::string &data, const json_parse_t &f); /// Builder class for creating JSON documents without lambdas class JsonBuilder { public: - JsonBuilder(); - JsonObject root() { if (!root_created_) { root_ = doc_.to(); @@ -60,9 +58,11 @@ class JsonBuilder { private: #ifdef USE_PSRAM - SpiRamAllocator allocator_; // Just a regular member on the stack! + SpiRamAllocator allocator_; // Just a regular member on the stack! + JsonDocument doc_{&allocator_}; // Initialize with allocator +#else + JsonDocument doc_; // Default initialization #endif - JsonDocument doc_; JsonObject root_; bool root_created_{false}; }; From bd11ffd395773537fa169e8a2a611d17cfc819c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 21:47:15 -0500 Subject: [PATCH 1162/1626] preen --- esphome/components/json/json_util.h | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index d85e7eefe0..fb991a7168 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -14,11 +14,18 @@ namespace esphome { namespace json { #ifdef USE_PSRAM -// Allocator for JSON that uses PSRAM on supported devices +// Build an allocator for the JSON Library using the RAMAllocator class +// This is only compiled when PSRAM is enabled struct SpiRamAllocator : ArduinoJson::Allocator { void *allocate(size_t size) override { return allocator_.allocate(size); } void deallocate(void *ptr) override { + // ArduinoJson's Allocator interface doesn't provide the size parameter in deallocate. + // RAMAllocator::deallocate() requires the size, which we don't have access to here. + // RAMAllocator::deallocate implementation just calls free() regardless of whether + // the memory was allocated with heap_caps_malloc or malloc. + // This is safe because ESP-IDF's heap implementation internally tracks the memory region + // and routes free() to the appropriate heap. free(ptr); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } From 192e935ef23ff50597208122213e06ee56ede39a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 21:47:18 -0500 Subject: [PATCH 1163/1626] preen --- esphome/components/json/json_util.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index fb991a7168..69b809ec49 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -65,10 +65,10 @@ class JsonBuilder { private: #ifdef USE_PSRAM - SpiRamAllocator allocator_; // Just a regular member on the stack! - JsonDocument doc_{&allocator_}; // Initialize with allocator + SpiRamAllocator allocator_; + JsonDocument doc_{&allocator_}; #else - JsonDocument doc_; // Default initialization + JsonDocument doc_; #endif JsonObject root_; bool root_created_{false}; From 6d1de2106ec3b226a996f66a9f9593083a15eb5b Mon Sep 17 00:00:00 2001 From: jokujossai <43237978+jokujossai@users.noreply.github.com> Date: Tue, 16 Sep 2025 06:28:36 +0300 Subject: [PATCH 1164/1626] [mqtt] fix publish payload length when payload contains null characters (#10744) --- esphome/components/mqtt/mqtt_client.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 7675280f1a..7ab6efd1a1 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -491,7 +491,7 @@ bool MQTTClientComponent::publish(const std::string &topic, const std::string &p bool MQTTClientComponent::publish(const std::string &topic, const char *payload, size_t payload_length, uint8_t qos, bool retain) { - return publish({.topic = topic, .payload = payload, .qos = qos, .retain = retain}); + return publish({.topic = topic, .payload = std::string(payload, payload_length), .qos = qos, .retain = retain}); } bool MQTTClientComponent::publish(const MQTTMessage &message) { From 157ea2daa424e1174feaa376b5ae4350a2015c8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 23:25:41 -0500 Subject: [PATCH 1165/1626] [core] Make StringRef convertToJson inline to save 250+ bytes flash --- esphome/core/string_ref.cpp | 12 ------------ esphome/core/string_ref.h | 2 +- 2 files changed, 1 insertion(+), 13 deletions(-) delete mode 100644 esphome/core/string_ref.cpp diff --git a/esphome/core/string_ref.cpp b/esphome/core/string_ref.cpp deleted file mode 100644 index ce1e33cbb7..0000000000 --- a/esphome/core/string_ref.cpp +++ /dev/null @@ -1,12 +0,0 @@ -#include "string_ref.h" - -namespace esphome { - -#ifdef USE_JSON - -// NOLINTNEXTLINE(readability-identifier-naming) -void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(src.c_str()); } - -#endif // USE_JSON - -} // namespace esphome diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index c4320107e3..efaa17181d 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -130,7 +130,7 @@ inline std::string operator+(const StringRef &lhs, const char *rhs) { #ifdef USE_JSON // NOLINTNEXTLINE(readability-identifier-naming) -void convertToJson(const StringRef &src, JsonVariant dst); +inline void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(src.c_str()); } #endif // USE_JSON } // namespace esphome From 660223e269ef5742b70518d9df6d3390f459b9ad Mon Sep 17 00:00:00 2001 From: jokujossai Date: Tue, 16 Sep 2025 08:00:22 +0300 Subject: [PATCH 1166/1626] [ade7880] fix channel a voltage registry (#10750) --- esphome/components/ade7880/ade7880.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp index 55f834bf86..fd560e0676 100644 --- a/esphome/components/ade7880/ade7880.cpp +++ b/esphome/components/ade7880/ade7880.cpp @@ -113,7 +113,7 @@ void ADE7880::update() { if (this->channel_a_ != nullptr) { auto *chan = this->channel_a_; this->update_sensor_from_s24zp_register16_(chan->current, AIRMS, [](float val) { return val / 100000.0f; }); - this->update_sensor_from_s24zp_register16_(chan->voltage, BVRMS, [](float val) { return val / 10000.0f; }); + this->update_sensor_from_s24zp_register16_(chan->voltage, AVRMS, [](float val) { return val / 10000.0f; }); this->update_sensor_from_s24zp_register16_(chan->active_power, AWATT, [](float val) { return val / 100.0f; }); this->update_sensor_from_s24zp_register16_(chan->apparent_power, AVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, APF, From 4b3a997a8edd54735bc7313b001ca07d13f5c38b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Sep 2025 08:17:34 -0500 Subject: [PATCH 1167/1626] Improve coverage for various core modules (#10663) --- tests/unit_tests/conftest.py | 7 + .../test_config_validation_paths.py | 187 +++++++++++++++++ tests/unit_tests/test_external_files.py | 196 ++++++++++++++++++ tests/unit_tests/test_platformio_api.py | 129 ++++++++++++ tests/unit_tests/test_storage_json.py | 182 ++++++++++++++++ 5 files changed, 701 insertions(+) create mode 100644 tests/unit_tests/test_config_validation_paths.py create mode 100644 tests/unit_tests/test_external_files.py create mode 100644 tests/unit_tests/test_platformio_api.py create mode 100644 tests/unit_tests/test_storage_json.py diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index aac5a642f6..a1e438b577 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -36,3 +36,10 @@ def fixture_path() -> Path: Location of all fixture files. """ return here / "fixtures" + + +@pytest.fixture +def setup_core(tmp_path: Path) -> Path: + """Set up CORE with test paths.""" + CORE.config_path = str(tmp_path / "test.yaml") + return tmp_path diff --git a/tests/unit_tests/test_config_validation_paths.py b/tests/unit_tests/test_config_validation_paths.py new file mode 100644 index 0000000000..f8f038390e --- /dev/null +++ b/tests/unit_tests/test_config_validation_paths.py @@ -0,0 +1,187 @@ +"""Tests for config_validation.py path-related functions.""" + +from pathlib import Path + +import pytest +import voluptuous as vol + +from esphome import config_validation as cv + + +def test_directory_valid_path(setup_core: Path) -> None: + """Test directory validator with valid directory.""" + test_dir = setup_core / "test_directory" + test_dir.mkdir() + + result = cv.directory("test_directory") + + assert result == "test_directory" + + +def test_directory_absolute_path(setup_core: Path) -> None: + """Test directory validator with absolute path.""" + test_dir = setup_core / "test_directory" + test_dir.mkdir() + + result = cv.directory(str(test_dir)) + + assert result == str(test_dir) + + +def test_directory_nonexistent_path(setup_core: Path) -> None: + """Test directory validator raises error for non-existent directory.""" + with pytest.raises( + vol.Invalid, match="Could not find directory.*nonexistent_directory" + ): + cv.directory("nonexistent_directory") + + +def test_directory_file_instead_of_directory(setup_core: Path) -> None: + """Test directory validator raises error when path is a file.""" + test_file = setup_core / "test_file.txt" + test_file.write_text("content") + + with pytest.raises(vol.Invalid, match="is not a directory"): + cv.directory("test_file.txt") + + +def test_directory_with_parent_directory(setup_core: Path) -> None: + """Test directory validator with nested directory structure.""" + nested_dir = setup_core / "parent" / "child" / "grandchild" + nested_dir.mkdir(parents=True) + + result = cv.directory("parent/child/grandchild") + + assert result == "parent/child/grandchild" + + +def test_file_valid_path(setup_core: Path) -> None: + """Test file_ validator with valid file.""" + test_file = setup_core / "test_file.yaml" + test_file.write_text("test content") + + result = cv.file_("test_file.yaml") + + assert result == "test_file.yaml" + + +def test_file_absolute_path(setup_core: Path) -> None: + """Test file_ validator with absolute path.""" + test_file = setup_core / "test_file.yaml" + test_file.write_text("test content") + + result = cv.file_(str(test_file)) + + assert result == str(test_file) + + +def test_file_nonexistent_path(setup_core: Path) -> None: + """Test file_ validator raises error for non-existent file.""" + with pytest.raises(vol.Invalid, match="Could not find file.*nonexistent_file.yaml"): + cv.file_("nonexistent_file.yaml") + + +def test_file_directory_instead_of_file(setup_core: Path) -> None: + """Test file_ validator raises error when path is a directory.""" + test_dir = setup_core / "test_directory" + test_dir.mkdir() + + with pytest.raises(vol.Invalid, match="is not a file"): + cv.file_("test_directory") + + +def test_file_with_parent_directory(setup_core: Path) -> None: + """Test file_ validator with file in nested directory.""" + nested_dir = setup_core / "configs" / "sensors" + nested_dir.mkdir(parents=True) + test_file = nested_dir / "temperature.yaml" + test_file.write_text("sensor config") + + result = cv.file_("configs/sensors/temperature.yaml") + + assert result == "configs/sensors/temperature.yaml" + + +def test_directory_handles_trailing_slash(setup_core: Path) -> None: + """Test directory validator handles trailing slashes correctly.""" + test_dir = setup_core / "test_dir" + test_dir.mkdir() + + result = cv.directory("test_dir/") + assert result == "test_dir/" + + result = cv.directory("test_dir") + assert result == "test_dir" + + +def test_file_handles_various_extensions(setup_core: Path) -> None: + """Test file_ validator works with different file extensions.""" + yaml_file = setup_core / "config.yaml" + yaml_file.write_text("yaml content") + assert cv.file_("config.yaml") == "config.yaml" + + yml_file = setup_core / "config.yml" + yml_file.write_text("yml content") + assert cv.file_("config.yml") == "config.yml" + + txt_file = setup_core / "readme.txt" + txt_file.write_text("text content") + assert cv.file_("readme.txt") == "readme.txt" + + no_ext_file = setup_core / "LICENSE" + no_ext_file.write_text("license content") + assert cv.file_("LICENSE") == "LICENSE" + + +def test_directory_with_symlink(setup_core: Path) -> None: + """Test directory validator follows symlinks.""" + actual_dir = setup_core / "actual_directory" + actual_dir.mkdir() + + symlink_dir = setup_core / "symlink_directory" + symlink_dir.symlink_to(actual_dir) + + result = cv.directory("symlink_directory") + assert result == "symlink_directory" + + +def test_file_with_symlink(setup_core: Path) -> None: + """Test file_ validator follows symlinks.""" + actual_file = setup_core / "actual_file.txt" + actual_file.write_text("content") + + symlink_file = setup_core / "symlink_file.txt" + symlink_file.symlink_to(actual_file) + + result = cv.file_("symlink_file.txt") + assert result == "symlink_file.txt" + + +def test_directory_error_shows_full_path(setup_core: Path) -> None: + """Test directory validator error message includes full path.""" + with pytest.raises(vol.Invalid, match=".*missing_dir.*full path:.*"): + cv.directory("missing_dir") + + +def test_file_error_shows_full_path(setup_core: Path) -> None: + """Test file_ validator error message includes full path.""" + with pytest.raises(vol.Invalid, match=".*missing_file.yaml.*full path:.*"): + cv.file_("missing_file.yaml") + + +def test_directory_with_spaces_in_name(setup_core: Path) -> None: + """Test directory validator handles spaces in directory names.""" + dir_with_spaces = setup_core / "my test directory" + dir_with_spaces.mkdir() + + result = cv.directory("my test directory") + assert result == "my test directory" + + +def test_file_with_spaces_in_name(setup_core: Path) -> None: + """Test file_ validator handles spaces in file names.""" + file_with_spaces = setup_core / "my test file.yaml" + file_with_spaces.write_text("content") + + result = cv.file_("my test file.yaml") + assert result == "my test file.yaml" diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py new file mode 100644 index 0000000000..3fa7de2f64 --- /dev/null +++ b/tests/unit_tests/test_external_files.py @@ -0,0 +1,196 @@ +"""Tests for external_files.py functions.""" + +from pathlib import Path +import time +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from esphome import external_files +from esphome.config_validation import Invalid +from esphome.core import CORE, TimePeriod + + +def test_compute_local_file_dir(setup_core: Path) -> None: + """Test compute_local_file_dir creates and returns correct path.""" + domain = "font" + + result = external_files.compute_local_file_dir(domain) + + assert isinstance(result, Path) + assert result == Path(CORE.data_dir) / domain + assert result.exists() + assert result.is_dir() + + +def test_compute_local_file_dir_nested(setup_core: Path) -> None: + """Test compute_local_file_dir works with nested domains.""" + domain = "images/icons" + + result = external_files.compute_local_file_dir(domain) + + assert result == Path(CORE.data_dir) / "images" / "icons" + assert result.exists() + assert result.is_dir() + + +def test_is_file_recent_with_recent_file(setup_core: Path) -> None: + """Test is_file_recent returns True for recently created file.""" + test_file = setup_core / "recent.txt" + test_file.write_text("content") + + refresh = TimePeriod(seconds=3600) + + result = external_files.is_file_recent(str(test_file), refresh) + + assert result is True + + +def test_is_file_recent_with_old_file(setup_core: Path) -> None: + """Test is_file_recent returns False for old file.""" + test_file = setup_core / "old.txt" + test_file.write_text("content") + + old_time = time.time() - 7200 + + with patch("os.path.getctime", return_value=old_time): + refresh = TimePeriod(seconds=3600) + + result = external_files.is_file_recent(str(test_file), refresh) + + assert result is False + + +def test_is_file_recent_nonexistent_file(setup_core: Path) -> None: + """Test is_file_recent returns False for non-existent file.""" + test_file = setup_core / "nonexistent.txt" + refresh = TimePeriod(seconds=3600) + + result = external_files.is_file_recent(str(test_file), refresh) + + assert result is False + + +def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None: + """Test is_file_recent with zero refresh period returns False.""" + test_file = setup_core / "test.txt" + test_file.write_text("content") + + # Mock getctime to return a time 10 seconds ago + with patch("os.path.getctime", return_value=time.time() - 10): + refresh = TimePeriod(seconds=0) + result = external_files.is_file_recent(str(test_file), refresh) + assert result is False + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_not_modified( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed returns False when file not modified.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_head.return_value = mock_response + + url = "https://example.com/file.txt" + result = external_files.has_remote_file_changed(url, str(test_file)) + + assert result is False + mock_head.assert_called_once() + + call_args = mock_head.call_args + headers = call_args[1]["headers"] + assert external_files.IF_MODIFIED_SINCE in headers + assert external_files.CACHE_CONTROL in headers + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_modified( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed returns True when file modified.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_head.return_value = mock_response + + url = "https://example.com/file.txt" + result = external_files.has_remote_file_changed(url, str(test_file)) + + assert result is True + + +def test_has_remote_file_changed_no_local_file(setup_core: Path) -> None: + """Test has_remote_file_changed returns True when local file doesn't exist.""" + test_file = setup_core / "nonexistent.txt" + + url = "https://example.com/file.txt" + result = external_files.has_remote_file_changed(url, str(test_file)) + + assert result is True + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_network_error( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed handles network errors gracefully.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_head.side_effect = requests.exceptions.RequestException("Network error") + + url = "https://example.com/file.txt" + + with pytest.raises(Invalid, match="Could not check if.*Network error"): + external_files.has_remote_file_changed(url, str(test_file)) + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_timeout( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed respects timeout.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_head.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.has_remote_file_changed(url, str(test_file)) + + call_args = mock_head.call_args + assert call_args[1]["timeout"] == external_files.NETWORK_TIMEOUT + + +def test_compute_local_file_dir_creates_parent_dirs(setup_core: Path) -> None: + """Test compute_local_file_dir creates parent directories.""" + domain = "level1/level2/level3/level4" + + result = external_files.compute_local_file_dir(domain) + + assert result.exists() + assert result.is_dir() + assert result.parent.name == "level3" + assert result.parent.parent.name == "level2" + assert result.parent.parent.parent.name == "level1" + + +def test_is_file_recent_handles_float_seconds(setup_core: Path) -> None: + """Test is_file_recent works with float seconds in TimePeriod.""" + test_file = setup_core / "test.txt" + test_file.write_text("content") + + refresh = TimePeriod(seconds=3600.5) + + result = external_files.is_file_recent(str(test_file), refresh) + + assert result is True diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py new file mode 100644 index 0000000000..a1fa963e51 --- /dev/null +++ b/tests/unit_tests/test_platformio_api.py @@ -0,0 +1,129 @@ +"""Tests for platformio_api.py path functions.""" + +from pathlib import Path +from unittest.mock import patch + +from esphome import platformio_api +from esphome.core import CORE + + +def test_idedata_firmware_elf_path(setup_core: Path) -> None: + """Test IDEData.firmware_elf_path returns correct path.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + raw_data = {"prog_path": "/path/to/firmware.elf"} + idedata = platformio_api.IDEData(raw_data) + + assert idedata.firmware_elf_path == "/path/to/firmware.elf" + + +def test_idedata_firmware_bin_path(setup_core: Path) -> None: + """Test IDEData.firmware_bin_path returns Path with .bin extension.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + prog_path = str(Path("/path/to/firmware.elf")) + raw_data = {"prog_path": prog_path} + idedata = platformio_api.IDEData(raw_data) + + result = idedata.firmware_bin_path + assert isinstance(result, str) + expected = str(Path("/path/to/firmware.bin")) + assert result == expected + assert result.endswith(".bin") + + +def test_idedata_firmware_bin_path_preserves_directory(setup_core: Path) -> None: + """Test firmware_bin_path preserves the directory structure.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + prog_path = str(Path("/complex/path/to/build/firmware.elf")) + raw_data = {"prog_path": prog_path} + idedata = platformio_api.IDEData(raw_data) + + result = idedata.firmware_bin_path + expected = str(Path("/complex/path/to/build/firmware.bin")) + assert result == expected + + +def test_idedata_extra_flash_images(setup_core: Path) -> None: + """Test IDEData.extra_flash_images returns list of FlashImage objects.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + raw_data = { + "prog_path": "/path/to/firmware.elf", + "extra": { + "flash_images": [ + {"path": "/path/to/bootloader.bin", "offset": "0x1000"}, + {"path": "/path/to/partition.bin", "offset": "0x8000"}, + ] + }, + } + idedata = platformio_api.IDEData(raw_data) + + images = idedata.extra_flash_images + assert len(images) == 2 + assert all(isinstance(img, platformio_api.FlashImage) for img in images) + assert images[0].path == "/path/to/bootloader.bin" + assert images[0].offset == "0x1000" + assert images[1].path == "/path/to/partition.bin" + assert images[1].offset == "0x8000" + + +def test_idedata_extra_flash_images_empty(setup_core: Path) -> None: + """Test extra_flash_images returns empty list when no extra images.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + raw_data = {"prog_path": "/path/to/firmware.elf", "extra": {"flash_images": []}} + idedata = platformio_api.IDEData(raw_data) + + images = idedata.extra_flash_images + assert images == [] + + +def test_idedata_cc_path(setup_core: Path) -> None: + """Test IDEData.cc_path returns compiler path.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + raw_data = { + "prog_path": "/path/to/firmware.elf", + "cc_path": "/Users/test/.platformio/packages/toolchain-xtensa32/bin/xtensa-esp32-elf-gcc", + } + idedata = platformio_api.IDEData(raw_data) + + assert ( + idedata.cc_path + == "/Users/test/.platformio/packages/toolchain-xtensa32/bin/xtensa-esp32-elf-gcc" + ) + + +def test_flash_image_dataclass() -> None: + """Test FlashImage dataclass stores path and offset correctly.""" + image = platformio_api.FlashImage(path="/path/to/image.bin", offset="0x10000") + + assert image.path == "/path/to/image.bin" + assert image.offset == "0x10000" + + +def test_load_idedata_returns_dict(setup_core: Path) -> None: + """Test _load_idedata returns parsed idedata dict when successful.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + + # Create required files + platformio_ini = setup_core / "build" / "test" / "platformio.ini" + platformio_ini.parent.mkdir(parents=True, exist_ok=True) + platformio_ini.touch() + + idedata_path = setup_core / ".esphome" / "idedata" / "test.json" + idedata_path.parent.mkdir(parents=True, exist_ok=True) + idedata_path.write_text('{"prog_path": "/test/firmware.elf"}') + + with patch("esphome.platformio_api.run_platformio_cli_run") as mock_run: + mock_run.return_value = '{"prog_path": "/test/firmware.elf"}' + + config = {"name": "test"} + result = platformio_api._load_idedata(config) + + assert result is not None + assert isinstance(result, dict) + assert result["prog_path"] == "/test/firmware.elf" diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py new file mode 100644 index 0000000000..52de327bbc --- /dev/null +++ b/tests/unit_tests/test_storage_json.py @@ -0,0 +1,182 @@ +"""Tests for storage_json.py path functions.""" + +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + +from esphome import storage_json +from esphome.core import CORE + + +def test_storage_path(setup_core: Path) -> None: + """Test storage_path returns correct path for current config.""" + CORE.config_path = str(setup_core / "my_device.yaml") + + result = storage_json.storage_path() + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "storage" / "my_device.yaml.json") + assert result == expected + + +def test_ext_storage_path(setup_core: Path) -> None: + """Test ext_storage_path returns correct path for given filename.""" + result = storage_json.ext_storage_path("other_device.yaml") + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "storage" / "other_device.yaml.json") + assert result == expected + + +def test_ext_storage_path_handles_various_extensions(setup_core: Path) -> None: + """Test ext_storage_path works with different file extensions.""" + result_yml = storage_json.ext_storage_path("device.yml") + assert result_yml.endswith("device.yml.json") + + result_no_ext = storage_json.ext_storage_path("device") + assert result_no_ext.endswith("device.json") + + result_path = storage_json.ext_storage_path("my/device.yaml") + assert result_path.endswith("device.yaml.json") + + +def test_esphome_storage_path(setup_core: Path) -> None: + """Test esphome_storage_path returns correct path.""" + result = storage_json.esphome_storage_path() + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "esphome.json") + assert result == expected + + +def test_ignored_devices_storage_path(setup_core: Path) -> None: + """Test ignored_devices_storage_path returns correct path.""" + result = storage_json.ignored_devices_storage_path() + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "ignored-devices.json") + assert result == expected + + +def test_trash_storage_path(setup_core: Path) -> None: + """Test trash_storage_path returns correct path.""" + CORE.config_path = str(setup_core / "configs" / "device.yaml") + + result = storage_json.trash_storage_path() + + expected = str(setup_core / "configs" / "trash") + assert result == expected + + +def test_archive_storage_path(setup_core: Path) -> None: + """Test archive_storage_path returns correct path.""" + CORE.config_path = str(setup_core / "configs" / "device.yaml") + + result = storage_json.archive_storage_path() + + expected = str(setup_core / "configs" / "archive") + assert result == expected + + +def test_storage_path_with_subdirectory(setup_core: Path) -> None: + """Test storage paths work correctly when config is in subdirectory.""" + subdir = setup_core / "configs" / "basement" + subdir.mkdir(parents=True, exist_ok=True) + CORE.config_path = str(subdir / "sensor.yaml") + + result = storage_json.storage_path() + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "storage" / "sensor.yaml.json") + assert result == expected + + +def test_storage_json_firmware_bin_path_property(setup_core: Path) -> None: + """Test StorageJSON firmware_bin_path property.""" + storage = storage_json.StorageJSON( + storage_version=1, + name="test_device", + friendly_name="Test Device", + comment=None, + esphome_version="2024.1.0", + src_version=None, + address="192.168.1.100", + web_port=80, + target_platform="ESP32", + build_path="build/test_device", + firmware_bin_path="/path/to/firmware.bin", + loaded_integrations={"wifi", "api"}, + loaded_platforms=set(), + no_mdns=False, + ) + + assert storage.firmware_bin_path == "/path/to/firmware.bin" + + +def test_storage_json_save_creates_directory(setup_core: Path, tmp_path: Path) -> None: + """Test StorageJSON.save creates storage directory if it doesn't exist.""" + storage_dir = tmp_path / "new_data" / "storage" + storage_file = storage_dir / "test.json" + + assert not storage_dir.exists() + + storage = storage_json.StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=None, + address="test.local", + web_port=None, + target_platform="ESP8266", + build_path=None, + firmware_bin_path=None, + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + ) + + with patch("esphome.storage_json.write_file_if_changed") as mock_write: + storage.save(str(storage_file)) + mock_write.assert_called_once() + call_args = mock_write.call_args[0] + assert call_args[0] == str(storage_file) + + +def test_storage_json_from_wizard(setup_core: Path) -> None: + """Test StorageJSON.from_wizard creates correct storage object.""" + storage = storage_json.StorageJSON.from_wizard( + name="my_device", + friendly_name="My Device", + address="my_device.local", + platform="ESP32", + ) + + assert storage.name == "my_device" + assert storage.friendly_name == "My Device" + assert storage.address == "my_device.local" + assert storage.target_platform == "ESP32" + assert storage.build_path is None + assert storage.firmware_bin_path is None + + +@pytest.mark.skipif(sys.platform == "win32", reason="HA addons don't run on Windows") +@patch("esphome.core.is_ha_addon") +def test_storage_paths_with_ha_addon(mock_is_ha_addon: bool, tmp_path: Path) -> None: + """Test storage paths when running as Home Assistant addon.""" + mock_is_ha_addon.return_value = True + + CORE.config_path = str(tmp_path / "test.yaml") + + result = storage_json.storage_path() + # When is_ha_addon is True, CORE.data_dir returns "/data" + # This is the standard mount point for HA addon containers + expected = str(Path("/data") / "storage" / "test.yaml.json") + assert result == expected + + result = storage_json.esphome_storage_path() + expected = str(Path("/data") / "esphome.json") + assert result == expected From 8890071360e00793640d62e36f0e7c1f110d8a28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:31:38 -0500 Subject: [PATCH 1168/1626] Add additional test coverage ahead of Path conversion (#10700) --- tests/unit_tests/conftest.py | 44 ++ tests/unit_tests/core/test_config.py | 329 ++++++++++++++- tests/unit_tests/test_platformio_api.py | 521 +++++++++++++++++++++++- tests/unit_tests/test_storage_json.py | 492 +++++++++++++++++++++- 4 files changed, 1369 insertions(+), 17 deletions(-) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index a1e438b577..06d06d0506 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -9,8 +9,10 @@ not be part of a unit test suite. """ +from collections.abc import Generator from pathlib import Path import sys +from unittest.mock import Mock, patch import pytest @@ -43,3 +45,45 @@ def setup_core(tmp_path: Path) -> Path: """Set up CORE with test paths.""" CORE.config_path = str(tmp_path / "test.yaml") return tmp_path + + +@pytest.fixture +def mock_write_file_if_changed() -> Generator[Mock, None, None]: + """Mock write_file_if_changed for storage_json.""" + with patch("esphome.storage_json.write_file_if_changed") as mock: + yield mock + + +@pytest.fixture +def mock_copy_file_if_changed() -> Generator[Mock, None, None]: + """Mock copy_file_if_changed for core.config.""" + with patch("esphome.core.config.copy_file_if_changed") as mock: + yield mock + + +@pytest.fixture +def mock_run_platformio_cli() -> Generator[Mock, None, None]: + """Mock run_platformio_cli for platformio_api.""" + with patch("esphome.platformio_api.run_platformio_cli") as mock: + yield mock + + +@pytest.fixture +def mock_run_platformio_cli_run() -> Generator[Mock, None, None]: + """Mock run_platformio_cli_run for platformio_api.""" + with patch("esphome.platformio_api.run_platformio_cli_run") as mock: + yield mock + + +@pytest.fixture +def mock_decode_pc() -> Generator[Mock, None, None]: + """Mock _decode_pc for platformio_api.""" + with patch("esphome.platformio_api._decode_pc") as mock: + yield mock + + +@pytest.fixture +def mock_run_external_command() -> Generator[Mock, None, None]: + """Mock run_external_command for platformio_api.""" + with patch("esphome.platformio_api.run_external_command") as mock: + yield mock diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index f5ba5221ed..484c30d59e 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1,15 +1,34 @@ """Unit tests for core config functionality including areas and devices.""" from collections.abc import Callable +import os from pathlib import Path +import types from typing import Any +from unittest.mock import MagicMock, Mock, patch import pytest from esphome import config_validation as cv, core -from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES -from esphome.core import config -from esphome.core.config import Area, validate_area_config +from esphome.const import ( + CONF_AREA, + CONF_AREAS, + CONF_BUILD_PATH, + CONF_DEVICES, + CONF_ESPHOME, + CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, + KEY_CORE, +) +from esphome.core import CORE, config +from esphome.core.config import ( + Area, + preload_core_config, + valid_include, + valid_project_name, + validate_area_config, + validate_hostname, +) from .common import load_config_from_fixture @@ -245,3 +264,307 @@ def test_add_platform_defines_priority() -> None: f"_add_platform_defines priority ({config._add_platform_defines.priority}) must be lower than " f"globals priority ({globals_to_code.priority}) to fix issue #10431 (sensor count bug with lambdas)" ) + + +def test_valid_include_with_angle_brackets() -> None: + """Test valid_include accepts angle bracket includes.""" + assert valid_include("") == "" + + +def test_valid_include_with_valid_file(tmp_path: Path) -> None: + """Test valid_include accepts valid include files.""" + CORE.config_path = str(tmp_path / "test.yaml") + include_file = tmp_path / "include.h" + include_file.touch() + + assert valid_include(str(include_file)) == str(include_file) + + +def test_valid_include_with_valid_directory(tmp_path: Path) -> None: + """Test valid_include accepts valid directories.""" + CORE.config_path = str(tmp_path / "test.yaml") + include_dir = tmp_path / "includes" + include_dir.mkdir() + + assert valid_include(str(include_dir)) == str(include_dir) + + +def test_valid_include_invalid_extension(tmp_path: Path) -> None: + """Test valid_include rejects files with invalid extensions.""" + CORE.config_path = str(tmp_path / "test.yaml") + invalid_file = tmp_path / "file.txt" + invalid_file.touch() + + with pytest.raises(cv.Invalid, match="Include has invalid file extension"): + valid_include(str(invalid_file)) + + +def test_valid_project_name_valid() -> None: + """Test valid_project_name accepts valid project names.""" + assert valid_project_name("esphome.my_project") == "esphome.my_project" + + +def test_valid_project_name_no_namespace() -> None: + """Test valid_project_name rejects names without namespace.""" + with pytest.raises(cv.Invalid, match="project name needs to have a namespace"): + valid_project_name("my_project") + + +def test_valid_project_name_multiple_dots() -> None: + """Test valid_project_name rejects names with multiple dots.""" + with pytest.raises(cv.Invalid, match="project name needs to have a namespace"): + valid_project_name("esphome.my.project") + + +def test_validate_hostname_valid() -> None: + """Test validate_hostname accepts valid hostnames.""" + config = {CONF_NAME: "my-device", CONF_NAME_ADD_MAC_SUFFIX: False} + assert validate_hostname(config) == config + + +def test_validate_hostname_too_long() -> None: + """Test validate_hostname rejects hostnames that are too long.""" + config = { + CONF_NAME: "a" * 32, # 32 chars, max is 31 + CONF_NAME_ADD_MAC_SUFFIX: False, + } + with pytest.raises(cv.Invalid, match="Hostnames can only be 31 characters long"): + validate_hostname(config) + + +def test_validate_hostname_too_long_with_mac_suffix() -> None: + """Test validate_hostname accounts for MAC suffix length.""" + config = { + CONF_NAME: "a" * 25, # 25 chars, max is 24 with MAC suffix + CONF_NAME_ADD_MAC_SUFFIX: True, + } + with pytest.raises(cv.Invalid, match="Hostnames can only be 24 characters long"): + validate_hostname(config) + + +def test_validate_hostname_with_underscore(caplog) -> None: + """Test validate_hostname warns about underscores.""" + config = {CONF_NAME: "my_device", CONF_NAME_ADD_MAC_SUFFIX: False} + assert validate_hostname(config) == config + assert ( + "Using the '_' (underscore) character in the hostname is discouraged" + in caplog.text + ) + + +def test_preload_core_config_basic(setup_core: Path) -> None: + """Test preload_core_config sets basic CORE attributes.""" + config = { + CONF_ESPHOME: { + CONF_NAME: "test_device", + }, + "esp32": {}, + } + result = {} + + platform = preload_core_config(config, result) + + assert CORE.name == "test_device" + assert platform == "esp32" + assert KEY_CORE in CORE.data + assert CONF_BUILD_PATH in config[CONF_ESPHOME] + + +def test_preload_core_config_with_build_path(setup_core: Path) -> None: + """Test preload_core_config uses provided build path.""" + config = { + CONF_ESPHOME: { + CONF_NAME: "test_device", + CONF_BUILD_PATH: "/custom/build/path", + }, + "esp8266": {}, + } + result = {} + + platform = preload_core_config(config, result) + + assert config[CONF_ESPHOME][CONF_BUILD_PATH] == "/custom/build/path" + assert platform == "esp8266" + + +def test_preload_core_config_env_build_path(setup_core: Path) -> None: + """Test preload_core_config uses ESPHOME_BUILD_PATH env var.""" + config = { + CONF_ESPHOME: { + CONF_NAME: "test_device", + }, + "rp2040": {}, + } + result = {} + + with patch.dict(os.environ, {"ESPHOME_BUILD_PATH": "/env/build"}): + platform = preload_core_config(config, result) + + assert CONF_BUILD_PATH in config[CONF_ESPHOME] + assert "test_device" in config[CONF_ESPHOME][CONF_BUILD_PATH] + assert platform == "rp2040" + + +def test_preload_core_config_no_platform(setup_core: Path) -> None: + """Test preload_core_config raises when no platform is specified.""" + config = { + CONF_ESPHOME: { + CONF_NAME: "test_device", + }, + } + result = {} + + # Mock _is_target_platform to avoid expensive component loading + with patch("esphome.core.config._is_target_platform") as mock_is_platform: + # Return True for known platforms + mock_is_platform.side_effect = lambda name: name in [ + "esp32", + "esp8266", + "rp2040", + ] + + with pytest.raises(cv.Invalid, match="Platform missing"): + preload_core_config(config, result) + + +def test_preload_core_config_multiple_platforms(setup_core: Path) -> None: + """Test preload_core_config raises when multiple platforms are specified.""" + config = { + CONF_ESPHOME: { + CONF_NAME: "test_device", + }, + "esp32": {}, + "esp8266": {}, + } + result = {} + + # Mock _is_target_platform to avoid expensive component loading + with patch("esphome.core.config._is_target_platform") as mock_is_platform: + # Return True for known platforms + mock_is_platform.side_effect = lambda name: name in [ + "esp32", + "esp8266", + "rp2040", + ] + + with pytest.raises(cv.Invalid, match="Found multiple target platform blocks"): + preload_core_config(config, result) + + +def test_include_file_header(tmp_path: Path, mock_copy_file_if_changed: Mock) -> None: + """Test include_file adds include statement for header files.""" + src_file = tmp_path / "source.h" + src_file.write_text("// Header content") + + CORE.build_path = str(tmp_path / "build") + + with patch("esphome.core.config.cg") as mock_cg: + # Mock RawStatement to capture the text + mock_raw_statement = MagicMock() + mock_raw_statement.text = "" + + def raw_statement_side_effect(text): + mock_raw_statement.text = text + return mock_raw_statement + + mock_cg.RawStatement.side_effect = raw_statement_side_effect + + config.include_file(str(src_file), "test.h") + + mock_copy_file_if_changed.assert_called_once() + mock_cg.add_global.assert_called_once() + # Check that include statement was added + assert '#include "test.h"' in mock_raw_statement.text + + +def test_include_file_cpp(tmp_path: Path, mock_copy_file_if_changed: Mock) -> None: + """Test include_file does not add include for cpp files.""" + src_file = tmp_path / "source.cpp" + src_file.write_text("// CPP content") + + CORE.build_path = str(tmp_path / "build") + + with patch("esphome.core.config.cg") as mock_cg: + config.include_file(str(src_file), "test.cpp") + + mock_copy_file_if_changed.assert_called_once() + # Should not add include statement for .cpp files + mock_cg.add_global.assert_not_called() + + +def test_get_usable_cpu_count() -> None: + """Test get_usable_cpu_count returns CPU count.""" + count = config.get_usable_cpu_count() + assert isinstance(count, int) + assert count > 0 + + +def test_get_usable_cpu_count_with_process_cpu_count() -> None: + """Test get_usable_cpu_count uses process_cpu_count when available.""" + # Test with process_cpu_count (Python 3.13+) + # Create a mock os module with process_cpu_count + + mock_os = types.SimpleNamespace(process_cpu_count=lambda: 8, cpu_count=lambda: 4) + + with patch("esphome.core.config.os", mock_os): + # When process_cpu_count exists, it should be used + count = config.get_usable_cpu_count() + assert count == 8 + + # Test fallback to cpu_count when process_cpu_count not available + mock_os_no_process = types.SimpleNamespace(cpu_count=lambda: 4) + + with patch("esphome.core.config.os", mock_os_no_process): + count = config.get_usable_cpu_count() + assert count == 4 + + +def test_list_target_platforms(tmp_path: Path) -> None: + """Test _list_target_platforms returns available platforms.""" + # Create mock components directory structure + components_dir = tmp_path / "components" + components_dir.mkdir() + + # Create platform and non-platform directories with __init__.py + platforms = ["esp32", "esp8266", "rp2040", "libretiny", "host"] + non_platforms = ["sensor"] + + for component in platforms + non_platforms: + component_dir = components_dir / component + component_dir.mkdir() + (component_dir / "__init__.py").touch() + + # Create a file (not a directory) + (components_dir / "README.md").touch() + + # Create a directory without __init__.py + (components_dir / "no_init").mkdir() + + # Mock Path(__file__).parents[1] to return our tmp_path + with patch("esphome.core.config.Path") as mock_path: + mock_file_path = MagicMock() + mock_file_path.parents = [MagicMock(), tmp_path] + mock_path.return_value = mock_file_path + + platforms = config._list_target_platforms() + + assert isinstance(platforms, list) + # Should include platform components + assert "esp32" in platforms + assert "esp8266" in platforms + assert "rp2040" in platforms + assert "libretiny" in platforms + assert "host" in platforms + # Should not include non-platform components + assert "sensor" not in platforms + assert "README.md" not in platforms + assert "no_init" not in platforms + + +def test_is_target_platform() -> None: + """Test _is_target_platform identifies valid platforms.""" + assert config._is_target_platform("esp32") is True + assert config._is_target_platform("esp8266") is True + assert config._is_target_platform("rp2040") is True + assert config._is_target_platform("invalid_platform") is False + assert config._is_target_platform("api") is False # Component but not platform diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index a1fa963e51..7c7883d391 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -1,10 +1,16 @@ """Tests for platformio_api.py path functions.""" +import json +import os from pathlib import Path -from unittest.mock import patch +import shutil +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock, patch + +import pytest from esphome import platformio_api -from esphome.core import CORE +from esphome.core import CORE, EsphomeError def test_idedata_firmware_elf_path(setup_core: Path) -> None: @@ -104,7 +110,9 @@ def test_flash_image_dataclass() -> None: assert image.offset == "0x10000" -def test_load_idedata_returns_dict(setup_core: Path) -> None: +def test_load_idedata_returns_dict( + setup_core: Path, mock_run_platformio_cli_run +) -> None: """Test _load_idedata returns parsed idedata dict when successful.""" CORE.build_path = str(setup_core / "build" / "test") CORE.name = "test" @@ -118,12 +126,511 @@ def test_load_idedata_returns_dict(setup_core: Path) -> None: idedata_path.parent.mkdir(parents=True, exist_ok=True) idedata_path.write_text('{"prog_path": "/test/firmware.elf"}') - with patch("esphome.platformio_api.run_platformio_cli_run") as mock_run: - mock_run.return_value = '{"prog_path": "/test/firmware.elf"}' + mock_run_platformio_cli_run.return_value = '{"prog_path": "/test/firmware.elf"}' - config = {"name": "test"} - result = platformio_api._load_idedata(config) + config = {"name": "test"} + result = platformio_api._load_idedata(config) assert result is not None assert isinstance(result, dict) assert result["prog_path"] == "/test/firmware.elf" + + +def test_load_idedata_uses_cache_when_valid( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test _load_idedata uses cached data when unchanged.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + + # Create platformio.ini + platformio_ini = setup_core / "build" / "test" / "platformio.ini" + platformio_ini.parent.mkdir(parents=True, exist_ok=True) + platformio_ini.write_text("content") + + # Create idedata cache file that's newer + idedata_path = setup_core / ".esphome" / "idedata" / "test.json" + idedata_path.parent.mkdir(parents=True, exist_ok=True) + idedata_path.write_text('{"prog_path": "/cached/firmware.elf"}') + + # Make idedata newer than platformio.ini + platformio_ini_mtime = platformio_ini.stat().st_mtime + os.utime(idedata_path, (platformio_ini_mtime + 1, platformio_ini_mtime + 1)) + + config = {"name": "test"} + result = platformio_api._load_idedata(config) + + # Should not call _run_idedata since cache is valid + mock_run_platformio_cli_run.assert_not_called() + + assert result["prog_path"] == "/cached/firmware.elf" + + +def test_load_idedata_regenerates_when_platformio_ini_newer( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test _load_idedata regenerates when platformio.ini is newer.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + + # Create idedata cache file first + idedata_path = setup_core / ".esphome" / "idedata" / "test.json" + idedata_path.parent.mkdir(parents=True, exist_ok=True) + idedata_path.write_text('{"prog_path": "/old/firmware.elf"}') + + # Create platformio.ini that's newer + idedata_mtime = idedata_path.stat().st_mtime + platformio_ini = setup_core / "build" / "test" / "platformio.ini" + platformio_ini.parent.mkdir(parents=True, exist_ok=True) + platformio_ini.write_text("content") + # Make platformio.ini newer than idedata + os.utime(platformio_ini, (idedata_mtime + 1, idedata_mtime + 1)) + + # Mock platformio to return new data + new_data = {"prog_path": "/new/firmware.elf"} + mock_run_platformio_cli_run.return_value = json.dumps(new_data) + + config = {"name": "test"} + result = platformio_api._load_idedata(config) + + # Should call _run_idedata since platformio.ini is newer + mock_run_platformio_cli_run.assert_called_once() + + assert result["prog_path"] == "/new/firmware.elf" + + +def test_load_idedata_regenerates_on_corrupted_cache( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test _load_idedata regenerates when cache file is corrupted.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + + # Create platformio.ini + platformio_ini = setup_core / "build" / "test" / "platformio.ini" + platformio_ini.parent.mkdir(parents=True, exist_ok=True) + platformio_ini.write_text("content") + + # Create corrupted idedata cache file + idedata_path = setup_core / ".esphome" / "idedata" / "test.json" + idedata_path.parent.mkdir(parents=True, exist_ok=True) + idedata_path.write_text('{"prog_path": invalid json') + + # Make idedata newer so it would be used if valid + platformio_ini_mtime = platformio_ini.stat().st_mtime + os.utime(idedata_path, (platformio_ini_mtime + 1, platformio_ini_mtime + 1)) + + # Mock platformio to return new data + new_data = {"prog_path": "/new/firmware.elf"} + mock_run_platformio_cli_run.return_value = json.dumps(new_data) + + config = {"name": "test"} + result = platformio_api._load_idedata(config) + + # Should call _run_idedata since cache is corrupted + mock_run_platformio_cli_run.assert_called_once() + + assert result["prog_path"] == "/new/firmware.elf" + + +def test_run_idedata_parses_json_from_output( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test _run_idedata extracts JSON from platformio output.""" + config = {"name": "test"} + + expected_data = { + "prog_path": "/path/to/firmware.elf", + "cc_path": "/path/to/gcc", + "extra": {"flash_images": []}, + } + + # Simulate platformio output with JSON embedded + mock_run_platformio_cli_run.return_value = ( + f"Some preamble\n{json.dumps(expected_data)}\nSome postamble" + ) + + result = platformio_api._run_idedata(config) + + assert result == expected_data + + +def test_run_idedata_raises_on_no_json( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test _run_idedata raises EsphomeError when no JSON found.""" + config = {"name": "test"} + + mock_run_platformio_cli_run.return_value = "No JSON in this output" + + with pytest.raises(EsphomeError): + platformio_api._run_idedata(config) + + +def test_run_idedata_raises_on_invalid_json( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test _run_idedata raises on malformed JSON.""" + config = {"name": "test"} + mock_run_platformio_cli_run.return_value = '{"invalid": json"}' + + # The ValueError from json.loads is re-raised + with pytest.raises(ValueError): + platformio_api._run_idedata(config) + + +def test_run_platformio_cli_sets_environment_variables( + setup_core: Path, mock_run_external_command: Mock +) -> None: + """Test run_platformio_cli sets correct environment variables.""" + CORE.build_path = str(setup_core / "build" / "test") + + with patch.dict(os.environ, {}, clear=False): + mock_run_external_command.return_value = 0 + platformio_api.run_platformio_cli("test", "arg") + + # Check environment variables were set + assert os.environ["PLATFORMIO_FORCE_COLOR"] == "true" + assert ( + setup_core / "build" / "test" + in Path(os.environ["PLATFORMIO_BUILD_DIR"]).parents + or Path(os.environ["PLATFORMIO_BUILD_DIR"]) == setup_core / "build" / "test" + ) + assert "PLATFORMIO_LIBDEPS_DIR" in os.environ + assert "PYTHONWARNINGS" in os.environ + + # Check command was called correctly + mock_run_external_command.assert_called_once() + args = mock_run_external_command.call_args[0] + assert "platformio" in args + assert "test" in args + assert "arg" in args + + +def test_run_platformio_cli_run_builds_command( + setup_core: Path, mock_run_platformio_cli: Mock +) -> None: + """Test run_platformio_cli_run builds correct command.""" + CORE.build_path = str(setup_core / "build" / "test") + mock_run_platformio_cli.return_value = 0 + + config = {"name": "test"} + platformio_api.run_platformio_cli_run(config, True, "extra", "args") + + mock_run_platformio_cli.assert_called_once_with( + "run", "-d", CORE.build_path, "-v", "extra", "args" + ) + + +def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> None: + """Test run_compile with process limit.""" + from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME + + CORE.build_path = str(setup_core / "build" / "test") + config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}} + mock_run_platformio_cli_run.return_value = 0 + + platformio_api.run_compile(config, verbose=True) + + mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4") + + +def test_get_idedata_caches_result( + setup_core: Path, mock_run_platformio_cli_run: Mock +) -> None: + """Test get_idedata caches result in CORE.data.""" + from esphome.const import KEY_CORE + + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + CORE.data[KEY_CORE] = {} + + # Create platformio.ini to avoid regeneration + platformio_ini = setup_core / "build" / "test" / "platformio.ini" + platformio_ini.parent.mkdir(parents=True, exist_ok=True) + platformio_ini.write_text("content") + + # Mock platformio to return data + idedata = {"prog_path": "/test/firmware.elf"} + mock_run_platformio_cli_run.return_value = json.dumps(idedata) + + config = {"name": "test"} + + # First call should load and cache + result1 = platformio_api.get_idedata(config) + mock_run_platformio_cli_run.assert_called_once() + + # Second call should use cache from CORE.data + result2 = platformio_api.get_idedata(config) + mock_run_platformio_cli_run.assert_called_once() # Still only called once + + assert result1 is result2 + assert isinstance(result1, platformio_api.IDEData) + assert result1.firmware_elf_path == "/test/firmware.elf" + + +def test_idedata_addr2line_path_windows(setup_core: Path) -> None: + """Test IDEData.addr2line_path on Windows.""" + raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"} + idedata = platformio_api.IDEData(raw_data) + + result = idedata.addr2line_path + assert result == "C:\\tools\\addr2line.exe" + + +def test_idedata_addr2line_path_unix(setup_core: Path) -> None: + """Test IDEData.addr2line_path on Unix.""" + raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"} + idedata = platformio_api.IDEData(raw_data) + + result = idedata.addr2line_path + assert result == "/usr/bin/addr2line" + + +def test_patch_structhash(setup_core: Path) -> None: + """Test patch_structhash monkey patches platformio functions.""" + # Create simple namespace objects to act as modules + mock_cli = SimpleNamespace() + mock_helpers = SimpleNamespace() + mock_run = SimpleNamespace(cli=mock_cli, helpers=mock_helpers) + + # Mock platformio modules + with patch.dict( + "sys.modules", + { + "platformio.run.cli": mock_cli, + "platformio.run.helpers": mock_helpers, + "platformio.run": mock_run, + "platformio.project.helpers": MagicMock(), + "platformio.fs": MagicMock(), + "platformio": MagicMock(), + }, + ): + # Call patch_structhash + platformio_api.patch_structhash() + + # Verify both modules had clean_build_dir patched + # Check that clean_build_dir was set on both modules + assert hasattr(mock_cli, "clean_build_dir") + assert hasattr(mock_helpers, "clean_build_dir") + + # Verify they got the same function assigned + assert mock_cli.clean_build_dir is mock_helpers.clean_build_dir + + # Verify it's a real function (not a Mock) + assert callable(mock_cli.clean_build_dir) + assert mock_cli.clean_build_dir.__name__ == "patched_clean_build_dir" + + +def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None: + """Test patched_clean_build_dir removes build dir when platformio.ini is newer.""" + build_dir = setup_core / "build" + build_dir.mkdir() + platformio_ini = setup_core / "platformio.ini" + platformio_ini.write_text("config") + + # Make platformio.ini newer than build_dir + build_mtime = build_dir.stat().st_mtime + os.utime(platformio_ini, (build_mtime + 1, build_mtime + 1)) + + # Track if directory was removed + removed_paths: list[str] = [] + + def track_rmtree(path: str) -> None: + removed_paths.append(path) + shutil.rmtree(path) + + # Create mock modules that patch_structhash expects + mock_cli = SimpleNamespace() + mock_helpers = SimpleNamespace() + mock_project_helpers = MagicMock() + mock_project_helpers.get_project_dir.return_value = str(setup_core) + mock_fs = SimpleNamespace(rmtree=track_rmtree) + + with patch.dict( + "sys.modules", + { + "platformio": SimpleNamespace(fs=mock_fs), + "platformio.fs": mock_fs, + "platformio.project.helpers": mock_project_helpers, + "platformio.run": SimpleNamespace(cli=mock_cli, helpers=mock_helpers), + "platformio.run.cli": mock_cli, + "platformio.run.helpers": mock_helpers, + }, + ): + # Call patch_structhash to install the patched function + platformio_api.patch_structhash() + + # Call the patched function + mock_helpers.clean_build_dir(str(build_dir), []) + + # Verify directory was removed and recreated + assert len(removed_paths) == 1 + assert removed_paths[0] == str(build_dir) + assert build_dir.exists() # makedirs recreated it + + +def test_patched_clean_build_dir_keeps_updated(setup_core: Path) -> None: + """Test patched_clean_build_dir keeps build dir when it's up to date.""" + build_dir = setup_core / "build" + build_dir.mkdir() + test_file = build_dir / "test.txt" + test_file.write_text("test content") + + platformio_ini = setup_core / "platformio.ini" + platformio_ini.write_text("config") + + # Make build_dir newer than platformio.ini + ini_mtime = platformio_ini.stat().st_mtime + os.utime(build_dir, (ini_mtime + 1, ini_mtime + 1)) + + # Track if rmtree is called + removed_paths: list[str] = [] + + def track_rmtree(path: str) -> None: + removed_paths.append(path) + + # Create mock modules + mock_cli = SimpleNamespace() + mock_helpers = SimpleNamespace() + mock_project_helpers = MagicMock() + mock_project_helpers.get_project_dir.return_value = str(setup_core) + mock_fs = SimpleNamespace(rmtree=track_rmtree) + + with patch.dict( + "sys.modules", + { + "platformio": SimpleNamespace(fs=mock_fs), + "platformio.fs": mock_fs, + "platformio.project.helpers": mock_project_helpers, + "platformio.run": SimpleNamespace(cli=mock_cli, helpers=mock_helpers), + "platformio.run.cli": mock_cli, + "platformio.run.helpers": mock_helpers, + }, + ): + # Call patch_structhash to install the patched function + platformio_api.patch_structhash() + + # Call the patched function + mock_helpers.clean_build_dir(str(build_dir), []) + + # Verify rmtree was NOT called + assert len(removed_paths) == 0 + + # Verify directory and file still exist + assert build_dir.exists() + assert test_file.exists() + assert test_file.read_text() == "test content" + + +def test_patched_clean_build_dir_creates_missing(setup_core: Path) -> None: + """Test patched_clean_build_dir creates build dir when it doesn't exist.""" + build_dir = setup_core / "build" + platformio_ini = setup_core / "platformio.ini" + platformio_ini.write_text("config") + + # Ensure build_dir doesn't exist + assert not build_dir.exists() + + # Track if rmtree is called + removed_paths: list[str] = [] + + def track_rmtree(path: str) -> None: + removed_paths.append(path) + + # Create mock modules + mock_cli = SimpleNamespace() + mock_helpers = SimpleNamespace() + mock_project_helpers = MagicMock() + mock_project_helpers.get_project_dir.return_value = str(setup_core) + mock_fs = SimpleNamespace(rmtree=track_rmtree) + + with patch.dict( + "sys.modules", + { + "platformio": SimpleNamespace(fs=mock_fs), + "platformio.fs": mock_fs, + "platformio.project.helpers": mock_project_helpers, + "platformio.run": SimpleNamespace(cli=mock_cli, helpers=mock_helpers), + "platformio.run.cli": mock_cli, + "platformio.run.helpers": mock_helpers, + }, + ): + # Call patch_structhash to install the patched function + platformio_api.patch_structhash() + + # Call the patched function + mock_helpers.clean_build_dir(str(build_dir), []) + + # Verify rmtree was NOT called + assert len(removed_paths) == 0 + + # Verify directory was created + assert build_dir.exists() + + +def test_process_stacktrace_esp8266_exception(setup_core: Path, caplog) -> None: + """Test process_stacktrace handles ESP8266 exceptions.""" + config = {"name": "test"} + + # Test exception type parsing + line = "Exception (28):" + backtrace_state = False + + result = platformio_api.process_stacktrace(config, line, backtrace_state) + + assert "Access to invalid address: LOAD (wild pointer?)" in caplog.text + assert result is False + + +def test_process_stacktrace_esp8266_backtrace( + setup_core: Path, mock_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP8266 multi-line backtrace.""" + config = {"name": "test"} + + # Start of backtrace + line1 = ">>>stack>>>" + state = platformio_api.process_stacktrace(config, line1, False) + assert state is True + + # Backtrace content with addresses + line2 = "40201234 40205678" + state = platformio_api.process_stacktrace(config, line2, state) + assert state is True + assert mock_decode_pc.call_count == 2 + + # End of backtrace + line3 = "<< None: + """Test process_stacktrace handles ESP32 single-line backtrace.""" + config = {"name": "test"} + + line = "Backtrace: 0x40081234:0x3ffb1234 0x40085678:0x3ffb5678" + state = platformio_api.process_stacktrace(config, line, False) + + # Should decode both addresses + assert mock_decode_pc.call_count == 2 + mock_decode_pc.assert_any_call(config, "40081234") + mock_decode_pc.assert_any_call(config, "40085678") + assert state is False + + +def test_process_stacktrace_bad_alloc( + setup_core: Path, mock_decode_pc: Mock, caplog +) -> None: + """Test process_stacktrace handles bad alloc messages.""" + config = {"name": "test"} + + line = "last failed alloc call: 40201234(512)" + state = platformio_api.process_stacktrace(config, line, False) + + assert "Memory allocation of 512 bytes failed at 40201234" in caplog.text + mock_decode_pc.assert_called_once_with(config, "40201234") + assert state is False diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 52de327bbc..e1abe565b1 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -1,12 +1,15 @@ """Tests for storage_json.py path functions.""" +from datetime import datetime +import json from pathlib import Path import sys -from unittest.mock import patch +from unittest.mock import MagicMock, Mock, patch import pytest from esphome import storage_json +from esphome.const import CONF_DISABLED, CONF_MDNS from esphome.core import CORE @@ -115,7 +118,9 @@ def test_storage_json_firmware_bin_path_property(setup_core: Path) -> None: assert storage.firmware_bin_path == "/path/to/firmware.bin" -def test_storage_json_save_creates_directory(setup_core: Path, tmp_path: Path) -> None: +def test_storage_json_save_creates_directory( + setup_core: Path, tmp_path: Path, mock_write_file_if_changed: Mock +) -> None: """Test StorageJSON.save creates storage directory if it doesn't exist.""" storage_dir = tmp_path / "new_data" / "storage" storage_file = storage_dir / "test.json" @@ -139,11 +144,10 @@ def test_storage_json_save_creates_directory(setup_core: Path, tmp_path: Path) - no_mdns=False, ) - with patch("esphome.storage_json.write_file_if_changed") as mock_write: - storage.save(str(storage_file)) - mock_write.assert_called_once() - call_args = mock_write.call_args[0] - assert call_args[0] == str(storage_file) + storage.save(str(storage_file)) + mock_write_file_if_changed.assert_called_once() + call_args = mock_write_file_if_changed.call_args[0] + assert call_args[0] == str(storage_file) def test_storage_json_from_wizard(setup_core: Path) -> None: @@ -180,3 +184,477 @@ def test_storage_paths_with_ha_addon(mock_is_ha_addon: bool, tmp_path: Path) -> result = storage_json.esphome_storage_path() expected = str(Path("/data") / "esphome.json") assert result == expected + + +def test_storage_json_as_dict() -> None: + """Test StorageJSON.as_dict returns correct dictionary.""" + storage = storage_json.StorageJSON( + storage_version=1, + name="test_device", + friendly_name="Test Device", + comment="Test comment", + esphome_version="2024.1.0", + src_version=1, + address="192.168.1.100", + web_port=80, + target_platform="ESP32", + build_path="/path/to/build", + firmware_bin_path="/path/to/firmware.bin", + loaded_integrations={"wifi", "api", "ota"}, + loaded_platforms={"sensor", "binary_sensor"}, + no_mdns=True, + framework="arduino", + core_platform="esp32", + ) + + result = storage.as_dict() + + assert result["storage_version"] == 1 + assert result["name"] == "test_device" + assert result["friendly_name"] == "Test Device" + assert result["comment"] == "Test comment" + assert result["esphome_version"] == "2024.1.0" + assert result["src_version"] == 1 + assert result["address"] == "192.168.1.100" + assert result["web_port"] == 80 + assert result["esp_platform"] == "ESP32" + assert result["build_path"] == "/path/to/build" + assert result["firmware_bin_path"] == "/path/to/firmware.bin" + assert "api" in result["loaded_integrations"] + assert "wifi" in result["loaded_integrations"] + assert "ota" in result["loaded_integrations"] + assert result["loaded_integrations"] == sorted( + ["wifi", "api", "ota"] + ) # Should be sorted + assert "sensor" in result["loaded_platforms"] + assert result["loaded_platforms"] == sorted( + ["sensor", "binary_sensor"] + ) # Should be sorted + assert result["no_mdns"] is True + assert result["framework"] == "arduino" + assert result["core_platform"] == "esp32" + + +def test_storage_json_to_json() -> None: + """Test StorageJSON.to_json returns valid JSON string.""" + storage = storage_json.StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=None, + address="test.local", + web_port=None, + target_platform="ESP8266", + build_path=None, + firmware_bin_path=None, + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + ) + + json_str = storage.to_json() + + # Should be valid JSON + parsed = json.loads(json_str) + assert parsed["name"] == "test" + assert parsed["storage_version"] == 1 + + # Should end with newline + assert json_str.endswith("\n") + + +def test_storage_json_save(tmp_path: Path) -> None: + """Test StorageJSON.save writes file correctly.""" + storage = storage_json.StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=None, + address="test.local", + web_port=None, + target_platform="ESP32", + build_path=None, + firmware_bin_path=None, + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + ) + + save_path = tmp_path / "test.json" + + with patch("esphome.storage_json.write_file_if_changed") as mock_write: + storage.save(str(save_path)) + mock_write.assert_called_once_with(str(save_path), storage.to_json()) + + +def test_storage_json_from_esphome_core(setup_core: Path) -> None: + """Test StorageJSON.from_esphome_core creates correct storage object.""" + # Mock CORE object + mock_core = MagicMock() + mock_core.name = "my_device" + mock_core.friendly_name = "My Device" + mock_core.comment = "A test device" + mock_core.address = "192.168.1.50" + mock_core.web_port = 8080 + mock_core.target_platform = "esp32" + mock_core.is_esp32 = True + mock_core.build_path = "/build/my_device" + mock_core.firmware_bin = "/build/my_device/firmware.bin" + mock_core.loaded_integrations = {"wifi", "api"} + mock_core.loaded_platforms = {"sensor"} + mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} + mock_core.target_framework = "esp-idf" + + with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: + mock_variant.return_value = "ESP32-C3" + + result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) + + assert result.name == "my_device" + assert result.friendly_name == "My Device" + assert result.comment == "A test device" + assert result.address == "192.168.1.50" + assert result.web_port == 8080 + assert result.target_platform == "ESP32-C3" + assert result.build_path == "/build/my_device" + assert result.firmware_bin_path == "/build/my_device/firmware.bin" + assert result.loaded_integrations == {"wifi", "api"} + assert result.loaded_platforms == {"sensor"} + assert result.no_mdns is True + assert result.framework == "esp-idf" + assert result.core_platform == "esp32" + + +def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: + """Test from_esphome_core with mDNS enabled.""" + mock_core = MagicMock() + mock_core.name = "test" + mock_core.friendly_name = "Test" + mock_core.comment = None + mock_core.address = "test.local" + mock_core.web_port = None + mock_core.target_platform = "esp8266" + mock_core.is_esp32 = False + mock_core.build_path = "/build" + mock_core.firmware_bin = "/build/firmware.bin" + mock_core.loaded_integrations = set() + mock_core.loaded_platforms = set() + mock_core.config = {} # No MDNS config means enabled + mock_core.target_framework = "arduino" + + result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) + + assert result.no_mdns is False + + +def test_storage_json_load_valid_file(tmp_path: Path) -> None: + """Test StorageJSON.load with valid JSON file.""" + storage_data = { + "storage_version": 1, + "name": "loaded_device", + "friendly_name": "Loaded Device", + "comment": "Loaded from file", + "esphome_version": "2024.1.0", + "src_version": 2, + "address": "10.0.0.1", + "web_port": 8080, + "esp_platform": "ESP32", + "build_path": "/loaded/build", + "firmware_bin_path": "/loaded/firmware.bin", + "loaded_integrations": ["wifi", "api"], + "loaded_platforms": ["sensor"], + "no_mdns": True, + "framework": "arduino", + "core_platform": "esp32", + } + + file_path = tmp_path / "storage.json" + file_path.write_text(json.dumps(storage_data)) + + result = storage_json.StorageJSON.load(str(file_path)) + + assert result is not None + assert result.name == "loaded_device" + assert result.friendly_name == "Loaded Device" + assert result.comment == "Loaded from file" + assert result.esphome_version == "2024.1.0" + assert result.src_version == 2 + assert result.address == "10.0.0.1" + assert result.web_port == 8080 + assert result.target_platform == "ESP32" + assert result.build_path == "/loaded/build" + assert result.firmware_bin_path == "/loaded/firmware.bin" + assert result.loaded_integrations == {"wifi", "api"} + assert result.loaded_platforms == {"sensor"} + assert result.no_mdns is True + assert result.framework == "arduino" + assert result.core_platform == "esp32" + + +def test_storage_json_load_invalid_file(tmp_path: Path) -> None: + """Test StorageJSON.load with invalid JSON file.""" + file_path = tmp_path / "invalid.json" + file_path.write_text("not valid json{") + + result = storage_json.StorageJSON.load(str(file_path)) + + assert result is None + + +def test_storage_json_load_nonexistent_file() -> None: + """Test StorageJSON.load with non-existent file.""" + result = storage_json.StorageJSON.load("/nonexistent/file.json") + + assert result is None + + +def test_storage_json_equality() -> None: + """Test StorageJSON equality comparison.""" + storage1 = storage_json.StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="test.local", + web_port=80, + target_platform="ESP32", + build_path="/build", + firmware_bin_path="/firmware.bin", + loaded_integrations={"wifi"}, + loaded_platforms=set(), + no_mdns=False, + ) + + storage2 = storage_json.StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="test.local", + web_port=80, + target_platform="ESP32", + build_path="/build", + firmware_bin_path="/firmware.bin", + loaded_integrations={"wifi"}, + loaded_platforms=set(), + no_mdns=False, + ) + + storage3 = storage_json.StorageJSON( + storage_version=1, + name="different", # Different name + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="test.local", + web_port=80, + target_platform="ESP32", + build_path="/build", + firmware_bin_path="/firmware.bin", + loaded_integrations={"wifi"}, + loaded_platforms=set(), + no_mdns=False, + ) + + assert storage1 == storage2 + assert storage1 != storage3 + assert storage1 != "not a storage object" + + +def test_esphome_storage_json_as_dict() -> None: + """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" + storage = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="secret123", + last_update_check="2024-01-15T10:30:00", + remote_version="2024.1.1", + ) + + result = storage.as_dict() + + assert result["storage_version"] == 1 + assert result["cookie_secret"] == "secret123" + assert result["last_update_check"] == "2024-01-15T10:30:00" + assert result["remote_version"] == "2024.1.1" + + +def test_esphome_storage_json_last_update_check_property() -> None: + """Test EsphomeStorageJSON.last_update_check property.""" + storage = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="secret", + last_update_check="2024-01-15T10:30:00", + remote_version=None, + ) + + # Test getter + result = storage.last_update_check + assert isinstance(result, datetime) + assert result.year == 2024 + assert result.month == 1 + assert result.day == 15 + assert result.hour == 10 + assert result.minute == 30 + + # Test setter + new_date = datetime(2024, 2, 20, 15, 45, 30) + storage.last_update_check = new_date + assert storage.last_update_check_str == "2024-02-20T15:45:30" + + +def test_esphome_storage_json_last_update_check_invalid() -> None: + """Test EsphomeStorageJSON.last_update_check with invalid date.""" + storage = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="secret", + last_update_check="invalid date", + remote_version=None, + ) + + result = storage.last_update_check + assert result is None + + +def test_esphome_storage_json_to_json() -> None: + """Test EsphomeStorageJSON.to_json returns valid JSON string.""" + storage = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="mysecret", + last_update_check="2024-01-15T10:30:00", + remote_version="2024.1.1", + ) + + json_str = storage.to_json() + + # Should be valid JSON + parsed = json.loads(json_str) + assert parsed["cookie_secret"] == "mysecret" + assert parsed["storage_version"] == 1 + + # Should end with newline + assert json_str.endswith("\n") + + +def test_esphome_storage_json_save(tmp_path: Path) -> None: + """Test EsphomeStorageJSON.save writes file correctly.""" + storage = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="secret", + last_update_check=None, + remote_version=None, + ) + + save_path = tmp_path / "esphome.json" + + with patch("esphome.storage_json.write_file_if_changed") as mock_write: + storage.save(str(save_path)) + mock_write.assert_called_once_with(str(save_path), storage.to_json()) + + +def test_esphome_storage_json_load_valid_file(tmp_path: Path) -> None: + """Test EsphomeStorageJSON.load with valid JSON file.""" + storage_data = { + "storage_version": 1, + "cookie_secret": "loaded_secret", + "last_update_check": "2024-01-20T14:30:00", + "remote_version": "2024.1.2", + } + + file_path = tmp_path / "esphome.json" + file_path.write_text(json.dumps(storage_data)) + + result = storage_json.EsphomeStorageJSON.load(str(file_path)) + + assert result is not None + assert result.storage_version == 1 + assert result.cookie_secret == "loaded_secret" + assert result.last_update_check_str == "2024-01-20T14:30:00" + assert result.remote_version == "2024.1.2" + + +def test_esphome_storage_json_load_invalid_file(tmp_path: Path) -> None: + """Test EsphomeStorageJSON.load with invalid JSON file.""" + file_path = tmp_path / "invalid.json" + file_path.write_text("not valid json{") + + result = storage_json.EsphomeStorageJSON.load(str(file_path)) + + assert result is None + + +def test_esphome_storage_json_load_nonexistent_file() -> None: + """Test EsphomeStorageJSON.load with non-existent file.""" + result = storage_json.EsphomeStorageJSON.load("/nonexistent/file.json") + + assert result is None + + +def test_esphome_storage_json_get_default() -> None: + """Test EsphomeStorageJSON.get_default creates default storage.""" + with patch("esphome.storage_json.os.urandom") as mock_urandom: + # Mock urandom to return predictable bytes + mock_urandom.return_value = b"test" * 16 # 64 bytes + + result = storage_json.EsphomeStorageJSON.get_default() + + assert result.storage_version == 1 + assert len(result.cookie_secret) == 128 # 64 bytes hex = 128 chars + assert result.last_update_check is None + assert result.remote_version is None + + +def test_esphome_storage_json_equality() -> None: + """Test EsphomeStorageJSON equality comparison.""" + storage1 = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="secret", + last_update_check="2024-01-15T10:30:00", + remote_version="2024.1.1", + ) + + storage2 = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="secret", + last_update_check="2024-01-15T10:30:00", + remote_version="2024.1.1", + ) + + storage3 = storage_json.EsphomeStorageJSON( + storage_version=1, + cookie_secret="different", # Different secret + last_update_check="2024-01-15T10:30:00", + remote_version="2024.1.1", + ) + + assert storage1 == storage2 + assert storage1 != storage3 + assert storage1 != "not a storage object" + + +def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: + """Test loading storage with legacy esphomeyaml_version field.""" + storage_data = { + "storage_version": 1, + "name": "legacy_device", + "friendly_name": "Legacy Device", + "esphomeyaml_version": "1.14.0", # Legacy field name + "address": "legacy.local", + "esp_platform": "ESP8266", + } + + file_path = tmp_path / "legacy.json" + file_path.write_text(json.dumps(storage_data)) + + result = storage_json.StorageJSON.load(str(file_path)) + + assert result is not None + assert result.esphome_version == "1.14.0" # Should map to esphome_version From 04f4f79cb46f3adc3b5122e4f8b29e5533ae22e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 16:16:26 -0500 Subject: [PATCH 1169/1626] [select] Use const references to avoid unnecessary vector copies (#10741) --- esphome/components/select/select.cpp | 6 +++--- esphome/components/select/select_call.cpp | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 37887da27c..beb72aa320 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -28,12 +28,12 @@ bool Select::has_option(const std::string &option) const { return this->index_of bool Select::has_index(size_t index) const { return index < this->size(); } size_t Select::size() const { - auto options = traits.get_options(); + const auto &options = traits.get_options(); return options.size(); } optional Select::index_of(const std::string &option) const { - auto options = traits.get_options(); + const auto &options = traits.get_options(); auto it = std::find(options.begin(), options.end(), option); if (it == options.end()) { return {}; @@ -51,7 +51,7 @@ optional Select::active_index() const { optional Select::at(size_t index) const { if (this->has_index(index)) { - auto options = traits.get_options(); + const auto &options = traits.get_options(); return options.at(index); } else { return {}; diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index 85f755645c..a8272f8622 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -45,7 +45,7 @@ void SelectCall::perform() { auto *parent = this->parent_; const auto *name = parent->get_name().c_str(); const auto &traits = parent->traits; - auto options = traits.get_options(); + const auto &options = traits.get_options(); if (this->operation_ == SELECT_OP_NONE) { ESP_LOGW(TAG, "'%s' - SelectCall performed without selecting an operation", name); From ddb8fedef7aee6c1c02fb3f7537c2c555ea1c86a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 18:04:35 -0500 Subject: [PATCH 1170/1626] [dashboard] Fix archive handler to properly delete build folders using correct path (#10724) --- esphome/dashboard/web_server.py | 7 +-- tests/dashboard/test_web_server.py | 93 +++++++++++++++++++++++++++- tests/unit_tests/core/test_config.py | 9 +++ 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 294a180794..e6c5fd3d84 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -1038,12 +1038,9 @@ class ArchiveRequestHandler(BaseHandler): shutil.move(config_file, os.path.join(archive_path, configuration)) storage_json = StorageJSON.load(storage_path) - if storage_json is not None: + if storage_json is not None and storage_json.build_path: # Delete build folder (if exists) - name = storage_json.name - build_folder = os.path.join(settings.config_dir, name) - if build_folder is not None: - shutil.rmtree(build_folder, os.path.join(archive_path, name)) + shutil.rmtree(storage_json.build_path, ignore_errors=True) class UnArchiveRequestHandler(BaseHandler): diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index e206090ac0..1938617f20 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -589,7 +589,7 @@ async def test_archive_request_handler_post( mock_ext_storage_path: MagicMock, tmp_path: Path, ) -> None: - """Test ArchiveRequestHandler.post method.""" + """Test ArchiveRequestHandler.post method without storage_json.""" # Set up temp directories config_dir = Path(get_fixture_path("conf")) @@ -616,6 +616,97 @@ async def test_archive_request_handler_post( ).read_text() == "esphome:\n name: test_archive\n" +@pytest.mark.asyncio +async def test_archive_handler_with_build_folder( + dashboard: DashboardTestHelper, + mock_archive_storage_path: MagicMock, + mock_ext_storage_path: MagicMock, + mock_dashboard_settings: MagicMock, + mock_storage_json: MagicMock, + tmp_path: Path, +) -> None: + """Test ArchiveRequestHandler.post with storage_json and build folder.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + build_dir = tmp_path / "build" + build_dir.mkdir() + + configuration = "test_device.yaml" + test_config = config_dir / configuration + test_config.write_text("esphome:\n name: test_device\n") + + build_folder = build_dir / "test_device" + build_folder.mkdir() + (build_folder / "firmware.bin").write_text("binary content") + (build_folder / ".pioenvs").mkdir() + + mock_dashboard_settings.config_dir = str(config_dir) + mock_dashboard_settings.rel_path.return_value = str(test_config) + mock_archive_storage_path.return_value = str(archive_dir) + + mock_storage = MagicMock() + mock_storage.name = "test_device" + mock_storage.build_path = str(build_folder) + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/archive", + method="POST", + body=f"configuration={configuration}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 + + assert not test_config.exists() + assert (archive_dir / configuration).exists() + + assert not build_folder.exists() + assert not (archive_dir / "test_device").exists() + + +@pytest.mark.asyncio +async def test_archive_handler_no_build_folder( + dashboard: DashboardTestHelper, + mock_archive_storage_path: MagicMock, + mock_ext_storage_path: MagicMock, + mock_dashboard_settings: MagicMock, + mock_storage_json: MagicMock, + tmp_path: Path, +) -> None: + """Test ArchiveRequestHandler.post with storage_json but no build folder.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + + configuration = "test_device.yaml" + test_config = config_dir / configuration + test_config.write_text("esphome:\n name: test_device\n") + + mock_dashboard_settings.config_dir = str(config_dir) + mock_dashboard_settings.rel_path.return_value = str(test_config) + mock_archive_storage_path.return_value = str(archive_dir) + + mock_storage = MagicMock() + mock_storage.name = "test_device" + mock_storage.build_path = None + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/archive", + method="POST", + body=f"configuration={configuration}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 + + assert not test_config.exists() + assert (archive_dir / configuration).exists() + assert not (archive_dir / "test_device").exists() + + @pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") @pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 484c30d59e..46fe0148d8 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -368,6 +368,9 @@ def test_preload_core_config_basic(setup_core: Path) -> None: assert platform == "esp32" assert KEY_CORE in CORE.data assert CONF_BUILD_PATH in config[CONF_ESPHOME] + # Verify default build path is "build/" + build_path = config[CONF_ESPHOME][CONF_BUILD_PATH] + assert build_path.endswith(os.path.join("build", "test_device")) def test_preload_core_config_with_build_path(setup_core: Path) -> None: @@ -402,6 +405,12 @@ def test_preload_core_config_env_build_path(setup_core: Path) -> None: assert CONF_BUILD_PATH in config[CONF_ESPHOME] assert "test_device" in config[CONF_ESPHOME][CONF_BUILD_PATH] + # Verify it uses the env var path with device name appended + build_path = config[CONF_ESPHOME][CONF_BUILD_PATH] + expected_path = os.path.join("/env/build", "test_device") + assert build_path == expected_path or build_path == expected_path.replace( + "/", os.sep + ) assert platform == "rp2040" From 04c1b90e5767b771780bac2b95abe4302d1416b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Sep 2025 18:46:07 -0500 Subject: [PATCH 1171/1626] [ethernet] Conditionally compile PHY-specific code to reduce flash usage (#10747) --- esphome/components/ethernet/__init__.py | 11 +++++++++++ esphome/components/ethernet/ethernet_component.cpp | 4 ++++ esphome/components/ethernet/ethernet_component.h | 2 ++ esphome/core/defines.h | 1 + 4 files changed, 18 insertions(+) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index a26238553c..151da7d0e5 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -77,6 +77,13 @@ ETHERNET_TYPES = { "DM9051": EthernetType.ETHERNET_TYPE_DM9051, } +# PHY types that need compile-time defines for conditional compilation +_PHY_TYPE_TO_DEFINE = { + "KSZ8081": "USE_ETHERNET_KSZ8081", + "KSZ8081RNA": "USE_ETHERNET_KSZ8081", + # Add other PHY types here only if they need conditional compilation +} + SPI_ETHERNET_TYPES = ["W5500", "DM9051"] SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10) @@ -345,6 +352,10 @@ async def to_code(config): if CONF_MANUAL_IP in config: cg.add(var.set_manual_ip(manual_ip(config[CONF_MANUAL_IP]))) + # Add compile-time define for PHY types with specific code + if phy_define := _PHY_TYPE_TO_DEFINE.get(config[CONF_TYPE]): + cg.add_define(phy_define) + cg.add_define("USE_ETHERNET") # Disable WiFi when using Ethernet to save memory diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index a48fd27383..ff14d19427 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -229,10 +229,12 @@ void EthernetComponent::setup() { ESPHL_ERROR_CHECK(err, "ETH driver install error"); #ifndef USE_ETHERNET_SPI +#ifdef USE_ETHERNET_KSZ8081 if (this->type_ == ETHERNET_TYPE_KSZ8081RNA && this->clk_mode_ == EMAC_CLK_OUT) { // KSZ8081RNA default is incorrect. It expects a 25MHz clock instead of the 50MHz we provide. this->ksz8081_set_clock_reference_(mac); } +#endif // USE_ETHERNET_KSZ8081 for (const auto &phy_register : this->phy_registers_) { this->write_phy_register_(mac, phy_register); @@ -721,6 +723,7 @@ bool EthernetComponent::powerdown() { #ifndef USE_ETHERNET_SPI +#ifdef USE_ETHERNET_KSZ8081 constexpr uint8_t KSZ80XX_PC2R_REG_ADDR = 0x1F; void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { @@ -749,6 +752,7 @@ void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { ESP_LOGVV(TAG, "KSZ8081 PHY Control 2: %s", format_hex_pretty((u_int8_t *) &phy_control_2, 2).c_str()); } } +#endif // USE_ETHERNET_KSZ8081 void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) { esp_err_t err; diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 3d2713ee5c..bbb9d7fb60 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -104,8 +104,10 @@ class EthernetComponent : public Component { void start_connect_(); void finish_connect_(); void dump_connect_params_(); +#ifdef USE_ETHERNET_KSZ8081 /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. void ksz8081_set_clock_reference_(esp_eth_mac_t *mac); +#endif /// @brief Set arbitratry PHY registers from config. void write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9a7e090b83..6e8d5ed74c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -175,6 +175,7 @@ #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 2, 1) #define USE_ETHERNET +#define USE_ETHERNET_KSZ8081 #endif #ifdef USE_ESP_IDF From e33dcda9075a97a7784ec70bd560c0893978ba52 Mon Sep 17 00:00:00 2001 From: jokujossai <43237978+jokujossai@users.noreply.github.com> Date: Tue, 16 Sep 2025 06:28:36 +0300 Subject: [PATCH 1172/1626] [mqtt] fix publish payload length when payload contains null characters (#10744) --- esphome/components/mqtt/mqtt_client.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 7675280f1a..7ab6efd1a1 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -491,7 +491,7 @@ bool MQTTClientComponent::publish(const std::string &topic, const std::string &p bool MQTTClientComponent::publish(const std::string &topic, const char *payload, size_t payload_length, uint8_t qos, bool retain) { - return publish({.topic = topic, .payload = payload, .qos = qos, .retain = retain}); + return publish({.topic = topic, .payload = std::string(payload, payload_length), .qos = qos, .retain = retain}); } bool MQTTClientComponent::publish(const MQTTMessage &message) { From 54616ae1b46f8f4a60709b1c822c49f1ec9bcd60 Mon Sep 17 00:00:00 2001 From: jokujossai Date: Tue, 16 Sep 2025 08:00:22 +0300 Subject: [PATCH 1173/1626] [ade7880] fix channel a voltage registry (#10750) --- esphome/components/ade7880/ade7880.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp index 55f834bf86..fd560e0676 100644 --- a/esphome/components/ade7880/ade7880.cpp +++ b/esphome/components/ade7880/ade7880.cpp @@ -113,7 +113,7 @@ void ADE7880::update() { if (this->channel_a_ != nullptr) { auto *chan = this->channel_a_; this->update_sensor_from_s24zp_register16_(chan->current, AIRMS, [](float val) { return val / 100000.0f; }); - this->update_sensor_from_s24zp_register16_(chan->voltage, BVRMS, [](float val) { return val / 10000.0f; }); + this->update_sensor_from_s24zp_register16_(chan->voltage, AVRMS, [](float val) { return val / 10000.0f; }); this->update_sensor_from_s24zp_register16_(chan->active_power, AWATT, [](float val) { return val / 100.0f; }); this->update_sensor_from_s24zp_register16_(chan->apparent_power, AVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, APF, From 6ac395da6d785fa8de588abf70dc8a873b9534bf Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Sep 2025 20:35:23 +1200 Subject: [PATCH 1174/1626] Bump version to 2025.9.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index d35c01b144..96faf3a1e0 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.9.0b2 +PROJECT_NUMBER = 2025.9.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 03dc33df89..e23c919bce 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.9.0b2" +__version__ = "2025.9.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From bc73346f1fbf072f6cb1008607da7aae2188cf30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 10:49:05 -0500 Subject: [PATCH 1175/1626] [core] Fix clean build files to properly clear PlatformIO cache --- esphome/core/__init__.py | 9 +++++++++ esphome/writer.py | 7 +++++++ tests/unit_tests/test_core.py | 34 +++++++++++++++++++++++++++++++++ tests/unit_tests/test_writer.py | 12 ++++++++++++ 4 files changed, 62 insertions(+) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 89e3eff7d8..571ce9375f 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -694,6 +694,15 @@ class EsphomeCore: def relative_piolibdeps_path(self, *path): return self.relative_build_path(".piolibdeps", *path) + @property + def platformio_cache_dir(self) -> str: + """Get the PlatformIO cache directory path.""" + # Check if running in Docker/HA addon with custom cache dir + if cache_dir := os.environ.get("PLATFORMIO_CACHE_DIR"): + return cache_dir + # Default PlatformIO cache location + return os.path.expanduser("~/.platformio/.cache") + @property def firmware_bin(self): if self.is_libretiny: diff --git a/esphome/writer.py b/esphome/writer.py index b8fe44abdd..adddc85c80 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -315,6 +315,13 @@ def clean_build(): _LOGGER.info("Deleting %s", dependencies_lock) os.remove(dependencies_lock) + # Clean PlatformIO cache to resolve CMake compiler detection issues + # This helps when toolchain paths change or get corrupted + cache_dir = CORE.platformio_cache_dir + if os.path.isdir(cache_dir): + _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) + shutil.rmtree(cache_dir) + GITIGNORE_CONTENT = """# Gitignore settings for ESPHome # This is an example and may include too much for your use-case. diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 9a69329e80..b36bc8f4c0 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -660,3 +660,37 @@ class TestEsphomeCore: os.environ.pop("ESPHOME_IS_HA_ADDON", None) os.environ.pop("ESPHOME_DATA_DIR", None) assert target.data_dir == expected_default + + def test_platformio_cache_dir_with_env_var(self): + """Test platformio_cache_dir when PLATFORMIO_CACHE_DIR env var is set.""" + target = core.EsphomeCore() + test_cache_dir = "/custom/cache/dir" + + with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": test_cache_dir}): + assert target.platformio_cache_dir == test_cache_dir + + def test_platformio_cache_dir_without_env_var(self): + """Test platformio_cache_dir defaults to ~/.platformio/.cache.""" + target = core.EsphomeCore() + + with patch.dict(os.environ, {}, clear=True): + # Ensure env var is not set + os.environ.pop("PLATFORMIO_CACHE_DIR", None) + expected = os.path.expanduser("~/.platformio/.cache") + assert target.platformio_cache_dir == expected + + def test_platformio_cache_dir_empty_env_var(self): + """Test platformio_cache_dir with empty env var falls back to default.""" + target = core.EsphomeCore() + + with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": ""}): + expected = os.path.expanduser("~/.platformio/.cache") + assert target.platformio_cache_dir == expected + + def test_platformio_cache_dir_docker_addon_path(self): + """Test platformio_cache_dir in Docker/HA addon environment.""" + target = core.EsphomeCore() + addon_cache = "/data/cache/platformio" + + with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": addon_cache}): + assert target.platformio_cache_dir == addon_cache diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index f1f86a322e..593e3d2eae 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -349,15 +349,25 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") + # Create PlatformIO cache directory + platformio_cache_dir = tmp_path / ".platformio" / ".cache" + platformio_cache_dir.mkdir(parents=True) + (platformio_cache_dir / "downloads").mkdir() + (platformio_cache_dir / "http").mkdir() + (platformio_cache_dir / "tmp").mkdir() + (platformio_cache_dir / "downloads" / "package.tar.gz").write_text("package") + # Setup mocks mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) mock_core.relative_build_path.return_value = str(dependencies_lock) + mock_core.platformio_cache_dir = str(platformio_cache_dir) # Verify all exist before assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() + assert platformio_cache_dir.exists() # Call the function with caplog.at_level("INFO"): @@ -367,12 +377,14 @@ def test_clean_build( assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() + assert not platformio_cache_dir.exists() # Verify logging assert "Deleting" in caplog.text assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text + assert "PlatformIO cache" in caplog.text @patch("esphome.writer.CORE") From 6d1cec6112bfadbc1e6e2090bee49bdce744052b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 10:51:39 -0500 Subject: [PATCH 1176/1626] review --- esphome/core/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 571ce9375f..2aa0fd7193 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -698,7 +698,7 @@ class EsphomeCore: def platformio_cache_dir(self) -> str: """Get the PlatformIO cache directory path.""" # Check if running in Docker/HA addon with custom cache dir - if cache_dir := os.environ.get("PLATFORMIO_CACHE_DIR"): + if (cache_dir := os.environ.get("PLATFORMIO_CACHE_DIR")) and cache_dir.strip(): return cache_dir # Default PlatformIO cache location return os.path.expanduser("~/.platformio/.cache") From cb733962251d198d2926e8771c2791fbaab3b699 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 10:51:50 -0500 Subject: [PATCH 1177/1626] review --- tests/unit_tests/test_core.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index b36bc8f4c0..4677140ad2 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -687,6 +687,14 @@ class TestEsphomeCore: expected = os.path.expanduser("~/.platformio/.cache") assert target.platformio_cache_dir == expected + def test_platformio_cache_dir_whitespace_env_var(self): + """Test platformio_cache_dir with whitespace-only env var falls back to default.""" + target = core.EsphomeCore() + + with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": " "}): + expected = os.path.expanduser("~/.platformio/.cache") + assert target.platformio_cache_dir == expected + def test_platformio_cache_dir_docker_addon_path(self): """Test platformio_cache_dir in Docker/HA addon environment.""" target = core.EsphomeCore() From 8e13335ff6f7a166355a87fc6255c2ffd09f473d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 11:49:36 -0500 Subject: [PATCH 1178/1626] fixes --- esphome/__main__.py | 7 +- .../external_components/__init__.py | 14 +- esphome/components/packages/__init__.py | 18 ++- esphome/config.py | 24 +-- esphome/git.py | 2 +- .../external_components/test_init.py | 113 ++++++++++++++ tests/component_tests/packages/test_init.py | 102 ++++++++++++ .../component_tests/packages/test_packages.py | 93 +++++++++++ tests/unit_tests/core/test_config.py | 115 ++++++++++++++ tests/unit_tests/test_git.py | 147 ++++++++++++++++++ 10 files changed, 610 insertions(+), 25 deletions(-) create mode 100644 tests/component_tests/external_components/test_init.py create mode 100644 tests/component_tests/packages/test_init.py create mode 100644 tests/unit_tests/test_git.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 0147a82530..885eaafe15 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1244,7 +1244,12 @@ def run_esphome(argv): CORE.config_path = conf_path CORE.dashboard = args.dashboard - config = read_config(dict(args.substitution) if args.substitution else {}) + # For logs command, skip updating external components + skip_external = args.command == "logs" + config = read_config( + dict(args.substitution) if args.substitution else {}, + skip_external_update=skip_external, + ) if config is None: return 2 CORE.config = config diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index a09217fd21..5362a2269f 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -39,11 +39,13 @@ async def to_code(config): pass -def _process_git_config(config: dict, refresh) -> str: +def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str: + # When skip_update is True, set refresh to None to prevent updates + actual_refresh = None if skip_update else refresh repo_dir, _ = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), - refresh=refresh, + refresh=actual_refresh, domain=DOMAIN, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), @@ -70,12 +72,12 @@ def _process_git_config(config: dict, refresh) -> str: return components_dir -def _process_single_config(config: dict): +def _process_single_config(config: dict, skip_update: bool = False): conf = config[CONF_SOURCE] if conf[CONF_TYPE] == TYPE_GIT: with cv.prepend_path([CONF_SOURCE]): components_dir = _process_git_config( - config[CONF_SOURCE], config[CONF_REFRESH] + config[CONF_SOURCE], config[CONF_REFRESH], skip_update ) elif conf[CONF_TYPE] == TYPE_LOCAL: components_dir = Path(CORE.relative_config_path(conf[CONF_PATH])) @@ -105,7 +107,7 @@ def _process_single_config(config: dict): loader.install_meta_finder(components_dir, allowed_components=allowed_components) -def do_external_components_pass(config: dict) -> None: +def do_external_components_pass(config: dict, skip_update: bool = False) -> None: conf = config.get(DOMAIN) if conf is None: return @@ -113,4 +115,4 @@ def do_external_components_pass(config: dict) -> None: conf = CONFIG_SCHEMA(conf) for i, c in enumerate(conf): with cv.prepend_path(i): - _process_single_config(c) + _process_single_config(c, skip_update) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 2e7dc0e197..2f964984cc 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -106,11 +106,13 @@ CONFIG_SCHEMA = cv.Any( ) -def _process_base_package(config: dict) -> dict: +def _process_base_package(config: dict, skip_update: bool = False) -> dict: + # When skip_update is True, set refresh to None to prevent updates + actual_refresh = None if skip_update else config[CONF_REFRESH] repo_dir, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), - refresh=config[CONF_REFRESH], + refresh=actual_refresh, domain=DOMAIN, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), @@ -180,16 +182,16 @@ def _process_base_package(config: dict) -> dict: return {"packages": packages} -def _process_package(package_config, config): +def _process_package(package_config, config, skip_update: bool = False): recursive_package = package_config if CONF_URL in package_config: - package_config = _process_base_package(package_config) + package_config = _process_base_package(package_config, skip_update) if isinstance(package_config, dict): - recursive_package = do_packages_pass(package_config) + recursive_package = do_packages_pass(package_config, skip_update) return merge_config(recursive_package, config) -def do_packages_pass(config: dict): +def do_packages_pass(config: dict, skip_update: bool = False): if CONF_PACKAGES not in config: return config packages = config[CONF_PACKAGES] @@ -198,10 +200,10 @@ def do_packages_pass(config: dict): if isinstance(packages, dict): for package_name, package_config in reversed(packages.items()): with cv.prepend_path(package_name): - config = _process_package(package_config, config) + config = _process_package(package_config, config, skip_update) elif isinstance(packages, list): for package_config in reversed(packages): - config = _process_package(package_config, config) + config = _process_package(package_config, config, skip_update) else: raise cv.Invalid( f"Packages must be a key to value mapping or list, got {type(packages)} instead" diff --git a/esphome/config.py b/esphome/config.py index 90325cbf6e..36892fcd25 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -846,7 +846,9 @@ class PinUseValidationCheck(ConfigValidationStep): def validate_config( - config: dict[str, Any], command_line_substitutions: dict[str, Any] + config: dict[str, Any], + command_line_substitutions: dict[str, Any], + skip_external_update: bool = False, ) -> Config: result = Config() @@ -859,7 +861,7 @@ def validate_config( result.add_output_path([CONF_PACKAGES], CONF_PACKAGES) try: - config = do_packages_pass(config) + config = do_packages_pass(config, skip_update=skip_external_update) except vol.Invalid as err: result.update(config) result.add_error(err) @@ -896,7 +898,7 @@ def validate_config( result.add_output_path([CONF_EXTERNAL_COMPONENTS], CONF_EXTERNAL_COMPONENTS) try: - do_external_components_pass(config) + do_external_components_pass(config, skip_update=skip_external_update) except vol.Invalid as err: result.update(config) result.add_error(err) @@ -1020,7 +1022,9 @@ class InvalidYAMLError(EsphomeError): self.base_exc = base_exc -def _load_config(command_line_substitutions: dict[str, Any]) -> Config: +def _load_config( + command_line_substitutions: dict[str, Any], skip_external_update: bool = False +) -> Config: """Load the configuration file.""" try: config = yaml_util.load_yaml(CORE.config_path) @@ -1028,7 +1032,7 @@ def _load_config(command_line_substitutions: dict[str, Any]) -> Config: raise InvalidYAMLError(e) from e try: - return validate_config(config, command_line_substitutions) + return validate_config(config, command_line_substitutions, skip_external_update) except EsphomeError: raise except Exception: @@ -1036,9 +1040,11 @@ def _load_config(command_line_substitutions: dict[str, Any]) -> Config: raise -def load_config(command_line_substitutions: dict[str, Any]) -> Config: +def load_config( + command_line_substitutions: dict[str, Any], skip_external_update: bool = False +) -> Config: try: - return _load_config(command_line_substitutions) + return _load_config(command_line_substitutions, skip_external_update) except vol.Invalid as err: raise EsphomeError(f"Error while parsing config: {err}") from err @@ -1178,10 +1184,10 @@ def strip_default_ids(config): return config -def read_config(command_line_substitutions): +def read_config(command_line_substitutions, skip_external_update=False): _LOGGER.info("Reading configuration %s...", CORE.config_path) try: - res = load_config(command_line_substitutions) + res = load_config(command_line_substitutions, skip_external_update) except EsphomeError as err: _LOGGER.error("Error while reading config: %s", err) return None diff --git a/esphome/git.py b/esphome/git.py index 56aedd1519..c60b928d7c 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -90,7 +90,7 @@ def clone_or_update( if not file_timestamp.exists(): file_timestamp = Path(repo_dir / ".git" / "HEAD") age = datetime.now() - datetime.fromtimestamp(file_timestamp.stat().st_mtime) - if refresh is None or age.total_seconds() > refresh.total_seconds: + if refresh is not None and age.total_seconds() > refresh.total_seconds: old_sha = run_git_command(["git", "rev-parse", "HEAD"], str(repo_dir)) _LOGGER.info("Updating %s", key) _LOGGER.debug("Location: %s", repo_dir) diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py new file mode 100644 index 0000000000..bdec13fe0f --- /dev/null +++ b/tests/component_tests/external_components/test_init.py @@ -0,0 +1,113 @@ +"""Tests for the external_components skip_update functionality.""" + +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +from esphome.components.external_components import do_external_components_pass +from esphome.const import ( + CONF_EXTERNAL_COMPONENTS, + CONF_REFRESH, + CONF_SOURCE, + CONF_URL, + TYPE_GIT, +) + + +@patch("esphome.git.clone_or_update") +@patch("esphome.loader.install_meta_finder") +def test_external_components_skip_update_true( + mock_install_meta: MagicMock, mock_clone_or_update: MagicMock +) -> None: + """Test that external components don't update when skip_update=True.""" + # Setup mocks + test_path = Path("/tmp/test/components") + test_path.mkdir(parents=True, exist_ok=True) + mock_clone_or_update.return_value = (test_path.parent, None) + + config: dict[str, Any] = { + CONF_EXTERNAL_COMPONENTS: [ + { + CONF_SOURCE: { + "type": TYPE_GIT, + CONF_URL: "https://github.com/test/components", + }, + CONF_REFRESH: "1d", + "components": "all", + } + ] + } + + # Call with skip_update=True + do_external_components_pass(config, skip_update=True) + + # Verify clone_or_update was called with refresh=None + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] is None + + +@patch("esphome.git.clone_or_update") +@patch("esphome.loader.install_meta_finder") +def test_external_components_skip_update_false( + mock_install_meta: MagicMock, mock_clone_or_update: MagicMock +) -> None: + """Test that external components update when skip_update=False.""" + # Setup mocks + test_path = Path("/tmp/test/components") + test_path.mkdir(parents=True, exist_ok=True) + mock_clone_or_update.return_value = (test_path.parent, None) + + config: dict[str, Any] = { + CONF_EXTERNAL_COMPONENTS: [ + { + CONF_SOURCE: { + "type": TYPE_GIT, + CONF_URL: "https://github.com/test/components", + }, + CONF_REFRESH: "1d", + "components": "all", + } + ] + } + + # Call with skip_update=False + do_external_components_pass(config, skip_update=False) + + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] == "1d" + + +@patch("esphome.git.clone_or_update") +@patch("esphome.loader.install_meta_finder") +def test_external_components_default_no_skip( + mock_install_meta: MagicMock, mock_clone_or_update: MagicMock +) -> None: + """Test that external components update by default when skip_update not specified.""" + # Setup mocks + test_path = Path("/tmp/test/components") + test_path.mkdir(parents=True, exist_ok=True) + mock_clone_or_update.return_value = (test_path.parent, None) + + config: dict[str, Any] = { + CONF_EXTERNAL_COMPONENTS: [ + { + CONF_SOURCE: { + "type": TYPE_GIT, + CONF_URL: "https://github.com/test/components", + }, + CONF_REFRESH: "1d", + "components": "all", + } + ] + } + + # Call without skip_update parameter + do_external_components_pass(config) + + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] == "1d" diff --git a/tests/component_tests/packages/test_init.py b/tests/component_tests/packages/test_init.py new file mode 100644 index 0000000000..fbf12829ef --- /dev/null +++ b/tests/component_tests/packages/test_init.py @@ -0,0 +1,102 @@ +"""Tests for the packages component skip_update functionality.""" + +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +from esphome.components.packages import do_packages_pass +from esphome.const import CONF_FILES, CONF_PACKAGES, CONF_REFRESH, CONF_URL +from esphome.util import OrderedDict + + +@patch("esphome.git.clone_or_update") +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +def test_packages_skip_update_true( + mock_is_file: MagicMock, mock_load_yaml: MagicMock, mock_clone_or_update: MagicMock +) -> None: + """Test that packages don't update when skip_update=True.""" + # Setup mocks + mock_clone_or_update.return_value = (Path("/tmp/test"), None) + mock_is_file.return_value = True + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config: dict[str, Any] = { + CONF_PACKAGES: { + "test_package": { + CONF_URL: "https://github.com/test/repo", + CONF_FILES: ["test.yaml"], + CONF_REFRESH: "1d", + } + } + } + + # Call with skip_update=True + do_packages_pass(config, skip_update=True) + + # Verify clone_or_update was called with refresh=None + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] is None + + +@patch("esphome.git.clone_or_update") +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +def test_packages_skip_update_false( + mock_is_file: MagicMock, mock_load_yaml: MagicMock, mock_clone_or_update: MagicMock +) -> None: + """Test that packages update when skip_update=False.""" + # Setup mocks + mock_clone_or_update.return_value = (Path("/tmp/test"), None) + mock_is_file.return_value = True + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config: dict[str, Any] = { + CONF_PACKAGES: { + "test_package": { + CONF_URL: "https://github.com/test/repo", + CONF_FILES: ["test.yaml"], + CONF_REFRESH: "1d", + } + } + } + + # Call with skip_update=False (default) + do_packages_pass(config, skip_update=False) + + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] == "1d" + + +@patch("esphome.git.clone_or_update") +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +def test_packages_default_no_skip( + mock_is_file: MagicMock, mock_load_yaml: MagicMock, mock_clone_or_update: MagicMock +) -> None: + """Test that packages update by default when skip_update not specified.""" + # Setup mocks + mock_clone_or_update.return_value = (Path("/tmp/test"), None) + mock_is_file.return_value = True + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config: dict[str, Any] = { + CONF_PACKAGES: { + "test_package": { + CONF_URL: "https://github.com/test/repo", + CONF_FILES: ["test.yaml"], + CONF_REFRESH: "1d", + } + } + } + + # Call without skip_update parameter + do_packages_pass(config) + + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] == "1d" diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 4712daad0d..99ed661649 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -732,3 +732,96 @@ def test_remote_packages_with_files_and_vars( actual = do_packages_pass(config) assert actual == expected + + +@patch("esphome.git.clone_or_update") +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +def test_packages_skip_update_true( + mock_is_file: MagicMock, mock_load_yaml: MagicMock, mock_clone_or_update: MagicMock +) -> None: + """Test that packages don't update when skip_update=True.""" + # Setup mocks + mock_clone_or_update.return_value = (Path("/tmp/test"), None) + mock_is_file.return_value = True + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config = { + CONF_PACKAGES: { + "test_package": { + CONF_URL: "https://github.com/test/repo", + CONF_FILES: ["test.yaml"], + CONF_REFRESH: "1d", + } + } + } + + # Call with skip_update=True + do_packages_pass(config, skip_update=True) + + # Verify clone_or_update was called with refresh=None + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] is None + + +@patch("esphome.git.clone_or_update") +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +def test_packages_skip_update_false( + mock_is_file: MagicMock, mock_load_yaml: MagicMock, mock_clone_or_update: MagicMock +) -> None: + """Test that packages update when skip_update=False.""" + # Setup mocks + mock_clone_or_update.return_value = (Path("/tmp/test"), None) + mock_is_file.return_value = True + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config = { + CONF_PACKAGES: { + "test_package": { + CONF_URL: "https://github.com/test/repo", + CONF_FILES: ["test.yaml"], + CONF_REFRESH: "1d", + } + } + } + + # Call with skip_update=False (default) + do_packages_pass(config, skip_update=False) + + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] == "1d" + + +@patch("esphome.git.clone_or_update") +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +def test_packages_default_no_skip( + mock_is_file: MagicMock, mock_load_yaml: MagicMock, mock_clone_or_update: MagicMock +) -> None: + """Test that packages update by default when skip_update not specified.""" + # Setup mocks + mock_clone_or_update.return_value = (Path("/tmp/test"), None) + mock_is_file.return_value = True + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config = { + CONF_PACKAGES: { + "test_package": { + CONF_URL: "https://github.com/test/repo", + CONF_FILES: ["test.yaml"], + CONF_REFRESH: "1d", + } + } + } + + # Call without skip_update parameter + do_packages_pass(config) + + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] == "1d" diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 7d3b90794b..921863e2bc 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -852,3 +852,118 @@ async def test_add_includes_overwrites_existing_files( mock_copy_file_if_changed.assert_called_once_with( str(include_file), str(Path(CORE.build_path) / "src" / "header.h") ) + + +# Tests for skip_external_update functionality + + +@patch("esphome.yaml_util.load_yaml") +@patch("esphome.components.packages.do_packages_pass") +@patch("esphome.components.external_components.do_external_components_pass") +def test_validate_config_skip_update_true( + mock_ext_pass: MagicMock, mock_pkg_pass: MagicMock, mock_load_yaml: MagicMock +) -> None: + """Test that validate_config propagates skip_update=True.""" + from esphome.config import validate_config + from esphome.const import CONF_EXTERNAL_COMPONENTS, CONF_PACKAGES + + config_dict: dict[str, Any] = { + CONF_ESPHOME: {CONF_NAME: "test"}, + CONF_PACKAGES: {"test": {}}, + CONF_EXTERNAL_COMPONENTS: [{}], + } + + # Mock do_packages_pass to return config unchanged + mock_pkg_pass.side_effect = lambda c, **kwargs: c + + # Call validate_config with skip_external_update=True + validate_config(config_dict, {}, skip_external_update=True) + + # Verify both were called with skip_update=True + mock_pkg_pass.assert_called_once() + assert mock_pkg_pass.call_args.kwargs.get("skip_update") is True + + mock_ext_pass.assert_called_once() + assert mock_ext_pass.call_args.kwargs.get("skip_update") is True + + +@patch("esphome.yaml_util.load_yaml") +@patch("esphome.components.packages.do_packages_pass") +@patch("esphome.components.external_components.do_external_components_pass") +def test_validate_config_skip_update_false( + mock_ext_pass: MagicMock, mock_pkg_pass: MagicMock, mock_load_yaml: MagicMock +) -> None: + """Test that validate_config propagates skip_update=False.""" + from esphome.config import validate_config + from esphome.const import CONF_EXTERNAL_COMPONENTS, CONF_PACKAGES + + config_dict: dict[str, Any] = { + CONF_ESPHOME: {CONF_NAME: "test"}, + CONF_PACKAGES: {"test": {}}, + CONF_EXTERNAL_COMPONENTS: [{}], + } + + # Mock do_packages_pass to return config unchanged + mock_pkg_pass.side_effect = lambda c, **kwargs: c + + # Call validate_config with skip_external_update=False + validate_config(config_dict, {}, skip_external_update=False) + + # Verify both were called with skip_update=False + mock_pkg_pass.assert_called_once() + assert mock_pkg_pass.call_args.kwargs.get("skip_update") is False + + mock_ext_pass.assert_called_once() + assert mock_ext_pass.call_args.kwargs.get("skip_update") is False + + +@patch("esphome.yaml_util.load_yaml") +@patch("esphome.components.packages.do_packages_pass") +@patch("esphome.components.external_components.do_external_components_pass") +def test_validate_config_default_false( + mock_ext_pass: MagicMock, mock_pkg_pass: MagicMock, mock_load_yaml: MagicMock +) -> None: + """Test that validate_config defaults to skip_update=False.""" + from esphome.config import validate_config + from esphome.const import CONF_EXTERNAL_COMPONENTS, CONF_PACKAGES + + config_dict: dict[str, Any] = { + CONF_ESPHOME: {CONF_NAME: "test"}, + CONF_PACKAGES: {"test": {}}, + CONF_EXTERNAL_COMPONENTS: [{}], + } + + # Mock do_packages_pass to return config unchanged + mock_pkg_pass.side_effect = lambda c, **kwargs: c + + # Call validate_config without skip_external_update parameter + validate_config(config_dict, {}) + + # Verify both were called with skip_update=False (default) + mock_pkg_pass.assert_called_once() + assert mock_pkg_pass.call_args.kwargs.get("skip_update") is False + + mock_ext_pass.assert_called_once() + assert mock_ext_pass.call_args.kwargs.get("skip_update") is False + + +@patch("esphome.config.load_config") +def test_read_config_skip_update_parameter(mock_load_config: MagicMock) -> None: + """Test that read_config passes skip_external_update correctly.""" + from esphome.config import read_config + + # Setup + CORE.config_path = "test.yaml" + mock_load_config.return_value = MagicMock(errors=[]) + + # Test with skip_external_update=True + read_config({}, skip_external_update=True) + mock_load_config.assert_called_with({}, True) + + # Test with skip_external_update=False + read_config({}, skip_external_update=False) + mock_load_config.assert_called_with({}, False) + + # Test default (should be False) + read_config({}) + mock_load_config.assert_called_with({}, False) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py new file mode 100644 index 0000000000..99b0f12441 --- /dev/null +++ b/tests/unit_tests/test_git.py @@ -0,0 +1,147 @@ +"""Tests for git.py module.""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock, Mock, patch + +from esphome import git +from esphome.core import TimePeriodSeconds + + +@patch("esphome.git.run_git_command") +@patch("pathlib.Path.is_dir") +def test_clone_or_update_with_none_refresh_no_update( + mock_is_dir: MagicMock, mock_run_git: MagicMock +) -> None: + """Test that refresh=None skips updates for existing repos.""" + # Setup - repo already exists + mock_is_dir.return_value = True + + # Mock file timestamps + with patch("pathlib.Path.exists") as mock_exists: + mock_exists.return_value = True + with patch("pathlib.Path.stat") as mock_stat: + mock_stat_result = Mock() + mock_stat_result.st_mtime = datetime.now().timestamp() + mock_stat.return_value = mock_stat_result + + # Call with refresh=None + repo_dir, revert = git.clone_or_update( + url="https://github.com/test/repo", + ref=None, + refresh=None, + domain="test", + ) + + # Should NOT call git fetch or any update commands + mock_run_git.assert_not_called() + assert revert is None + + +@patch("esphome.git.run_git_command") +@patch("pathlib.Path.is_dir") +def test_clone_or_update_with_refresh_updates_old_repo( + mock_is_dir: MagicMock, mock_run_git: MagicMock +) -> None: + """Test that refresh triggers update for old repos.""" + # Setup - repo already exists + mock_is_dir.return_value = True + mock_run_git.return_value = "abc123" # mock SHA + + # Mock file timestamps - 2 days old + with patch("pathlib.Path.exists") as mock_exists: + mock_exists.return_value = True + with patch("pathlib.Path.stat") as mock_stat: + mock_stat_result = Mock() + old_time = datetime.now() - timedelta(days=2) + mock_stat_result.st_mtime = old_time.timestamp() + mock_stat.return_value = mock_stat_result + + # Call with refresh=1d (1 day) + refresh = TimePeriodSeconds(days=1) + repo_dir, revert = git.clone_or_update( + url="https://github.com/test/repo", + ref=None, + refresh=refresh, + domain="test", + ) + + # Should call git fetch and update commands + assert mock_run_git.called + # Check for fetch command + fetch_calls = [ + call for call in mock_run_git.call_args_list if "fetch" in str(call) + ] + assert len(fetch_calls) > 0 + + +@patch("esphome.git.run_git_command") +@patch("pathlib.Path.is_dir") +def test_clone_or_update_with_refresh_skips_fresh_repo( + mock_is_dir: MagicMock, mock_run_git: MagicMock +) -> None: + """Test that refresh doesn't update fresh repos.""" + # Setup - repo already exists + mock_is_dir.return_value = True + + # Mock file timestamps - 1 hour old + with patch("pathlib.Path.exists") as mock_exists: + mock_exists.return_value = True + with patch("pathlib.Path.stat") as mock_stat: + mock_stat_result = Mock() + recent_time = datetime.now() - timedelta(hours=1) + mock_stat_result.st_mtime = recent_time.timestamp() + mock_stat.return_value = mock_stat_result + + # Call with refresh=1d (1 day) + refresh = TimePeriodSeconds(days=1) + repo_dir, revert = git.clone_or_update( + url="https://github.com/test/repo", + ref=None, + refresh=refresh, + domain="test", + ) + + # Should NOT call git fetch since repo is fresh + mock_run_git.assert_not_called() + assert revert is None + + +@patch("esphome.git.run_git_command") +@patch("pathlib.Path.is_dir") +def test_clone_or_update_clones_missing_repo( + mock_is_dir: MagicMock, mock_run_git: MagicMock +) -> None: + """Test that missing repos are cloned regardless of refresh setting.""" + # Setup - repo doesn't exist + mock_is_dir.return_value = False + + # Test with refresh=None + repo_dir, revert = git.clone_or_update( + url="https://github.com/test/repo", + ref=None, + refresh=None, + domain="test", + ) + + # Should call git clone + assert mock_run_git.called + clone_calls = [call for call in mock_run_git.call_args_list if "clone" in str(call)] + assert len(clone_calls) > 0 + + # Reset mock + mock_run_git.reset_mock() + + # Test with refresh=1d + mock_is_dir.return_value = False + refresh = TimePeriodSeconds(days=1) + repo_dir, revert = git.clone_or_update( + url="https://github.com/test/repo2", + ref=None, + refresh=refresh, + domain="test", + ) + + # Should still call git clone + assert mock_run_git.called + clone_calls = [call for call in mock_run_git.call_args_list if "clone" in str(call)] + assert len(clone_calls) > 0 From 7d87dbe641bccd458a16966e52378800676f2e7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 11:51:44 -0500 Subject: [PATCH 1179/1626] fixes --- tests/component_tests/conftest.py | 21 ++++++++++++ tests/component_tests/packages/test_init.py | 36 ++++++++------------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 2045b03502..1756e4de14 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Generator from pathlib import Path import sys from typing import Any +from unittest import mock import pytest @@ -135,3 +136,23 @@ def generate_main() -> Generator[Callable[[str | Path], str]]: return CORE.cpp_main_section yield generator + + +@pytest.fixture +def mock_clone_or_update() -> Generator[Any]: + """Mock git.clone_or_update for testing.""" + with mock.patch("esphome.git.clone_or_update") as mock_func: + # Default return value + mock_func.return_value = (Path("/tmp/test"), None) + yield mock_func + + +@pytest.fixture +def mock_load_yaml() -> Generator[Any]: + """Mock yaml_util.load_yaml for testing.""" + from esphome.util import OrderedDict + + with mock.patch("esphome.yaml_util.load_yaml") as mock_func: + # Default return value + mock_func.return_value = OrderedDict({"sensor": []}) + yield mock_func diff --git a/tests/component_tests/packages/test_init.py b/tests/component_tests/packages/test_init.py index fbf12829ef..25493c2770 100644 --- a/tests/component_tests/packages/test_init.py +++ b/tests/component_tests/packages/test_init.py @@ -2,24 +2,20 @@ from pathlib import Path from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from esphome.components.packages import do_packages_pass from esphome.const import CONF_FILES, CONF_PACKAGES, CONF_REFRESH, CONF_URL -from esphome.util import OrderedDict -@patch("esphome.git.clone_or_update") -@patch("esphome.yaml_util.load_yaml") -@patch("pathlib.Path.is_file") def test_packages_skip_update_true( - mock_is_file: MagicMock, mock_load_yaml: MagicMock, mock_clone_or_update: MagicMock + mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock ) -> None: """Test that packages don't update when skip_update=True.""" # Setup mocks - mock_clone_or_update.return_value = (Path("/tmp/test"), None) - mock_is_file.return_value = True - mock_load_yaml.return_value = OrderedDict({"sensor": []}) + with MagicMock() as mock_is_file: + mock_is_file.return_value = True + Path.is_file = mock_is_file config: dict[str, Any] = { CONF_PACKAGES: { @@ -40,17 +36,14 @@ def test_packages_skip_update_true( assert call_args.kwargs["refresh"] is None -@patch("esphome.git.clone_or_update") -@patch("esphome.yaml_util.load_yaml") -@patch("pathlib.Path.is_file") def test_packages_skip_update_false( - mock_is_file: MagicMock, mock_load_yaml: MagicMock, mock_clone_or_update: MagicMock + mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock ) -> None: """Test that packages update when skip_update=False.""" # Setup mocks - mock_clone_or_update.return_value = (Path("/tmp/test"), None) - mock_is_file.return_value = True - mock_load_yaml.return_value = OrderedDict({"sensor": []}) + with MagicMock() as mock_is_file: + mock_is_file.return_value = True + Path.is_file = mock_is_file config: dict[str, Any] = { CONF_PACKAGES: { @@ -71,17 +64,14 @@ def test_packages_skip_update_false( assert call_args.kwargs["refresh"] == "1d" -@patch("esphome.git.clone_or_update") -@patch("esphome.yaml_util.load_yaml") -@patch("pathlib.Path.is_file") def test_packages_default_no_skip( - mock_is_file: MagicMock, mock_load_yaml: MagicMock, mock_clone_or_update: MagicMock + mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock ) -> None: """Test that packages update by default when skip_update not specified.""" # Setup mocks - mock_clone_or_update.return_value = (Path("/tmp/test"), None) - mock_is_file.return_value = True - mock_load_yaml.return_value = OrderedDict({"sensor": []}) + with MagicMock() as mock_is_file: + mock_is_file.return_value = True + Path.is_file = mock_is_file config: dict[str, Any] = { CONF_PACKAGES: { From 9be832a23cf18628ddd7d8dc1ea9d0465d9af4e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 11:51:55 -0500 Subject: [PATCH 1180/1626] fixes --- tests/component_tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 1756e4de14..fe765118ff 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -18,6 +18,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.types import ConfigType +from esphome.util import OrderedDict # Add package root to python path here = Path(__file__).parent @@ -150,7 +151,6 @@ def mock_clone_or_update() -> Generator[Any]: @pytest.fixture def mock_load_yaml() -> Generator[Any]: """Mock yaml_util.load_yaml for testing.""" - from esphome.util import OrderedDict with mock.patch("esphome.yaml_util.load_yaml") as mock_func: # Default return value From 586f24e02d70086e14c14bb2fe7a600d29d2ee4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 11:54:09 -0500 Subject: [PATCH 1181/1626] fixes --- .../external_components/test_init.py | 168 ++++++++++-------- tests/component_tests/packages/test_init.py | 46 +++-- 2 files changed, 124 insertions(+), 90 deletions(-) diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py index bdec13fe0f..da1bdc3d90 100644 --- a/tests/component_tests/external_components/test_init.py +++ b/tests/component_tests/external_components/test_init.py @@ -14,100 +14,118 @@ from esphome.const import ( ) -@patch("esphome.git.clone_or_update") -@patch("esphome.loader.install_meta_finder") def test_external_components_skip_update_true( - mock_install_meta: MagicMock, mock_clone_or_update: MagicMock + tmp_path: Path, mock_clone_or_update: MagicMock ) -> None: """Test that external components don't update when skip_update=True.""" - # Setup mocks - test_path = Path("/tmp/test/components") - test_path.mkdir(parents=True, exist_ok=True) - mock_clone_or_update.return_value = (test_path.parent, None) + # Create a components directory structure + components_dir = tmp_path / "components" + components_dir.mkdir() - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } + # Create a test component + test_component_dir = components_dir / "test_component" + test_component_dir.mkdir() + (test_component_dir / "__init__.py").write_text("# Test component") - # Call with skip_update=True - do_external_components_pass(config, skip_update=True) + # Set up mock to return our tmp_path + mock_clone_or_update.return_value = (tmp_path, None) - # Verify clone_or_update was called with refresh=None - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] is None + with patch("esphome.loader.install_meta_finder"): + config: dict[str, Any] = { + CONF_EXTERNAL_COMPONENTS: [ + { + CONF_SOURCE: { + "type": TYPE_GIT, + CONF_URL: "https://github.com/test/components", + }, + CONF_REFRESH: "1d", + "components": "all", + } + ] + } + + # Call with skip_update=True + do_external_components_pass(config, skip_update=True) + + # Verify clone_or_update was called with refresh=None + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] is None -@patch("esphome.git.clone_or_update") -@patch("esphome.loader.install_meta_finder") def test_external_components_skip_update_false( - mock_install_meta: MagicMock, mock_clone_or_update: MagicMock + tmp_path: Path, mock_clone_or_update: MagicMock ) -> None: """Test that external components update when skip_update=False.""" - # Setup mocks - test_path = Path("/tmp/test/components") - test_path.mkdir(parents=True, exist_ok=True) - mock_clone_or_update.return_value = (test_path.parent, None) + # Create a components directory structure + components_dir = tmp_path / "components" + components_dir.mkdir() - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } + # Create a test component + test_component_dir = components_dir / "test_component" + test_component_dir.mkdir() + (test_component_dir / "__init__.py").write_text("# Test component") - # Call with skip_update=False - do_external_components_pass(config, skip_update=False) + # Set up mock to return our tmp_path + mock_clone_or_update.return_value = (tmp_path, None) - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] == "1d" + with patch("esphome.loader.install_meta_finder"): + config: dict[str, Any] = { + CONF_EXTERNAL_COMPONENTS: [ + { + CONF_SOURCE: { + "type": TYPE_GIT, + CONF_URL: "https://github.com/test/components", + }, + CONF_REFRESH: "1d", + "components": "all", + } + ] + } + + # Call with skip_update=False + do_external_components_pass(config, skip_update=False) + + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] == "1d" -@patch("esphome.git.clone_or_update") -@patch("esphome.loader.install_meta_finder") def test_external_components_default_no_skip( - mock_install_meta: MagicMock, mock_clone_or_update: MagicMock + tmp_path: Path, mock_clone_or_update: MagicMock ) -> None: """Test that external components update by default when skip_update not specified.""" - # Setup mocks - test_path = Path("/tmp/test/components") - test_path.mkdir(parents=True, exist_ok=True) - mock_clone_or_update.return_value = (test_path.parent, None) + # Create a components directory structure + components_dir = tmp_path / "components" + components_dir.mkdir() - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } + # Create a test component + test_component_dir = components_dir / "test_component" + test_component_dir.mkdir() + (test_component_dir / "__init__.py").write_text("# Test component") - # Call without skip_update parameter - do_external_components_pass(config) + # Set up mock to return our tmp_path + mock_clone_or_update.return_value = (tmp_path, None) - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] == "1d" + with patch("esphome.loader.install_meta_finder"): + config: dict[str, Any] = { + CONF_EXTERNAL_COMPONENTS: [ + { + CONF_SOURCE: { + "type": TYPE_GIT, + CONF_URL: "https://github.com/test/components", + }, + CONF_REFRESH: "1d", + "components": "all", + } + ] + } + + # Call without skip_update parameter + do_external_components_pass(config) + + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] == "1d" diff --git a/tests/component_tests/packages/test_init.py b/tests/component_tests/packages/test_init.py index 25493c2770..4f66f83e84 100644 --- a/tests/component_tests/packages/test_init.py +++ b/tests/component_tests/packages/test_init.py @@ -6,16 +6,22 @@ from unittest.mock import MagicMock from esphome.components.packages import do_packages_pass from esphome.const import CONF_FILES, CONF_PACKAGES, CONF_REFRESH, CONF_URL +from esphome.util import OrderedDict def test_packages_skip_update_true( - mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock + tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock ) -> None: """Test that packages don't update when skip_update=True.""" - # Setup mocks - with MagicMock() as mock_is_file: - mock_is_file.return_value = True - Path.is_file = mock_is_file + # Set up mock to return our tmp_path + mock_clone_or_update.return_value = (tmp_path, None) + + # Create the test yaml file + test_file = tmp_path / "test.yaml" + test_file.write_text("sensor: []") + + # Set mock_load_yaml to return some valid config + mock_load_yaml.return_value = OrderedDict({"sensor": []}) config: dict[str, Any] = { CONF_PACKAGES: { @@ -37,13 +43,18 @@ def test_packages_skip_update_true( def test_packages_skip_update_false( - mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock + tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock ) -> None: """Test that packages update when skip_update=False.""" - # Setup mocks - with MagicMock() as mock_is_file: - mock_is_file.return_value = True - Path.is_file = mock_is_file + # Set up mock to return our tmp_path + mock_clone_or_update.return_value = (tmp_path, None) + + # Create the test yaml file + test_file = tmp_path / "test.yaml" + test_file.write_text("sensor: []") + + # Set mock_load_yaml to return some valid config + mock_load_yaml.return_value = OrderedDict({"sensor": []}) config: dict[str, Any] = { CONF_PACKAGES: { @@ -65,13 +76,18 @@ def test_packages_skip_update_false( def test_packages_default_no_skip( - mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock + tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock ) -> None: """Test that packages update by default when skip_update not specified.""" - # Setup mocks - with MagicMock() as mock_is_file: - mock_is_file.return_value = True - Path.is_file = mock_is_file + # Set up mock to return our tmp_path + mock_clone_or_update.return_value = (tmp_path, None) + + # Create the test yaml file + test_file = tmp_path / "test.yaml" + test_file.write_text("sensor: []") + + # Set mock_load_yaml to return some valid config + mock_load_yaml.return_value = OrderedDict({"sensor": []}) config: dict[str, Any] = { CONF_PACKAGES: { From c39320c5152057a83f66886e0a81e5917e62f02e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 11:57:10 -0500 Subject: [PATCH 1182/1626] fixes --- tests/component_tests/conftest.py | 7 + .../external_components/test_init.py | 119 ++++----- .../component_tests/packages/test_packages.py | 93 ------- tests/unit_tests/conftest.py | 7 + tests/unit_tests/test_git.py | 247 ++++++++++-------- 5 files changed, 212 insertions(+), 261 deletions(-) diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index fe765118ff..189549bcd8 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -156,3 +156,10 @@ def mock_load_yaml() -> Generator[Any]: # Default return value mock_func.return_value = OrderedDict({"sensor": []}) yield mock_func + + +@pytest.fixture +def mock_install_meta_finder() -> Generator[Any]: + """Mock loader.install_meta_finder for testing.""" + with mock.patch("esphome.loader.install_meta_finder") as mock_func: + yield mock_func diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py index da1bdc3d90..efc81c5475 100644 --- a/tests/component_tests/external_components/test_init.py +++ b/tests/component_tests/external_components/test_init.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from esphome.components.external_components import do_external_components_pass from esphome.const import ( @@ -15,7 +15,7 @@ from esphome.const import ( def test_external_components_skip_update_true( - tmp_path: Path, mock_clone_or_update: MagicMock + tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock ) -> None: """Test that external components don't update when skip_update=True.""" # Create a components directory structure @@ -30,31 +30,30 @@ def test_external_components_skip_update_true( # Set up mock to return our tmp_path mock_clone_or_update.return_value = (tmp_path, None) - with patch("esphome.loader.install_meta_finder"): - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } + config: dict[str, Any] = { + CONF_EXTERNAL_COMPONENTS: [ + { + CONF_SOURCE: { + "type": TYPE_GIT, + CONF_URL: "https://github.com/test/components", + }, + CONF_REFRESH: "1d", + "components": "all", + } + ] + } - # Call with skip_update=True - do_external_components_pass(config, skip_update=True) + # Call with skip_update=True + do_external_components_pass(config, skip_update=True) - # Verify clone_or_update was called with refresh=None - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] is None + # Verify clone_or_update was called with refresh=None + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] is None def test_external_components_skip_update_false( - tmp_path: Path, mock_clone_or_update: MagicMock + tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock ) -> None: """Test that external components update when skip_update=False.""" # Create a components directory structure @@ -69,31 +68,30 @@ def test_external_components_skip_update_false( # Set up mock to return our tmp_path mock_clone_or_update.return_value = (tmp_path, None) - with patch("esphome.loader.install_meta_finder"): - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } + config: dict[str, Any] = { + CONF_EXTERNAL_COMPONENTS: [ + { + CONF_SOURCE: { + "type": TYPE_GIT, + CONF_URL: "https://github.com/test/components", + }, + CONF_REFRESH: "1d", + "components": "all", + } + ] + } - # Call with skip_update=False - do_external_components_pass(config, skip_update=False) + # Call with skip_update=False + do_external_components_pass(config, skip_update=False) - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] == "1d" + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] == "1d" def test_external_components_default_no_skip( - tmp_path: Path, mock_clone_or_update: MagicMock + tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock ) -> None: """Test that external components update by default when skip_update not specified.""" # Create a components directory structure @@ -108,24 +106,23 @@ def test_external_components_default_no_skip( # Set up mock to return our tmp_path mock_clone_or_update.return_value = (tmp_path, None) - with patch("esphome.loader.install_meta_finder"): - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } + config: dict[str, Any] = { + CONF_EXTERNAL_COMPONENTS: [ + { + CONF_SOURCE: { + "type": TYPE_GIT, + CONF_URL: "https://github.com/test/components", + }, + CONF_REFRESH: "1d", + "components": "all", + } + ] + } - # Call without skip_update parameter - do_external_components_pass(config) + # Call without skip_update parameter + do_external_components_pass(config) - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] == "1d" + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + assert call_args.kwargs["refresh"] == "1d" diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 99ed661649..4712daad0d 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -732,96 +732,3 @@ def test_remote_packages_with_files_and_vars( actual = do_packages_pass(config) assert actual == expected - - -@patch("esphome.git.clone_or_update") -@patch("esphome.yaml_util.load_yaml") -@patch("pathlib.Path.is_file") -def test_packages_skip_update_true( - mock_is_file: MagicMock, mock_load_yaml: MagicMock, mock_clone_or_update: MagicMock -) -> None: - """Test that packages don't update when skip_update=True.""" - # Setup mocks - mock_clone_or_update.return_value = (Path("/tmp/test"), None) - mock_is_file.return_value = True - mock_load_yaml.return_value = OrderedDict({"sensor": []}) - - config = { - CONF_PACKAGES: { - "test_package": { - CONF_URL: "https://github.com/test/repo", - CONF_FILES: ["test.yaml"], - CONF_REFRESH: "1d", - } - } - } - - # Call with skip_update=True - do_packages_pass(config, skip_update=True) - - # Verify clone_or_update was called with refresh=None - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] is None - - -@patch("esphome.git.clone_or_update") -@patch("esphome.yaml_util.load_yaml") -@patch("pathlib.Path.is_file") -def test_packages_skip_update_false( - mock_is_file: MagicMock, mock_load_yaml: MagicMock, mock_clone_or_update: MagicMock -) -> None: - """Test that packages update when skip_update=False.""" - # Setup mocks - mock_clone_or_update.return_value = (Path("/tmp/test"), None) - mock_is_file.return_value = True - mock_load_yaml.return_value = OrderedDict({"sensor": []}) - - config = { - CONF_PACKAGES: { - "test_package": { - CONF_URL: "https://github.com/test/repo", - CONF_FILES: ["test.yaml"], - CONF_REFRESH: "1d", - } - } - } - - # Call with skip_update=False (default) - do_packages_pass(config, skip_update=False) - - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] == "1d" - - -@patch("esphome.git.clone_or_update") -@patch("esphome.yaml_util.load_yaml") -@patch("pathlib.Path.is_file") -def test_packages_default_no_skip( - mock_is_file: MagicMock, mock_load_yaml: MagicMock, mock_clone_or_update: MagicMock -) -> None: - """Test that packages update by default when skip_update not specified.""" - # Setup mocks - mock_clone_or_update.return_value = (Path("/tmp/test"), None) - mock_is_file.return_value = True - mock_load_yaml.return_value = OrderedDict({"sensor": []}) - - config = { - CONF_PACKAGES: { - "test_package": { - CONF_URL: "https://github.com/test/repo", - CONF_FILES: ["test.yaml"], - CONF_REFRESH: "1d", - } - } - } - - # Call without skip_update parameter - do_packages_pass(config) - - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] == "1d" diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 06d06d0506..d2ba831b56 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -87,3 +87,10 @@ def mock_run_external_command() -> Generator[Mock, None, None]: """Mock run_external_command for platformio_api.""" with patch("esphome.platformio_api.run_external_command") as mock: yield mock + + +@pytest.fixture +def mock_run_git_command() -> Generator[Mock, None, None]: + """Mock run_git_command for git module.""" + with patch("esphome.git.run_git_command") as mock: + yield mock diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 99b0f12441..a0364ea7bc 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -1,147 +1,180 @@ """Tests for git.py module.""" from datetime import datetime, timedelta -from unittest.mock import MagicMock, Mock, patch +from pathlib import Path +from unittest.mock import Mock from esphome import git from esphome.core import TimePeriodSeconds -@patch("esphome.git.run_git_command") -@patch("pathlib.Path.is_dir") def test_clone_or_update_with_none_refresh_no_update( - mock_is_dir: MagicMock, mock_run_git: MagicMock + tmp_path: Path, mock_run_git_command: Mock ) -> None: """Test that refresh=None skips updates for existing repos.""" - # Setup - repo already exists - mock_is_dir.return_value = True + # Create a fake git repo directory + repo_dir = tmp_path / ".esphome" / "external_components" / "test" / "test_repo" + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() - # Mock file timestamps - with patch("pathlib.Path.exists") as mock_exists: - mock_exists.return_value = True - with patch("pathlib.Path.stat") as mock_stat: - mock_stat_result = Mock() - mock_stat_result.st_mtime = datetime.now().timestamp() - mock_stat.return_value = mock_stat_result + # Create FETCH_HEAD file with current timestamp + fetch_head = git_dir / "FETCH_HEAD" + fetch_head.write_text("test") - # Call with refresh=None - repo_dir, revert = git.clone_or_update( - url="https://github.com/test/repo", - ref=None, - refresh=None, - domain="test", - ) + # Mock _compute_destination_path to return our test directory + with Mock() as mock_compute: + mock_compute.return_value = repo_dir + git._compute_destination_path = mock_compute - # Should NOT call git fetch or any update commands - mock_run_git.assert_not_called() - assert revert is None + # Call with refresh=None + result_dir, revert = git.clone_or_update( + url="https://github.com/test/repo", + ref=None, + refresh=None, + domain="test", + ) + + # Should NOT call git commands since refresh=None and repo exists + mock_run_git_command.assert_not_called() + assert revert is None -@patch("esphome.git.run_git_command") -@patch("pathlib.Path.is_dir") def test_clone_or_update_with_refresh_updates_old_repo( - mock_is_dir: MagicMock, mock_run_git: MagicMock + tmp_path: Path, mock_run_git_command: Mock ) -> None: """Test that refresh triggers update for old repos.""" - # Setup - repo already exists - mock_is_dir.return_value = True - mock_run_git.return_value = "abc123" # mock SHA + # Create a fake git repo directory + repo_dir = tmp_path / ".esphome" / "external_components" / "test" / "test_repo" + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() - # Mock file timestamps - 2 days old - with patch("pathlib.Path.exists") as mock_exists: - mock_exists.return_value = True - with patch("pathlib.Path.stat") as mock_stat: - mock_stat_result = Mock() - old_time = datetime.now() - timedelta(days=2) - mock_stat_result.st_mtime = old_time.timestamp() - mock_stat.return_value = mock_stat_result + # Create FETCH_HEAD file with old timestamp (2 days ago) + fetch_head = git_dir / "FETCH_HEAD" + fetch_head.write_text("test") + old_time = datetime.now() - timedelta(days=2) + fetch_head.touch() # Create the file + # Set modification time to 2 days ago + import os - # Call with refresh=1d (1 day) - refresh = TimePeriodSeconds(days=1) - repo_dir, revert = git.clone_or_update( - url="https://github.com/test/repo", - ref=None, - refresh=refresh, - domain="test", - ) + os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp())) - # Should call git fetch and update commands - assert mock_run_git.called - # Check for fetch command - fetch_calls = [ - call for call in mock_run_git.call_args_list if "fetch" in str(call) - ] - assert len(fetch_calls) > 0 + # Mock _compute_destination_path to return our test directory + with Mock() as mock_compute: + mock_compute.return_value = repo_dir + git._compute_destination_path = mock_compute + + # Mock git command responses + mock_run_git_command.return_value = "abc123" # SHA for rev-parse + + # Call with refresh=1d (1 day) + refresh = TimePeriodSeconds(days=1) + result_dir, revert = git.clone_or_update( + url="https://github.com/test/repo", + ref=None, + refresh=refresh, + domain="test", + ) + + # Should call git fetch and update commands since repo is older than refresh + assert mock_run_git_command.called + # Check for fetch command + fetch_calls = [ + call + for call in mock_run_git_command.call_args_list + if len(call[0]) > 0 and "fetch" in call[0][0] + ] + assert len(fetch_calls) > 0 -@patch("esphome.git.run_git_command") -@patch("pathlib.Path.is_dir") def test_clone_or_update_with_refresh_skips_fresh_repo( - mock_is_dir: MagicMock, mock_run_git: MagicMock + tmp_path: Path, mock_run_git_command: Mock ) -> None: """Test that refresh doesn't update fresh repos.""" - # Setup - repo already exists - mock_is_dir.return_value = True + # Create a fake git repo directory + repo_dir = tmp_path / ".esphome" / "external_components" / "test" / "test_repo" + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() - # Mock file timestamps - 1 hour old - with patch("pathlib.Path.exists") as mock_exists: - mock_exists.return_value = True - with patch("pathlib.Path.stat") as mock_stat: - mock_stat_result = Mock() - recent_time = datetime.now() - timedelta(hours=1) - mock_stat_result.st_mtime = recent_time.timestamp() - mock_stat.return_value = mock_stat_result + # Create FETCH_HEAD file with recent timestamp (1 hour ago) + fetch_head = git_dir / "FETCH_HEAD" + fetch_head.write_text("test") + recent_time = datetime.now() - timedelta(hours=1) + fetch_head.touch() # Create the file + # Set modification time to 1 hour ago + import os - # Call with refresh=1d (1 day) - refresh = TimePeriodSeconds(days=1) - repo_dir, revert = git.clone_or_update( - url="https://github.com/test/repo", - ref=None, - refresh=refresh, - domain="test", - ) + os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) - # Should NOT call git fetch since repo is fresh - mock_run_git.assert_not_called() - assert revert is None + # Mock _compute_destination_path to return our test directory + with Mock() as mock_compute: + mock_compute.return_value = repo_dir + git._compute_destination_path = mock_compute + + # Call with refresh=1d (1 day) + refresh = TimePeriodSeconds(days=1) + result_dir, revert = git.clone_or_update( + url="https://github.com/test/repo", + ref=None, + refresh=refresh, + domain="test", + ) + + # Should NOT call git fetch since repo is fresh + mock_run_git_command.assert_not_called() + assert revert is None -@patch("esphome.git.run_git_command") -@patch("pathlib.Path.is_dir") def test_clone_or_update_clones_missing_repo( - mock_is_dir: MagicMock, mock_run_git: MagicMock + tmp_path: Path, mock_run_git_command: Mock ) -> None: """Test that missing repos are cloned regardless of refresh setting.""" - # Setup - repo doesn't exist - mock_is_dir.return_value = False + # Create base directory but not the repo itself + base_dir = tmp_path / ".esphome" / "external_components" / "test" + base_dir.mkdir(parents=True) + repo_dir = base_dir / "test_repo" - # Test with refresh=None - repo_dir, revert = git.clone_or_update( - url="https://github.com/test/repo", - ref=None, - refresh=None, - domain="test", - ) + # Mock _compute_destination_path to return our test directory + with Mock() as mock_compute: + mock_compute.return_value = repo_dir + git._compute_destination_path = mock_compute - # Should call git clone - assert mock_run_git.called - clone_calls = [call for call in mock_run_git.call_args_list if "clone" in str(call)] - assert len(clone_calls) > 0 + # Test with refresh=None + result_dir, revert = git.clone_or_update( + url="https://github.com/test/repo", + ref=None, + refresh=None, + domain="test", + ) - # Reset mock - mock_run_git.reset_mock() + # Should call git clone + assert mock_run_git_command.called + clone_calls = [ + call + for call in mock_run_git_command.call_args_list + if len(call[0]) > 0 and "clone" in call[0][0] + ] + assert len(clone_calls) > 0 - # Test with refresh=1d - mock_is_dir.return_value = False - refresh = TimePeriodSeconds(days=1) - repo_dir, revert = git.clone_or_update( - url="https://github.com/test/repo2", - ref=None, - refresh=refresh, - domain="test", - ) + # Reset mock + mock_run_git_command.reset_mock() - # Should still call git clone - assert mock_run_git.called - clone_calls = [call for call in mock_run_git.call_args_list if "clone" in str(call)] - assert len(clone_calls) > 0 + # Test with refresh=1d - should still clone since repo doesn't exist + refresh = TimePeriodSeconds(days=1) + result_dir2, revert2 = git.clone_or_update( + url="https://github.com/test/repo2", + ref=None, + refresh=refresh, + domain="test", + ) + + # Should still call git clone + assert mock_run_git_command.called + clone_calls = [ + call + for call in mock_run_git_command.call_args_list + if len(call[0]) > 0 and "clone" in call[0][0] + ] + assert len(clone_calls) > 0 From 452a12892e645b6cd8d42f63904fa1dd357c77b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 12:01:01 -0500 Subject: [PATCH 1183/1626] fix reg --- esphome/components/external_components/__init__.py | 7 ++++--- esphome/git.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index 5362a2269f..6b7754f2f4 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -17,7 +17,7 @@ from esphome.const import ( TYPE_GIT, TYPE_LOCAL, ) -from esphome.core import CORE +from esphome.core import CORE, TimePeriodSeconds _LOGGER = logging.getLogger(__name__) @@ -40,8 +40,9 @@ async def to_code(config): def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str: - # When skip_update is True, set refresh to None to prevent updates - actual_refresh = None if skip_update else refresh + # When skip_update is True, set a very large refresh value to prevent updates + # Using 100 years in seconds to effectively disable refresh + actual_refresh = TimePeriodSeconds(days=36500) if skip_update else refresh repo_dir, _ = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), diff --git a/esphome/git.py b/esphome/git.py index c60b928d7c..56aedd1519 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -90,7 +90,7 @@ def clone_or_update( if not file_timestamp.exists(): file_timestamp = Path(repo_dir / ".git" / "HEAD") age = datetime.now() - datetime.fromtimestamp(file_timestamp.stat().st_mtime) - if refresh is not None and age.total_seconds() > refresh.total_seconds: + if refresh is None or age.total_seconds() > refresh.total_seconds: old_sha = run_git_command(["git", "rev-parse", "HEAD"], str(repo_dir)) _LOGGER.info("Updating %s", key) _LOGGER.debug("Location: %s", repo_dir) From edd8fa8d6f1e4d773d2592d6ebba0cfc3c818830 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 12:02:57 -0500 Subject: [PATCH 1184/1626] cleaner --- .../external_components/__init__.py | 7 +- esphome/components/packages/__init__.py | 4 +- esphome/git.py | 8 ++ tests/unit_tests/test_git.py | 83 +++++++++++-------- 4 files changed, 61 insertions(+), 41 deletions(-) diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index 6b7754f2f4..ceb402c5b7 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -17,7 +17,7 @@ from esphome.const import ( TYPE_GIT, TYPE_LOCAL, ) -from esphome.core import CORE, TimePeriodSeconds +from esphome.core import CORE _LOGGER = logging.getLogger(__name__) @@ -40,9 +40,8 @@ async def to_code(config): def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str: - # When skip_update is True, set a very large refresh value to prevent updates - # Using 100 years in seconds to effectively disable refresh - actual_refresh = TimePeriodSeconds(days=36500) if skip_update else refresh + # When skip_update is True, use NEVER_REFRESH to prevent updates + actual_refresh = git.NEVER_REFRESH if skip_update else refresh repo_dir, _ = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 2f964984cc..fdc75d995a 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -107,8 +107,8 @@ CONFIG_SCHEMA = cv.Any( def _process_base_package(config: dict, skip_update: bool = False) -> dict: - # When skip_update is True, set refresh to None to prevent updates - actual_refresh = None if skip_update else config[CONF_REFRESH] + # When skip_update is True, use NEVER_REFRESH to prevent updates + actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH] repo_dir, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), diff --git a/esphome/git.py b/esphome/git.py index 56aedd1519..62fe37a3fe 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -13,6 +13,9 @@ from esphome.core import CORE, TimePeriodSeconds _LOGGER = logging.getLogger(__name__) +# Special value to indicate never refresh +NEVER_REFRESH = TimePeriodSeconds(seconds=-1) + def run_git_command(cmd, cwd=None) -> str: _LOGGER.debug("Running git command: %s", " ".join(cmd)) @@ -85,6 +88,11 @@ def clone_or_update( else: # Check refresh needed + # Skip refresh if NEVER_REFRESH is specified + if refresh == NEVER_REFRESH: + _LOGGER.debug("Skipping update for %s (refresh disabled)", key) + return repo_dir, None + file_timestamp = Path(repo_dir / ".git" / "FETCH_HEAD") # On first clone, FETCH_HEAD does not exists if not file_timestamp.exists(): diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index a0364ea7bc..b4bf6ed827 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -2,16 +2,16 @@ from datetime import datetime, timedelta from pathlib import Path -from unittest.mock import Mock +from unittest.mock import Mock, patch from esphome import git from esphome.core import TimePeriodSeconds -def test_clone_or_update_with_none_refresh_no_update( +def test_clone_or_update_with_never_refresh( tmp_path: Path, mock_run_git_command: Mock ) -> None: - """Test that refresh=None skips updates for existing repos.""" + """Test that NEVER_REFRESH skips updates for existing repos.""" # Create a fake git repo directory repo_dir = tmp_path / ".esphome" / "external_components" / "test" / "test_repo" repo_dir.mkdir(parents=True) @@ -23,20 +23,18 @@ def test_clone_or_update_with_none_refresh_no_update( fetch_head.write_text("test") # Mock _compute_destination_path to return our test directory - with Mock() as mock_compute: - mock_compute.return_value = repo_dir - git._compute_destination_path = mock_compute - - # Call with refresh=None + with patch.object(git, "_compute_destination_path", return_value=repo_dir): + # Call with NEVER_REFRESH result_dir, revert = git.clone_or_update( url="https://github.com/test/repo", ref=None, - refresh=None, + refresh=git.NEVER_REFRESH, domain="test", ) - # Should NOT call git commands since refresh=None and repo exists + # Should NOT call git commands since NEVER_REFRESH and repo exists mock_run_git_command.assert_not_called() + assert result_dir == repo_dir assert revert is None @@ -61,10 +59,7 @@ def test_clone_or_update_with_refresh_updates_old_repo( os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp())) # Mock _compute_destination_path to return our test directory - with Mock() as mock_compute: - mock_compute.return_value = repo_dir - git._compute_destination_path = mock_compute - + with patch.object(git, "_compute_destination_path", return_value=repo_dir): # Mock git command responses mock_run_git_command.return_value = "abc123" # SHA for rev-parse @@ -109,10 +104,7 @@ def test_clone_or_update_with_refresh_skips_fresh_repo( os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) # Mock _compute_destination_path to return our test directory - with Mock() as mock_compute: - mock_compute.return_value = repo_dir - git._compute_destination_path = mock_compute - + with patch.object(git, "_compute_destination_path", return_value=repo_dir): # Call with refresh=1d (1 day) refresh = TimePeriodSeconds(days=1) result_dir, revert = git.clone_or_update( @@ -124,6 +116,7 @@ def test_clone_or_update_with_refresh_skips_fresh_repo( # Should NOT call git fetch since repo is fresh mock_run_git_command.assert_not_called() + assert result_dir == repo_dir assert revert is None @@ -137,15 +130,12 @@ def test_clone_or_update_clones_missing_repo( repo_dir = base_dir / "test_repo" # Mock _compute_destination_path to return our test directory - with Mock() as mock_compute: - mock_compute.return_value = repo_dir - git._compute_destination_path = mock_compute - - # Test with refresh=None + with patch.object(git, "_compute_destination_path", return_value=repo_dir): + # Test with NEVER_REFRESH - should still clone since repo doesn't exist result_dir, revert = git.clone_or_update( url="https://github.com/test/repo", ref=None, - refresh=None, + refresh=git.NEVER_REFRESH, domain="test", ) @@ -158,23 +148,46 @@ def test_clone_or_update_clones_missing_repo( ] assert len(clone_calls) > 0 - # Reset mock - mock_run_git_command.reset_mock() - # Test with refresh=1d - should still clone since repo doesn't exist - refresh = TimePeriodSeconds(days=1) - result_dir2, revert2 = git.clone_or_update( - url="https://github.com/test/repo2", +def test_clone_or_update_with_none_refresh_always_updates( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Test that refresh=None always updates existing repos.""" + # Create a fake git repo directory + repo_dir = tmp_path / ".esphome" / "external_components" / "test" / "test_repo" + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() + + # Create FETCH_HEAD file with very recent timestamp (1 second ago) + fetch_head = git_dir / "FETCH_HEAD" + fetch_head.write_text("test") + recent_time = datetime.now() - timedelta(seconds=1) + fetch_head.touch() # Create the file + # Set modification time to 1 second ago + import os + + os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) + + # Mock _compute_destination_path to return our test directory + with patch.object(git, "_compute_destination_path", return_value=repo_dir): + # Mock git command responses + mock_run_git_command.return_value = "abc123" # SHA for rev-parse + + # Call with refresh=None (default behavior) + result_dir, revert = git.clone_or_update( + url="https://github.com/test/repo", ref=None, - refresh=refresh, + refresh=None, domain="test", ) - # Should still call git clone + # Should call git fetch and update commands since refresh=None means always update assert mock_run_git_command.called - clone_calls = [ + # Check for fetch command + fetch_calls = [ call for call in mock_run_git_command.call_args_list - if len(call[0]) > 0 and "clone" in call[0][0] + if len(call[0]) > 0 and "fetch" in call[0][0] ] - assert len(clone_calls) > 0 + assert len(fetch_calls) > 0 From 81cfc30f3a4ea3c3761be309d07a026d1564213b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 12:03:28 -0500 Subject: [PATCH 1185/1626] cleaner --- tests/component_tests/external_components/test_init.py | 6 ++++-- tests/component_tests/packages/test_init.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py index efc81c5475..d1c328b3cb 100644 --- a/tests/component_tests/external_components/test_init.py +++ b/tests/component_tests/external_components/test_init.py @@ -46,10 +46,12 @@ def test_external_components_skip_update_true( # Call with skip_update=True do_external_components_pass(config, skip_update=True) - # Verify clone_or_update was called with refresh=None + # Verify clone_or_update was called with NEVER_REFRESH mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] is None + from esphome import git + + assert call_args.kwargs["refresh"] == git.NEVER_REFRESH def test_external_components_skip_update_false( diff --git a/tests/component_tests/packages/test_init.py b/tests/component_tests/packages/test_init.py index 4f66f83e84..dfd4147c14 100644 --- a/tests/component_tests/packages/test_init.py +++ b/tests/component_tests/packages/test_init.py @@ -36,10 +36,12 @@ def test_packages_skip_update_true( # Call with skip_update=True do_packages_pass(config, skip_update=True) - # Verify clone_or_update was called with refresh=None + # Verify clone_or_update was called with NEVER_REFRESH mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] is None + from esphome import git + + assert call_args.kwargs["refresh"] == git.NEVER_REFRESH def test_packages_skip_update_false( From 1793b6a27b6a9a41fbddaa3cf770da3f346bbb5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 12:04:05 -0500 Subject: [PATCH 1186/1626] cleaner --- tests/component_tests/external_components/test_init.py | 8 ++++++-- tests/component_tests/packages/test_init.py | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py index d1c328b3cb..905c0afa8b 100644 --- a/tests/component_tests/external_components/test_init.py +++ b/tests/component_tests/external_components/test_init.py @@ -89,7 +89,9 @@ def test_external_components_skip_update_false( # Verify clone_or_update was called with actual refresh value mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] == "1d" + from esphome.core import TimePeriodSeconds + + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) def test_external_components_default_no_skip( @@ -127,4 +129,6 @@ def test_external_components_default_no_skip( # Verify clone_or_update was called with actual refresh value mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] == "1d" + from esphome.core import TimePeriodSeconds + + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) diff --git a/tests/component_tests/packages/test_init.py b/tests/component_tests/packages/test_init.py index dfd4147c14..779244e2ed 100644 --- a/tests/component_tests/packages/test_init.py +++ b/tests/component_tests/packages/test_init.py @@ -74,7 +74,9 @@ def test_packages_skip_update_false( # Verify clone_or_update was called with actual refresh value mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] == "1d" + from esphome.core import TimePeriodSeconds + + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) def test_packages_default_no_skip( @@ -107,4 +109,6 @@ def test_packages_default_no_skip( # Verify clone_or_update was called with actual refresh value mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - assert call_args.kwargs["refresh"] == "1d" + from esphome.core import TimePeriodSeconds + + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) From c74777098f4173f64992e3b0f77068b87c722605 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 12:05:35 -0500 Subject: [PATCH 1187/1626] cleaner --- tests/unit_tests/core/test_config.py | 115 --------------------------- 1 file changed, 115 deletions(-) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 921863e2bc..7d3b90794b 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -852,118 +852,3 @@ async def test_add_includes_overwrites_existing_files( mock_copy_file_if_changed.assert_called_once_with( str(include_file), str(Path(CORE.build_path) / "src" / "header.h") ) - - -# Tests for skip_external_update functionality - - -@patch("esphome.yaml_util.load_yaml") -@patch("esphome.components.packages.do_packages_pass") -@patch("esphome.components.external_components.do_external_components_pass") -def test_validate_config_skip_update_true( - mock_ext_pass: MagicMock, mock_pkg_pass: MagicMock, mock_load_yaml: MagicMock -) -> None: - """Test that validate_config propagates skip_update=True.""" - from esphome.config import validate_config - from esphome.const import CONF_EXTERNAL_COMPONENTS, CONF_PACKAGES - - config_dict: dict[str, Any] = { - CONF_ESPHOME: {CONF_NAME: "test"}, - CONF_PACKAGES: {"test": {}}, - CONF_EXTERNAL_COMPONENTS: [{}], - } - - # Mock do_packages_pass to return config unchanged - mock_pkg_pass.side_effect = lambda c, **kwargs: c - - # Call validate_config with skip_external_update=True - validate_config(config_dict, {}, skip_external_update=True) - - # Verify both were called with skip_update=True - mock_pkg_pass.assert_called_once() - assert mock_pkg_pass.call_args.kwargs.get("skip_update") is True - - mock_ext_pass.assert_called_once() - assert mock_ext_pass.call_args.kwargs.get("skip_update") is True - - -@patch("esphome.yaml_util.load_yaml") -@patch("esphome.components.packages.do_packages_pass") -@patch("esphome.components.external_components.do_external_components_pass") -def test_validate_config_skip_update_false( - mock_ext_pass: MagicMock, mock_pkg_pass: MagicMock, mock_load_yaml: MagicMock -) -> None: - """Test that validate_config propagates skip_update=False.""" - from esphome.config import validate_config - from esphome.const import CONF_EXTERNAL_COMPONENTS, CONF_PACKAGES - - config_dict: dict[str, Any] = { - CONF_ESPHOME: {CONF_NAME: "test"}, - CONF_PACKAGES: {"test": {}}, - CONF_EXTERNAL_COMPONENTS: [{}], - } - - # Mock do_packages_pass to return config unchanged - mock_pkg_pass.side_effect = lambda c, **kwargs: c - - # Call validate_config with skip_external_update=False - validate_config(config_dict, {}, skip_external_update=False) - - # Verify both were called with skip_update=False - mock_pkg_pass.assert_called_once() - assert mock_pkg_pass.call_args.kwargs.get("skip_update") is False - - mock_ext_pass.assert_called_once() - assert mock_ext_pass.call_args.kwargs.get("skip_update") is False - - -@patch("esphome.yaml_util.load_yaml") -@patch("esphome.components.packages.do_packages_pass") -@patch("esphome.components.external_components.do_external_components_pass") -def test_validate_config_default_false( - mock_ext_pass: MagicMock, mock_pkg_pass: MagicMock, mock_load_yaml: MagicMock -) -> None: - """Test that validate_config defaults to skip_update=False.""" - from esphome.config import validate_config - from esphome.const import CONF_EXTERNAL_COMPONENTS, CONF_PACKAGES - - config_dict: dict[str, Any] = { - CONF_ESPHOME: {CONF_NAME: "test"}, - CONF_PACKAGES: {"test": {}}, - CONF_EXTERNAL_COMPONENTS: [{}], - } - - # Mock do_packages_pass to return config unchanged - mock_pkg_pass.side_effect = lambda c, **kwargs: c - - # Call validate_config without skip_external_update parameter - validate_config(config_dict, {}) - - # Verify both were called with skip_update=False (default) - mock_pkg_pass.assert_called_once() - assert mock_pkg_pass.call_args.kwargs.get("skip_update") is False - - mock_ext_pass.assert_called_once() - assert mock_ext_pass.call_args.kwargs.get("skip_update") is False - - -@patch("esphome.config.load_config") -def test_read_config_skip_update_parameter(mock_load_config: MagicMock) -> None: - """Test that read_config passes skip_external_update correctly.""" - from esphome.config import read_config - - # Setup - CORE.config_path = "test.yaml" - mock_load_config.return_value = MagicMock(errors=[]) - - # Test with skip_external_update=True - read_config({}, skip_external_update=True) - mock_load_config.assert_called_with({}, True) - - # Test with skip_external_update=False - read_config({}, skip_external_update=False) - mock_load_config.assert_called_with({}, False) - - # Test default (should be False) - read_config({}) - mock_load_config.assert_called_with({}, False) From d249e54e8b613d50348c3c435e276d1f97f9620d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 12:08:18 -0500 Subject: [PATCH 1188/1626] cleanup, less mocking --- tests/unit_tests/test_git.py | 261 +++++++++++++++++++++-------------- 1 file changed, 157 insertions(+), 104 deletions(-) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index b4bf6ed827..ebe7177bd2 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -1,19 +1,34 @@ """Tests for git.py module.""" from datetime import datetime, timedelta +import hashlib +import os from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import Mock from esphome import git -from esphome.core import TimePeriodSeconds +from esphome.core import CORE, TimePeriodSeconds def test_clone_or_update_with_never_refresh( tmp_path: Path, mock_run_git_command: Mock ) -> None: """Test that NEVER_REFRESH skips updates for existing repos.""" - # Create a fake git repo directory - repo_dir = tmp_path / ".esphome" / "external_components" / "test" / "test_repo" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = str(tmp_path / "test.yaml") + + # Compute the expected repo directory path + url = "https://github.com/test/repo" + ref = None + key = f"{url}@{ref}" + domain = "test" + + # Compute hash-based directory name (matching _compute_destination_path logic) + h = hashlib.new("sha256") + h.update(key.encode()) + repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + + # Create the git repo directory structure repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() @@ -22,28 +37,39 @@ def test_clone_or_update_with_never_refresh( fetch_head = git_dir / "FETCH_HEAD" fetch_head.write_text("test") - # Mock _compute_destination_path to return our test directory - with patch.object(git, "_compute_destination_path", return_value=repo_dir): - # Call with NEVER_REFRESH - result_dir, revert = git.clone_or_update( - url="https://github.com/test/repo", - ref=None, - refresh=git.NEVER_REFRESH, - domain="test", - ) + # Call with NEVER_REFRESH + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=git.NEVER_REFRESH, + domain=domain, + ) - # Should NOT call git commands since NEVER_REFRESH and repo exists - mock_run_git_command.assert_not_called() - assert result_dir == repo_dir - assert revert is None + # Should NOT call git commands since NEVER_REFRESH and repo exists + mock_run_git_command.assert_not_called() + assert result_dir == repo_dir + assert revert is None def test_clone_or_update_with_refresh_updates_old_repo( tmp_path: Path, mock_run_git_command: Mock ) -> None: """Test that refresh triggers update for old repos.""" - # Create a fake git repo directory - repo_dir = tmp_path / ".esphome" / "external_components" / "test" / "test_repo" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = str(tmp_path / "test.yaml") + + # Compute the expected repo directory path + url = "https://github.com/test/repo" + ref = None + key = f"{url}@{ref}" + domain = "test" + + # Compute hash-based directory name (matching _compute_destination_path logic) + h = hashlib.new("sha256") + h.update(key.encode()) + repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + + # Create the git repo directory structure repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() @@ -54,41 +80,50 @@ def test_clone_or_update_with_refresh_updates_old_repo( old_time = datetime.now() - timedelta(days=2) fetch_head.touch() # Create the file # Set modification time to 2 days ago - import os - os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp())) - # Mock _compute_destination_path to return our test directory - with patch.object(git, "_compute_destination_path", return_value=repo_dir): - # Mock git command responses - mock_run_git_command.return_value = "abc123" # SHA for rev-parse + # Mock git command responses + mock_run_git_command.return_value = "abc123" # SHA for rev-parse - # Call with refresh=1d (1 day) - refresh = TimePeriodSeconds(days=1) - result_dir, revert = git.clone_or_update( - url="https://github.com/test/repo", - ref=None, - refresh=refresh, - domain="test", - ) + # Call with refresh=1d (1 day) + refresh = TimePeriodSeconds(days=1) + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) - # Should call git fetch and update commands since repo is older than refresh - assert mock_run_git_command.called - # Check for fetch command - fetch_calls = [ - call - for call in mock_run_git_command.call_args_list - if len(call[0]) > 0 and "fetch" in call[0][0] - ] - assert len(fetch_calls) > 0 + # Should call git fetch and update commands since repo is older than refresh + assert mock_run_git_command.called + # Check for fetch command + fetch_calls = [ + call + for call in mock_run_git_command.call_args_list + if len(call[0]) > 0 and "fetch" in call[0][0] + ] + assert len(fetch_calls) > 0 def test_clone_or_update_with_refresh_skips_fresh_repo( tmp_path: Path, mock_run_git_command: Mock ) -> None: """Test that refresh doesn't update fresh repos.""" - # Create a fake git repo directory - repo_dir = tmp_path / ".esphome" / "external_components" / "test" / "test_repo" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = str(tmp_path / "test.yaml") + + # Compute the expected repo directory path + url = "https://github.com/test/repo" + ref = None + key = f"{url}@{ref}" + domain = "test" + + # Compute hash-based directory name (matching _compute_destination_path logic) + h = hashlib.new("sha256") + h.update(key.encode()) + repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + + # Create the git repo directory structure repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() @@ -99,62 +134,84 @@ def test_clone_or_update_with_refresh_skips_fresh_repo( recent_time = datetime.now() - timedelta(hours=1) fetch_head.touch() # Create the file # Set modification time to 1 hour ago - import os - os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) - # Mock _compute_destination_path to return our test directory - with patch.object(git, "_compute_destination_path", return_value=repo_dir): - # Call with refresh=1d (1 day) - refresh = TimePeriodSeconds(days=1) - result_dir, revert = git.clone_or_update( - url="https://github.com/test/repo", - ref=None, - refresh=refresh, - domain="test", - ) + # Call with refresh=1d (1 day) + refresh = TimePeriodSeconds(days=1) + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) - # Should NOT call git fetch since repo is fresh - mock_run_git_command.assert_not_called() - assert result_dir == repo_dir - assert revert is None + # Should NOT call git fetch since repo is fresh + mock_run_git_command.assert_not_called() + assert result_dir == repo_dir + assert revert is None def test_clone_or_update_clones_missing_repo( tmp_path: Path, mock_run_git_command: Mock ) -> None: """Test that missing repos are cloned regardless of refresh setting.""" - # Create base directory but not the repo itself - base_dir = tmp_path / ".esphome" / "external_components" / "test" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = str(tmp_path / "test.yaml") + + # Compute the expected repo directory path + url = "https://github.com/test/repo" + ref = None + key = f"{url}@{ref}" + domain = "test" + + # Compute hash-based directory name (matching _compute_destination_path logic) + h = hashlib.new("sha256") + h.update(key.encode()) + repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + + # Create base directory but NOT the repo itself + base_dir = tmp_path / ".esphome" / domain base_dir.mkdir(parents=True) - repo_dir = base_dir / "test_repo" + # repo_dir should NOT exist + assert not repo_dir.exists() - # Mock _compute_destination_path to return our test directory - with patch.object(git, "_compute_destination_path", return_value=repo_dir): - # Test with NEVER_REFRESH - should still clone since repo doesn't exist - result_dir, revert = git.clone_or_update( - url="https://github.com/test/repo", - ref=None, - refresh=git.NEVER_REFRESH, - domain="test", - ) + # Test with NEVER_REFRESH - should still clone since repo doesn't exist + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=git.NEVER_REFRESH, + domain=domain, + ) - # Should call git clone - assert mock_run_git_command.called - clone_calls = [ - call - for call in mock_run_git_command.call_args_list - if len(call[0]) > 0 and "clone" in call[0][0] - ] - assert len(clone_calls) > 0 + # Should call git clone + assert mock_run_git_command.called + clone_calls = [ + call + for call in mock_run_git_command.call_args_list + if len(call[0]) > 0 and "clone" in call[0][0] + ] + assert len(clone_calls) > 0 def test_clone_or_update_with_none_refresh_always_updates( tmp_path: Path, mock_run_git_command: Mock ) -> None: """Test that refresh=None always updates existing repos.""" - # Create a fake git repo directory - repo_dir = tmp_path / ".esphome" / "external_components" / "test" / "test_repo" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = str(tmp_path / "test.yaml") + + # Compute the expected repo directory path + url = "https://github.com/test/repo" + ref = None + key = f"{url}@{ref}" + domain = "test" + + # Compute hash-based directory name (matching _compute_destination_path logic) + h = hashlib.new("sha256") + h.update(key.encode()) + repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + + # Create the git repo directory structure repo_dir.mkdir(parents=True) git_dir = repo_dir / ".git" git_dir.mkdir() @@ -165,29 +222,25 @@ def test_clone_or_update_with_none_refresh_always_updates( recent_time = datetime.now() - timedelta(seconds=1) fetch_head.touch() # Create the file # Set modification time to 1 second ago - import os - os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) - # Mock _compute_destination_path to return our test directory - with patch.object(git, "_compute_destination_path", return_value=repo_dir): - # Mock git command responses - mock_run_git_command.return_value = "abc123" # SHA for rev-parse + # Mock git command responses + mock_run_git_command.return_value = "abc123" # SHA for rev-parse - # Call with refresh=None (default behavior) - result_dir, revert = git.clone_or_update( - url="https://github.com/test/repo", - ref=None, - refresh=None, - domain="test", - ) + # Call with refresh=None (default behavior) + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=None, + domain=domain, + ) - # Should call git fetch and update commands since refresh=None means always update - assert mock_run_git_command.called - # Check for fetch command - fetch_calls = [ - call - for call in mock_run_git_command.call_args_list - if len(call[0]) > 0 and "fetch" in call[0][0] - ] - assert len(fetch_calls) > 0 + # Should call git fetch and update commands since refresh=None means always update + assert mock_run_git_command.called + # Check for fetch command + fetch_calls = [ + call + for call in mock_run_git_command.call_args_list + if len(call[0]) > 0 and "fetch" in call[0][0] + ] + assert len(fetch_calls) > 0 From 1f4b10f52374c72ca80815e87999108346d25608 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 14:54:56 -0500 Subject: [PATCH 1189/1626] Bump pytest-mock from 3.15.0 to 3.15.1 (#10759) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5ec9c98408..2e2f8a1c0d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ pre-commit # Unit tests pytest==8.4.2 pytest-cov==7.0.0 -pytest-mock==3.15.0 +pytest-mock==3.15.1 pytest-asyncio==1.2.0 pytest-xdist==3.8.0 asyncmock==0.4.2 From 22989592f0b862ec3750277b23e50ec4a42cd9e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 14:56:54 -0500 Subject: [PATCH 1190/1626] [wizard] Fix KeyError when running wizard with empty OTA password (#10753) --- esphome/wizard.py | 50 ++++++++++++++++++---- tests/unit_tests/test_wizard.py | 73 ++++++++++++++++++++++----------- 2 files changed, 91 insertions(+), 32 deletions(-) diff --git a/esphome/wizard.py b/esphome/wizard.py index cb599df59a..3edf519816 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -1,6 +1,7 @@ import os import random import string +from typing import Literal, NotRequired, TypedDict, Unpack import unicodedata import voluptuous as vol @@ -103,11 +104,25 @@ HARDWARE_BASE_CONFIGS = { } -def sanitize_double_quotes(value): +def sanitize_double_quotes(value: str) -> str: return value.replace("\\", "\\\\").replace('"', '\\"') -def wizard_file(**kwargs): +class WizardFileKwargs(TypedDict): + """Keyword arguments for wizard_file function.""" + + name: str + platform: Literal["ESP8266", "ESP32", "RP2040", "BK72XX", "LN882X", "RTL87XX"] + board: str + ssid: NotRequired[str] + psk: NotRequired[str] + password: NotRequired[str] + ota_password: NotRequired[str] + api_encryption_key: NotRequired[str] + friendly_name: NotRequired[str] + + +def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str: letters = string.ascii_letters + string.digits ap_name_base = kwargs["name"].replace("_", " ").title() ap_name = f"{ap_name_base} Fallback Hotspot" @@ -180,7 +195,25 @@ captive_portal: return config -def wizard_write(path, **kwargs): +class WizardWriteKwargs(TypedDict): + """Keyword arguments for wizard_write function.""" + + name: str + type: Literal["basic", "empty", "upload"] + # Required for "basic" type + board: NotRequired[str] + platform: NotRequired[str] + ssid: NotRequired[str] + psk: NotRequired[str] + password: NotRequired[str] + ota_password: NotRequired[str] + api_encryption_key: NotRequired[str] + friendly_name: NotRequired[str] + # Required for "upload" type + file_text: NotRequired[str] + + +def wizard_write(path: str, **kwargs: Unpack[WizardWriteKwargs]) -> bool: from esphome.components.bk72xx import boards as bk72xx_boards from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards @@ -237,14 +270,14 @@ def wizard_write(path, **kwargs): if get_bool_env(ENV_QUICKWIZARD): - def sleep(time): + def sleep(time: float) -> None: pass else: from time import sleep -def safe_print_step(step, big): +def safe_print_step(step: int, big: str) -> None: safe_print() safe_print() safe_print(f"============= STEP {step} =============") @@ -253,14 +286,14 @@ def safe_print_step(step, big): sleep(0.25) -def default_input(text, default): +def default_input(text: str, default: str) -> str: safe_print() safe_print(f"Press ENTER for default ({default})") return safe_input(text.format(default)) or default # From https://stackoverflow.com/a/518232/8924614 -def strip_accents(value): +def strip_accents(value: str) -> str: return "".join( c for c in unicodedata.normalize("NFD", str(value)) @@ -268,7 +301,7 @@ def strip_accents(value): ) -def wizard(path): +def wizard(path: str) -> int: from esphome.components.bk72xx import boards as bk72xx_boards from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards @@ -509,6 +542,7 @@ def wizard(path): ssid=ssid, psk=psk, password=password, + type="basic", ): return 1 diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index fea2fb5558..7af4db813a 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -1,9 +1,12 @@ """Tests for the wizard.py file.""" import os +from pathlib import Path +from typing import Any from unittest.mock import MagicMock import pytest +from pytest import MonkeyPatch from esphome.components.bk72xx.boards import BK72XX_BOARD_PINS from esphome.components.esp32.boards import ESP32_BOARD_PINS @@ -15,7 +18,7 @@ import esphome.wizard as wz @pytest.fixture -def default_config(): +def default_config() -> dict[str, Any]: return { "type": "basic", "name": "test-name", @@ -28,7 +31,7 @@ def default_config(): @pytest.fixture -def wizard_answers(): +def wizard_answers() -> list[str]: return [ "test-node", # Name of the node "ESP8266", # platform @@ -53,7 +56,9 @@ def test_sanitize_quotes_replaces_with_escaped_char(): assert output_str == '\\"key\\": \\"value\\"' -def test_config_file_fallback_ap_includes_descriptive_name(default_config): +def test_config_file_fallback_ap_includes_descriptive_name( + default_config: dict[str, Any], +): """ The fallback AP should include the node and a descriptive name """ @@ -67,7 +72,9 @@ def test_config_file_fallback_ap_includes_descriptive_name(default_config): assert 'ssid: "Test Node Fallback Hotspot"' in config -def test_config_file_fallback_ap_name_less_than_32_chars(default_config): +def test_config_file_fallback_ap_name_less_than_32_chars( + default_config: dict[str, Any], +): """ The fallback AP name must be less than 32 chars. Since it is composed of the node name and "Fallback Hotspot" this can be too long and needs truncating @@ -82,7 +89,7 @@ def test_config_file_fallback_ap_name_less_than_32_chars(default_config): assert 'ssid: "A Very Long Name For This Node"' in config -def test_config_file_should_include_ota(default_config): +def test_config_file_should_include_ota(default_config: dict[str, Any]): """ The Over-The-Air update should be enabled by default """ @@ -95,7 +102,9 @@ def test_config_file_should_include_ota(default_config): assert "ota:" in config -def test_config_file_should_include_ota_when_password_set(default_config): +def test_config_file_should_include_ota_when_password_set( + default_config: dict[str, Any], +): """ The Over-The-Air update should be enabled when a password is set """ @@ -109,7 +118,9 @@ def test_config_file_should_include_ota_when_password_set(default_config): assert "ota:" in config -def test_wizard_write_sets_platform(default_config, tmp_path, monkeypatch): +def test_wizard_write_sets_platform( + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch +): """ If the platform is not explicitly set, use "ESP8266" if the board is one of the ESP8266 boards """ @@ -126,7 +137,7 @@ def test_wizard_write_sets_platform(default_config, tmp_path, monkeypatch): assert "esp8266:" in generated_config -def test_wizard_empty_config(tmp_path, monkeypatch): +def test_wizard_empty_config(tmp_path: Path, monkeypatch: MonkeyPatch): """ The wizard should be able to create an empty configuration """ @@ -146,7 +157,7 @@ def test_wizard_empty_config(tmp_path, monkeypatch): assert generated_config == "" -def test_wizard_upload_config(tmp_path, monkeypatch): +def test_wizard_upload_config(tmp_path: Path, monkeypatch: MonkeyPatch): """ The wizard should be able to import an base64 encoded configuration """ @@ -168,7 +179,7 @@ def test_wizard_upload_config(tmp_path, monkeypatch): def test_wizard_write_defaults_platform_from_board_esp8266( - default_config, tmp_path, monkeypatch + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch ): """ If the platform is not explicitly set, use "ESP8266" if the board is one of the ESP8266 boards @@ -189,7 +200,7 @@ def test_wizard_write_defaults_platform_from_board_esp8266( def test_wizard_write_defaults_platform_from_board_esp32( - default_config, tmp_path, monkeypatch + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch ): """ If the platform is not explicitly set, use "ESP32" if the board is one of the ESP32 boards @@ -210,7 +221,7 @@ def test_wizard_write_defaults_platform_from_board_esp32( def test_wizard_write_defaults_platform_from_board_bk72xx( - default_config, tmp_path, monkeypatch + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch ): """ If the platform is not explicitly set, use "BK72XX" if the board is one of BK72XX boards @@ -231,7 +242,7 @@ def test_wizard_write_defaults_platform_from_board_bk72xx( def test_wizard_write_defaults_platform_from_board_ln882x( - default_config, tmp_path, monkeypatch + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch ): """ If the platform is not explicitly set, use "LN882X" if the board is one of LN882X boards @@ -252,7 +263,7 @@ def test_wizard_write_defaults_platform_from_board_ln882x( def test_wizard_write_defaults_platform_from_board_rtl87xx( - default_config, tmp_path, monkeypatch + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch ): """ If the platform is not explicitly set, use "RTL87XX" if the board is one of RTL87XX boards @@ -272,7 +283,7 @@ def test_wizard_write_defaults_platform_from_board_rtl87xx( assert "rtl87xx:" in generated_config -def test_safe_print_step_prints_step_number_and_description(monkeypatch): +def test_safe_print_step_prints_step_number_and_description(monkeypatch: MonkeyPatch): """ The safe_print_step function prints the step number and the passed description """ @@ -296,7 +307,7 @@ def test_safe_print_step_prints_step_number_and_description(monkeypatch): assert any(f"STEP {step_num}" in arg for arg in all_args) -def test_default_input_uses_default_if_no_input_supplied(monkeypatch): +def test_default_input_uses_default_if_no_input_supplied(monkeypatch: MonkeyPatch): """ The default_input() function should return the supplied default value if the user doesn't enter anything """ @@ -312,7 +323,7 @@ def test_default_input_uses_default_if_no_input_supplied(monkeypatch): assert retval == default_string -def test_default_input_uses_user_supplied_value(monkeypatch): +def test_default_input_uses_user_supplied_value(monkeypatch: MonkeyPatch): """ The default_input() function should return the value that the user enters """ @@ -376,7 +387,9 @@ def test_wizard_rejects_existing_files(tmpdir): assert retval == 2 -def test_wizard_accepts_default_answers_esp8266(tmpdir, monkeypatch, wizard_answers): +def test_wizard_accepts_default_answers_esp8266( + tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): """ The wizard should accept the given default answers for esp8266 """ @@ -396,7 +409,9 @@ def test_wizard_accepts_default_answers_esp8266(tmpdir, monkeypatch, wizard_answ assert retval == 0 -def test_wizard_accepts_default_answers_esp32(tmpdir, monkeypatch, wizard_answers): +def test_wizard_accepts_default_answers_esp32( + tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): """ The wizard should accept the given default answers for esp32 """ @@ -418,7 +433,9 @@ def test_wizard_accepts_default_answers_esp32(tmpdir, monkeypatch, wizard_answer assert retval == 0 -def test_wizard_offers_better_node_name(tmpdir, monkeypatch, wizard_answers): +def test_wizard_offers_better_node_name( + tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): """ When the node name does not conform, a better alternative is offered * Removes special chars @@ -449,7 +466,9 @@ def test_wizard_offers_better_node_name(tmpdir, monkeypatch, wizard_answers): assert wz.default_input.call_args.args[1] == expected_name -def test_wizard_requires_correct_platform(tmpdir, monkeypatch, wizard_answers): +def test_wizard_requires_correct_platform( + tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): """ When the platform is not either esp32 or esp8266, the wizard should reject it """ @@ -471,7 +490,9 @@ def test_wizard_requires_correct_platform(tmpdir, monkeypatch, wizard_answers): assert retval == 0 -def test_wizard_requires_correct_board(tmpdir, monkeypatch, wizard_answers): +def test_wizard_requires_correct_board( + tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): """ When the board is not a valid esp8266 board, the wizard should reject it """ @@ -493,7 +514,9 @@ def test_wizard_requires_correct_board(tmpdir, monkeypatch, wizard_answers): assert retval == 0 -def test_wizard_requires_valid_ssid(tmpdir, monkeypatch, wizard_answers): +def test_wizard_requires_valid_ssid( + tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): """ When the board is not a valid esp8266 board, the wizard should reject it """ @@ -515,7 +538,9 @@ def test_wizard_requires_valid_ssid(tmpdir, monkeypatch, wizard_answers): assert retval == 0 -def test_wizard_write_protects_existing_config(tmpdir, default_config, monkeypatch): +def test_wizard_write_protects_existing_config( + tmpdir, default_config: dict[str, Any], monkeypatch: MonkeyPatch +): """ The wizard_write function should not overwrite existing config files and return False """ From 27fa18dcec29db3d0cd49df5dd49fcbf53095c0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 15:09:35 -0500 Subject: [PATCH 1191/1626] [core] Fix clean build files to properly clear PlatformIO cache (#10754) --- esphome/writer.py | 13 ++++ tests/unit_tests/test_writer.py | 103 +++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index b8fe44abdd..2a9c6a770d 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -315,6 +315,19 @@ def clean_build(): _LOGGER.info("Deleting %s", dependencies_lock) os.remove(dependencies_lock) + # Clean PlatformIO cache to resolve CMake compiler detection issues + # This helps when toolchain paths change or get corrupted + try: + from platformio.project.helpers import get_project_cache_dir + except ImportError: + # PlatformIO is not available, skip cache cleaning + pass + else: + cache_dir = get_project_cache_dir() + if cache_dir and cache_dir.strip() and os.path.isdir(cache_dir): + _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) + shutil.rmtree(cache_dir) + GITIGNORE_CONTENT = """# Gitignore settings for ESPHome # This is an example and may include too much for your use-case. diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index f1f86a322e..970e0fada6 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -349,6 +349,14 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") + # Create PlatformIO cache directory + platformio_cache_dir = tmp_path / ".platformio" / ".cache" + platformio_cache_dir.mkdir(parents=True) + (platformio_cache_dir / "downloads").mkdir() + (platformio_cache_dir / "http").mkdir() + (platformio_cache_dir / "tmp").mkdir() + (platformio_cache_dir / "downloads" / "package.tar.gz").write_text("package") + # Setup mocks mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) @@ -358,21 +366,30 @@ def test_clean_build( assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() + assert platformio_cache_dir.exists() - # Call the function - with caplog.at_level("INFO"): - clean_build() + # Mock PlatformIO's get_project_cache_dir + with patch( + "platformio.project.helpers.get_project_cache_dir" + ) as mock_get_cache_dir: + mock_get_cache_dir.return_value = str(platformio_cache_dir) + + # Call the function + with caplog.at_level("INFO"): + clean_build() # Verify all were removed assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() + assert not platformio_cache_dir.exists() # Verify logging assert "Deleting" in caplog.text assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text + assert "PlatformIO cache" in caplog.text @patch("esphome.writer.CORE") @@ -446,6 +463,86 @@ def test_clean_build_nothing_exists( assert not dependencies_lock.exists() +@patch("esphome.writer.CORE") +def test_clean_build_platformio_not_available( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_build when PlatformIO is not available.""" + # Create directory structure and files + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + + piolibdeps_dir = tmp_path / ".piolibdeps" + piolibdeps_dir.mkdir() + + dependencies_lock = tmp_path / "dependencies.lock" + dependencies_lock.write_text("lock file") + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) + mock_core.relative_build_path.return_value = str(dependencies_lock) + + # Verify all exist before + assert pioenvs_dir.exists() + assert piolibdeps_dir.exists() + assert dependencies_lock.exists() + + # Mock import error for platformio + with ( + patch.dict("sys.modules", {"platformio.project.helpers": None}), + caplog.at_level("INFO"), + ): + # Call the function + clean_build() + + # Verify standard paths were removed but no cache cleaning attempted + assert not pioenvs_dir.exists() + assert not piolibdeps_dir.exists() + assert not dependencies_lock.exists() + + # Verify no cache logging + assert "PlatformIO cache" not in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_build_empty_cache_dir( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_build when get_project_cache_dir returns empty/whitespace.""" + # Create directory structure and files + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + mock_core.relative_piolibdeps_path.return_value = str(tmp_path / ".piolibdeps") + mock_core.relative_build_path.return_value = str(tmp_path / "dependencies.lock") + + # Verify pioenvs exists before + assert pioenvs_dir.exists() + + # Mock PlatformIO's get_project_cache_dir to return whitespace + with patch( + "platformio.project.helpers.get_project_cache_dir" + ) as mock_get_cache_dir: + mock_get_cache_dir.return_value = " " # Whitespace only + + # Call the function + with caplog.at_level("INFO"): + clean_build() + + # Verify pioenvs was removed + assert not pioenvs_dir.exists() + + # Verify no cache cleaning was attempted due to empty string + assert "PlatformIO cache" not in caplog.text + + @patch("esphome.writer.CORE") def test_write_gitignore_creates_new_file( mock_core: MagicMock, From 61ab682099645a4534bb2f27a4aec2c6e2fef5fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Sep 2025 00:04:51 -0500 Subject: [PATCH 1192/1626] Add additional coverage for util and writer (#10683) --- tests/unit_tests/test_util.py | 167 +++++++++++++ tests/unit_tests/test_writer.py | 416 +++++++++++++++++++++++++++++++- 2 files changed, 582 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 74d6a74709..34f40a651f 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -141,3 +141,170 @@ def test_list_yaml_files_mixed_extensions(tmp_path: Path) -> None: str(yaml_file), str(yml_file), } + + +def test_list_yaml_files_does_not_recurse_into_subdirectories(tmp_path: Path) -> None: + """Test that list_yaml_files only finds files in specified directory, not subdirectories.""" + # Create directory structure with YAML files at different depths + root = tmp_path / "configs" + root.mkdir() + + # Create YAML files in the root directory + (root / "config1.yaml").write_text("test: 1") + (root / "config2.yml").write_text("test: 2") + (root / "device.yaml").write_text("test: device") + + # Create subdirectory with YAML files (should NOT be found) + subdir = root / "subdir" + subdir.mkdir() + (subdir / "nested1.yaml").write_text("test: nested1") + (subdir / "nested2.yml").write_text("test: nested2") + + # Create deeper subdirectory (should NOT be found) + deep_subdir = subdir / "deeper" + deep_subdir.mkdir() + (deep_subdir / "very_nested.yaml").write_text("test: very_nested") + + # Test listing files from the root directory + result = util.list_yaml_files([str(root)]) + + # Should only find the 3 files in root, not the 3 in subdirectories + assert len(result) == 3 + + # Check that only root-level files are found + assert str(root / "config1.yaml") in result + assert str(root / "config2.yml") in result + assert str(root / "device.yaml") in result + + # Ensure nested files are NOT found + for r in result: + assert "subdir" not in r + assert "deeper" not in r + assert "nested1.yaml" not in r + assert "nested2.yml" not in r + assert "very_nested.yaml" not in r + + +def test_list_yaml_files_excludes_secrets(tmp_path: Path) -> None: + """Test that secrets.yaml and secrets.yml are excluded.""" + root = tmp_path / "configs" + root.mkdir() + + # Create various YAML files including secrets + (root / "config.yaml").write_text("test: config") + (root / "secrets.yaml").write_text("wifi_password: secret123") + (root / "secrets.yml").write_text("api_key: secret456") + (root / "device.yaml").write_text("test: device") + + result = util.list_yaml_files([str(root)]) + + # Should find 2 files (config.yaml and device.yaml), not secrets + assert len(result) == 2 + assert str(root / "config.yaml") in result + assert str(root / "device.yaml") in result + assert str(root / "secrets.yaml") not in result + assert str(root / "secrets.yml") not in result + + +def test_list_yaml_files_excludes_hidden_files(tmp_path: Path) -> None: + """Test that hidden files (starting with .) are excluded.""" + root = tmp_path / "configs" + root.mkdir() + + # Create regular and hidden YAML files + (root / "config.yaml").write_text("test: config") + (root / ".hidden.yaml").write_text("test: hidden") + (root / ".backup.yml").write_text("test: backup") + (root / "device.yaml").write_text("test: device") + + result = util.list_yaml_files([str(root)]) + + # Should find only non-hidden files + assert len(result) == 2 + assert str(root / "config.yaml") in result + assert str(root / "device.yaml") in result + assert str(root / ".hidden.yaml") not in result + assert str(root / ".backup.yml") not in result + + +def test_filter_yaml_files_basic() -> None: + """Test filter_yaml_files function.""" + files = [ + "/path/to/config.yaml", + "/path/to/device.yml", + "/path/to/readme.txt", + "/path/to/script.py", + "/path/to/data.json", + "/path/to/another.yaml", + ] + + result = util.filter_yaml_files(files) + + assert len(result) == 3 + assert "/path/to/config.yaml" in result + assert "/path/to/device.yml" in result + assert "/path/to/another.yaml" in result + assert "/path/to/readme.txt" not in result + assert "/path/to/script.py" not in result + assert "/path/to/data.json" not in result + + +def test_filter_yaml_files_excludes_secrets() -> None: + """Test that filter_yaml_files excludes secrets files.""" + files = [ + "/path/to/config.yaml", + "/path/to/secrets.yaml", + "/path/to/secrets.yml", + "/path/to/device.yaml", + "/some/dir/secrets.yaml", + ] + + result = util.filter_yaml_files(files) + + assert len(result) == 2 + assert "/path/to/config.yaml" in result + assert "/path/to/device.yaml" in result + assert "/path/to/secrets.yaml" not in result + assert "/path/to/secrets.yml" not in result + assert "/some/dir/secrets.yaml" not in result + + +def test_filter_yaml_files_excludes_hidden() -> None: + """Test that filter_yaml_files excludes hidden files.""" + files = [ + "/path/to/config.yaml", + "/path/to/.hidden.yaml", + "/path/to/.backup.yml", + "/path/to/device.yaml", + "/some/dir/.config.yaml", + ] + + result = util.filter_yaml_files(files) + + assert len(result) == 2 + assert "/path/to/config.yaml" in result + assert "/path/to/device.yaml" in result + assert "/path/to/.hidden.yaml" not in result + assert "/path/to/.backup.yml" not in result + assert "/some/dir/.config.yaml" not in result + + +def test_filter_yaml_files_case_sensitive() -> None: + """Test that filter_yaml_files is case-sensitive for extensions.""" + files = [ + "/path/to/config.yaml", + "/path/to/config.YAML", + "/path/to/config.YML", + "/path/to/config.Yaml", + "/path/to/config.yml", + ] + + result = util.filter_yaml_files(files) + + # Should only match lowercase .yaml and .yml + assert len(result) == 2 + assert "/path/to/config.yaml" in result + assert "/path/to/config.yml" in result + assert "/path/to/config.YAML" not in result + assert "/path/to/config.YML" not in result + assert "/path/to/config.Yaml" not in result diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index f47947ff37..f1f86a322e 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1,13 +1,34 @@ """Test writer module functionality.""" from collections.abc import Callable +from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch import pytest +from esphome.core import EsphomeError from esphome.storage_json import StorageJSON -from esphome.writer import storage_should_clean, update_storage_json +from esphome.writer import ( + CPP_AUTO_GENERATE_BEGIN, + CPP_AUTO_GENERATE_END, + CPP_INCLUDE_BEGIN, + CPP_INCLUDE_END, + GITIGNORE_CONTENT, + clean_build, + clean_cmake_cache, + storage_should_clean, + update_storage_json, + write_cpp, + write_gitignore, +) + + +@pytest.fixture +def mock_copy_src_tree(): + """Mock copy_src_tree to avoid side effects during tests.""" + with patch("esphome.writer.copy_src_tree"): + yield @pytest.fixture @@ -218,3 +239,396 @@ def test_update_storage_json_logging_components_removed( # Verify save was called new_storage.save.assert_called_once_with("/test/path") + + +@patch("esphome.writer.CORE") +def test_clean_cmake_cache( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_cmake_cache removes CMakeCache.txt file.""" + # Create directory structure + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + device_dir = pioenvs_dir / "test_device" + device_dir.mkdir() + cmake_cache_file = device_dir / "CMakeCache.txt" + cmake_cache_file.write_text("# CMake cache file") + + # Setup mocks + mock_core.relative_pioenvs_path.side_effect = [ + str(pioenvs_dir), # First call for directory check + str(cmake_cache_file), # Second call for file path + ] + mock_core.name = "test_device" + + # Verify file exists before + assert cmake_cache_file.exists() + + # Call the function + with caplog.at_level("INFO"): + clean_cmake_cache() + + # Verify file was removed + assert not cmake_cache_file.exists() + + # Verify logging + assert "Deleting" in caplog.text + assert "CMakeCache.txt" in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_cmake_cache_no_pioenvs_dir( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_cmake_cache when pioenvs directory doesn't exist.""" + # Setup non-existent directory path + pioenvs_dir = tmp_path / ".pioenvs" + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + + # Verify directory doesn't exist + assert not pioenvs_dir.exists() + + # Call the function - should not crash + clean_cmake_cache() + + # Verify directory still doesn't exist + assert not pioenvs_dir.exists() + + +@patch("esphome.writer.CORE") +def test_clean_cmake_cache_no_cmake_file( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_cmake_cache when CMakeCache.txt doesn't exist.""" + # Create directory structure without CMakeCache.txt + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + device_dir = pioenvs_dir / "test_device" + device_dir.mkdir() + cmake_cache_file = device_dir / "CMakeCache.txt" + + # Setup mocks + mock_core.relative_pioenvs_path.side_effect = [ + str(pioenvs_dir), # First call for directory check + str(cmake_cache_file), # Second call for file path + ] + mock_core.name = "test_device" + + # Verify file doesn't exist + assert not cmake_cache_file.exists() + + # Call the function - should not crash + clean_cmake_cache() + + # Verify file still doesn't exist + assert not cmake_cache_file.exists() + + +@patch("esphome.writer.CORE") +def test_clean_build( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_build removes all build artifacts.""" + # Create directory structure and files + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + (pioenvs_dir / "test_file.o").write_text("object file") + + piolibdeps_dir = tmp_path / ".piolibdeps" + piolibdeps_dir.mkdir() + (piolibdeps_dir / "library").mkdir() + + dependencies_lock = tmp_path / "dependencies.lock" + dependencies_lock.write_text("lock file") + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) + mock_core.relative_build_path.return_value = str(dependencies_lock) + + # Verify all exist before + assert pioenvs_dir.exists() + assert piolibdeps_dir.exists() + assert dependencies_lock.exists() + + # Call the function + with caplog.at_level("INFO"): + clean_build() + + # Verify all were removed + assert not pioenvs_dir.exists() + assert not piolibdeps_dir.exists() + assert not dependencies_lock.exists() + + # Verify logging + assert "Deleting" in caplog.text + assert ".pioenvs" in caplog.text + assert ".piolibdeps" in caplog.text + assert "dependencies.lock" in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_build_partial_exists( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_build when only some paths exist.""" + # Create only pioenvs directory + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + (pioenvs_dir / "test_file.o").write_text("object file") + + piolibdeps_dir = tmp_path / ".piolibdeps" + dependencies_lock = tmp_path / "dependencies.lock" + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) + mock_core.relative_build_path.return_value = str(dependencies_lock) + + # Verify only pioenvs exists + assert pioenvs_dir.exists() + assert not piolibdeps_dir.exists() + assert not dependencies_lock.exists() + + # Call the function + with caplog.at_level("INFO"): + clean_build() + + # Verify only existing path was removed + assert not pioenvs_dir.exists() + assert not piolibdeps_dir.exists() + assert not dependencies_lock.exists() + + # Verify logging - only pioenvs should be logged + assert "Deleting" in caplog.text + assert ".pioenvs" in caplog.text + assert ".piolibdeps" not in caplog.text + assert "dependencies.lock" not in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_build_nothing_exists( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_build when no build artifacts exist.""" + # Setup paths that don't exist + pioenvs_dir = tmp_path / ".pioenvs" + piolibdeps_dir = tmp_path / ".piolibdeps" + dependencies_lock = tmp_path / "dependencies.lock" + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) + mock_core.relative_build_path.return_value = str(dependencies_lock) + + # Verify nothing exists + assert not pioenvs_dir.exists() + assert not piolibdeps_dir.exists() + assert not dependencies_lock.exists() + + # Call the function - should not crash + clean_build() + + # Verify nothing was created + assert not pioenvs_dir.exists() + assert not piolibdeps_dir.exists() + assert not dependencies_lock.exists() + + +@patch("esphome.writer.CORE") +def test_write_gitignore_creates_new_file( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test write_gitignore creates a new .gitignore file when it doesn't exist.""" + gitignore_path = tmp_path / ".gitignore" + + # Setup mocks + mock_core.relative_config_path.return_value = str(gitignore_path) + + # Verify file doesn't exist + assert not gitignore_path.exists() + + # Call the function + write_gitignore() + + # Verify file was created with correct content + assert gitignore_path.exists() + assert gitignore_path.read_text() == GITIGNORE_CONTENT + + +@patch("esphome.writer.CORE") +def test_write_gitignore_skips_existing_file( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test write_gitignore doesn't overwrite existing .gitignore file.""" + gitignore_path = tmp_path / ".gitignore" + existing_content = "# Custom gitignore\n/custom_dir/\n" + gitignore_path.write_text(existing_content) + + # Setup mocks + mock_core.relative_config_path.return_value = str(gitignore_path) + + # Verify file exists with custom content + assert gitignore_path.exists() + assert gitignore_path.read_text() == existing_content + + # Call the function + write_gitignore() + + # Verify file was not modified + assert gitignore_path.exists() + assert gitignore_path.read_text() == existing_content + + +@patch("esphome.writer.write_file_if_changed") # Mock to capture output +@patch("esphome.writer.copy_src_tree") # Keep this mock as it's complex +@patch("esphome.writer.CORE") +def test_write_cpp_with_existing_file( + mock_core: MagicMock, + mock_copy_src_tree: MagicMock, + mock_write_file: MagicMock, + tmp_path: Path, +) -> None: + """Test write_cpp when main.cpp already exists.""" + # Create a real file with markers + main_cpp = tmp_path / "main.cpp" + existing_content = f"""#include "esphome.h" +{CPP_INCLUDE_BEGIN} +// Old includes +{CPP_INCLUDE_END} +void setup() {{ +{CPP_AUTO_GENERATE_BEGIN} +// Old code +{CPP_AUTO_GENERATE_END} +}} +void loop() {{}}""" + main_cpp.write_text(existing_content) + + # Setup mocks + mock_core.relative_src_path.return_value = str(main_cpp) + mock_core.cpp_global_section = "// Global section" + + # Call the function + test_code = " // New generated code" + write_cpp(test_code) + + # Verify copy_src_tree was called + mock_copy_src_tree.assert_called_once() + + # Get the content that would be written + mock_write_file.assert_called_once() + written_path, written_content = mock_write_file.call_args[0] + + # Check that markers are preserved and content is updated + assert CPP_INCLUDE_BEGIN in written_content + assert CPP_INCLUDE_END in written_content + assert CPP_AUTO_GENERATE_BEGIN in written_content + assert CPP_AUTO_GENERATE_END in written_content + assert test_code in written_content + assert "// Global section" in written_content + + +@patch("esphome.writer.write_file_if_changed") # Mock to capture output +@patch("esphome.writer.copy_src_tree") # Keep this mock as it's complex +@patch("esphome.writer.CORE") +def test_write_cpp_creates_new_file( + mock_core: MagicMock, + mock_copy_src_tree: MagicMock, + mock_write_file: MagicMock, + tmp_path: Path, +) -> None: + """Test write_cpp when main.cpp doesn't exist.""" + # Setup path for new file + main_cpp = tmp_path / "main.cpp" + + # Setup mocks + mock_core.relative_src_path.return_value = str(main_cpp) + mock_core.cpp_global_section = "// Global section" + + # Verify file doesn't exist + assert not main_cpp.exists() + + # Call the function + test_code = " // Generated code" + write_cpp(test_code) + + # Verify copy_src_tree was called + mock_copy_src_tree.assert_called_once() + + # Get the content that would be written + mock_write_file.assert_called_once() + written_path, written_content = mock_write_file.call_args[0] + assert written_path == str(main_cpp) + + # Check that all necessary parts are in the new file + assert '#include "esphome.h"' in written_content + assert CPP_INCLUDE_BEGIN in written_content + assert CPP_INCLUDE_END in written_content + assert CPP_AUTO_GENERATE_BEGIN in written_content + assert CPP_AUTO_GENERATE_END in written_content + assert test_code in written_content + assert "void setup()" in written_content + assert "void loop()" in written_content + assert "App.setup();" in written_content + assert "App.loop();" in written_content + + +@pytest.mark.usefixtures("mock_copy_src_tree") +@patch("esphome.writer.CORE") +def test_write_cpp_with_missing_end_marker( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test write_cpp raises error when end marker is missing.""" + # Create a file with begin marker but no end marker + main_cpp = tmp_path / "main.cpp" + existing_content = f"""#include "esphome.h" +{CPP_AUTO_GENERATE_BEGIN} +// Code without end marker""" + main_cpp.write_text(existing_content) + + # Setup mocks + mock_core.relative_src_path.return_value = str(main_cpp) + + # Call should raise an error + with pytest.raises(EsphomeError, match="Could not find auto generated code end"): + write_cpp("// New code") + + +@pytest.mark.usefixtures("mock_copy_src_tree") +@patch("esphome.writer.CORE") +def test_write_cpp_with_duplicate_markers( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test write_cpp raises error when duplicate markers exist.""" + # Create a file with duplicate begin markers + main_cpp = tmp_path / "main.cpp" + existing_content = f"""#include "esphome.h" +{CPP_AUTO_GENERATE_BEGIN} +// First section +{CPP_AUTO_GENERATE_END} +{CPP_AUTO_GENERATE_BEGIN} +// Duplicate section +{CPP_AUTO_GENERATE_END}""" + main_cpp.write_text(existing_content) + + # Setup mocks + mock_core.relative_src_path.return_value = str(main_cpp) + + # Call should raise an error + with pytest.raises(EsphomeError, match="Found multiple auto generate code begins"): + write_cpp("// New code") From adc3d3127db9879a8d223da335de0db1fb59390a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 14:56:54 -0500 Subject: [PATCH 1193/1626] [wizard] Fix KeyError when running wizard with empty OTA password (#10753) --- esphome/wizard.py | 50 ++++++++++++++++++---- tests/unit_tests/test_wizard.py | 73 ++++++++++++++++++++++----------- 2 files changed, 91 insertions(+), 32 deletions(-) diff --git a/esphome/wizard.py b/esphome/wizard.py index cb599df59a..3edf519816 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -1,6 +1,7 @@ import os import random import string +from typing import Literal, NotRequired, TypedDict, Unpack import unicodedata import voluptuous as vol @@ -103,11 +104,25 @@ HARDWARE_BASE_CONFIGS = { } -def sanitize_double_quotes(value): +def sanitize_double_quotes(value: str) -> str: return value.replace("\\", "\\\\").replace('"', '\\"') -def wizard_file(**kwargs): +class WizardFileKwargs(TypedDict): + """Keyword arguments for wizard_file function.""" + + name: str + platform: Literal["ESP8266", "ESP32", "RP2040", "BK72XX", "LN882X", "RTL87XX"] + board: str + ssid: NotRequired[str] + psk: NotRequired[str] + password: NotRequired[str] + ota_password: NotRequired[str] + api_encryption_key: NotRequired[str] + friendly_name: NotRequired[str] + + +def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str: letters = string.ascii_letters + string.digits ap_name_base = kwargs["name"].replace("_", " ").title() ap_name = f"{ap_name_base} Fallback Hotspot" @@ -180,7 +195,25 @@ captive_portal: return config -def wizard_write(path, **kwargs): +class WizardWriteKwargs(TypedDict): + """Keyword arguments for wizard_write function.""" + + name: str + type: Literal["basic", "empty", "upload"] + # Required for "basic" type + board: NotRequired[str] + platform: NotRequired[str] + ssid: NotRequired[str] + psk: NotRequired[str] + password: NotRequired[str] + ota_password: NotRequired[str] + api_encryption_key: NotRequired[str] + friendly_name: NotRequired[str] + # Required for "upload" type + file_text: NotRequired[str] + + +def wizard_write(path: str, **kwargs: Unpack[WizardWriteKwargs]) -> bool: from esphome.components.bk72xx import boards as bk72xx_boards from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards @@ -237,14 +270,14 @@ def wizard_write(path, **kwargs): if get_bool_env(ENV_QUICKWIZARD): - def sleep(time): + def sleep(time: float) -> None: pass else: from time import sleep -def safe_print_step(step, big): +def safe_print_step(step: int, big: str) -> None: safe_print() safe_print() safe_print(f"============= STEP {step} =============") @@ -253,14 +286,14 @@ def safe_print_step(step, big): sleep(0.25) -def default_input(text, default): +def default_input(text: str, default: str) -> str: safe_print() safe_print(f"Press ENTER for default ({default})") return safe_input(text.format(default)) or default # From https://stackoverflow.com/a/518232/8924614 -def strip_accents(value): +def strip_accents(value: str) -> str: return "".join( c for c in unicodedata.normalize("NFD", str(value)) @@ -268,7 +301,7 @@ def strip_accents(value): ) -def wizard(path): +def wizard(path: str) -> int: from esphome.components.bk72xx import boards as bk72xx_boards from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards @@ -509,6 +542,7 @@ def wizard(path): ssid=ssid, psk=psk, password=password, + type="basic", ): return 1 diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index fea2fb5558..7af4db813a 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -1,9 +1,12 @@ """Tests for the wizard.py file.""" import os +from pathlib import Path +from typing import Any from unittest.mock import MagicMock import pytest +from pytest import MonkeyPatch from esphome.components.bk72xx.boards import BK72XX_BOARD_PINS from esphome.components.esp32.boards import ESP32_BOARD_PINS @@ -15,7 +18,7 @@ import esphome.wizard as wz @pytest.fixture -def default_config(): +def default_config() -> dict[str, Any]: return { "type": "basic", "name": "test-name", @@ -28,7 +31,7 @@ def default_config(): @pytest.fixture -def wizard_answers(): +def wizard_answers() -> list[str]: return [ "test-node", # Name of the node "ESP8266", # platform @@ -53,7 +56,9 @@ def test_sanitize_quotes_replaces_with_escaped_char(): assert output_str == '\\"key\\": \\"value\\"' -def test_config_file_fallback_ap_includes_descriptive_name(default_config): +def test_config_file_fallback_ap_includes_descriptive_name( + default_config: dict[str, Any], +): """ The fallback AP should include the node and a descriptive name """ @@ -67,7 +72,9 @@ def test_config_file_fallback_ap_includes_descriptive_name(default_config): assert 'ssid: "Test Node Fallback Hotspot"' in config -def test_config_file_fallback_ap_name_less_than_32_chars(default_config): +def test_config_file_fallback_ap_name_less_than_32_chars( + default_config: dict[str, Any], +): """ The fallback AP name must be less than 32 chars. Since it is composed of the node name and "Fallback Hotspot" this can be too long and needs truncating @@ -82,7 +89,7 @@ def test_config_file_fallback_ap_name_less_than_32_chars(default_config): assert 'ssid: "A Very Long Name For This Node"' in config -def test_config_file_should_include_ota(default_config): +def test_config_file_should_include_ota(default_config: dict[str, Any]): """ The Over-The-Air update should be enabled by default """ @@ -95,7 +102,9 @@ def test_config_file_should_include_ota(default_config): assert "ota:" in config -def test_config_file_should_include_ota_when_password_set(default_config): +def test_config_file_should_include_ota_when_password_set( + default_config: dict[str, Any], +): """ The Over-The-Air update should be enabled when a password is set """ @@ -109,7 +118,9 @@ def test_config_file_should_include_ota_when_password_set(default_config): assert "ota:" in config -def test_wizard_write_sets_platform(default_config, tmp_path, monkeypatch): +def test_wizard_write_sets_platform( + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch +): """ If the platform is not explicitly set, use "ESP8266" if the board is one of the ESP8266 boards """ @@ -126,7 +137,7 @@ def test_wizard_write_sets_platform(default_config, tmp_path, monkeypatch): assert "esp8266:" in generated_config -def test_wizard_empty_config(tmp_path, monkeypatch): +def test_wizard_empty_config(tmp_path: Path, monkeypatch: MonkeyPatch): """ The wizard should be able to create an empty configuration """ @@ -146,7 +157,7 @@ def test_wizard_empty_config(tmp_path, monkeypatch): assert generated_config == "" -def test_wizard_upload_config(tmp_path, monkeypatch): +def test_wizard_upload_config(tmp_path: Path, monkeypatch: MonkeyPatch): """ The wizard should be able to import an base64 encoded configuration """ @@ -168,7 +179,7 @@ def test_wizard_upload_config(tmp_path, monkeypatch): def test_wizard_write_defaults_platform_from_board_esp8266( - default_config, tmp_path, monkeypatch + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch ): """ If the platform is not explicitly set, use "ESP8266" if the board is one of the ESP8266 boards @@ -189,7 +200,7 @@ def test_wizard_write_defaults_platform_from_board_esp8266( def test_wizard_write_defaults_platform_from_board_esp32( - default_config, tmp_path, monkeypatch + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch ): """ If the platform is not explicitly set, use "ESP32" if the board is one of the ESP32 boards @@ -210,7 +221,7 @@ def test_wizard_write_defaults_platform_from_board_esp32( def test_wizard_write_defaults_platform_from_board_bk72xx( - default_config, tmp_path, monkeypatch + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch ): """ If the platform is not explicitly set, use "BK72XX" if the board is one of BK72XX boards @@ -231,7 +242,7 @@ def test_wizard_write_defaults_platform_from_board_bk72xx( def test_wizard_write_defaults_platform_from_board_ln882x( - default_config, tmp_path, monkeypatch + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch ): """ If the platform is not explicitly set, use "LN882X" if the board is one of LN882X boards @@ -252,7 +263,7 @@ def test_wizard_write_defaults_platform_from_board_ln882x( def test_wizard_write_defaults_platform_from_board_rtl87xx( - default_config, tmp_path, monkeypatch + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch ): """ If the platform is not explicitly set, use "RTL87XX" if the board is one of RTL87XX boards @@ -272,7 +283,7 @@ def test_wizard_write_defaults_platform_from_board_rtl87xx( assert "rtl87xx:" in generated_config -def test_safe_print_step_prints_step_number_and_description(monkeypatch): +def test_safe_print_step_prints_step_number_and_description(monkeypatch: MonkeyPatch): """ The safe_print_step function prints the step number and the passed description """ @@ -296,7 +307,7 @@ def test_safe_print_step_prints_step_number_and_description(monkeypatch): assert any(f"STEP {step_num}" in arg for arg in all_args) -def test_default_input_uses_default_if_no_input_supplied(monkeypatch): +def test_default_input_uses_default_if_no_input_supplied(monkeypatch: MonkeyPatch): """ The default_input() function should return the supplied default value if the user doesn't enter anything """ @@ -312,7 +323,7 @@ def test_default_input_uses_default_if_no_input_supplied(monkeypatch): assert retval == default_string -def test_default_input_uses_user_supplied_value(monkeypatch): +def test_default_input_uses_user_supplied_value(monkeypatch: MonkeyPatch): """ The default_input() function should return the value that the user enters """ @@ -376,7 +387,9 @@ def test_wizard_rejects_existing_files(tmpdir): assert retval == 2 -def test_wizard_accepts_default_answers_esp8266(tmpdir, monkeypatch, wizard_answers): +def test_wizard_accepts_default_answers_esp8266( + tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): """ The wizard should accept the given default answers for esp8266 """ @@ -396,7 +409,9 @@ def test_wizard_accepts_default_answers_esp8266(tmpdir, monkeypatch, wizard_answ assert retval == 0 -def test_wizard_accepts_default_answers_esp32(tmpdir, monkeypatch, wizard_answers): +def test_wizard_accepts_default_answers_esp32( + tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): """ The wizard should accept the given default answers for esp32 """ @@ -418,7 +433,9 @@ def test_wizard_accepts_default_answers_esp32(tmpdir, monkeypatch, wizard_answer assert retval == 0 -def test_wizard_offers_better_node_name(tmpdir, monkeypatch, wizard_answers): +def test_wizard_offers_better_node_name( + tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): """ When the node name does not conform, a better alternative is offered * Removes special chars @@ -449,7 +466,9 @@ def test_wizard_offers_better_node_name(tmpdir, monkeypatch, wizard_answers): assert wz.default_input.call_args.args[1] == expected_name -def test_wizard_requires_correct_platform(tmpdir, monkeypatch, wizard_answers): +def test_wizard_requires_correct_platform( + tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): """ When the platform is not either esp32 or esp8266, the wizard should reject it """ @@ -471,7 +490,9 @@ def test_wizard_requires_correct_platform(tmpdir, monkeypatch, wizard_answers): assert retval == 0 -def test_wizard_requires_correct_board(tmpdir, monkeypatch, wizard_answers): +def test_wizard_requires_correct_board( + tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): """ When the board is not a valid esp8266 board, the wizard should reject it """ @@ -493,7 +514,9 @@ def test_wizard_requires_correct_board(tmpdir, monkeypatch, wizard_answers): assert retval == 0 -def test_wizard_requires_valid_ssid(tmpdir, monkeypatch, wizard_answers): +def test_wizard_requires_valid_ssid( + tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): """ When the board is not a valid esp8266 board, the wizard should reject it """ @@ -515,7 +538,9 @@ def test_wizard_requires_valid_ssid(tmpdir, monkeypatch, wizard_answers): assert retval == 0 -def test_wizard_write_protects_existing_config(tmpdir, default_config, monkeypatch): +def test_wizard_write_protects_existing_config( + tmpdir, default_config: dict[str, Any], monkeypatch: MonkeyPatch +): """ The wizard_write function should not overwrite existing config files and return False """ From 12cadf0a04fda110f6ab09e337e485ef3bbceb5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 15:09:35 -0500 Subject: [PATCH 1194/1626] [core] Fix clean build files to properly clear PlatformIO cache (#10754) --- esphome/writer.py | 13 ++++ tests/unit_tests/test_writer.py | 103 +++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index b8fe44abdd..2a9c6a770d 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -315,6 +315,19 @@ def clean_build(): _LOGGER.info("Deleting %s", dependencies_lock) os.remove(dependencies_lock) + # Clean PlatformIO cache to resolve CMake compiler detection issues + # This helps when toolchain paths change or get corrupted + try: + from platformio.project.helpers import get_project_cache_dir + except ImportError: + # PlatformIO is not available, skip cache cleaning + pass + else: + cache_dir = get_project_cache_dir() + if cache_dir and cache_dir.strip() and os.path.isdir(cache_dir): + _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) + shutil.rmtree(cache_dir) + GITIGNORE_CONTENT = """# Gitignore settings for ESPHome # This is an example and may include too much for your use-case. diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index f1f86a322e..970e0fada6 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -349,6 +349,14 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") + # Create PlatformIO cache directory + platformio_cache_dir = tmp_path / ".platformio" / ".cache" + platformio_cache_dir.mkdir(parents=True) + (platformio_cache_dir / "downloads").mkdir() + (platformio_cache_dir / "http").mkdir() + (platformio_cache_dir / "tmp").mkdir() + (platformio_cache_dir / "downloads" / "package.tar.gz").write_text("package") + # Setup mocks mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) @@ -358,21 +366,30 @@ def test_clean_build( assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() + assert platformio_cache_dir.exists() - # Call the function - with caplog.at_level("INFO"): - clean_build() + # Mock PlatformIO's get_project_cache_dir + with patch( + "platformio.project.helpers.get_project_cache_dir" + ) as mock_get_cache_dir: + mock_get_cache_dir.return_value = str(platformio_cache_dir) + + # Call the function + with caplog.at_level("INFO"): + clean_build() # Verify all were removed assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() + assert not platformio_cache_dir.exists() # Verify logging assert "Deleting" in caplog.text assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text + assert "PlatformIO cache" in caplog.text @patch("esphome.writer.CORE") @@ -446,6 +463,86 @@ def test_clean_build_nothing_exists( assert not dependencies_lock.exists() +@patch("esphome.writer.CORE") +def test_clean_build_platformio_not_available( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_build when PlatformIO is not available.""" + # Create directory structure and files + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + + piolibdeps_dir = tmp_path / ".piolibdeps" + piolibdeps_dir.mkdir() + + dependencies_lock = tmp_path / "dependencies.lock" + dependencies_lock.write_text("lock file") + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) + mock_core.relative_build_path.return_value = str(dependencies_lock) + + # Verify all exist before + assert pioenvs_dir.exists() + assert piolibdeps_dir.exists() + assert dependencies_lock.exists() + + # Mock import error for platformio + with ( + patch.dict("sys.modules", {"platformio.project.helpers": None}), + caplog.at_level("INFO"), + ): + # Call the function + clean_build() + + # Verify standard paths were removed but no cache cleaning attempted + assert not pioenvs_dir.exists() + assert not piolibdeps_dir.exists() + assert not dependencies_lock.exists() + + # Verify no cache logging + assert "PlatformIO cache" not in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_build_empty_cache_dir( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_build when get_project_cache_dir returns empty/whitespace.""" + # Create directory structure and files + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + mock_core.relative_piolibdeps_path.return_value = str(tmp_path / ".piolibdeps") + mock_core.relative_build_path.return_value = str(tmp_path / "dependencies.lock") + + # Verify pioenvs exists before + assert pioenvs_dir.exists() + + # Mock PlatformIO's get_project_cache_dir to return whitespace + with patch( + "platformio.project.helpers.get_project_cache_dir" + ) as mock_get_cache_dir: + mock_get_cache_dir.return_value = " " # Whitespace only + + # Call the function + with caplog.at_level("INFO"): + clean_build() + + # Verify pioenvs was removed + assert not pioenvs_dir.exists() + + # Verify no cache cleaning was attempted due to empty string + assert "PlatformIO cache" not in caplog.text + + @patch("esphome.writer.CORE") def test_write_gitignore_creates_new_file( mock_core: MagicMock, From e542816f7d65de8072bf3d6dc26a3a583c132821 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 17 Sep 2025 09:22:54 +1200 Subject: [PATCH 1195/1626] Bump version to 2025.9.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 96faf3a1e0..cca1a80137 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.9.0b3 +PROJECT_NUMBER = 2025.9.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index e23c919bce..c58104f114 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.9.0b3" +__version__ = "2025.9.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 404e679e6636145de2544abdc7d9676ae34346f2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:02:12 +1200 Subject: [PATCH 1196/1626] Bump version to 2025.9.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index cca1a80137..9cbb8a3b9a 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.9.0b4 +PROJECT_NUMBER = 2025.9.0 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index c58104f114..44f0fb3dd6 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.9.0b4" +__version__ = "2025.9.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0794235159c43e6ea80afe9cb38779f8d045c502 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Sep 2025 21:27:08 -0500 Subject: [PATCH 1197/1626] [esp32_improv] Disable loop by default until provisioning needed --- esphome/components/esp32_improv/esp32_improv_component.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index d41094fda1..d47cc50a00 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -31,6 +31,9 @@ void ESP32ImprovComponent::setup() { #endif global_ble_server->on(BLEServerEvt::EmptyEvt::ON_DISCONNECT, [this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); }); + + // Start with loop disabled - will be enabled by start() when needed + this->disable_loop(); } void ESP32ImprovComponent::setup_characteristics() { From fd888eaa6863756e398bed3d164bf8e9c0bf97af Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:29:58 -0500 Subject: [PATCH 1198/1626] Bump aioesphomeapi from 40.2.1 to 41.1.0 (#10776) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 296485bdae..e29a926a7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==40.2.1 +aioesphomeapi==41.1.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 11c595bb09a775a674f08fbef16cd15a304a5ffc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Sep 2025 14:38:02 -0500 Subject: [PATCH 1199/1626] [mqtt] Fix KeyError when MQTT logging configured without explicit level (#10774) --- esphome/__main__.py | 2 +- tests/unit_tests/test_main.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 0147a82530..f54fa8e3c6 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -212,7 +212,7 @@ def has_mqtt_logging() -> bool: if CONF_TOPIC not in log_topic: return False - return log_topic[CONF_LEVEL] != "NONE" + return log_topic.get(CONF_LEVEL, None) != "NONE" def has_mqtt() -> bool: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bfebb44545..fff0b2cd48 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1226,6 +1226,18 @@ def test_has_mqtt_logging_no_log_topic() -> None: setup_core(config={}) assert has_mqtt_logging() is False + # Setup MQTT config with CONF_LOG_TOPIC but no CONF_LEVEL (regression test for #10771) + # This simulates the default configuration created by validate_config in the MQTT component + setup_core( + config={ + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + CONF_LOG_TOPIC: {CONF_TOPIC: "esphome/debug"}, + } + } + ) + assert has_mqtt_logging() is True + def test_has_mqtt() -> None: """Test has_mqtt function.""" From 28541bdb1c54627bee9f8798edb100b48df1b612 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Wed, 17 Sep 2025 21:38:18 +0200 Subject: [PATCH 1200/1626] Migrate to SPDX license specifier in pyproject.toml (#10768) --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4943c48eb0..b7b4a48d7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "esphome" -license = {text = "MIT"} +license = "MIT" description = "ESPHome is a system to configure your microcontrollers by simple yet powerful configuration files and control them remotely through Home Automation systems." readme = "README.md" authors = [ @@ -15,7 +15,6 @@ classifiers = [ "Environment :: Console", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: MIT License", "Programming Language :: C++", "Programming Language :: Python :: 3", "Topic :: Home Automation", From 429e989b695fca2811151e83afea55aacf2b25c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Sep 2025 14:40:32 -0500 Subject: [PATCH 1201/1626] [core] Make StringRef convertToJson inline to save 250+ bytes flash (#10751) --- esphome/core/string_ref.cpp | 12 ------------ esphome/core/string_ref.h | 2 +- 2 files changed, 1 insertion(+), 13 deletions(-) delete mode 100644 esphome/core/string_ref.cpp diff --git a/esphome/core/string_ref.cpp b/esphome/core/string_ref.cpp deleted file mode 100644 index ce1e33cbb7..0000000000 --- a/esphome/core/string_ref.cpp +++ /dev/null @@ -1,12 +0,0 @@ -#include "string_ref.h" - -namespace esphome { - -#ifdef USE_JSON - -// NOLINTNEXTLINE(readability-identifier-naming) -void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(src.c_str()); } - -#endif // USE_JSON - -} // namespace esphome diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index c4320107e3..efaa17181d 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -130,7 +130,7 @@ inline std::string operator+(const StringRef &lhs, const char *rhs) { #ifdef USE_JSON // NOLINTNEXTLINE(readability-identifier-naming) -void convertToJson(const StringRef &src, JsonVariant dst); +inline void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(src.c_str()); } #endif // USE_JSON } // namespace esphome From ff2df278d61c3a89f5ce316edfb005685ec46b31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Sep 2025 14:42:37 -0500 Subject: [PATCH 1202/1626] [api] Rename ConnectRequest/Response to AuthenticationRequest/Response (#10726) --- esphome/components/api/api.proto | 6 +++--- esphome/components/api/api_connection.cpp | 6 +++--- esphome/components/api/api_connection.h | 2 +- esphome/components/api/api_pb2.cpp | 6 +++--- esphome/components/api/api_pb2.h | 8 ++++---- esphome/components/api/api_pb2_dump.cpp | 7 +++++-- esphome/components/api/api_pb2_service.cpp | 12 ++++++------ esphome/components/api/api_pb2_service.h | 6 +++--- 8 files changed, 28 insertions(+), 25 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f82e762345..37e4c16bfc 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -7,7 +7,7 @@ service APIConnection { option (needs_setup_connection) = false; option (needs_authentication) = false; } - rpc connect (ConnectRequest) returns (ConnectResponse) { + rpc authenticate (AuthenticationRequest) returns (AuthenticationResponse) { option (needs_setup_connection) = false; option (needs_authentication) = false; } @@ -129,7 +129,7 @@ message HelloResponse { // Message sent at the beginning of each connection to authenticate the client // Can only be sent by the client and only at the beginning of the connection -message ConnectRequest { +message AuthenticationRequest { option (id) = 3; option (source) = SOURCE_CLIENT; option (no_delay) = true; @@ -141,7 +141,7 @@ message ConnectRequest { // Confirmation of successful connection. After this the connection is available for all traffic. // Can only be sent by the server and only at the beginning of the connection -message ConnectResponse { +message AuthenticationResponse { option (id) = 4; option (source) = SOURCE_SERVER; option (no_delay) = true; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 79f9fe9a08..dfcab4bda6 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1387,14 +1387,14 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { return this->send_message(resp, HelloResponse::MESSAGE_TYPE); } #ifdef USE_API_PASSWORD -bool APIConnection::send_connect_response(const ConnectRequest &msg) { - ConnectResponse resp; +bool APIConnection::send_authenticate_response(const AuthenticationRequest &msg) { + AuthenticationResponse resp; // bool invalid_password = 1; resp.invalid_password = !this->parent_->check_password(msg.password); if (!resp.invalid_password) { this->complete_authentication_(); } - return this->send_message(resp, ConnectResponse::MESSAGE_TYPE); + return this->send_message(resp, AuthenticationResponse::MESSAGE_TYPE); } #endif // USE_API_PASSWORD diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 70fc881a82..7d50aa4591 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -198,7 +198,7 @@ class APIConnection final : public APIServerConnection { #endif bool send_hello_response(const HelloRequest &msg) override; #ifdef USE_API_PASSWORD - bool send_connect_response(const ConnectRequest &msg) override; + bool send_authenticate_response(const AuthenticationRequest &msg) override; #endif bool send_disconnect_response(const DisconnectRequest &msg) override; bool send_ping_response(const PingRequest &msg) override; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 7b1601515e..4f2130466a 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -43,7 +43,7 @@ void HelloResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->name_ref_.size()); } #ifdef USE_API_PASSWORD -bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { +bool AuthenticationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: this->password = value.as_string(); @@ -53,8 +53,8 @@ bool ConnectRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value } return true; } -void ConnectResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->invalid_password); } -void ConnectResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->invalid_password); } +void AuthenticationResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->invalid_password); } +void AuthenticationResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->invalid_password); } #endif #ifdef USE_AREAS void AreaInfo::encode(ProtoWriteBuffer buffer) const { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 421789a770..e68fce75aa 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -361,12 +361,12 @@ class HelloResponse final : public ProtoMessage { protected: }; #ifdef USE_API_PASSWORD -class ConnectRequest final : public ProtoDecodableMessage { +class AuthenticationRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 3; static constexpr uint8_t ESTIMATED_SIZE = 9; #ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "connect_request"; } + const char *message_name() const override { return "authentication_request"; } #endif std::string password{}; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -376,12 +376,12 @@ class ConnectRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -class ConnectResponse final : public ProtoMessage { +class AuthenticationResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 4; static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "connect_response"; } + const char *message_name() const override { return "authentication_response"; } #endif bool invalid_password{false}; void encode(ProtoWriteBuffer buffer) const override; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 53af7eefef..222aa2b603 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -670,8 +670,11 @@ void HelloResponse::dump_to(std::string &out) const { dump_field(out, "name", this->name_ref_); } #ifdef USE_API_PASSWORD -void ConnectRequest::dump_to(std::string &out) const { dump_field(out, "password", this->password); } -void ConnectResponse::dump_to(std::string &out) const { dump_field(out, "invalid_password", this->invalid_password); } +void AuthenticationRequest::dump_to(std::string &out) const { dump_field(out, "password", this->password); } +void AuthenticationResponse::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "AuthenticationResponse"); + dump_field(out, "invalid_password", this->invalid_password); +} #endif void DisconnectRequest::dump_to(std::string &out) const { out.append("DisconnectRequest {}"); } void DisconnectResponse::dump_to(std::string &out) const { out.append("DisconnectResponse {}"); } diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 1bcbfe1ba2..ef7acbc6b2 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -25,13 +25,13 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #ifdef USE_API_PASSWORD - case ConnectRequest::MESSAGE_TYPE: { - ConnectRequest msg; + case AuthenticationRequest::MESSAGE_TYPE: { + AuthenticationRequest msg; msg.decode(msg_data, msg_size); #ifdef HAS_PROTO_MESSAGE_DUMP - ESP_LOGVV(TAG, "on_connect_request: %s", msg.dump().c_str()); + ESP_LOGVV(TAG, "on_authentication_request: %s", msg.dump().c_str()); #endif - this->on_connect_request(msg); + this->on_authentication_request(msg); break; } #endif @@ -600,8 +600,8 @@ void APIServerConnection::on_hello_request(const HelloRequest &msg) { } } #ifdef USE_API_PASSWORD -void APIServerConnection::on_connect_request(const ConnectRequest &msg) { - if (!this->send_connect_response(msg)) { +void APIServerConnection::on_authentication_request(const AuthenticationRequest &msg) { + if (!this->send_authenticate_response(msg)) { this->on_fatal_error(); } } diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index cc7604c083..f81ac1a337 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -27,7 +27,7 @@ class APIServerConnectionBase : public ProtoService { virtual void on_hello_request(const HelloRequest &value){}; #ifdef USE_API_PASSWORD - virtual void on_connect_request(const ConnectRequest &value){}; + virtual void on_authentication_request(const AuthenticationRequest &value){}; #endif virtual void on_disconnect_request(const DisconnectRequest &value){}; @@ -216,7 +216,7 @@ class APIServerConnection : public APIServerConnectionBase { public: virtual bool send_hello_response(const HelloRequest &msg) = 0; #ifdef USE_API_PASSWORD - virtual bool send_connect_response(const ConnectRequest &msg) = 0; + virtual bool send_authenticate_response(const AuthenticationRequest &msg) = 0; #endif virtual bool send_disconnect_response(const DisconnectRequest &msg) = 0; virtual bool send_ping_response(const PingRequest &msg) = 0; @@ -339,7 +339,7 @@ class APIServerConnection : public APIServerConnectionBase { protected: void on_hello_request(const HelloRequest &msg) override; #ifdef USE_API_PASSWORD - void on_connect_request(const ConnectRequest &msg) override; + void on_authentication_request(const AuthenticationRequest &msg) override; #endif void on_disconnect_request(const DisconnectRequest &msg) override; void on_ping_request(const PingRequest &msg) override; From b8cee477fe41bef0a7d6ada0f375b7fdf31a573f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Sep 2025 18:23:34 -0400 Subject: [PATCH 1203/1626] [esp32] Use arduino as an idf component (#10647) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 409 ++++++++---------- esphome/components/esp32_ble/__init__.py | 65 ++- .../components/esp32_ble_beacon/__init__.py | 7 +- .../components/esp32_ble_server/__init__.py | 3 +- .../components/esp32_ble_tracker/__init__.py | 25 +- esphome/components/esp32_camera/__init__.py | 4 +- esphome/components/ethernet/__init__.py | 14 +- esphome/components/i2s_audio/__init__.py | 3 +- .../improv_serial/improv_serial_component.cpp | 27 +- .../improv_serial/improv_serial_component.h | 14 +- esphome/components/logger/__init__.py | 31 +- esphome/components/logger/logger.cpp | 20 +- esphome/components/logger/logger.h | 30 +- esphome/components/logger/logger_esp32.cpp | 48 -- esphome/components/logger/logger_zephyr.cpp | 5 +- esphome/components/network/__init__.py | 10 +- esphome/components/nextion/display.py | 8 +- esphome/components/psram/__init__.py | 47 +- esphome/components/uart/__init__.py | 31 +- .../uart/uart_component_esp32_arduino.cpp | 214 --------- .../uart/uart_component_esp32_arduino.h | 60 --- .../uart/uart_component_esp_idf.cpp | 2 +- .../components/uart/uart_component_esp_idf.h | 4 +- esphome/components/wifi/__init__.py | 2 +- esphome/components/wireguard/__init__.py | 2 +- esphome/platformio_api.py | 2 + 26 files changed, 328 insertions(+), 759 deletions(-) delete mode 100644 esphome/components/uart/uart_component_esp32_arduino.cpp delete mode 100644 esphome/components/uart/uart_component_esp32_arduino.h diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 50a47765bf..1a2cbd9daf 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -36,7 +36,6 @@ from esphome.const import ( __version__, ) from esphome.core import CORE, HexInt, TimePeriod -from esphome.cpp_generator import RawExpression import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, mkdir_p, write_file_if_changed from esphome.types import ConfigType @@ -157,8 +156,6 @@ def set_core_data(config): conf = config[CONF_FRAMEWORK] if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "esp-idf" - CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] = {} - CORE.data[KEY_ESP32][KEY_COMPONENTS] = {} elif conf[CONF_TYPE] == FRAMEWORK_ARDUINO: CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" if variant not in ARDUINO_ALLOWED_VARIANTS: @@ -166,6 +163,8 @@ def set_core_data(config): f"ESPHome does not support using the Arduino framework for the {variant}. Please use the ESP-IDF framework instead.", path=[CONF_FRAMEWORK, CONF_TYPE], ) + CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] = {} + CORE.data[KEY_ESP32][KEY_COMPONENTS] = {} CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( config[CONF_FRAMEWORK][CONF_VERSION] ) @@ -236,8 +235,6 @@ SdkconfigValueType = bool | int | HexInt | str | RawSdkconfigValue def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType): """Set an esp-idf sdkconfig value.""" - if not CORE.using_esp_idf: - raise ValueError("Not an esp-idf project") CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS][name] = value @@ -252,8 +249,6 @@ def add_idf_component( submodules: list[str] | None = None, ): """Add an esp-idf component to the project.""" - if not CORE.using_esp_idf: - raise ValueError("Not an esp-idf project") if not repo and not ref and not path: raise ValueError("Requires at least one of repo, ref or path") if refresh or submodules or components: @@ -367,47 +362,49 @@ SUPPORTED_PIOARDUINO_ESP_IDF_5X = [ ] -def _arduino_check_versions(value): +def _check_versions(value): value = value.copy() - lookups = { - "dev": (cv.Version(3, 2, 1), "https://github.com/espressif/arduino-esp32.git"), - "latest": (cv.Version(3, 2, 1), None), - "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), - } + if value[CONF_TYPE] == FRAMEWORK_ARDUINO: + lookups = { + "dev": ( + cv.Version(3, 2, 1), + "https://github.com/espressif/arduino-esp32.git", + ), + "latest": (cv.Version(3, 2, 1), None), + "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), + } - if value[CONF_VERSION] in lookups: - if CONF_SOURCE in value: - raise cv.Invalid( - "Framework version needs to be explicitly specified when custom source is used." - ) + if value[CONF_VERSION] in lookups: + if CONF_SOURCE in value: + raise cv.Invalid( + "Framework version needs to be explicitly specified when custom source is used." + ) - version, source = lookups[value[CONF_VERSION]] - else: - version = cv.Version.parse(cv.version_number(value[CONF_VERSION])) - source = value.get(CONF_SOURCE, None) + version, source = lookups[value[CONF_VERSION]] + else: + version = cv.Version.parse(cv.version_number(value[CONF_VERSION])) + source = value.get(CONF_SOURCE, None) - value[CONF_VERSION] = str(version) - value[CONF_SOURCE] = source or _format_framework_arduino_version(version) + value[CONF_VERSION] = str(version) + value[CONF_SOURCE] = source or _format_framework_arduino_version(version) - value[CONF_PLATFORM_VERSION] = value.get( - CONF_PLATFORM_VERSION, _parse_platform_version(str(ARDUINO_PLATFORM_VERSION)) - ) - - if value[CONF_SOURCE].startswith("http"): - # prefix is necessary or platformio will complain with a cryptic error - value[CONF_SOURCE] = f"framework-arduinoespressif32@{value[CONF_SOURCE]}" - - if version != RECOMMENDED_ARDUINO_FRAMEWORK_VERSION: - _LOGGER.warning( - "The selected Arduino framework version is not the recommended one. " - "If there are connectivity or build issues please remove the manual version." + value[CONF_PLATFORM_VERSION] = value.get( + CONF_PLATFORM_VERSION, + _parse_platform_version(str(ARDUINO_PLATFORM_VERSION)), ) - return value + if value[CONF_SOURCE].startswith("http"): + # prefix is necessary or platformio will complain with a cryptic error + value[CONF_SOURCE] = f"framework-arduinoespressif32@{value[CONF_SOURCE]}" + if version != RECOMMENDED_ARDUINO_FRAMEWORK_VERSION: + _LOGGER.warning( + "The selected Arduino framework version is not the recommended one. " + "If there are connectivity or build issues please remove the manual version." + ) + + return value -def _esp_idf_check_versions(value): - value = value.copy() lookups = { "dev": (cv.Version(5, 4, 2), "https://github.com/espressif/esp-idf.git"), "latest": (cv.Version(5, 2, 2), None), @@ -589,24 +586,6 @@ def final_validate(config): return config -ARDUINO_FRAMEWORK_SCHEMA = cv.All( - cv.Schema( - { - cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, - cv.Optional(CONF_ADVANCED, default={}): cv.Schema( - { - cv.Optional( - CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False - ): cv.boolean, - } - ), - } - ), - _arduino_check_versions, -) - CONF_SDKCONFIG_OPTIONS = "sdkconfig_options" CONF_ENABLE_LWIP_DHCP_SERVER = "enable_lwip_dhcp_server" CONF_ENABLE_LWIP_MDNS_QUERIES = "enable_lwip_mdns_queries" @@ -625,9 +604,14 @@ def _validate_idf_component(config: ConfigType) -> ConfigType: return config -ESP_IDF_FRAMEWORK_SCHEMA = cv.All( +FRAMEWORK_ESP_IDF = "esp-idf" +FRAMEWORK_ARDUINO = "arduino" +FRAMEWORK_SCHEMA = cv.All( cv.Schema( { + cv.Optional(CONF_TYPE, default=FRAMEWORK_ARDUINO): cv.one_of( + FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO + ), cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, cv.Optional(CONF_RELEASE): cv.string_strict, cv.Optional(CONF_SOURCE): cv.string_strict, @@ -691,7 +675,7 @@ ESP_IDF_FRAMEWORK_SCHEMA = cv.All( ), } ), - _esp_idf_check_versions, + _check_versions, ) @@ -758,32 +742,18 @@ def _set_default_framework(config): config = config.copy() variant = config[CONF_VARIANT] + config[CONF_FRAMEWORK] = FRAMEWORK_SCHEMA({}) if variant in ARDUINO_ALLOWED_VARIANTS: - config[CONF_FRAMEWORK] = ARDUINO_FRAMEWORK_SCHEMA({}) config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ARDUINO - # Show the migration message _show_framework_migration_message( config.get(CONF_NAME, "This device"), variant ) else: - config[CONF_FRAMEWORK] = ESP_IDF_FRAMEWORK_SCHEMA({}) config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ESP_IDF return config -FRAMEWORK_ESP_IDF = "esp-idf" -FRAMEWORK_ARDUINO = "arduino" -FRAMEWORK_SCHEMA = cv.typed_schema( - { - FRAMEWORK_ESP_IDF: ESP_IDF_FRAMEWORK_SCHEMA, - FRAMEWORK_ARDUINO: ARDUINO_FRAMEWORK_SCHEMA, - }, - lower=True, - space="-", -) - - FLASH_SIZES = [ "2MB", "4MB", @@ -851,139 +821,145 @@ async def to_code(config): os.path.join(os.path.dirname(__file__), "post_build.py.script"), ) - freq = config[CONF_CPU_FREQUENCY][:-3] if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_platformio_option("framework", "espidf") cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") - cg.add_build_flag("-Wno-nonnull-compare") - - cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) - - add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True) - add_idf_sdkconfig_option( - f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True - ) - add_idf_sdkconfig_option("CONFIG_PARTITION_TABLE_SINGLE_APP", False) - add_idf_sdkconfig_option("CONFIG_PARTITION_TABLE_CUSTOM", True) - add_idf_sdkconfig_option( - "CONFIG_PARTITION_TABLE_CUSTOM_FILENAME", "partitions.csv" - ) - - # Increase freertos tick speed from 100Hz to 1kHz so that delay() resolution is 1ms - add_idf_sdkconfig_option("CONFIG_FREERTOS_HZ", 1000) - - # Setup watchdog - add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True) - add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) - add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False) - add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False) - - # Disable dynamic log level control to save memory - add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False) - - # Set default CPU frequency - add_idf_sdkconfig_option(f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{freq}", True) - - # Apply LWIP optimization settings - advanced = conf[CONF_ADVANCED] - # DHCP server: only disable if explicitly set to false - # WiFi component handles its own optimization when AP mode is not used - if ( - CONF_ENABLE_LWIP_DHCP_SERVER in advanced - and not advanced[CONF_ENABLE_LWIP_DHCP_SERVER] - ): - add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) - if not advanced.get(CONF_ENABLE_LWIP_MDNS_QUERIES, True): - add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) - if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False): - add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) - if advanced.get(CONF_EXECUTE_FROM_PSRAM, False): - add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True) - add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True) - - # Apply LWIP core locking for better socket performance - # This is already enabled by default in Arduino framework, where it provides - # significant performance benefits. Our benchmarks show socket operations are - # 24-200% faster with core locking enabled: - # - select() on 4 sockets: ~190μs (Arduino/core locking) vs ~235μs (ESP-IDF default) - # - Up to 200% slower under load when all operations queue through tcpip_thread - # Enabling this makes ESP-IDF socket performance match Arduino framework. - if advanced.get(CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, True): - add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_CORE_LOCKING", True) - if advanced.get(CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, True): - add_idf_sdkconfig_option("CONFIG_LWIP_CHECK_THREAD_SAFETY", True) - - cg.add_platformio_option("board_build.partitions", "partitions.csv") - if CONF_PARTITIONS in config: - add_extra_build_file( - "partitions.csv", CORE.relative_config_path(config[CONF_PARTITIONS]) - ) - - if assertion_level := advanced.get(CONF_ASSERTION_LEVEL): - for key, flag in ASSERTION_LEVELS.items(): - add_idf_sdkconfig_option(flag, assertion_level == key) - - add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False) - compiler_optimization = advanced.get(CONF_COMPILER_OPTIMIZATION) - for key, flag in COMPILER_OPTIMIZATIONS.items(): - add_idf_sdkconfig_option(flag, compiler_optimization == key) - - add_idf_sdkconfig_option( - "CONFIG_LWIP_ESP_LWIP_ASSERT", - conf[CONF_ADVANCED][CONF_ENABLE_LWIP_ASSERT], - ) - - if advanced.get(CONF_IGNORE_EFUSE_MAC_CRC): - add_idf_sdkconfig_option("CONFIG_ESP_MAC_IGNORE_MAC_CRC_ERROR", True) - add_idf_sdkconfig_option( - "CONFIG_ESP_PHY_CALIBRATION_AND_DATA_STORAGE", False - ) - if advanced.get(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): - _LOGGER.warning( - "Using experimental features in ESP-IDF may result in unexpected failures." - ) - add_idf_sdkconfig_option("CONFIG_IDF_EXPERIMENTAL_FEATURES", True) - - cg.add_define( - "USE_ESP_IDF_VERSION_CODE", - cg.RawExpression( - f"VERSION_CODE({framework_ver.major}, {framework_ver.minor}, {framework_ver.patch})" - ), - ) - - add_idf_sdkconfig_option( - f"CONFIG_LOG_DEFAULT_LEVEL_{conf[CONF_LOG_LEVEL]}", True - ) - - for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): - add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) - - for component in conf[CONF_COMPONENTS]: - add_idf_component( - name=component[CONF_NAME], - repo=component.get(CONF_SOURCE), - ref=component.get(CONF_REF), - path=component.get(CONF_PATH), - ) - elif conf[CONF_TYPE] == FRAMEWORK_ARDUINO: - cg.add_platformio_option("framework", "arduino") + else: + cg.add_platformio_option("framework", "arduino, espidf") cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") - cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) - - if CONF_PARTITIONS in config: - cg.add_platformio_option("board_build.partitions", config[CONF_PARTITIONS]) - else: - cg.add_platformio_option("board_build.partitions", "partitions.csv") - + cg.add_platformio_option( + "board_build.embed_txtfiles", + [ + "managed_components/espressif__esp_insights/server_certs/https_server.crt", + "managed_components/espressif__esp_rainmaker/server_certs/rmaker_mqtt_server.crt", + "managed_components/espressif__esp_rainmaker/server_certs/rmaker_claim_service_server.crt", + "managed_components/espressif__esp_rainmaker/server_certs/rmaker_ota_server.crt", + ], + ) cg.add_define( "USE_ARDUINO_VERSION_CODE", cg.RawExpression( f"VERSION_CODE({framework_ver.major}, {framework_ver.minor}, {framework_ver.patch})" ), ) - cg.add(RawExpression(f"setCpuFrequencyMhz({freq})")) + add_idf_sdkconfig_option("CONFIG_AUTOSTART_ARDUINO", True) + add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True) + add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) + + cg.add_build_flag("-Wno-nonnull-compare") + + cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) + + add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True) + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True + ) + add_idf_sdkconfig_option("CONFIG_PARTITION_TABLE_SINGLE_APP", False) + add_idf_sdkconfig_option("CONFIG_PARTITION_TABLE_CUSTOM", True) + add_idf_sdkconfig_option("CONFIG_PARTITION_TABLE_CUSTOM_FILENAME", "partitions.csv") + + # Increase freertos tick speed from 100Hz to 1kHz so that delay() resolution is 1ms + add_idf_sdkconfig_option("CONFIG_FREERTOS_HZ", 1000) + + # Setup watchdog + add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True) + add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) + add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False) + add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False) + + # Disable dynamic log level control to save memory + add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False) + + # Set default CPU frequency + add_idf_sdkconfig_option( + f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{config[CONF_CPU_FREQUENCY][:-3]}", True + ) + + # Apply LWIP optimization settings + advanced = conf[CONF_ADVANCED] + # DHCP server: only disable if explicitly set to false + # WiFi component handles its own optimization when AP mode is not used + # When using Arduino with Ethernet, DHCP server functions must be available + # for the Network library to compile, even if not actively used + if ( + CONF_ENABLE_LWIP_DHCP_SERVER in advanced + and not advanced[CONF_ENABLE_LWIP_DHCP_SERVER] + and not ( + conf[CONF_TYPE] == FRAMEWORK_ARDUINO + and "ethernet" in CORE.loaded_integrations + ) + ): + add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + if not advanced.get(CONF_ENABLE_LWIP_MDNS_QUERIES, True): + add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) + if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False): + add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) + if advanced.get(CONF_EXECUTE_FROM_PSRAM, False): + add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True) + add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True) + + # Apply LWIP core locking for better socket performance + # This is already enabled by default in Arduino framework, where it provides + # significant performance benefits. Our benchmarks show socket operations are + # 24-200% faster with core locking enabled: + # - select() on 4 sockets: ~190μs (Arduino/core locking) vs ~235μs (ESP-IDF default) + # - Up to 200% slower under load when all operations queue through tcpip_thread + # Enabling this makes ESP-IDF socket performance match Arduino framework. + if advanced.get(CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, True): + add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_CORE_LOCKING", True) + if advanced.get(CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, True): + add_idf_sdkconfig_option("CONFIG_LWIP_CHECK_THREAD_SAFETY", True) + + cg.add_platformio_option("board_build.partitions", "partitions.csv") + if CONF_PARTITIONS in config: + add_extra_build_file( + "partitions.csv", CORE.relative_config_path(config[CONF_PARTITIONS]) + ) + + if assertion_level := advanced.get(CONF_ASSERTION_LEVEL): + for key, flag in ASSERTION_LEVELS.items(): + add_idf_sdkconfig_option(flag, assertion_level == key) + + add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False) + compiler_optimization = advanced.get(CONF_COMPILER_OPTIMIZATION) + for key, flag in COMPILER_OPTIMIZATIONS.items(): + add_idf_sdkconfig_option(flag, compiler_optimization == key) + + add_idf_sdkconfig_option( + "CONFIG_LWIP_ESP_LWIP_ASSERT", + conf[CONF_ADVANCED][CONF_ENABLE_LWIP_ASSERT], + ) + + if advanced.get(CONF_IGNORE_EFUSE_MAC_CRC): + add_idf_sdkconfig_option("CONFIG_ESP_MAC_IGNORE_MAC_CRC_ERROR", True) + add_idf_sdkconfig_option("CONFIG_ESP_PHY_CALIBRATION_AND_DATA_STORAGE", False) + if advanced.get(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): + _LOGGER.warning( + "Using experimental features in ESP-IDF may result in unexpected failures." + ) + add_idf_sdkconfig_option("CONFIG_IDF_EXPERIMENTAL_FEATURES", True) + + cg.add_define( + "USE_ESP_IDF_VERSION_CODE", + cg.RawExpression( + f"VERSION_CODE({framework_ver.major}, {framework_ver.minor}, {framework_ver.patch})" + ), + ) + + add_idf_sdkconfig_option(f"CONFIG_LOG_DEFAULT_LEVEL_{conf[CONF_LOG_LEVEL]}", True) + + for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): + add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) + + for component in conf[CONF_COMPONENTS]: + add_idf_component( + name=component[CONF_NAME], + repo=component.get(CONF_SOURCE), + ref=component.get(CONF_REF), + path=component.get(CONF_PATH), + ) APP_PARTITION_SIZES = { @@ -1057,6 +1033,7 @@ def _write_sdkconfig(): ) + "\n" ) + if write_file_if_changed(internal_path, contents): # internal changed, update real one write_file_if_changed(sdk_path, contents) @@ -1088,34 +1065,32 @@ def _write_idf_component_yml(): # Called by writer.py def copy_files(): - if ( - CORE.using_arduino - and "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES] - ): - write_file_if_changed( - CORE.relative_build_path("partitions.csv"), - get_arduino_partition_csv( - CORE.platformio_options.get("board_upload.flash_size") - ), - ) - if CORE.using_esp_idf: - _write_sdkconfig() - _write_idf_component_yml() - if "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]: + _write_sdkconfig() + _write_idf_component_yml() + + if "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]: + if CORE.using_arduino: + write_file_if_changed( + CORE.relative_build_path("partitions.csv"), + get_arduino_partition_csv( + CORE.platformio_options.get("board_upload.flash_size") + ), + ) + else: write_file_if_changed( CORE.relative_build_path("partitions.csv"), get_idf_partition_csv( CORE.platformio_options.get("board_upload.flash_size") ), ) - # IDF build scripts look for version string to put in the build. - # However, if the build path does not have an initialized git repo, - # and no version.txt file exists, the CMake script fails for some setups. - # Fix by manually pasting a version.txt file, containing the ESPHome version - write_file_if_changed( - CORE.relative_build_path("version.txt"), - __version__, - ) + # IDF build scripts look for version string to put in the build. + # However, if the build path does not have an initialized git repo, + # and no version.txt file exists, the CMake script fails for some setups. + # Fix by manually pasting a version.txt file, containing the ESPHome version + write_file_if_changed( + CORE.relative_build_path("version.txt"), + __version__, + ) for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values(): if file[KEY_PATH].startswith("http"): diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index d2eaa3ce6f..8886dc415b 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -12,7 +12,7 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import CORE, TimePeriod +from esphome.core import TimePeriod import esphome.final_validate as fv DEPENDENCIES = ["esp32"] @@ -261,43 +261,40 @@ async def to_code(config): cg.add(var.set_name(name)) await cg.register_component(var, config) - if CORE.using_esp_idf: - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) - # Register the core BLE loggers that are always needed - register_bt_logger(BTLoggers.GAP, BTLoggers.BTM, BTLoggers.HCI) + # Register the core BLE loggers that are always needed + register_bt_logger(BTLoggers.GAP, BTLoggers.BTM, BTLoggers.HCI) - # Apply logger settings if log disabling is enabled - if config.get(CONF_DISABLE_BT_LOGS, False): - # Disable all Bluetooth loggers that are not required - for logger in BTLoggers: - if logger not in _required_loggers: - add_idf_sdkconfig_option(f"{logger.value}_NONE", True) + # Apply logger settings if log disabling is enabled + if config.get(CONF_DISABLE_BT_LOGS, False): + # Disable all Bluetooth loggers that are not required + for logger in BTLoggers: + if logger not in _required_loggers: + add_idf_sdkconfig_option(f"{logger.value}_NONE", True) - # Set BLE connection establishment timeout to match aioesphomeapi/bleak-retry-connector - # Default is 20 seconds instead of ESP-IDF's 30 seconds. Because there is no way to - # cancel a BLE connection in progress, when aioesphomeapi times out at 20 seconds, - # the connection slot remains occupied for the remaining time, preventing new connection - # attempts and wasting valuable connection slots. - if CONF_CONNECTION_TIMEOUT in config: - timeout_seconds = int(config[CONF_CONNECTION_TIMEOUT].total_seconds) - add_idf_sdkconfig_option( - "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds - ) - # Increase GATT client connection retry count for problematic devices - # Default in ESP-IDF is 3, we increase to 10 for better reliability with - # low-power/timing-sensitive devices - add_idf_sdkconfig_option("CONFIG_BT_GATTC_CONNECT_RETRY_COUNT", 10) + # Set BLE connection establishment timeout to match aioesphomeapi/bleak-retry-connector + # Default is 20 seconds instead of ESP-IDF's 30 seconds. Because there is no way to + # cancel a BLE connection in progress, when aioesphomeapi times out at 20 seconds, + # the connection slot remains occupied for the remaining time, preventing new connection + # attempts and wasting valuable connection slots. + if CONF_CONNECTION_TIMEOUT in config: + timeout_seconds = int(config[CONF_CONNECTION_TIMEOUT].total_seconds) + add_idf_sdkconfig_option("CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds) + # Increase GATT client connection retry count for problematic devices + # Default in ESP-IDF is 3, we increase to 10 for better reliability with + # low-power/timing-sensitive devices + add_idf_sdkconfig_option("CONFIG_BT_GATTC_CONNECT_RETRY_COUNT", 10) - # Set the maximum number of notification registrations - # This controls how many BLE characteristics can have notifications enabled - # across all connections for a single GATT client interface - # https://github.com/esphome/issues/issues/6808 - if CONF_MAX_NOTIFICATIONS in config: - add_idf_sdkconfig_option( - "CONFIG_BT_GATTC_NOTIF_REG_MAX", config[CONF_MAX_NOTIFICATIONS] - ) + # Set the maximum number of notification registrations + # This controls how many BLE characteristics can have notifications enabled + # across all connections for a single GATT client interface + # https://github.com/esphome/issues/issues/6808 + if CONF_MAX_NOTIFICATIONS in config: + add_idf_sdkconfig_option( + "CONFIG_BT_GATTC_NOTIF_REG_MAX", config[CONF_MAX_NOTIFICATIONS] + ) cg.add_define("USE_ESP32_BLE") diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 8fc4fe941d..794f5637a4 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -4,7 +4,7 @@ from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.esp32_ble import CONF_BLE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TX_POWER, CONF_TYPE, CONF_UUID -from esphome.core import CORE, TimePeriod +from esphome.core import TimePeriod AUTO_LOAD = ["esp32_ble"] DEPENDENCIES = ["esp32"] @@ -86,6 +86,5 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") - if CORE.using_esp_idf: - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 8ddb15a7f8..a8bb99b745 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -573,8 +573,7 @@ async def to_code(config): ) cg.add_define("USE_ESP32_BLE_SERVER") cg.add_define("USE_ESP32_BLE_ADVERTISING") - if CORE.using_esp_idf: - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) @automation.register_action( diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 558143b007..8655d5a02a 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -342,19 +342,18 @@ async def to_code(config): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) - if CORE.using_esp_idf: - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - if config.get(CONF_SOFTWARE_COEXISTENCE): - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", True) - # https://github.com/espressif/esp-idf/issues/4101 - # https://github.com/espressif/esp-idf/issues/2503 - # Match arduino CONFIG_BTU_TASK_STACK_SIZE - # https://github.com/espressif/arduino-esp32/blob/fd72cf46ad6fc1a6de99c1d83ba8eba17d80a4ee/tools/sdk/esp32/sdkconfig#L1866 - add_idf_sdkconfig_option("CONFIG_BT_BTU_TASK_STACK_SIZE", 8192) - add_idf_sdkconfig_option("CONFIG_BT_ACL_CONNECTIONS", 9) - add_idf_sdkconfig_option( - "CONFIG_BTDM_CTRL_BLE_MAX_CONN", config[CONF_MAX_CONNECTIONS] - ) + add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + if config.get(CONF_SOFTWARE_COEXISTENCE): + add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", True) + # https://github.com/espressif/esp-idf/issues/4101 + # https://github.com/espressif/esp-idf/issues/2503 + # Match arduino CONFIG_BTU_TASK_STACK_SIZE + # https://github.com/espressif/arduino-esp32/blob/fd72cf46ad6fc1a6de99c1d83ba8eba17d80a4ee/tools/sdk/esp32/sdkconfig#L1866 + add_idf_sdkconfig_option("CONFIG_BT_BTU_TASK_STACK_SIZE", 8192) + add_idf_sdkconfig_option("CONFIG_BT_ACL_CONNECTIONS", 9) + add_idf_sdkconfig_option( + "CONFIG_BTDM_CTRL_BLE_MAX_CONN", config[CONF_MAX_CONNECTIONS] + ) cg.add_define("USE_OTA_STATE_CALLBACK") # To be notified when an OTA update starts cg.add_define("USE_ESP32_BLE_CLIENT") diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 6206fe4682..d8ba098645 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -21,7 +21,6 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_VSYNC_PIN, ) -from esphome.core import CORE from esphome.core.entity_helpers import setup_entity import esphome.final_validate as fv @@ -344,8 +343,7 @@ async def to_code(config): cg.add_define("USE_CAMERA") - if CORE.using_esp_idf: - add_idf_component(name="espressif/esp32-camera", ref="2.1.1") + add_idf_component(name="espressif/esp32-camera", ref="2.1.1") for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 151da7d0e5..95711f2b80 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -322,11 +322,8 @@ async def to_code(config): cg.add(var.set_clock_speed(config[CONF_CLOCK_SPEED])) cg.add_define("USE_ETHERNET_SPI") - if CORE.using_esp_idf: - add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True) - add_idf_sdkconfig_option( - f"CONFIG_ETH_SPI_ETHERNET_{config[CONF_TYPE]}", True - ) + add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True) + add_idf_sdkconfig_option(f"CONFIG_ETH_SPI_ETHERNET_{config[CONF_TYPE]}", True) elif config[CONF_TYPE] == "OPENETH": cg.add_define("USE_ETHERNET_OPENETH") add_idf_sdkconfig_option("CONFIG_ETH_USE_OPENETH", True) @@ -359,10 +356,9 @@ async def to_code(config): cg.add_define("USE_ETHERNET") # Disable WiFi when using Ethernet to save memory - if CORE.using_esp_idf: - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) - # Also disable WiFi/BT coexistence since WiFi is disabled - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) + # Also disable WiFi/BT coexistence since WiFi is disabled + add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) if CORE.using_arduino: cg.add_library("WiFi", None) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index cff91a546f..8ceff26d84 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -262,8 +262,7 @@ async def to_code(config): cg.add_define("USE_I2S_LEGACY") # Helps avoid callbacks being skipped due to processor load - if CORE.using_esp_idf: - add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True) + add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True) cg.add(var.set_lrclk_pin(config[CONF_I2S_LRCLK_PIN])) if CONF_I2S_BCLK_PIN in config: diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index ae4927828b..528a155a7f 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -15,11 +15,10 @@ static const char *const TAG = "improv_serial"; void ImprovSerialComponent::setup() { global_improv_serial_component = this; -#ifdef USE_ARDUINO - this->hw_serial_ = logger::global_logger->get_hw_serial(); -#endif -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 this->uart_num_ = logger::global_logger->get_uart_num(); +#elif defined(USE_ARDUINO) + this->hw_serial_ = logger::global_logger->get_hw_serial(); #endif if (wifi::global_wifi_component->has_sta()) { @@ -34,13 +33,7 @@ void ImprovSerialComponent::dump_config() { ESP_LOGCONFIG(TAG, "Improv Serial:") optional ImprovSerialComponent::read_byte_() { optional byte; uint8_t data = 0; -#ifdef USE_ARDUINO - if (this->hw_serial_->available()) { - this->hw_serial_->readBytes(&data, 1); - byte = data; - } -#endif -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 switch (logger::global_logger->get_uart()) { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: @@ -76,16 +69,18 @@ optional ImprovSerialComponent::read_byte_() { default: break; } +#elif defined(USE_ARDUINO) + if (this->hw_serial_->available()) { + this->hw_serial_->readBytes(&data, 1); + byte = data; + } #endif return byte; } void ImprovSerialComponent::write_data_(std::vector &data) { data.push_back('\n'); -#ifdef USE_ARDUINO - this->hw_serial_->write(data.data(), data.size()); -#endif -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 switch (logger::global_logger->get_uart()) { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: @@ -112,6 +107,8 @@ void ImprovSerialComponent::write_data_(std::vector &data) { default: break; } +#elif defined(USE_ARDUINO) + this->hw_serial_->write(data.data(), data.size()); #endif } diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 5d2534c2fc..c3c9aee24e 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -9,10 +9,7 @@ #include #include -#ifdef USE_ARDUINO -#include -#endif -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include #if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S3) || \ defined(USE_ESP32_VARIANT_ESP32H2) @@ -22,6 +19,8 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include #endif +#elif defined(USE_ARDUINO) +#include #endif namespace esphome { @@ -60,11 +59,10 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase { optional read_byte_(); void write_data_(std::vector &data); -#ifdef USE_ARDUINO - Stream *hw_serial_{nullptr}; -#endif -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 uart_port_t uart_num_; +#elif defined(USE_ARDUINO) + Stream *hw_serial_{nullptr}; #endif std::vector rx_buffer_; diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 2865355278..7d1a591f0c 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -117,8 +117,6 @@ UART_SELECTION_LIBRETINY = { COMPONENT_RTL87XX: [DEFAULT, UART0, UART1, UART2], } -ESP_ARDUINO_UNSUPPORTED_USB_UARTS = [USB_SERIAL_JTAG] - UART_SELECTION_RP2040 = [USB_CDC, UART0, UART1] UART_SELECTION_NRF52 = [USB_CDC, UART0] @@ -153,13 +151,7 @@ is_log_level = cv.one_of(*LOG_LEVELS, upper=True) def uart_selection(value): if CORE.is_esp32: - if CORE.using_arduino and value.upper() in ESP_ARDUINO_UNSUPPORTED_USB_UARTS: - raise cv.Invalid(f"Arduino framework does not support {value}.") variant = get_esp32_variant() - if CORE.using_esp_idf and variant == VARIANT_ESP32C3 and value == USB_CDC: - raise cv.Invalid( - f"{value} is not supported for variant {variant} when using ESP-IDF." - ) if variant in UART_SELECTION_ESP32: return cv.one_of(*UART_SELECTION_ESP32[variant], upper=True)(value) if CORE.is_esp8266: @@ -226,14 +218,11 @@ CONFIG_SCHEMA = cv.All( esp8266=UART0, esp32=UART0, esp32_s2=USB_CDC, - esp32_s3_arduino=USB_CDC, - esp32_s3_idf=USB_SERIAL_JTAG, - esp32_c3_arduino=USB_CDC, - esp32_c3_idf=USB_SERIAL_JTAG, - esp32_c5_idf=USB_SERIAL_JTAG, - esp32_c6_arduino=USB_CDC, - esp32_c6_idf=USB_SERIAL_JTAG, - esp32_p4_idf=USB_SERIAL_JTAG, + esp32_s3=USB_SERIAL_JTAG, + esp32_c3=USB_SERIAL_JTAG, + esp32_c5=USB_SERIAL_JTAG, + esp32_c6=USB_SERIAL_JTAG, + esp32_p4=USB_SERIAL_JTAG, rp2040=USB_CDC, bk72xx=DEFAULT, ln882x=DEFAULT, @@ -346,15 +335,7 @@ async def to_code(config): if config.get(CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH): cg.add_build_flag("-DUSE_STORE_LOG_STR_IN_FLASH") - if CORE.using_arduino and config[CONF_HARDWARE_UART] == USB_CDC: - cg.add_build_flag("-DARDUINO_USB_CDC_ON_BOOT=1") - if CORE.is_esp32 and get_esp32_variant() in ( - VARIANT_ESP32C3, - VARIANT_ESP32C6, - ): - cg.add_build_flag("-DARDUINO_USB_MODE=1") - - if CORE.using_esp_idf: + if CORE.is_esp32: if config[CONF_HARDWARE_UART] == USB_CDC: add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_CDC", True) elif config[CONF_HARDWARE_UART] == USB_SERIAL_JTAG: diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 5f0e78fc0d..4a69bd9853 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -173,24 +173,8 @@ void Logger::init_log_buffer(size_t total_buffer_size) { } #endif -#ifndef USE_ZEPHYR -#if defined(USE_LOGGER_USB_CDC) || defined(USE_ESP32) -void Logger::loop() { -#if defined(USE_LOGGER_USB_CDC) && defined(USE_ARDUINO) - if (this->uart_ == UART_SELECTION_USB_CDC) { - static bool opened = false; - if (opened == Serial) { - return; - } - if (false == opened) { - App.schedule_dump_config(); - } - opened = !opened; - } -#endif - this->process_messages_(); -} -#endif +#ifdef USE_ESPHOME_TASK_LOG_BUFFER +void Logger::loop() { this->process_messages_(); } #endif void Logger::process_messages_() { diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index a4cf5e3004..b5fb15d347 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -16,18 +16,18 @@ #endif #ifdef USE_ARDUINO -#if defined(USE_ESP8266) || defined(USE_ESP32) +#if defined(USE_ESP8266) #include -#endif // USE_ESP8266 || USE_ESP32 +#endif // USE_ESP8266 #ifdef USE_RP2040 #include #include #endif // USE_RP2040 #endif // USE_ARDUINO -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include -#endif // USE_ESP_IDF +#endif // USE_ESP32 #ifdef USE_ZEPHYR #include @@ -110,19 +110,17 @@ class Logger : public Component { #ifdef USE_ESPHOME_TASK_LOG_BUFFER void init_log_buffer(size_t total_buffer_size); #endif -#if defined(USE_LOGGER_USB_CDC) || defined(USE_ESP32) || defined(USE_ZEPHYR) +#if defined(USE_ESPHOME_TASK_LOG_BUFFER) || (defined(USE_ZEPHYR) && defined(USE_LOGGER_USB_CDC)) void loop() override; #endif /// Manually set the baud rate for serial, set to 0 to disable. void set_baud_rate(uint32_t baud_rate); uint32_t get_baud_rate() const { return baud_rate_; } -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_ESP32) Stream *get_hw_serial() const { return hw_serial_; } #endif -#ifdef USE_ESP_IDF - uart_port_t get_uart_num() const { return uart_num_; } -#endif #ifdef USE_ESP32 + uart_port_t get_uart_num() const { return uart_num_; } void create_pthread_key() { pthread_key_create(&log_recursion_key_, nullptr); } #endif #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) @@ -232,7 +230,7 @@ class Logger : public Component { // Group 4-byte aligned members first uint32_t baud_rate_; char *tx_buffer_{nullptr}; -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_ESP32) Stream *hw_serial_{nullptr}; #endif #if defined(USE_ZEPHYR) @@ -246,9 +244,7 @@ class Logger : public Component { // - Main task uses a dedicated member variable for efficiency // - Other tasks use pthread TLS with a dynamically created key via pthread_key_create pthread_key_t log_recursion_key_; // 4 bytes -#endif -#ifdef USE_ESP_IDF - uart_port_t uart_num_; // 4 bytes (enum defaults to int size) + uart_port_t uart_num_; // 4 bytes (enum defaults to int size) #endif // Large objects (internally aligned) @@ -380,15 +376,7 @@ class Logger : public Component { // will be processed on the next main loop iteration since: // - disable_loop() takes effect immediately // - enable_loop_soon_any_context() sets a pending flag that's checked at loop start -#if defined(USE_LOGGER_USB_CDC) && defined(USE_ARDUINO) - // Only disable if not using USB CDC (which needs loop for connection detection) - if (this->uart_ != UART_SELECTION_USB_CDC) { - this->disable_loop(); - } -#else - // No USB CDC support, always safe to disable this->disable_loop(); -#endif } #endif }; diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 6cb57c1540..7fc79e6f54 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -1,11 +1,8 @@ #ifdef USE_ESP32 #include "logger.h" -#if defined(USE_ESP32_FRAMEWORK_ARDUINO) || defined(USE_ESP_IDF) #include -#endif // USE_ESP32_FRAMEWORK_ARDUINO || USE_ESP_IDF -#ifdef USE_ESP_IDF #include #ifdef USE_LOGGER_USB_SERIAL_JTAG @@ -25,16 +22,12 @@ #include #include -#endif // USE_ESP_IDF - #include "esphome/core/log.h" namespace esphome::logger { static const char *const TAG = "logger"; -#ifdef USE_ESP_IDF - #ifdef USE_LOGGER_USB_SERIAL_JTAG static void init_usb_serial_jtag_() { setvbuf(stdin, NULL, _IONBF, 0); // Disable buffering on stdin @@ -89,42 +82,8 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) { uart_driver_install(uart_num, uart_buffer_size, uart_buffer_size, 10, nullptr, 0); } -#endif // USE_ESP_IDF - void Logger::pre_setup() { if (this->baud_rate_ > 0) { -#ifdef USE_ARDUINO - switch (this->uart_) { - case UART_SELECTION_UART0: -#if ARDUINO_USB_CDC_ON_BOOT - this->hw_serial_ = &Serial0; - Serial0.begin(this->baud_rate_); -#else - this->hw_serial_ = &Serial; - Serial.begin(this->baud_rate_); -#endif - break; - case UART_SELECTION_UART1: - this->hw_serial_ = &Serial1; - Serial1.begin(this->baud_rate_); - break; -#ifdef USE_ESP32_VARIANT_ESP32 - case UART_SELECTION_UART2: - this->hw_serial_ = &Serial2; - Serial2.begin(this->baud_rate_); - break; -#endif - -#ifdef USE_LOGGER_USB_CDC - case UART_SELECTION_USB_CDC: - this->hw_serial_ = &Serial; - Serial.begin(this->baud_rate_); - break; -#endif - } -#endif // USE_ARDUINO - -#ifdef USE_ESP_IDF this->uart_num_ = UART_NUM_0; switch (this->uart_) { case UART_SELECTION_UART0: @@ -151,21 +110,17 @@ void Logger::pre_setup() { break; #endif } -#endif // USE_ESP_IDF } global_logger = this; -#if defined(USE_ESP_IDF) || defined(USE_ESP32_FRAMEWORK_ARDUINO) esp_log_set_vprintf(esp_idf_log_vprintf_); if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE) { esp_log_level_set("*", ESP_LOG_VERBOSE); } -#endif // USE_ESP_IDF || USE_ESP32_FRAMEWORK_ARDUINO ESP_LOGI(TAG, "Log initialized"); } -#ifdef USE_ESP_IDF void HOT Logger::write_msg_(const char *msg) { if ( #if defined(USE_LOGGER_USB_CDC) && !defined(USE_LOGGER_USB_SERIAL_JTAG) @@ -186,9 +141,6 @@ void HOT Logger::write_msg_(const char *msg) { uart_write_bytes(this->uart_num_, "\n", 1); } } -#else -void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } -#endif const LogString *Logger::get_uart_selection_() { switch (this->uart_) { diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 817ca168f8..fb0c7dcca3 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -12,8 +12,8 @@ namespace esphome::logger { static const char *const TAG = "logger"; -void Logger::loop() { #ifdef USE_LOGGER_USB_CDC +void Logger::loop() { if (this->uart_ != UART_SELECTION_USB_CDC || nullptr == this->uart_dev_) { return; } @@ -30,9 +30,8 @@ void Logger::loop() { App.schedule_dump_config(); } opened = !opened; -#endif - this->process_messages_(); } +#endif void Logger::pre_setup() { if (this->baud_rate_ > 0) { diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 9679961b15..1a74350c4c 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -47,9 +47,13 @@ async def to_code(config): cg.add_define( "USE_NETWORK_MIN_IPV6_ADDR_COUNT", config[CONF_MIN_IPV6_ADDR_COUNT] ) - if CORE.using_esp_idf: - add_idf_sdkconfig_option("CONFIG_LWIP_IPV6", enable_ipv6) - add_idf_sdkconfig_option("CONFIG_LWIP_IPV6_AUTOCONFIG", enable_ipv6) + if CORE.is_esp32: + if CORE.using_esp_idf: + add_idf_sdkconfig_option("CONFIG_LWIP_IPV6", enable_ipv6) + add_idf_sdkconfig_option("CONFIG_LWIP_IPV6_AUTOCONFIG", enable_ipv6) + else: + add_idf_sdkconfig_option("CONFIG_LWIP_IPV6", True) + add_idf_sdkconfig_option("CONFIG_LWIP_IPV6_AUTOCONFIG", True) elif enable_ipv6: cg.add_build_flag("-DCONFIG_LWIP_IPV6") cg.add_build_flag("-DCONFIG_LWIP_IPV6_AUTOCONFIG") diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 4254ae45fe..ed6cd93027 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -153,10 +153,10 @@ async def to_code(config): if CONF_TFT_URL in config: cg.add_define("USE_NEXTION_TFT_UPLOAD") cg.add(var.set_tft_url(config[CONF_TFT_URL])) - if CORE.is_esp32 and CORE.using_arduino: - cg.add_library("NetworkClientSecure", None) - cg.add_library("HTTPClient", None) - elif CORE.is_esp32 and CORE.using_esp_idf: + if CORE.is_esp32: + if CORE.using_arduino: + cg.add_library("NetworkClientSecure", None) + cg.add_library("HTTPClient", None) esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index b5c87ae5a8..d559b2436b 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -121,33 +121,30 @@ async def to_code(config): if config[CONF_MODE] == TYPE_OCTAL: cg.add_platformio_option("board_build.arduino.memory_type", "qio_opi") - if CORE.using_esp_idf: - add_idf_sdkconfig_option( - f"CONFIG_{get_esp32_variant().upper()}_SPIRAM_SUPPORT", True - ) - add_idf_sdkconfig_option("CONFIG_SOC_SPIRAM_SUPPORTED", True) - add_idf_sdkconfig_option("CONFIG_SPIRAM", True) - add_idf_sdkconfig_option("CONFIG_SPIRAM_USE", True) - add_idf_sdkconfig_option("CONFIG_SPIRAM_USE_CAPS_ALLOC", True) - add_idf_sdkconfig_option("CONFIG_SPIRAM_IGNORE_NOTFOUND", True) + add_idf_sdkconfig_option( + f"CONFIG_{get_esp32_variant().upper()}_SPIRAM_SUPPORT", True + ) + add_idf_sdkconfig_option("CONFIG_SOC_SPIRAM_SUPPORTED", True) + add_idf_sdkconfig_option("CONFIG_SPIRAM", True) + add_idf_sdkconfig_option("CONFIG_SPIRAM_USE", True) + add_idf_sdkconfig_option("CONFIG_SPIRAM_USE_CAPS_ALLOC", True) + add_idf_sdkconfig_option("CONFIG_SPIRAM_IGNORE_NOTFOUND", True) - add_idf_sdkconfig_option( - f"CONFIG_SPIRAM_MODE_{SDK_MODES[config[CONF_MODE]]}", True - ) + add_idf_sdkconfig_option(f"CONFIG_SPIRAM_MODE_{SDK_MODES[config[CONF_MODE]]}", True) - # Remove MHz suffix, convert to int - speed = int(config[CONF_SPEED][:-3]) - add_idf_sdkconfig_option(f"CONFIG_SPIRAM_SPEED_{speed}M", True) - add_idf_sdkconfig_option("CONFIG_SPIRAM_SPEED", speed) - if config[CONF_MODE] == TYPE_OCTAL and speed == 120: - add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_FLASHFREQ_120M", True) - add_idf_sdkconfig_option("CONFIG_BOOTLOADER_FLASH_DC_AWARE", True) - if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(5, 4, 0): - add_idf_sdkconfig_option( - "CONFIG_SPIRAM_TIMING_TUNING_POINT_VIA_TEMPERATURE_SENSOR", True - ) - if config[CONF_ENABLE_ECC]: - add_idf_sdkconfig_option("CONFIG_SPIRAM_ECC_ENABLE", True) + # Remove MHz suffix, convert to int + speed = int(config[CONF_SPEED][:-3]) + add_idf_sdkconfig_option(f"CONFIG_SPIRAM_SPEED_{speed}M", True) + add_idf_sdkconfig_option("CONFIG_SPIRAM_SPEED", speed) + if config[CONF_MODE] == TYPE_OCTAL and speed == 120: + add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_FLASHFREQ_120M", True) + add_idf_sdkconfig_option("CONFIG_BOOTLOADER_FLASH_DC_AWARE", True) + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(5, 4, 0): + add_idf_sdkconfig_option( + "CONFIG_SPIRAM_TIMING_TUNING_POINT_VIA_TEMPERATURE_SENSOR", True + ) + if config[CONF_ENABLE_ECC]: + add_idf_sdkconfig_option("CONFIG_SPIRAM_ECC_ENABLE", True) cg.add_define("USE_PSRAM") diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 7d4c6360fe..4c8c991eb6 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -16,7 +16,6 @@ from esphome.const import ( CONF_DUMMY_RECEIVER_ID, CONF_ID, CONF_INVERT, - CONF_INVERTED, CONF_LAMBDA, CONF_NUMBER, CONF_PORT, @@ -39,9 +38,6 @@ uart_ns = cg.esphome_ns.namespace("uart") UARTComponent = uart_ns.class_("UARTComponent") IDFUARTComponent = uart_ns.class_("IDFUARTComponent", UARTComponent, cg.Component) -ESP32ArduinoUARTComponent = uart_ns.class_( - "ESP32ArduinoUARTComponent", UARTComponent, cg.Component -) ESP8266UartComponent = uart_ns.class_( "ESP8266UartComponent", UARTComponent, cg.Component ) @@ -53,7 +49,6 @@ HostUartComponent = uart_ns.class_("HostUartComponent", UARTComponent, cg.Compon NATIVE_UART_CLASSES = ( str(IDFUARTComponent), - str(ESP32ArduinoUARTComponent), str(ESP8266UartComponent), str(RP2040UartComponent), str(LibreTinyUARTComponent), @@ -119,20 +114,6 @@ def validate_rx_pin(value): return value -def validate_invert_esp32(config): - if ( - CORE.is_esp32 - and CORE.using_arduino - and CONF_TX_PIN in config - and CONF_RX_PIN in config - and config[CONF_TX_PIN][CONF_INVERTED] != config[CONF_RX_PIN][CONF_INVERTED] - ): - raise cv.Invalid( - "Different invert values for TX and RX pin are not supported for ESP32 when using Arduino." - ) - return config - - def validate_host_config(config): if CORE.is_host: if CONF_TX_PIN in config or CONF_RX_PIN in config: @@ -151,10 +132,7 @@ def _uart_declare_type(value): if CORE.is_esp8266: return cv.declare_id(ESP8266UartComponent)(value) if CORE.is_esp32: - if CORE.using_arduino: - return cv.declare_id(ESP32ArduinoUARTComponent)(value) - if CORE.using_esp_idf: - return cv.declare_id(IDFUARTComponent)(value) + return cv.declare_id(IDFUARTComponent)(value) if CORE.is_rp2040: return cv.declare_id(RP2040UartComponent)(value) if CORE.is_libretiny: @@ -255,7 +233,6 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.has_at_least_one_key(CONF_TX_PIN, CONF_RX_PIN, CONF_PORT), - validate_invert_esp32, validate_host_config, ) @@ -444,8 +421,10 @@ async def uart_write_to_code(config, action_id, template_arg, args): FILTER_SOURCE_FILES = filter_source_files_from_platform( { - "uart_component_esp32_arduino.cpp": {PlatformFramework.ESP32_ARDUINO}, - "uart_component_esp_idf.cpp": {PlatformFramework.ESP32_IDF}, + "uart_component_esp_idf.cpp": { + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + }, "uart_component_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "uart_component_host.cpp": {PlatformFramework.HOST_NATIVE}, "uart_component_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, diff --git a/esphome/components/uart/uart_component_esp32_arduino.cpp b/esphome/components/uart/uart_component_esp32_arduino.cpp deleted file mode 100644 index 4a1c326789..0000000000 --- a/esphome/components/uart/uart_component_esp32_arduino.cpp +++ /dev/null @@ -1,214 +0,0 @@ -#ifdef USE_ESP32_FRAMEWORK_ARDUINO -#include "uart_component_esp32_arduino.h" -#include "esphome/core/application.h" -#include "esphome/core/defines.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" - -#ifdef USE_LOGGER -#include "esphome/components/logger/logger.h" -#endif - -namespace esphome { -namespace uart { -static const char *const TAG = "uart.arduino_esp32"; - -static const uint32_t UART_PARITY_EVEN = 0 << 0; -static const uint32_t UART_PARITY_ODD = 1 << 0; -static const uint32_t UART_PARITY_ENABLE = 1 << 1; -static const uint32_t UART_NB_BIT_5 = 0 << 2; -static const uint32_t UART_NB_BIT_6 = 1 << 2; -static const uint32_t UART_NB_BIT_7 = 2 << 2; -static const uint32_t UART_NB_BIT_8 = 3 << 2; -static const uint32_t UART_NB_STOP_BIT_1 = 1 << 4; -static const uint32_t UART_NB_STOP_BIT_2 = 3 << 4; -static const uint32_t UART_TICK_APB_CLOCK = 1 << 27; - -uint32_t ESP32ArduinoUARTComponent::get_config() { - uint32_t config = 0; - - /* - * All bits numbers below come from - * framework-arduinoespressif32/cores/esp32/esp32-hal-uart.h - * And more specifically conf0 union in uart_dev_t. - * - * Below is bit used from conf0 union. - * : - * parity:0 0:even 1:odd - * parity_en:1 Set this bit to enable uart parity check. - * bit_num:2-4 0:5bits 1:6bits 2:7bits 3:8bits - * stop_bit_num:4-6 stop bit. 1:1bit 2:1.5bits 3:2bits - * tick_ref_always_on:27 select the clock.1:apb clock:ref_tick - */ - - if (this->parity_ == UART_CONFIG_PARITY_EVEN) { - config |= UART_PARITY_EVEN | UART_PARITY_ENABLE; - } else if (this->parity_ == UART_CONFIG_PARITY_ODD) { - config |= UART_PARITY_ODD | UART_PARITY_ENABLE; - } - - switch (this->data_bits_) { - case 5: - config |= UART_NB_BIT_5; - break; - case 6: - config |= UART_NB_BIT_6; - break; - case 7: - config |= UART_NB_BIT_7; - break; - case 8: - config |= UART_NB_BIT_8; - break; - } - - if (this->stop_bits_ == 1) { - config |= UART_NB_STOP_BIT_1; - } else { - config |= UART_NB_STOP_BIT_2; - } - - config |= UART_TICK_APB_CLOCK; - - return config; -} - -void ESP32ArduinoUARTComponent::setup() { - // Use Arduino HardwareSerial UARTs if all used pins match the ones - // preconfigured by the platform. For example if RX disabled but TX pin - // is 1 we still want to use Serial. - bool is_default_tx, is_default_rx; -#ifdef CONFIG_IDF_TARGET_ESP32C3 - is_default_tx = tx_pin_ == nullptr || tx_pin_->get_pin() == 21; - is_default_rx = rx_pin_ == nullptr || rx_pin_->get_pin() == 20; -#else - is_default_tx = tx_pin_ == nullptr || tx_pin_->get_pin() == 1; - is_default_rx = rx_pin_ == nullptr || rx_pin_->get_pin() == 3; -#endif - static uint8_t next_uart_num = 0; - if (is_default_tx && is_default_rx && next_uart_num == 0) { -#if ARDUINO_USB_CDC_ON_BOOT - this->hw_serial_ = &Serial0; -#else - this->hw_serial_ = &Serial; -#endif - next_uart_num++; - } else { -#ifdef USE_LOGGER - bool logger_uses_hardware_uart = true; - -#ifdef USE_LOGGER_USB_CDC - if (logger::global_logger->get_uart() == logger::UART_SELECTION_USB_CDC) { - // this is not a hardware UART, ignore it - logger_uses_hardware_uart = false; - } -#endif // USE_LOGGER_USB_CDC - -#ifdef USE_LOGGER_USB_SERIAL_JTAG - if (logger::global_logger->get_uart() == logger::UART_SELECTION_USB_SERIAL_JTAG) { - // this is not a hardware UART, ignore it - logger_uses_hardware_uart = false; - } -#endif // USE_LOGGER_USB_SERIAL_JTAG - - if (logger_uses_hardware_uart && logger::global_logger->get_baud_rate() > 0 && - logger::global_logger->get_uart() == next_uart_num) { - next_uart_num++; - } -#endif // USE_LOGGER - - if (next_uart_num >= SOC_UART_NUM) { - ESP_LOGW(TAG, "Maximum number of UART components created already."); - this->mark_failed(); - return; - } - - this->number_ = next_uart_num; - this->hw_serial_ = new HardwareSerial(next_uart_num++); // NOLINT(cppcoreguidelines-owning-memory) - } - - this->load_settings(false); -} - -void ESP32ArduinoUARTComponent::load_settings(bool dump_config) { - int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; - int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; - bool invert = false; - if (tx_pin_ != nullptr && tx_pin_->is_inverted()) - invert = true; - if (rx_pin_ != nullptr && rx_pin_->is_inverted()) - invert = true; - this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); - this->hw_serial_->begin(this->baud_rate_, get_config(), rx, tx, invert); - if (dump_config) { - ESP_LOGCONFIG(TAG, "UART %u was reloaded.", this->number_); - this->dump_config(); - } -} - -void ESP32ArduinoUARTComponent::dump_config() { - ESP_LOGCONFIG(TAG, "UART Bus %d:", this->number_); - LOG_PIN(" TX Pin: ", tx_pin_); - LOG_PIN(" RX Pin: ", rx_pin_); - if (this->rx_pin_ != nullptr) { - ESP_LOGCONFIG(TAG, " RX Buffer Size: %u", this->rx_buffer_size_); - } - ESP_LOGCONFIG(TAG, - " Baud Rate: %u baud\n" - " Data Bits: %u\n" - " Parity: %s\n" - " Stop bits: %u", - this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); - this->check_logger_conflict(); -} - -void ESP32ArduinoUARTComponent::write_array(const uint8_t *data, size_t len) { - this->hw_serial_->write(data, len); -#ifdef USE_UART_DEBUGGER - for (size_t i = 0; i < len; i++) { - this->debug_callback_.call(UART_DIRECTION_TX, data[i]); - } -#endif -} - -bool ESP32ArduinoUARTComponent::peek_byte(uint8_t *data) { - if (!this->check_read_timeout_()) - return false; - *data = this->hw_serial_->peek(); - return true; -} - -bool ESP32ArduinoUARTComponent::read_array(uint8_t *data, size_t len) { - if (!this->check_read_timeout_(len)) - return false; - this->hw_serial_->readBytes(data, len); -#ifdef USE_UART_DEBUGGER - for (size_t i = 0; i < len; i++) { - this->debug_callback_.call(UART_DIRECTION_RX, data[i]); - } -#endif - return true; -} - -int ESP32ArduinoUARTComponent::available() { return this->hw_serial_->available(); } -void ESP32ArduinoUARTComponent::flush() { - ESP_LOGVV(TAG, " Flushing"); - this->hw_serial_->flush(); -} - -void ESP32ArduinoUARTComponent::check_logger_conflict() { -#ifdef USE_LOGGER - if (this->hw_serial_ == nullptr || logger::global_logger->get_baud_rate() == 0) { - return; - } - - if (this->hw_serial_ == logger::global_logger->get_hw_serial()) { - ESP_LOGW(TAG, " You're using the same serial port for logging and the UART component. Please " - "disable logging over the serial port by setting logger->baud_rate to 0."); - } -#endif -} - -} // namespace uart -} // namespace esphome -#endif // USE_ESP32_FRAMEWORK_ARDUINO diff --git a/esphome/components/uart/uart_component_esp32_arduino.h b/esphome/components/uart/uart_component_esp32_arduino.h deleted file mode 100644 index de17d9718b..0000000000 --- a/esphome/components/uart/uart_component_esp32_arduino.h +++ /dev/null @@ -1,60 +0,0 @@ -#pragma once - -#ifdef USE_ESP32_FRAMEWORK_ARDUINO - -#include -#include -#include -#include "esphome/core/component.h" -#include "esphome/core/hal.h" -#include "esphome/core/log.h" -#include "uart_component.h" - -namespace esphome { -namespace uart { - -class ESP32ArduinoUARTComponent : public UARTComponent, public Component { - public: - void setup() override; - void dump_config() override; - float get_setup_priority() const override { return setup_priority::BUS; } - - void write_array(const uint8_t *data, size_t len) override; - - bool peek_byte(uint8_t *data) override; - bool read_array(uint8_t *data, size_t len) override; - - int available() override; - void flush() override; - - uint32_t get_config(); - - HardwareSerial *get_hw_serial() { return this->hw_serial_; } - uint8_t get_hw_serial_number() { return this->number_; } - - /** - * Load the UART with the current settings. - * @param dump_config (Optional, default `true`): True for displaying new settings or - * false to change it quitely - * - * Example: - * ```cpp - * id(uart1).load_settings(); - * ``` - * - * This will load the current UART interface with the latest settings (baud_rate, parity, etc). - */ - void load_settings(bool dump_config) override; - void load_settings() override { this->load_settings(true); } - - protected: - void check_logger_conflict() override; - - HardwareSerial *hw_serial_{nullptr}; - uint8_t number_{0}; -}; - -} // namespace uart -} // namespace esphome - -#endif // USE_ESP32_FRAMEWORK_ARDUINO diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 6bb4b16819..6517b4b503 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "uart_component_esp_idf.h" #include diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index 215641ebe2..606e17e5b6 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include #include "esphome/core/component.h" @@ -55,4 +55,4 @@ class IDFUARTComponent : public UARTComponent, public Component { } // namespace uart } // namespace esphome -#endif // USE_ESP_IDF +#endif // USE_ESP32 diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index c63a12f879..ef74c14924 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -402,7 +402,7 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) # Disable Enterprise WiFi support if no EAP is configured - if CORE.is_esp32 and CORE.using_esp_idf and not has_eap: + if CORE.is_esp32 and not has_eap: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", False) cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 8eff8e7b2a..50c7980215 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -118,7 +118,7 @@ async def to_code(config): # Workaround for crash on IDF 5+ # See https://github.com/trombik/esp_wireguard/issues/33#issuecomment-1568503651 - if CORE.using_esp_idf: + if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_LWIP_PPP_SUPPORT", True) # This flag is added here because the esp_wireguard library statically diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 267277ebe1..d8c8611884 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -70,6 +70,8 @@ FILTER_PLATFORMIO_LINES = [ r" - tool-esptool.* \(.*\)", r" - toolchain-.* \(.*\)", r"Creating BIN file .*", + r"Warning! Could not find file \".*.crt\"", + r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.", ] From f2c20c8ca8c63e8a668fabfacd757674c172d709 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Sep 2025 17:38:55 -0500 Subject: [PATCH 1204/1626] [esp32_ble_tracker] Remove Arduino-specific BLE limitations now that Arduino uses IDF --- .../components/bluetooth_proxy/__init__.py | 26 ++----------------- .../components/esp32_ble_tracker/__init__.py | 13 +++------- 2 files changed, 6 insertions(+), 33 deletions(-) diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index f21b5028c7..e815c3d2ee 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -6,8 +6,6 @@ from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.esp32_ble import BTLoggers import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_ID -from esphome.core import CORE -from esphome.log import AnsiFore, color AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"] DEPENDENCIES = ["api", "esp32"] @@ -48,26 +46,6 @@ def validate_connections(config): config ) - # Warn about connection slot waste when using Arduino framework - if CORE.using_arduino and connection_slots: - _LOGGER.warning( - "Bluetooth Proxy with active connections on Arduino framework has suboptimal performance.\n" - "If BLE connections fail, they can waste connection slots for 10 seconds because\n" - "Arduino doesn't allow configuring the BLE connection timeout (fixed at 30s).\n" - "ESP-IDF framework allows setting it to 20s to match client timeouts.\n" - "\n" - "To switch to ESP-IDF, add this to your YAML:\n" - " esp32:\n" - " framework:\n" - " type: esp-idf\n" - "\n" - "For detailed migration instructions, see:\n" - "%s", - color( - AnsiFore.BLUE, "https://esphome.io/guides/esp32_arduino_to_idf.html" - ), - ) - return { **config, CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)], @@ -89,11 +67,11 @@ CONFIG_SCHEMA = cv.All( default=DEFAULT_CONNECTION_SLOTS, ): cv.All( cv.positive_int, - cv.Range(min=1, max=esp32_ble_tracker.max_connections()), + cv.Range(min=1, max=esp32_ble_tracker.IDF_MAX_CONNECTIONS), ), cv.Optional(CONF_CONNECTIONS): cv.All( cv.ensure_list(CONNECTION_SCHEMA), - cv.Length(min=1, max=esp32_ble_tracker.max_connections()), + cv.Length(min=1, max=esp32_ble_tracker.IDF_MAX_CONNECTIONS), ), } ) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 8655d5a02a..787fb9fb65 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -150,10 +150,6 @@ def as_reversed_hex_array(value): ) -def max_connections() -> int: - return IDF_MAX_CONNECTIONS if CORE.using_esp_idf else DEFAULT_MAX_CONNECTIONS - - def consume_connection_slots( value: int, consumer: str ) -> Callable[[MutableMapping], MutableMapping]: @@ -172,7 +168,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(ESP32BLETracker), cv.GenerateID(esp32_ble.CONF_BLE_ID): cv.use_id(esp32_ble.ESP32BLE), cv.Optional(CONF_MAX_CONNECTIONS, default=DEFAULT_MAX_CONNECTIONS): cv.All( - cv.positive_int, cv.Range(min=0, max=max_connections()) + cv.positive_int, cv.Range(min=0, max=IDF_MAX_CONNECTIONS) ), cv.Optional(CONF_SCAN_PARAMETERS, default={}): cv.All( cv.Schema( @@ -238,9 +234,8 @@ def validate_remaining_connections(config): if used_slots <= config[CONF_MAX_CONNECTIONS]: return config slot_users = ", ".join(slots) - hard_limit = max_connections() - if used_slots < hard_limit: + if used_slots < IDF_MAX_CONNECTIONS: _LOGGER.warning( "esp32_ble_tracker exceeded `%s`: components attempted to consume %d " "connection slot(s) out of available configured maximum %d connection " @@ -262,9 +257,9 @@ def validate_remaining_connections(config): f"out of available configured maximum {config[CONF_MAX_CONNECTIONS]} " f"connection slot(s); Decrease the number of BLE clients ({slot_users})" ) - if config[CONF_MAX_CONNECTIONS] < hard_limit: + if config[CONF_MAX_CONNECTIONS] < IDF_MAX_CONNECTIONS: msg += f" or increase {CONF_MAX_CONNECTIONS}` to {used_slots}" - msg += f" to stay under the {hard_limit} connection slot(s) limit." + msg += f" to stay under the {IDF_MAX_CONNECTIONS} connection slot(s) limit." raise cv.Invalid(msg) From 455d2c233264757441ccf38f28479b2b223ba4ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Sep 2025 17:44:36 -0500 Subject: [PATCH 1205/1626] [ethernet] Remove redundant Arduino framework version check --- esphome/components/ethernet/__init__.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 95711f2b80..ef8e82a843 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -117,19 +117,15 @@ ManualIP = ethernet_ns.struct("ManualIP") def _is_framework_spi_polling_mode_supported(): # SPI Ethernet without IRQ feature is added in - # esp-idf >= (5.3+ ,5.2.1+, 5.1.4) and arduino-esp32 >= 3.0.0 + # esp-idf >= (5.3+ ,5.2.1+, 5.1.4) + # Note: Arduino now uses ESP-IDF as a component, so we only check IDF version framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - if CORE.using_esp_idf: - if framework_version >= cv.Version(5, 3, 0): - return True - if cv.Version(5, 3, 0) > framework_version >= cv.Version(5, 2, 1): - return True - if cv.Version(5, 2, 0) > framework_version >= cv.Version(5, 1, 4): # noqa: SIM103 - return True - return False - if CORE.using_arduino: - return framework_version >= cv.Version(3, 0, 0) - # fail safe: Unknown framework + if framework_version >= cv.Version(5, 3, 0): + return True + if cv.Version(5, 3, 0) > framework_version >= cv.Version(5, 2, 1): + return True + if cv.Version(5, 2, 0) > framework_version >= cv.Version(5, 1, 4): # noqa: SIM103 + return True return False From 55232c711a94d21d8ec808cbc63d84a1cfc3b18e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Sep 2025 17:48:50 -0500 Subject: [PATCH 1206/1626] drop splitdefault as well --- esphome/components/bluetooth_proxy/__init__.py | 4 +--- esphome/components/esp32_ble/__init__.py | 10 +++------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index e815c3d2ee..42a88f1421 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -59,9 +59,7 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(BluetoothProxy), cv.Optional(CONF_ACTIVE, default=True): cv.boolean, - cv.SplitDefault(CONF_CACHE_SERVICES, esp32_idf=True): cv.All( - cv.only_with_esp_idf, cv.boolean - ), + cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean, cv.Optional( CONF_CONNECTION_SLOTS, default=DEFAULT_CONNECTION_SLOTS, diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 8886dc415b..dae9799028 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -174,16 +174,12 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional( CONF_ADVERTISING_CYCLE_TIME, default="10s" ): cv.positive_time_period_milliseconds, - cv.SplitDefault(CONF_DISABLE_BT_LOGS, esp32_idf=True): cv.All( - cv.only_with_esp_idf, cv.boolean - ), - cv.SplitDefault(CONF_CONNECTION_TIMEOUT, esp32_idf="20s"): cv.All( - cv.only_with_esp_idf, + cv.Optional(CONF_DISABLE_BT_LOGS, default=True): cv.boolean, + cv.Optional(CONF_CONNECTION_TIMEOUT, default="20s"): cv.All( cv.positive_time_period_seconds, cv.Range(min=TimePeriod(seconds=10), max=TimePeriod(seconds=180)), ), - cv.SplitDefault(CONF_MAX_NOTIFICATIONS, esp32_idf=12): cv.All( - cv.only_with_esp_idf, + cv.Optional(CONF_MAX_NOTIFICATIONS, default=12): cv.All( cv.positive_int, cv.Range(min=1, max=64), ), From 4e5339801b71b1780e83bf04dae52ceeff2ac345 Mon Sep 17 00:00:00 2001 From: Subhash Chandra Date: Thu, 18 Sep 2025 05:44:31 +0530 Subject: [PATCH 1207/1626] [packet_transport] Refactor sensor/provider list handling to be idempotent (#10765) --- esphome/components/packet_transport/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index bfb2bbc4f8..43da7740fe 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -121,15 +121,11 @@ def transport_schema(cls): return TRANSPORT_SCHEMA.extend({cv.GenerateID(): cv.declare_id(cls)}) -# Build a list of sensors for this platform -CORE.data[DOMAIN] = {CONF_SENSORS: []} - - def get_sensors(transport_id): """Return the list of sensors for this platform.""" return ( sensor - for sensor in CORE.data[DOMAIN][CONF_SENSORS] + for sensor in CORE.data.setdefault(DOMAIN, {}).setdefault(CONF_SENSORS, []) if sensor[CONF_TRANSPORT_ID] == transport_id ) @@ -137,7 +133,8 @@ def get_sensors(transport_id): def validate_packet_transport_sensor(config): if CONF_NAME in config and CONF_INTERNAL not in config: raise cv.Invalid("Must provide internal: config when using name:") - CORE.data[DOMAIN][CONF_SENSORS].append(config) + conf_sensors = CORE.data.setdefault(DOMAIN, {}).setdefault(CONF_SENSORS, []) + conf_sensors.append(config) return config From 1ffb9d972a5cef5a6cdd32bf2c0acc44c8679974 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Sep 2025 20:11:30 -0500 Subject: [PATCH 1208/1626] [core] Fix ESP8266 mDNS compilation failure caused by incorrect coroutine priorities (#10773) --- esphome/components/captive_portal/__init__.py | 5 ++- esphome/components/esphome/ota/__init__.py | 5 ++- .../components/http_request/ota/__init__.py | 5 ++- esphome/components/mdns/__init__.py | 5 ++- esphome/components/ota/__init__.py | 5 ++- esphome/components/web_server/ota/__init__.py | 5 ++- .../components/web_server_base/__init__.py | 5 ++- esphome/coroutine.py | 23 +++++++++- .../mdns/test-comprehensive.esp8266-ard.yaml | 42 +++++++++++++++++++ tests/unit_tests/test_coroutine.py | 19 ++++++++- 10 files changed, 101 insertions(+), 18 deletions(-) create mode 100644 tests/components/mdns/test-comprehensive.esp8266-ard.yaml diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 39cafc7cb4..9f2af0a230 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -10,7 +10,8 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RTL87XX, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority AUTO_LOAD = ["web_server_base", "ota.web_server"] DEPENDENCIES = ["wifi"] @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.CAPTIVE_PORTAL) async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 7b579501ed..93216f9425 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,7 +16,8 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import coroutine_with_priority +from esphome.coroutine import CoroPriority import esphome.final_validate as fv _LOGGER = logging.getLogger(__name__) @@ -121,7 +122,7 @@ CONFIG_SCHEMA = ( FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_port(config[CONF_PORT])) diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index fd542e594a..d2c574d8c6 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -3,7 +3,8 @@ import esphome.codegen as cg from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_URL, CONF_USERNAME -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import coroutine_with_priority +from esphome.coroutine import CoroPriority from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index a21ef9d97b..a84fe5a249 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -11,7 +11,8 @@ from esphome.const import ( CONF_SERVICES, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] @@ -72,7 +73,7 @@ def mdns_service( ) -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.NETWORK_SERVICES) async def to_code(config): if config[CONF_DISABLED] is True: return diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index cf814fb1ee..eec39668db 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -10,7 +10,8 @@ from esphome.const import ( CONF_TRIGGER_ID, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority CODEOWNERS = ["@esphome/core"] AUTO_LOAD = ["md5", "safe_mode"] @@ -82,7 +83,7 @@ BASE_OTA_SCHEMA = cv.Schema( ) -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): cg.add_define("USE_OTA") diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 3ec7e65e1d..22e56639e1 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -3,7 +3,8 @@ from esphome.components.esp32 import add_idf_component from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network", "web_server_base"] @@ -22,7 +23,7 @@ CONFIG_SCHEMA = ( ) -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.WEB_SERVER_OTA) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 8add2f051f..2aff405036 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -1,7 +1,8 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.Schema( ) -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.WEB_SERVER_BASE) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/coroutine.py b/esphome/coroutine.py index 741a0c7c0c..0331c602c5 100644 --- a/esphome/coroutine.py +++ b/esphome/coroutine.py @@ -90,11 +90,30 @@ class CoroPriority(enum.IntEnum): # Examples: status_led (80) STATUS = 80 + # Web server infrastructure + # Examples: web_server_base (65) + WEB_SERVER_BASE = 65 + + # Network portal services + # Examples: captive_portal (64) + CAPTIVE_PORTAL = 64 + # Communication protocols and services - # Examples: web_server_base (65), captive_portal (64), wifi (60), ethernet (60), - # mdns (55), ota_updates (54), web_server_ota (52) + # Examples: wifi (60), ethernet (60) COMMUNICATION = 60 + # Network discovery and management services + # Examples: mdns (55) + NETWORK_SERVICES = 55 + + # OTA update services + # Examples: ota_updates (54) + OTA_UPDATES = 54 + + # Web-based OTA services + # Examples: web_server_ota (52) + WEB_SERVER_OTA = 52 + # Application-level services # Examples: safe_mode (50) APPLICATION = 50 diff --git a/tests/components/mdns/test-comprehensive.esp8266-ard.yaml b/tests/components/mdns/test-comprehensive.esp8266-ard.yaml new file mode 100644 index 0000000000..02767833a3 --- /dev/null +++ b/tests/components/mdns/test-comprehensive.esp8266-ard.yaml @@ -0,0 +1,42 @@ +# Comprehensive ESP8266 test for mdns with multiple network components +# Tests the complete priority chain: +# wifi (60) -> mdns (55) -> ota (54) -> web_server_ota (52) + +esphome: + name: mdns-comprehensive-test + +esp8266: + board: esp01_1m + +logger: + level: DEBUG + +wifi: + ssid: MySSID + password: password1 + +# web_server_base should run at priority 65 (before wifi) +web_server: + port: 80 + +# mdns should run at priority 55 (after wifi at 60) +mdns: + services: + - service: _http + protocol: _tcp + port: 80 + +# OTA should run at priority 54 (after mdns) +ota: + - platform: esphome + password: "otapassword" + +# Test status LED at priority 80 +status_led: + pin: + number: GPIO2 + inverted: true + +# Include API at priority 40 +api: + password: "apipassword" diff --git a/tests/unit_tests/test_coroutine.py b/tests/unit_tests/test_coroutine.py index 138b08edb5..e12c273294 100644 --- a/tests/unit_tests/test_coroutine.py +++ b/tests/unit_tests/test_coroutine.py @@ -13,7 +13,12 @@ def test_coro_priority_enum_values() -> None: assert CoroPriority.CORE == 100 assert CoroPriority.DIAGNOSTICS == 90 assert CoroPriority.STATUS == 80 + assert CoroPriority.WEB_SERVER_BASE == 65 + assert CoroPriority.CAPTIVE_PORTAL == 64 assert CoroPriority.COMMUNICATION == 60 + assert CoroPriority.NETWORK_SERVICES == 55 + assert CoroPriority.OTA_UPDATES == 54 + assert CoroPriority.WEB_SERVER_OTA == 52 assert CoroPriority.APPLICATION == 50 assert CoroPriority.WEB == 40 assert CoroPriority.AUTOMATION == 30 @@ -70,7 +75,12 @@ def test_float_and_enum_are_interchangeable() -> None: (CoroPriority.CORE, 100.0), (CoroPriority.DIAGNOSTICS, 90.0), (CoroPriority.STATUS, 80.0), + (CoroPriority.WEB_SERVER_BASE, 65.0), + (CoroPriority.CAPTIVE_PORTAL, 64.0), (CoroPriority.COMMUNICATION, 60.0), + (CoroPriority.NETWORK_SERVICES, 55.0), + (CoroPriority.OTA_UPDATES, 54.0), + (CoroPriority.WEB_SERVER_OTA, 52.0), (CoroPriority.APPLICATION, 50.0), (CoroPriority.WEB, 40.0), (CoroPriority.AUTOMATION, 30.0), @@ -164,8 +174,13 @@ def test_enum_priority_comparison() -> None: assert CoroPriority.NETWORK_TRANSPORT > CoroPriority.CORE assert CoroPriority.CORE > CoroPriority.DIAGNOSTICS assert CoroPriority.DIAGNOSTICS > CoroPriority.STATUS - assert CoroPriority.STATUS > CoroPriority.COMMUNICATION - assert CoroPriority.COMMUNICATION > CoroPriority.APPLICATION + assert CoroPriority.STATUS > CoroPriority.WEB_SERVER_BASE + assert CoroPriority.WEB_SERVER_BASE > CoroPriority.CAPTIVE_PORTAL + assert CoroPriority.CAPTIVE_PORTAL > CoroPriority.COMMUNICATION + assert CoroPriority.COMMUNICATION > CoroPriority.NETWORK_SERVICES + assert CoroPriority.NETWORK_SERVICES > CoroPriority.OTA_UPDATES + assert CoroPriority.OTA_UPDATES > CoroPriority.WEB_SERVER_OTA + assert CoroPriority.WEB_SERVER_OTA > CoroPriority.APPLICATION assert CoroPriority.APPLICATION > CoroPriority.WEB assert CoroPriority.WEB > CoroPriority.AUTOMATION assert CoroPriority.AUTOMATION > CoroPriority.BUS From d74cfefeef202129733cf6b5bfb0ed794daf3ad1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Sep 2025 23:39:14 -0500 Subject: [PATCH 1209/1626] [ethernet] Remove redundant Arduino framework version check (#10781) --- esphome/components/ethernet/__init__.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 95711f2b80..ef8e82a843 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -117,19 +117,15 @@ ManualIP = ethernet_ns.struct("ManualIP") def _is_framework_spi_polling_mode_supported(): # SPI Ethernet without IRQ feature is added in - # esp-idf >= (5.3+ ,5.2.1+, 5.1.4) and arduino-esp32 >= 3.0.0 + # esp-idf >= (5.3+ ,5.2.1+, 5.1.4) + # Note: Arduino now uses ESP-IDF as a component, so we only check IDF version framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - if CORE.using_esp_idf: - if framework_version >= cv.Version(5, 3, 0): - return True - if cv.Version(5, 3, 0) > framework_version >= cv.Version(5, 2, 1): - return True - if cv.Version(5, 2, 0) > framework_version >= cv.Version(5, 1, 4): # noqa: SIM103 - return True - return False - if CORE.using_arduino: - return framework_version >= cv.Version(3, 0, 0) - # fail safe: Unknown framework + if framework_version >= cv.Version(5, 3, 0): + return True + if cv.Version(5, 3, 0) > framework_version >= cv.Version(5, 2, 1): + return True + if cv.Version(5, 2, 0) > framework_version >= cv.Version(5, 1, 4): # noqa: SIM103 + return True return False From 37d526f00306eadcb8702e6b02232beb8398dd96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 05:22:22 -0500 Subject: [PATCH 1210/1626] [gpio] Fix unused function warnings when compiling with log level below DEBUG (#10779) --- esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index 45544c185b..7a35596194 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -6,6 +6,7 @@ namespace gpio { static const char *const TAG = "gpio.binary_sensor"; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG static const LogString *interrupt_type_to_string(gpio::InterruptType type) { switch (type) { case gpio::INTERRUPT_RISING_EDGE: @@ -22,6 +23,7 @@ static const LogString *interrupt_type_to_string(gpio::InterruptType type) { static const LogString *gpio_mode_to_string(bool use_interrupt) { return use_interrupt ? LOG_STR("interrupt") : LOG_STR("polling"); } +#endif void IRAM_ATTR GPIOBinarySensorStore::gpio_intr(GPIOBinarySensorStore *arg) { bool new_state = arg->isr_pin_.digital_read(); From 6781da45cb273d876718efd52f6a95a865f051cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 05:24:50 -0500 Subject: [PATCH 1211/1626] [esp32] Optimize NVS preferences memory usage by replacing vector with unique_ptr (#10729) --- esphome/components/esp32/preferences.cpp | 40 ++++++++++++++---------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index c5b07b497c..7bdbb265ca 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -17,7 +17,14 @@ static const char *const TAG = "esp32.preferences"; struct NVSData { std::string key; - std::vector data; + std::unique_ptr data; + size_t len; + + void set_data(const uint8_t *src, size_t size) { + data = std::make_unique(size); + memcpy(data.get(), src, size); + len = size; + } }; static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -30,26 +37,26 @@ class ESP32PreferenceBackend : public ESPPreferenceBackend { // try find in pending saves and update that for (auto &obj : s_pending_save) { if (obj.key == key) { - obj.data.assign(data, data + len); + obj.set_data(data, len); return true; } } NVSData save{}; save.key = key; - save.data.assign(data, data + len); - s_pending_save.emplace_back(save); - ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %d", key.c_str(), len); + save.set_data(data, len); + s_pending_save.emplace_back(std::move(save)); + ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %zu", key.c_str(), len); return true; } bool load(uint8_t *data, size_t len) override { // try find in pending saves and load from that for (auto &obj : s_pending_save) { if (obj.key == key) { - if (obj.data.size() != len) { + if (obj.len != len) { // size mismatch return false; } - memcpy(data, obj.data.data(), len); + memcpy(data, obj.data.get(), len); return true; } } @@ -61,7 +68,7 @@ class ESP32PreferenceBackend : public ESPPreferenceBackend { return false; } if (actual_len != len) { - ESP_LOGVV(TAG, "NVS length does not match (%u!=%u)", actual_len, len); + ESP_LOGVV(TAG, "NVS length does not match (%zu!=%zu)", actual_len, len); return false; } err = nvs_get_blob(nvs_handle, key.c_str(), data, &len); @@ -69,7 +76,7 @@ class ESP32PreferenceBackend : public ESPPreferenceBackend { ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", key.c_str(), esp_err_to_name(err)); return false; } else { - ESP_LOGVV(TAG, "nvs_get_blob: key: %s, len: %d", key.c_str(), len); + ESP_LOGVV(TAG, "nvs_get_blob: key: %s, len: %zu", key.c_str(), len); } return true; } @@ -112,7 +119,7 @@ class ESP32Preferences : public ESPPreferences { if (s_pending_save.empty()) return true; - ESP_LOGV(TAG, "Saving %d items...", s_pending_save.size()); + ESP_LOGV(TAG, "Saving %zu items...", s_pending_save.size()); // goal try write all pending saves even if one fails int cached = 0, written = 0, failed = 0; esp_err_t last_err = ESP_OK; @@ -123,11 +130,10 @@ class ESP32Preferences : public ESPPreferences { const auto &save = s_pending_save[i]; ESP_LOGVV(TAG, "Checking if NVS data %s has changed", save.key.c_str()); if (is_changed(nvs_handle, save)) { - esp_err_t err = nvs_set_blob(nvs_handle, save.key.c_str(), save.data.data(), save.data.size()); - ESP_LOGV(TAG, "sync: key: %s, len: %d", save.key.c_str(), save.data.size()); + esp_err_t err = nvs_set_blob(nvs_handle, save.key.c_str(), save.data.get(), save.len); + ESP_LOGV(TAG, "sync: key: %s, len: %zu", save.key.c_str(), save.len); if (err != 0) { - ESP_LOGV(TAG, "nvs_set_blob('%s', len=%u) failed: %s", save.key.c_str(), save.data.size(), - esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_set_blob('%s', len=%zu) failed: %s", save.key.c_str(), save.len, esp_err_to_name(err)); failed++; last_err = err; last_key = save.key; @@ -135,7 +141,7 @@ class ESP32Preferences : public ESPPreferences { } written++; } else { - ESP_LOGV(TAG, "NVS data not changed skipping %s len=%u", save.key.c_str(), save.data.size()); + ESP_LOGV(TAG, "NVS data not changed skipping %s len=%zu", save.key.c_str(), save.len); cached++; } s_pending_save.erase(s_pending_save.begin() + i); @@ -164,7 +170,7 @@ class ESP32Preferences : public ESPPreferences { return true; } // Check size first before allocating memory - if (actual_len != to_save.data.size()) { + if (actual_len != to_save.len) { return true; } auto stored_data = std::make_unique(actual_len); @@ -173,7 +179,7 @@ class ESP32Preferences : public ESPPreferences { ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", to_save.key.c_str(), esp_err_to_name(err)); return true; } - return memcmp(to_save.data.data(), stored_data.get(), to_save.data.size()) != 0; + return memcmp(to_save.data.get(), stored_data.get(), to_save.len) != 0; } bool reset() override { From a302cec993e2d0f70bcf554242cae8622d011025 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 05:25:29 -0500 Subject: [PATCH 1212/1626] [libretiny] Optimize preferences memory usage by replacing vector with unique_ptr (#10731) --- esphome/components/libretiny/preferences.cpp | 39 ++++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index fc535c99b4..871b186d8e 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -15,7 +15,14 @@ static const char *const TAG = "lt.preferences"; struct NVSData { std::string key; - std::vector data; + std::unique_ptr data; + size_t len; + + void set_data(const uint8_t *src, size_t size) { + data = std::make_unique(size); + memcpy(data.get(), src, size); + len = size; + } }; static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -30,15 +37,15 @@ class LibreTinyPreferenceBackend : public ESPPreferenceBackend { // try find in pending saves and update that for (auto &obj : s_pending_save) { if (obj.key == key) { - obj.data.assign(data, data + len); + obj.set_data(data, len); return true; } } NVSData save{}; save.key = key; - save.data.assign(data, data + len); - s_pending_save.emplace_back(save); - ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %d", key.c_str(), len); + save.set_data(data, len); + s_pending_save.emplace_back(std::move(save)); + ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %zu", key.c_str(), len); return true; } @@ -46,11 +53,11 @@ class LibreTinyPreferenceBackend : public ESPPreferenceBackend { // try find in pending saves and load from that for (auto &obj : s_pending_save) { if (obj.key == key) { - if (obj.data.size() != len) { + if (obj.len != len) { // size mismatch return false; } - memcpy(data, obj.data.data(), len); + memcpy(data, obj.data.get(), len); return true; } } @@ -58,10 +65,10 @@ class LibreTinyPreferenceBackend : public ESPPreferenceBackend { fdb_blob_make(blob, data, len); size_t actual_len = fdb_kv_get_blob(db, key.c_str(), blob); if (actual_len != len) { - ESP_LOGVV(TAG, "NVS length does not match (%u!=%u)", actual_len, len); + ESP_LOGVV(TAG, "NVS length does not match (%zu!=%zu)", actual_len, len); return false; } else { - ESP_LOGVV(TAG, "fdb_kv_get_blob: key: %s, len: %d", key.c_str(), len); + ESP_LOGVV(TAG, "fdb_kv_get_blob: key: %s, len: %zu", key.c_str(), len); } return true; } @@ -101,7 +108,7 @@ class LibreTinyPreferences : public ESPPreferences { if (s_pending_save.empty()) return true; - ESP_LOGV(TAG, "Saving %d items...", s_pending_save.size()); + ESP_LOGV(TAG, "Saving %zu items...", s_pending_save.size()); // goal try write all pending saves even if one fails int cached = 0, written = 0, failed = 0; fdb_err_t last_err = FDB_NO_ERR; @@ -112,11 +119,11 @@ class LibreTinyPreferences : public ESPPreferences { const auto &save = s_pending_save[i]; ESP_LOGVV(TAG, "Checking if FDB data %s has changed", save.key.c_str()); if (is_changed(&db, save)) { - ESP_LOGV(TAG, "sync: key: %s, len: %d", save.key.c_str(), save.data.size()); - fdb_blob_make(&blob, save.data.data(), save.data.size()); + ESP_LOGV(TAG, "sync: key: %s, len: %zu", save.key.c_str(), save.len); + fdb_blob_make(&blob, save.data.get(), save.len); fdb_err_t err = fdb_kv_set_blob(&db, save.key.c_str(), &blob); if (err != FDB_NO_ERR) { - ESP_LOGV(TAG, "fdb_kv_set_blob('%s', len=%u) failed: %d", save.key.c_str(), save.data.size(), err); + ESP_LOGV(TAG, "fdb_kv_set_blob('%s', len=%zu) failed: %d", save.key.c_str(), save.len, err); failed++; last_err = err; last_key = save.key; @@ -124,7 +131,7 @@ class LibreTinyPreferences : public ESPPreferences { } written++; } else { - ESP_LOGD(TAG, "FDB data not changed; skipping %s len=%u", save.key.c_str(), save.data.size()); + ESP_LOGD(TAG, "FDB data not changed; skipping %s len=%zu", save.key.c_str(), save.len); cached++; } s_pending_save.erase(s_pending_save.begin() + i); @@ -147,7 +154,7 @@ class LibreTinyPreferences : public ESPPreferences { } // Check size first - if different, data has changed - if (kv.value_len != to_save.data.size()) { + if (kv.value_len != to_save.len) { return true; } @@ -161,7 +168,7 @@ class LibreTinyPreferences : public ESPPreferences { } // Compare the actual data - return memcmp(to_save.data.data(), stored_data.get(), kv.value_len) != 0; + return memcmp(to_save.data.get(), stored_data.get(), kv.value_len) != 0; } bool reset() override { From bff257258e5842be055b0ca9084fbda56822aec5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 14:33:37 -0500 Subject: [PATCH 1213/1626] preen --- esphome/components/esphome/ota/__init__.py | 2 +- .../components/esphome/ota/ota_esphome.cpp | 170 +++++++++++++----- esphome/components/ota/ota_backend.h | 1 + esphome/components/sha256/__init__.py | 14 ++ esphome/components/sha256/sha256.cpp | 131 ++++++++++++++ esphome/components/sha256/sha256.h | 36 ++++ esphome/core/defines.h | 1 + esphome/espota2.py | 55 ++++-- 8 files changed, 344 insertions(+), 66 deletions(-) create mode 100644 esphome/components/sha256/__init__.py create mode 100644 esphome/components/sha256/sha256.cpp create mode 100644 esphome/components/sha256/sha256.h diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 93216f9425..c8bb055c16 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] -AUTO_LOAD = ["md5", "socket"] +AUTO_LOAD = ["md5", "sha256", "socket"] DEPENDENCIES = ["network"] esphome = cg.esphome_ns.namespace("esphome") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6654ef8748..23e58de3e6 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,6 +1,9 @@ #include "ota_esphome.h" #ifdef USE_OTA #include "esphome/components/md5/md5.h" +#ifdef USE_SHA256 +#include "esphome/components/sha256/sha256.h" +#endif #include "esphome/components/network/util.h" #include "esphome/components/ota/ota_backend.h" #include "esphome/components/ota/ota_backend_arduino_esp32.h" @@ -95,6 +98,111 @@ void ESPHomeOTAComponent::loop() { } static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; +#ifdef USE_SHA256 +static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +#endif + +// Template traits for hash algorithms +template struct HashTraits; + +template<> struct HashTraits { + static constexpr int nonce_size = 8; + static constexpr int hex_size = 32; + static constexpr const char *name = "MD5"; + static constexpr ota::OTAResponseTypes auth_request = ota::OTA_RESPONSE_REQUEST_AUTH; +}; + +#ifdef USE_SHA256 +template<> struct HashTraits { + static constexpr int nonce_size = 16; + static constexpr int hex_size = 64; + static constexpr const char *name = "SHA256"; + static constexpr ota::OTAResponseTypes auth_request = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; +}; +#endif + +// Template helper for hash-based authentication +template bool perform_hash_auth(ESPHomeOTAComponent *ota, const std::string &password) { + using Traits = HashTraits; + + // Minimize stack usage by reusing buffers + // We only need 2 buffers at most at the same time + constexpr size_t hex_buffer_size = Traits::hex_size + 1; + + // These two buffers are reused throughout the function + char hex_buffer1[hex_buffer_size]; // Used for: nonce -> expected result + char hex_buffer2[hex_buffer_size]; // Used for: cnonce -> response + + // Small stack buffer for auth request and nonce seed + uint8_t buf[1]; + char nonce_seed[17]; // Max: "%08x%08x" = 16 chars + null + + // Send auth request type + buf[0] = Traits::auth_request; + ota->writeall_(buf, 1); + + HashClass hasher; + hasher.init(); + + // Generate nonce seed + if (Traits::nonce_size == 8) { + sprintf(nonce_seed, "%08" PRIx32, random_uint32()); + } else { + sprintf(nonce_seed, "%08" PRIx32 "%08" PRIx32, random_uint32(), random_uint32()); + } + hasher.add(nonce_seed, Traits::nonce_size); + hasher.calculate(); + + // Use hex_buffer1 for nonce + hasher.get_hex(hex_buffer1); + hex_buffer1[Traits::hex_size] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::name, hex_buffer1); + + // Send nonce + if (!ota->writeall_(reinterpret_cast(hex_buffer1), Traits::hex_size)) { + ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::name); + return false; + } + + // Prepare challenge + hasher.init(); + hasher.add(password.c_str(), password.length()); + hasher.add(hex_buffer1, Traits::hex_size); // Add nonce + + // Receive cnonce into hex_buffer2 + if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::hex_size)) { + ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::name); + return false; + } + hex_buffer2[Traits::hex_size] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s CNonce is %s", Traits::name, hex_buffer2); + + // Add cnonce to hash + hasher.add(hex_buffer2, Traits::hex_size); + + // Calculate result - reuse hex_buffer1 for expected + hasher.calculate(); + hasher.get_hex(hex_buffer1); + hex_buffer1[Traits::hex_size] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::name, hex_buffer1); + + // Receive response - reuse hex_buffer2 + if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::hex_size)) { + ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::name); + return false; + } + hex_buffer2[Traits::hex_size] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Response is %s", Traits::name, hex_buffer2); + + // Compare + bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::hex_size) == 0; + + if (!matches) { + ESP_LOGW("esphome.ota", "Auth failed! %s passwords do not match", Traits::name); + } + + return matches; +} void ESPHomeOTAComponent::handle_handshake_() { /// Handle the initial OTA handshake. @@ -225,57 +333,23 @@ void ESPHomeOTAComponent::handle_data_() { #ifdef USE_OTA_PASSWORD if (!this->password_.empty()) { - buf[0] = ota::OTA_RESPONSE_REQUEST_AUTH; - this->writeall_(buf, 1); - md5::MD5Digest md5{}; - md5.init(); - sprintf(sbuf, "%08" PRIx32, random_uint32()); - md5.add(sbuf, 8); - md5.calculate(); - md5.get_hex(sbuf); - ESP_LOGV(TAG, "Auth: Nonce is %s", sbuf); + bool auth_success = false; - // Send nonce, 32 bytes hex MD5 - if (!this->writeall_(reinterpret_cast(sbuf), 32)) { - ESP_LOGW(TAG, "Auth: Writing nonce failed"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) +#ifdef USE_SHA256 + // Check if client supports SHA256 auth + bool use_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + + if (use_sha256) { + // Use SHA256 for authentication + auth_success = perform_hash_auth(this, this->password_); + } else +#endif // USE_SHA256 + { + // Fall back to MD5 for backward compatibility (or when SHA256 is not available) + auth_success = perform_hash_auth(this, this->password_); } - // prepare challenge - md5.init(); - md5.add(this->password_.c_str(), this->password_.length()); - // add nonce - md5.add(sbuf, 32); - - // Receive cnonce, 32 bytes hex MD5 - if (!this->readall_(buf, 32)) { - ESP_LOGW(TAG, "Auth: Reading cnonce failed"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - sbuf[32] = '\0'; - ESP_LOGV(TAG, "Auth: CNonce is %s", sbuf); - // add cnonce - md5.add(sbuf, 32); - - // calculate result - md5.calculate(); - md5.get_hex(sbuf); - ESP_LOGV(TAG, "Auth: Result is %s", sbuf); - - // Receive result, 32 bytes hex MD5 - if (!this->readall_(buf + 64, 32)) { - ESP_LOGW(TAG, "Auth: Reading response failed"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - sbuf[64 + 32] = '\0'; - ESP_LOGV(TAG, "Auth: Response is %s", sbuf + 64); - - bool matches = true; - for (uint8_t i = 0; i < 32; i++) - matches = matches && buf[i] == buf[64 + i]; - - if (!matches) { - ESP_LOGW(TAG, "Auth failed! Passwords do not match"); + if (!auth_success) { error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 372f24df5e..64ee0b9f7c 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -14,6 +14,7 @@ namespace ota { enum OTAResponseTypes { OTA_RESPONSE_OK = 0x00, OTA_RESPONSE_REQUEST_AUTH = 0x01, + OTA_RESPONSE_REQUEST_SHA256_AUTH = 0x02, OTA_RESPONSE_HEADER_OK = 0x40, OTA_RESPONSE_AUTH_OK = 0x41, diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py new file mode 100644 index 0000000000..4b4be4616e --- /dev/null +++ b/esphome/components/sha256/__init__.py @@ -0,0 +1,14 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.core import coroutine_with_priority + +CODEOWNERS = ["@esphome/core"] + +sha256_ns = cg.esphome_ns.namespace("sha256") + +CONFIG_SCHEMA = cv.All(cv.Schema({})) + + +@coroutine_with_priority(1.0) +async def to_code(config): + cg.add_define("USE_SHA256") diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp new file mode 100644 index 0000000000..1723fc6851 --- /dev/null +++ b/esphome/components/sha256/sha256.cpp @@ -0,0 +1,131 @@ +#include "sha256.h" +#include "esphome/core/helpers.h" +#include + +#ifdef USE_ESP32 +#include "mbedtls/sha256.h" +#elif defined(USE_ESP8266) || defined(USE_RP2040) +#include +#endif + +namespace esphome { +namespace sha256 { + +#ifdef USE_ESP32 +struct SHA256::SHA256Context { + mbedtls_sha256_context ctx; + uint8_t hash[32]; +}; + +SHA256::~SHA256() { + if (this->ctx_) { + mbedtls_sha256_free(&this->ctx_->ctx); + } +} + +void SHA256::init() { + if (!this->ctx_) { + this->ctx_ = std::make_unique(); + } + mbedtls_sha256_init(&this->ctx_->ctx); + mbedtls_sha256_starts(&this->ctx_->ctx, 0); // 0 = SHA256, not SHA224 +} + +void SHA256::add(const uint8_t *data, size_t len) { + if (!this->ctx_) { + this->init(); + } + mbedtls_sha256_update(&this->ctx_->ctx, data, len); +} + +void SHA256::calculate() { + if (!this->ctx_) { + this->init(); + } + mbedtls_sha256_finish(&this->ctx_->ctx, this->ctx_->hash); +} + +#elif defined(USE_ESP8266) || defined(USE_RP2040) + +struct SHA256::SHA256Context { + ::SHA256 sha; + uint8_t hash[32]; + bool calculated{false}; +}; + +SHA256::~SHA256() = default; + +void SHA256::init() { + if (!this->ctx_) { + this->ctx_ = std::make_unique(); + } + this->ctx_->sha.reset(); + this->ctx_->calculated = false; +} + +void SHA256::add(const uint8_t *data, size_t len) { + if (!this->ctx_) { + this->init(); + } + this->ctx_->sha.update(data, len); +} + +void SHA256::calculate() { + if (!this->ctx_) { + this->init(); + } + if (!this->ctx_->calculated) { + this->ctx_->sha.finalize(this->ctx_->hash, 32); + this->ctx_->calculated = true; + } +} + +#else +#error "SHA256 not supported on this platform" +#endif + +void SHA256::get_bytes(uint8_t *output) { + if (!this->ctx_) { + memset(output, 0, 32); + return; + } + memcpy(output, this->ctx_->hash, 32); +} + +void SHA256::get_hex(char *output) { + if (!this->ctx_) { + memset(output, '0', 64); + output[64] = '\0'; + return; + } + for (size_t i = 0; i < 32; i++) { + sprintf(output + i * 2, "%02x", this->ctx_->hash[i]); + } +} + +std::string SHA256::get_hex_string() { + char buf[65]; + this->get_hex(buf); + return std::string(buf); +} + +bool SHA256::equals_bytes(const uint8_t *expected) { + if (!this->ctx_) { + return false; + } + return memcmp(this->ctx_->hash, expected, 32) == 0; +} + +bool SHA256::equals_hex(const char *expected) { + if (!this->ctx_) { + return false; + } + uint8_t parsed[32]; + if (!parse_hex(expected, parsed, 32)) { + return false; + } + return this->equals_bytes(parsed); +} + +} // namespace sha256 +} // namespace esphome diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h new file mode 100644 index 0000000000..4d94c5b77d --- /dev/null +++ b/esphome/components/sha256/sha256.h @@ -0,0 +1,36 @@ +#pragma once + +#include "esphome/core/defines.h" +#include +#include +#include + +namespace esphome { +namespace sha256 { + +class SHA256 { + public: + SHA256() = default; + ~SHA256(); + + void init(); + void add(const uint8_t *data, size_t len); + void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + void add(const std::string &data) { this->add(data.c_str(), data.length()); } + + void calculate(); + + void get_bytes(uint8_t *output); + void get_hex(char *output); + std::string get_hex_string(); + + bool equals_bytes(const uint8_t *expected); + bool equals_hex(const char *expected); + + protected: + struct SHA256Context; + std::unique_ptr ctx_; +}; + +} // namespace sha256 +} // namespace esphome diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6e8d5ed74c..052ef11ec4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -115,6 +115,7 @@ #define USE_API_PLAINTEXT #define USE_API_SERVICES #define USE_MD5 +#define USE_SHA256 #define USE_MQTT #define USE_NETWORK #define USE_ONLINE_IMAGE_BMP_SUPPORT diff --git a/esphome/espota2.py b/esphome/espota2.py index 3d25af985b..8afd3a0a72 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -14,6 +14,7 @@ from esphome.helpers import resolve_ip_address RESPONSE_OK = 0x00 RESPONSE_REQUEST_AUTH = 0x01 +RESPONSE_REQUEST_SHA256_AUTH = 0x02 RESPONSE_HEADER_OK = 0x40 RESPONSE_AUTH_OK = 0x41 @@ -44,6 +45,7 @@ OTA_VERSION_2_0 = 2 MAGIC_BYTES = [0x6C, 0x26, 0xF7, 0x5C, 0x45] FEATURE_SUPPORTS_COMPRESSION = 0x01 +FEATURE_SUPPORTS_SHA256_AUTH = 0x02 UPLOAD_BLOCK_SIZE = 8192 @@ -209,10 +211,14 @@ def perform_ota( f"Device uses unsupported OTA version {version}, this ESPHome supports {supported_versions}" ) - # Features - send_check(sock, FEATURE_SUPPORTS_COMPRESSION, "features") + # Features - send both compression and SHA256 auth support + features_to_send = FEATURE_SUPPORTS_COMPRESSION | FEATURE_SUPPORTS_SHA256_AUTH + send_check(sock, features_to_send, "features") features = receive_exactly( - sock, 1, "features", [RESPONSE_HEADER_OK, RESPONSE_SUPPORTS_COMPRESSION] + sock, + 1, + "features", + None, # Accept any response )[0] if features == RESPONSE_SUPPORTS_COMPRESSION: @@ -221,31 +227,46 @@ def perform_ota( else: upload_contents = file_contents - (auth,) = receive_exactly( - sock, 1, "auth", [RESPONSE_REQUEST_AUTH, RESPONSE_AUTH_OK] - ) - if auth == RESPONSE_REQUEST_AUTH: + def perform_auth(sock, password, hash_func, nonce_size, hash_name): + """Perform challenge-response authentication using specified hash algorithm.""" if not password: raise OTAError("ESP requests password, but no password given!") + nonce = receive_exactly( - sock, 32, "authentication nonce", [], decode=False + sock, nonce_size, f"{hash_name} authentication nonce", [], decode=False ).decode() - _LOGGER.debug("Auth: Nonce is %s", nonce) - cnonce = hashlib.md5(str(random.random()).encode()).hexdigest() - _LOGGER.debug("Auth: CNonce is %s", cnonce) + _LOGGER.debug("Auth: %s Nonce is %s", hash_name, nonce) + + # Generate cnonce + cnonce = hash_func(str(random.random()).encode()).hexdigest() + _LOGGER.debug("Auth: %s CNonce is %s", hash_name, cnonce) send_check(sock, cnonce, "auth cnonce") - result_md5 = hashlib.md5() - result_md5.update(password.encode("utf-8")) - result_md5.update(nonce.encode()) - result_md5.update(cnonce.encode()) - result = result_md5.hexdigest() - _LOGGER.debug("Auth: Result is %s", result) + # Calculate challenge response + hasher = hash_func() + hasher.update(password.encode("utf-8")) + hasher.update(nonce.encode()) + hasher.update(cnonce.encode()) + result = hasher.hexdigest() + _LOGGER.debug("Auth: %s Result is %s", hash_name, result) send_check(sock, result, "auth result") receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK) + (auth,) = receive_exactly( + sock, + 1, + "auth", + [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], + ) + if auth == RESPONSE_REQUEST_SHA256_AUTH: + # SHA256 authentication + perform_auth(sock, password, hashlib.sha256, 64, "SHA256") + elif auth == RESPONSE_REQUEST_AUTH: + # MD5 authentication (backward compatibility) + perform_auth(sock, password, hashlib.md5, 32, "MD5") + # Set higher timeout during upload sock.settimeout(30.0) From 853d3ae331d1b26abde4d992fe52f63d00e3eaee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 14:46:49 -0500 Subject: [PATCH 1214/1626] preen --- esphome/components/esphome/ota/__init__.py | 18 ++++++++- .../components/esphome/ota/ota_esphome.cpp | 40 +++++++++++++------ esphome/components/sha256/__init__.py | 9 ----- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index c8bb055c16..5579b9ec37 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,7 +16,7 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv @@ -24,9 +24,17 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] -AUTO_LOAD = ["md5", "sha256", "socket"] DEPENDENCIES = ["network"] + +def AUTO_LOAD(): + """Conditionally auto-load sha256 only on platforms that support it.""" + base_components = ["md5", "socket"] + if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040: + return base_components + ["sha256"] + return base_components + + esphome = cg.esphome_ns.namespace("esphome") ESPHomeOTAComponent = esphome.class_("ESPHomeOTAComponent", OTAComponent) @@ -126,6 +134,12 @@ FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_port(config[CONF_PORT])) + + # Only include SHA256 support on platforms that have it + # This prevents including unnecessary SHA256 code on platforms like LibreTiny + if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040: + cg.add_define("USE_OTA_SHA256") + if CONF_PASSWORD in config: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_PASSWORD") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 23e58de3e6..06fd119c07 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,7 +1,7 @@ #include "ota_esphome.h" #ifdef USE_OTA #include "esphome/components/md5/md5.h" -#ifdef USE_SHA256 +#ifdef USE_OTA_SHA256 #include "esphome/components/sha256/sha256.h" #endif #include "esphome/components/network/util.h" @@ -98,7 +98,7 @@ void ESPHomeOTAComponent::loop() { } static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; -#ifdef USE_SHA256 +#ifdef USE_OTA_SHA256 static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; #endif @@ -112,7 +112,7 @@ template<> struct HashTraits { static constexpr ota::OTAResponseTypes auth_request = ota::OTA_RESPONSE_REQUEST_AUTH; }; -#ifdef USE_SHA256 +#ifdef USE_OTA_SHA256 template<> struct HashTraits { static constexpr int nonce_size = 16; static constexpr int hex_size = 64; @@ -133,9 +133,9 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co char hex_buffer1[hex_buffer_size]; // Used for: nonce -> expected result char hex_buffer2[hex_buffer_size]; // Used for: cnonce -> response - // Small stack buffer for auth request and nonce seed + // Small stack buffer for auth request and nonce seed bytes uint8_t buf[1]; - char nonce_seed[17]; // Max: "%08x%08x" = 16 chars + null + uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) // Send auth request type buf[0] = Traits::auth_request; @@ -144,13 +144,29 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co HashClass hasher; hasher.init(); - // Generate nonce seed + // Generate nonce seed bytes + uint32_t r1 = random_uint32(); + // Convert first uint32 to bytes (always needed for MD5) + nonce_bytes[0] = (r1 >> 24) & 0xFF; + nonce_bytes[1] = (r1 >> 16) & 0xFF; + nonce_bytes[2] = (r1 >> 8) & 0xFF; + nonce_bytes[3] = r1 & 0xFF; + if (Traits::nonce_size == 8) { - sprintf(nonce_seed, "%08" PRIx32, random_uint32()); - } else { - sprintf(nonce_seed, "%08" PRIx32 "%08" PRIx32, random_uint32(), random_uint32()); + // MD5: 8 chars = "%08x" format = 4 bytes from one random uint32 + hasher.add(nonce_bytes, 4); } - hasher.add(nonce_seed, Traits::nonce_size); +#ifdef USE_OTA_SHA256 + else { + // SHA256: 16 chars = "%08x%08x" format = 8 bytes from two random uint32s + uint32_t r2 = random_uint32(); + nonce_bytes[4] = (r2 >> 24) & 0xFF; + nonce_bytes[5] = (r2 >> 16) & 0xFF; + nonce_bytes[6] = (r2 >> 8) & 0xFF; + nonce_bytes[7] = r2 & 0xFF; + hasher.add(nonce_bytes, 8); + } +#endif hasher.calculate(); // Use hex_buffer1 for nonce @@ -335,7 +351,7 @@ void ESPHomeOTAComponent::handle_data_() { if (!this->password_.empty()) { bool auth_success = false; -#ifdef USE_SHA256 +#ifdef USE_OTA_SHA256 // Check if client supports SHA256 auth bool use_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; @@ -343,7 +359,7 @@ void ESPHomeOTAComponent::handle_data_() { // Use SHA256 for authentication auth_success = perform_hash_auth(this, this->password_); } else -#endif // USE_SHA256 +#endif // USE_OTA_SHA256 { // Fall back to MD5 for backward compatibility (or when SHA256 is not available) auth_success = perform_hash_auth(this, this->password_); diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index 4b4be4616e..e24da86e25 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -1,14 +1,5 @@ import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.core import coroutine_with_priority CODEOWNERS = ["@esphome/core"] sha256_ns = cg.esphome_ns.namespace("sha256") - -CONFIG_SCHEMA = cv.All(cv.Schema({})) - - -@coroutine_with_priority(1.0) -async def to_code(config): - cg.add_define("USE_SHA256") From f15c83462c8d8c2dafbeb0b865431013ba3df635 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 15:06:51 -0500 Subject: [PATCH 1215/1626] preen --- esphome/components/esphome/ota/__init__.py | 5 ++--- esphome/components/sha256/sha256.cpp | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 5579b9ec37..3134fadf26 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -30,7 +30,7 @@ DEPENDENCIES = ["network"] def AUTO_LOAD(): """Conditionally auto-load sha256 only on platforms that support it.""" base_components = ["md5", "socket"] - if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny: return base_components + ["sha256"] return base_components @@ -136,8 +136,7 @@ async def to_code(config): cg.add(var.set_port(config[CONF_PORT])) # Only include SHA256 support on platforms that have it - # This prevents including unnecessary SHA256 code on platforms like LibreTiny - if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny: cg.add_define("USE_OTA_SHA256") if CONF_PASSWORD in config: diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 1723fc6851..3788c28741 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -4,7 +4,7 @@ #ifdef USE_ESP32 #include "mbedtls/sha256.h" -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) #include #endif @@ -45,7 +45,7 @@ void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_->ctx, this->ctx_->hash); } -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) struct SHA256::SHA256Context { ::SHA256 sha; From 080fe6eae5bd0837c92f8cb0edf41bb7b64b96a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 15:11:52 -0500 Subject: [PATCH 1216/1626] preen --- esphome/components/sha256/sha256.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 3788c28741..a0f82b78ef 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -4,7 +4,7 @@ #ifdef USE_ESP32 #include "mbedtls/sha256.h" -#elif defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) +#elif defined(USE_ARDUINO) #include #endif @@ -45,7 +45,7 @@ void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_->ctx, this->ctx_->hash); } -#elif defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) +#elif defined(USE_ARDUINO) struct SHA256::SHA256Context { ::SHA256 sha; From 8b765715d67d8b2ef5f93b514ab7bdfc98c65c34 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 15:17:25 -0500 Subject: [PATCH 1217/1626] preen --- esphome/components/sha256/sha256.cpp | 6 ++---- esphome/components/sha256/sha256.h | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index a0f82b78ef..699579251e 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -8,8 +8,7 @@ #include #endif -namespace esphome { -namespace sha256 { +namespace esphome::sha256 { #ifdef USE_ESP32 struct SHA256::SHA256Context { @@ -127,5 +126,4 @@ bool SHA256::equals_hex(const char *expected) { return this->equals_bytes(parsed); } -} // namespace sha256 -} // namespace esphome +} // namespace esphome::sha256 diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 4d94c5b77d..dd1742ea0d 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -5,8 +5,7 @@ #include #include -namespace esphome { -namespace sha256 { +namespace esphome::sha256 { class SHA256 { public: @@ -32,5 +31,4 @@ class SHA256 { std::unique_ptr ctx_; }; -} // namespace sha256 -} // namespace esphome +} // namespace esphome::sha256 From fad0ec77939270e3070f6e5d004fd3b482c7e5ad Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 18 Sep 2025 15:43:14 -0500 Subject: [PATCH 1218/1626] [zwave_proxy] New component (#10762) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/api/api.proto | 29 +++ esphome/components/api/api_connection.cpp | 15 ++ esphome/components/api/api_connection.h | 5 + esphome/components/api/api_pb2.cpp | 36 +++ esphome/components/api/api_pb2.h | 46 +++- esphome/components/api/api_pb2_dump.cpp | 27 +++ esphome/components/api/api_pb2_service.cpp | 36 +++ esphome/components/api/api_pb2_service.h | 18 ++ esphome/components/zwave_proxy/__init__.py | 43 ++++ .../components/zwave_proxy/zwave_proxy.cpp | 224 ++++++++++++++++++ esphome/components/zwave_proxy/zwave_proxy.h | 73 ++++++ esphome/core/defines.h | 1 + tests/components/zwave_proxy/common.yaml | 15 ++ .../zwave_proxy/test.esp32-ard.yaml | 5 + .../zwave_proxy/test.esp32-c3-ard.yaml | 5 + .../zwave_proxy/test.esp32-c3-idf.yaml | 5 + .../zwave_proxy/test.esp32-idf.yaml | 5 + .../zwave_proxy/test.esp8266-ard.yaml | 5 + .../zwave_proxy/test.rp2040-ard.yaml | 5 + 20 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 esphome/components/zwave_proxy/__init__.py create mode 100644 esphome/components/zwave_proxy/zwave_proxy.cpp create mode 100644 esphome/components/zwave_proxy/zwave_proxy.h create mode 100644 tests/components/zwave_proxy/common.yaml create mode 100644 tests/components/zwave_proxy/test.esp32-ard.yaml create mode 100644 tests/components/zwave_proxy/test.esp32-c3-ard.yaml create mode 100644 tests/components/zwave_proxy/test.esp32-c3-idf.yaml create mode 100644 tests/components/zwave_proxy/test.esp32-idf.yaml create mode 100644 tests/components/zwave_proxy/test.esp8266-ard.yaml create mode 100644 tests/components/zwave_proxy/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index dc567ca5c0..e91116795a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -548,3 +548,4 @@ esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 esphome/components/zhlt01/* @cfeenstra1024 esphome/components/zio_ultrasonic/* @kahrendt +esphome/components/zwave_proxy/* @kbx81 diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 37e4c16bfc..fa64649df5 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -66,6 +66,9 @@ service APIConnection { rpc voice_assistant_set_configuration(VoiceAssistantSetConfiguration) returns (void) {} rpc alarm_control_panel_command (AlarmControlPanelCommandRequest) returns (void) {} + + rpc zwave_proxy_frame(ZWaveProxyFrame) returns (void) {} + rpc zwave_proxy_request(ZWaveProxyRequest) returns (void) {} } @@ -254,6 +257,9 @@ message DeviceInfoResponse { // Top-level area info to phase out suggested_area AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"]; + + // Indicates if Z-Wave proxy support is available and features supported + uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"]; } message ListEntitiesRequest { @@ -2276,3 +2282,26 @@ message UpdateCommandRequest { UpdateCommand command = 2; uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"]; } + +// ==================== Z-WAVE ==================== + +message ZWaveProxyFrame { + option (id) = 128; + option (source) = SOURCE_BOTH; + option (ifdef) = "USE_ZWAVE_PROXY"; + option (no_delay) = true; + + bytes data = 1 [(fixed_array_size) = 257]; +} + +enum ZWaveProxyRequestType { + ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; + ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; +} +message ZWaveProxyRequest { + option (id) = 129; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_ZWAVE_PROXY"; + + ZWaveProxyRequestType type = 1; +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index dfcab4bda6..697b02b915 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -30,6 +30,9 @@ #ifdef USE_VOICE_ASSISTANT #include "esphome/components/voice_assistant/voice_assistant.h" #endif +#ifdef USE_ZWAVE_PROXY +#include "esphome/components/zwave_proxy/zwave_proxy.h" +#endif namespace esphome::api { @@ -1203,7 +1206,16 @@ void APIConnection::voice_assistant_set_configuration(const VoiceAssistantSetCon voice_assistant::global_voice_assistant->on_set_configuration(msg.active_wake_words); } } +#endif +#ifdef USE_ZWAVE_PROXY +void APIConnection::zwave_proxy_frame(const ZWaveProxyFrame &msg) { + zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len); +} + +void APIConnection::zwave_proxy_request(const ZWaveProxyRequest &msg) { + zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type); +} #endif #ifdef USE_ALARM_CONTROL_PANEL @@ -1460,6 +1472,9 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { #ifdef USE_VOICE_ASSISTANT resp.voice_assistant_feature_flags = voice_assistant::global_voice_assistant->get_feature_flags(); #endif +#ifdef USE_ZWAVE_PROXY + resp.zwave_proxy_feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags(); +#endif #ifdef USE_API_NOISE resp.api_encryption_supported = true; #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7d50aa4591..380894a4ff 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -171,6 +171,11 @@ class APIConnection final : public APIServerConnection { void voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) override; #endif +#ifdef USE_ZWAVE_PROXY + void zwave_proxy_frame(const ZWaveProxyFrame &msg) override; + void zwave_proxy_request(const ZWaveProxyRequest &msg) override; +#endif + #ifdef USE_ALARM_CONTROL_PANEL bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel); void alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) override; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 4f2130466a..58a083ad06 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -129,6 +129,9 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { #ifdef USE_AREAS buffer.encode_message(22, this->area); #endif +#ifdef USE_ZWAVE_PROXY + buffer.encode_uint32(23, this->zwave_proxy_feature_flags); +#endif } void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_API_PASSWORD @@ -181,6 +184,9 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_AREAS size.add_message_object(2, this->area); #endif +#ifdef USE_ZWAVE_PROXY + size.add_uint32(2, this->zwave_proxy_feature_flags); +#endif } #ifdef USE_BINARY_SENSOR void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { @@ -3013,5 +3019,35 @@ bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { return true; } #endif +#ifdef USE_ZWAVE_PROXY +bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 1: { + const std::string &data_str = value.as_string(); + this->data_len = data_str.size(); + if (this->data_len > 257) { + this->data_len = 257; + } + memcpy(this->data, data_str.data(), this->data_len); + break; + } + default: + return false; + } + return true; +} +void ZWaveProxyFrame::encode(ProtoWriteBuffer buffer) const { buffer.encode_bytes(1, this->data, this->data_len); } +void ZWaveProxyFrame::calculate_size(ProtoSize &size) const { size.add_length(1, this->data_len); } +bool ZWaveProxyRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->type = static_cast(value.as_uint32()); + break; + default: + return false; + } + return true; +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index e68fce75aa..d52cb9eab3 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -276,6 +276,12 @@ enum UpdateCommand : uint32_t { UPDATE_COMMAND_CHECK = 2, }; #endif +#ifdef USE_ZWAVE_PROXY +enum ZWaveProxyRequestType : uint32_t { + ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0, + ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1, +}; +#endif } // namespace enums @@ -492,7 +498,7 @@ class DeviceInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint8_t ESTIMATED_SIZE = 247; + static constexpr uint8_t ESTIMATED_SIZE = 252; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "device_info_response"; } #endif @@ -552,6 +558,9 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_AREAS AreaInfo area{}; +#endif +#ifdef USE_ZWAVE_PROXY + uint32_t zwave_proxy_feature_flags{0}; #endif void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; @@ -2913,5 +2922,40 @@ class UpdateCommandRequest final : public CommandProtoMessage { bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif +#ifdef USE_ZWAVE_PROXY +class ZWaveProxyFrame final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 128; + static constexpr uint8_t ESTIMATED_SIZE = 33; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "z_wave_proxy_frame"; } +#endif + uint8_t data[257]{}; + uint16_t data_len{0}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; +}; +class ZWaveProxyRequest final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 129; + static constexpr uint8_t ESTIMATED_SIZE = 2; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "z_wave_proxy_request"; } +#endif + enums::ZWaveProxyRequestType type{}; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 222aa2b603..d24f9b3fdc 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -655,6 +655,18 @@ template<> const char *proto_enum_to_string(enums::UpdateC } } #endif +#ifdef USE_ZWAVE_PROXY +template<> const char *proto_enum_to_string(enums::ZWaveProxyRequestType value) { + switch (value) { + case enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE: + return "ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE"; + case enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE: + return "ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE"; + default: + return "UNKNOWN"; + } +} +#endif void HelloRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "HelloRequest"); @@ -754,6 +766,9 @@ void DeviceInfoResponse::dump_to(std::string &out) const { this->area.dump_to(out); out.append("\n"); #endif +#ifdef USE_ZWAVE_PROXY + dump_field(out, "zwave_proxy_feature_flags", this->zwave_proxy_feature_flags); +#endif } void ListEntitiesRequest::dump_to(std::string &out) const { out.append("ListEntitiesRequest {}"); } void ListEntitiesDoneResponse::dump_to(std::string &out) const { out.append("ListEntitiesDoneResponse {}"); } @@ -2107,6 +2122,18 @@ void UpdateCommandRequest::dump_to(std::string &out) const { #endif } #endif +#ifdef USE_ZWAVE_PROXY +void ZWaveProxyFrame::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "ZWaveProxyFrame"); + out.append(" data: "); + out.append(format_hex_pretty(this->data, this->data_len)); + out.append("\n"); +} +void ZWaveProxyRequest::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "ZWaveProxyRequest"); + dump_field(out, "type", static_cast(this->type)); +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index ef7acbc6b2..4afc66dc44 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -588,6 +588,28 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_bluetooth_scanner_set_mode_request(msg); break; } +#endif +#ifdef USE_ZWAVE_PROXY + case ZWaveProxyFrame::MESSAGE_TYPE: { + ZWaveProxyFrame msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "on_z_wave_proxy_frame: %s", msg.dump().c_str()); +#endif + this->on_z_wave_proxy_frame(msg); + break; + } +#endif +#ifdef USE_ZWAVE_PROXY + case ZWaveProxyRequest::MESSAGE_TYPE: { + ZWaveProxyRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + ESP_LOGVV(TAG, "on_z_wave_proxy_request: %s", msg.dump().c_str()); +#endif + this->on_z_wave_proxy_request(msg); + break; + } #endif default: break; @@ -899,5 +921,19 @@ void APIServerConnection::on_alarm_control_panel_command_request(const AlarmCont } } #endif +#ifdef USE_ZWAVE_PROXY +void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { + if (this->check_authenticated_()) { + this->zwave_proxy_frame(msg); + } +} +#endif +#ifdef USE_ZWAVE_PROXY +void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { + if (this->check_authenticated_()) { + this->zwave_proxy_request(msg); + } +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index f81ac1a337..9379dfee7d 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -207,6 +207,12 @@ class APIServerConnectionBase : public ProtoService { #ifdef USE_UPDATE virtual void on_update_command_request(const UpdateCommandRequest &value){}; +#endif +#ifdef USE_ZWAVE_PROXY + virtual void on_z_wave_proxy_frame(const ZWaveProxyFrame &value){}; +#endif +#ifdef USE_ZWAVE_PROXY + virtual void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif protected: void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; @@ -335,6 +341,12 @@ class APIServerConnection : public APIServerConnectionBase { #endif #ifdef USE_ALARM_CONTROL_PANEL virtual void alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) = 0; +#endif +#ifdef USE_ZWAVE_PROXY + virtual void zwave_proxy_frame(const ZWaveProxyFrame &msg) = 0; +#endif +#ifdef USE_ZWAVE_PROXY + virtual void zwave_proxy_request(const ZWaveProxyRequest &msg) = 0; #endif protected: void on_hello_request(const HelloRequest &msg) override; @@ -459,6 +471,12 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_ALARM_CONTROL_PANEL void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg) override; #endif +#ifdef USE_ZWAVE_PROXY + void on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) override; +#endif +#ifdef USE_ZWAVE_PROXY + void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override; +#endif }; } // namespace esphome::api diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py new file mode 100644 index 0000000000..d88f9f7041 --- /dev/null +++ b/esphome/components/zwave_proxy/__init__.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI +import esphome.final_validate as fv + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["api", "uart"] + +zwave_proxy_ns = cg.esphome_ns.namespace("zwave_proxy") +ZWaveProxy = zwave_proxy_ns.class_("ZWaveProxy", cg.Component, uart.UARTDevice) + + +def final_validate(config): + full_config = fv.full_config.get() + if (wifi_conf := full_config.get(CONF_WIFI)) and ( + wifi_conf.get(CONF_POWER_SAVE_MODE).lower() != "none" + ): + raise cv.Invalid( + f"{CONF_WIFI} {CONF_POWER_SAVE_MODE} must be set to 'none' when using Z-Wave proxy" + ) + + return config + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ZWaveProxy), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = final_validate + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + cg.add_define("USE_ZWAVE_PROXY") diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp new file mode 100644 index 0000000000..e39f857743 --- /dev/null +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -0,0 +1,224 @@ +#include "zwave_proxy.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/util.h" + +namespace esphome { +namespace zwave_proxy { + +static const char *const TAG = "zwave_proxy"; + +ZWaveProxy::ZWaveProxy() { global_zwave_proxy = this; } + +void ZWaveProxy::loop() { + if (this->response_handler_()) { + ESP_LOGV(TAG, "Handled late response"); + } + if (this->api_connection_ != nullptr && (!this->api_connection_->is_connection_setup() || !api_is_connected())) { + ESP_LOGW(TAG, "Subscriber disconnected"); + this->api_connection_ = nullptr; // Unsubscribe if disconnected + } + + while (this->available()) { + uint8_t byte; + if (!this->read_byte(&byte)) { + this->status_set_warning("UART read failed"); + return; + } + if (this->parse_byte_(byte)) { + ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); + if (this->api_connection_ != nullptr) { + // minimize copying to reduce CPU overhead + if (this->in_bootloader_) { + this->outgoing_proto_msg_.data_len = this->buffer_index_; + } else { + // If this is a data frame, use frame length indicator + 2 (for SoF + checksum), else assume 1 for ACK/NAK/CAN + this->outgoing_proto_msg_.data_len = this->buffer_[0] == ZWAVE_FRAME_TYPE_START ? this->buffer_[1] + 2 : 1; + } + std::memcpy(this->outgoing_proto_msg_.data, this->buffer_, this->outgoing_proto_msg_.data_len); + this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE); + } + } + } + this->status_clear_warning(); +} + +void ZWaveProxy::dump_config() { ESP_LOGCONFIG(TAG, "Z-Wave Proxy"); } + +void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) { + switch (type) { + case api::enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE: + if (this->api_connection_ != nullptr) { + ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + return; + } + this->api_connection_ = api_connection; + ESP_LOGV(TAG, "API connection is now subscribed"); + break; + case api::enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE: + if (this->api_connection_ != api_connection) { + ESP_LOGV(TAG, "API connection is not subscribed"); + return; + } + this->api_connection_ = nullptr; + break; + default: + ESP_LOGW(TAG, "Unknown request type: %d", type); + break; + } +} + +void ZWaveProxy::send_frame(const uint8_t *data, size_t length) { + if (length == 1 && data[0] == this->last_response_) { + ESP_LOGV(TAG, "Skipping sending duplicate response: 0x%02X", data[0]); + return; + } + ESP_LOGVV(TAG, "Sending: %s", format_hex_pretty(data, length).c_str()); + this->write_array(data, length); +} + +bool ZWaveProxy::parse_byte_(uint8_t byte) { + bool frame_completed = false; + // Basic parsing logic for received frames + switch (this->parsing_state_) { + case ZWAVE_PARSING_STATE_WAIT_START: + this->parse_start_(byte); + break; + case ZWAVE_PARSING_STATE_WAIT_LENGTH: + if (!byte) { + ESP_LOGW(TAG, "Invalid LENGTH: %u", byte); + this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; + return false; + } + ESP_LOGVV(TAG, "Received LENGTH: %u", byte); + this->end_frame_after_ = this->buffer_index_ + byte; + ESP_LOGVV(TAG, "Calculated EOF: %u", this->end_frame_after_); + this->buffer_[this->buffer_index_++] = byte; + this->checksum_ ^= byte; + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_TYPE; + break; + case ZWAVE_PARSING_STATE_WAIT_TYPE: + this->buffer_[this->buffer_index_++] = byte; + ESP_LOGVV(TAG, "Received TYPE: 0x%02X", byte); + this->checksum_ ^= byte; + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_COMMAND_ID; + break; + case ZWAVE_PARSING_STATE_WAIT_COMMAND_ID: + this->buffer_[this->buffer_index_++] = byte; + ESP_LOGVV(TAG, "Received COMMAND ID: 0x%02X", byte); + this->checksum_ ^= byte; + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_PAYLOAD; + break; + case ZWAVE_PARSING_STATE_WAIT_PAYLOAD: + this->buffer_[this->buffer_index_++] = byte; + this->checksum_ ^= byte; + ESP_LOGVV(TAG, "Received PAYLOAD: 0x%02X", byte); + if (this->buffer_index_ >= this->end_frame_after_) { + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_CHECKSUM; + } + break; + case ZWAVE_PARSING_STATE_WAIT_CHECKSUM: + this->buffer_[this->buffer_index_++] = byte; + ESP_LOGVV(TAG, "Received CHECKSUM: 0x%02X", byte); + ESP_LOGV(TAG, "Calculated CHECKSUM: 0x%02X", this->checksum_); + if (this->checksum_ != byte) { + ESP_LOGW(TAG, "Bad checksum: expected 0x%02X, got 0x%02X", this->checksum_, byte); + this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; + } else { + this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_ACK; + ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(this->buffer_, this->buffer_index_).c_str()); + frame_completed = true; + } + this->response_handler_(); + break; + case ZWAVE_PARSING_STATE_READ_BL_MENU: + this->buffer_[this->buffer_index_++] = byte; + if (!byte) { + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + frame_completed = true; + } + break; + case ZWAVE_PARSING_STATE_SEND_ACK: + case ZWAVE_PARSING_STATE_SEND_NAK: + break; // Should not happen, handled in loop() + default: + ESP_LOGW(TAG, "Bad parsing state; resetting"); + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + break; + } + return frame_completed; +} + +void ZWaveProxy::parse_start_(uint8_t byte) { + this->buffer_index_ = 0; + this->checksum_ = 0xFF; + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + switch (byte) { + case ZWAVE_FRAME_TYPE_START: + ESP_LOGVV(TAG, "Received START"); + if (this->in_bootloader_) { + ESP_LOGD(TAG, "Exited bootloader mode"); + this->in_bootloader_ = false; + } + this->buffer_[this->buffer_index_++] = byte; + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH; + return; + case ZWAVE_FRAME_TYPE_BL_MENU: + ESP_LOGVV(TAG, "Received BL_MENU"); + if (!this->in_bootloader_) { + ESP_LOGD(TAG, "Entered bootloader mode"); + this->in_bootloader_ = true; + } + this->buffer_[this->buffer_index_++] = byte; + this->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU; + return; + case ZWAVE_FRAME_TYPE_BL_BEGIN_UPLOAD: + ESP_LOGVV(TAG, "Received BL_BEGIN_UPLOAD"); + break; + case ZWAVE_FRAME_TYPE_ACK: + ESP_LOGVV(TAG, "Received ACK"); + break; + case ZWAVE_FRAME_TYPE_NAK: + ESP_LOGW(TAG, "Received NAK"); + break; + case ZWAVE_FRAME_TYPE_CAN: + ESP_LOGW(TAG, "Received CAN"); + break; + default: + ESP_LOGW(TAG, "Unrecognized START: 0x%02X", byte); + return; + } + // Forward response (ACK/NAK/CAN) back to client for processing + if (this->api_connection_ != nullptr) { + this->outgoing_proto_msg_.data[0] = byte; + this->outgoing_proto_msg_.data_len = 1; + this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE); + } +} + +bool ZWaveProxy::response_handler_() { + switch (this->parsing_state_) { + case ZWAVE_PARSING_STATE_SEND_ACK: + this->last_response_ = ZWAVE_FRAME_TYPE_ACK; + break; + case ZWAVE_PARSING_STATE_SEND_CAN: + this->last_response_ = ZWAVE_FRAME_TYPE_CAN; + break; + case ZWAVE_PARSING_STATE_SEND_NAK: + this->last_response_ = ZWAVE_FRAME_TYPE_NAK; + break; + default: + return false; // No response handled + } + + ESP_LOGVV(TAG, "Sending %s (0x%02X)", this->last_response_ == ZWAVE_FRAME_TYPE_ACK ? "ACK" : "NAK/CAN", + this->last_response_); + this->write_byte(this->last_response_); + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + return true; +} + +ZWaveProxy *global_zwave_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace zwave_proxy +} // namespace esphome diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h new file mode 100644 index 0000000000..a0f25849e4 --- /dev/null +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -0,0 +1,73 @@ +#pragma once + +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_pb2.h" +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" + +namespace esphome { +namespace zwave_proxy { + +enum ZWaveResponseTypes : uint8_t { + ZWAVE_FRAME_TYPE_ACK = 0x06, + ZWAVE_FRAME_TYPE_CAN = 0x18, + ZWAVE_FRAME_TYPE_NAK = 0x15, + ZWAVE_FRAME_TYPE_START = 0x01, + ZWAVE_FRAME_TYPE_BL_MENU = 0x0D, + ZWAVE_FRAME_TYPE_BL_BEGIN_UPLOAD = 0x43, +}; + +enum ZWaveParsingState : uint8_t { + ZWAVE_PARSING_STATE_WAIT_START, + ZWAVE_PARSING_STATE_WAIT_LENGTH, + ZWAVE_PARSING_STATE_WAIT_TYPE, + ZWAVE_PARSING_STATE_WAIT_COMMAND_ID, + ZWAVE_PARSING_STATE_WAIT_PAYLOAD, + ZWAVE_PARSING_STATE_WAIT_CHECKSUM, + ZWAVE_PARSING_STATE_SEND_ACK, + ZWAVE_PARSING_STATE_SEND_CAN, + ZWAVE_PARSING_STATE_SEND_NAK, + ZWAVE_PARSING_STATE_READ_BL_MENU, +}; + +enum ZWaveProxyFeature : uint32_t { + FEATURE_ZWAVE_PROXY_ENABLED = 1 << 0, +}; + +class ZWaveProxy : public uart::UARTDevice, public Component { + public: + ZWaveProxy(); + + void loop() override; + void dump_config() override; + + void zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type); + api::APIConnection *get_api_connection() { return this->api_connection_; } + + uint32_t get_feature_flags() const { return ZWaveProxyFeature::FEATURE_ZWAVE_PROXY_ENABLED; } + + void send_frame(const uint8_t *data, size_t length); + + protected: + bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) + void parse_start_(uint8_t byte); + bool response_handler_(); + + api::APIConnection *api_connection_{nullptr}; // Current subscribed client + + uint8_t buffer_[sizeof(api::ZWaveProxyFrame::data)]; // Fixed buffer for incoming data + uint8_t buffer_index_{0}; // Index for populating the data buffer + uint8_t checksum_{0}; // Checksum of the frame being parsed + uint8_t end_frame_after_{0}; // Payload reception ends after this index + uint8_t last_response_{0}; // Last response type sent + ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; + bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode + + // Pre-allocated message - always ready to send + api::ZWaveProxyFrame outgoing_proto_msg_; +}; + +extern ZWaveProxy *global_zwave_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace zwave_proxy +} // namespace esphome diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6e8d5ed74c..bdc59598f6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -100,6 +100,7 @@ #define USE_UART_DEBUGGER #define USE_UPDATE #define USE_VALVE +#define USE_ZWAVE_PROXY // Feature flags which do not work for zephyr #ifndef USE_ZEPHYR diff --git a/tests/components/zwave_proxy/common.yaml b/tests/components/zwave_proxy/common.yaml new file mode 100644 index 0000000000..08092ebe55 --- /dev/null +++ b/tests/components/zwave_proxy/common.yaml @@ -0,0 +1,15 @@ +wifi: + ssid: MySSID + password: password1 + power_save_mode: none + +uart: + - id: uart_zwave_proxy + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 115200 + +api: + +zwave_proxy: + id: zw_proxy diff --git a/tests/components/zwave_proxy/test.esp32-ard.yaml b/tests/components/zwave_proxy/test.esp32-ard.yaml new file mode 100644 index 0000000000..f486544afa --- /dev/null +++ b/tests/components/zwave_proxy/test.esp32-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +<<: !include common.yaml diff --git a/tests/components/zwave_proxy/test.esp32-c3-ard.yaml b/tests/components/zwave_proxy/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/zwave_proxy/test.esp32-c3-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/zwave_proxy/test.esp32-c3-idf.yaml b/tests/components/zwave_proxy/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/zwave_proxy/test.esp32-c3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/zwave_proxy/test.esp32-idf.yaml b/tests/components/zwave_proxy/test.esp32-idf.yaml new file mode 100644 index 0000000000..f486544afa --- /dev/null +++ b/tests/components/zwave_proxy/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +<<: !include common.yaml diff --git a/tests/components/zwave_proxy/test.esp8266-ard.yaml b/tests/components/zwave_proxy/test.esp8266-ard.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/zwave_proxy/test.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/zwave_proxy/test.rp2040-ard.yaml b/tests/components/zwave_proxy/test.rp2040-ard.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/zwave_proxy/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml From f6dc25c0cee9e71909908beaa1f4ddad3a88802d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Sep 2025 14:38:02 -0500 Subject: [PATCH 1219/1626] [mqtt] Fix KeyError when MQTT logging configured without explicit level (#10774) --- esphome/__main__.py | 2 +- tests/unit_tests/test_main.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 0147a82530..f54fa8e3c6 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -212,7 +212,7 @@ def has_mqtt_logging() -> bool: if CONF_TOPIC not in log_topic: return False - return log_topic[CONF_LEVEL] != "NONE" + return log_topic.get(CONF_LEVEL, None) != "NONE" def has_mqtt() -> bool: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bfebb44545..fff0b2cd48 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1226,6 +1226,18 @@ def test_has_mqtt_logging_no_log_topic() -> None: setup_core(config={}) assert has_mqtt_logging() is False + # Setup MQTT config with CONF_LOG_TOPIC but no CONF_LEVEL (regression test for #10771) + # This simulates the default configuration created by validate_config in the MQTT component + setup_core( + config={ + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + CONF_LOG_TOPIC: {CONF_TOPIC: "esphome/debug"}, + } + } + ) + assert has_mqtt_logging() is True + def test_has_mqtt() -> None: """Test has_mqtt function.""" From e3703b43c194d3b46b8d023b022b2be375124ed4 Mon Sep 17 00:00:00 2001 From: Subhash Chandra Date: Thu, 18 Sep 2025 05:44:31 +0530 Subject: [PATCH 1220/1626] [packet_transport] Refactor sensor/provider list handling to be idempotent (#10765) --- esphome/components/packet_transport/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index bfb2bbc4f8..43da7740fe 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -121,15 +121,11 @@ def transport_schema(cls): return TRANSPORT_SCHEMA.extend({cv.GenerateID(): cv.declare_id(cls)}) -# Build a list of sensors for this platform -CORE.data[DOMAIN] = {CONF_SENSORS: []} - - def get_sensors(transport_id): """Return the list of sensors for this platform.""" return ( sensor - for sensor in CORE.data[DOMAIN][CONF_SENSORS] + for sensor in CORE.data.setdefault(DOMAIN, {}).setdefault(CONF_SENSORS, []) if sensor[CONF_TRANSPORT_ID] == transport_id ) @@ -137,7 +133,8 @@ def get_sensors(transport_id): def validate_packet_transport_sensor(config): if CONF_NAME in config and CONF_INTERNAL not in config: raise cv.Invalid("Must provide internal: config when using name:") - CORE.data[DOMAIN][CONF_SENSORS].append(config) + conf_sensors = CORE.data.setdefault(DOMAIN, {}).setdefault(CONF_SENSORS, []) + conf_sensors.append(config) return config From a06cd849744f9e410323028c698c3c67d9f9f820 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Sep 2025 20:11:30 -0500 Subject: [PATCH 1221/1626] [core] Fix ESP8266 mDNS compilation failure caused by incorrect coroutine priorities (#10773) --- esphome/components/captive_portal/__init__.py | 5 ++- esphome/components/esphome/ota/__init__.py | 5 ++- .../components/http_request/ota/__init__.py | 5 ++- esphome/components/mdns/__init__.py | 5 ++- esphome/components/ota/__init__.py | 5 ++- esphome/components/web_server/ota/__init__.py | 5 ++- .../components/web_server_base/__init__.py | 5 ++- esphome/coroutine.py | 23 +++++++++- .../mdns/test-comprehensive.esp8266-ard.yaml | 42 +++++++++++++++++++ tests/unit_tests/test_coroutine.py | 19 ++++++++- 10 files changed, 101 insertions(+), 18 deletions(-) create mode 100644 tests/components/mdns/test-comprehensive.esp8266-ard.yaml diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 39cafc7cb4..9f2af0a230 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -10,7 +10,8 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RTL87XX, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority AUTO_LOAD = ["web_server_base", "ota.web_server"] DEPENDENCIES = ["wifi"] @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.CAPTIVE_PORTAL) async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 7b579501ed..93216f9425 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,7 +16,8 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import coroutine_with_priority +from esphome.coroutine import CoroPriority import esphome.final_validate as fv _LOGGER = logging.getLogger(__name__) @@ -121,7 +122,7 @@ CONFIG_SCHEMA = ( FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_port(config[CONF_PORT])) diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index fd542e594a..d2c574d8c6 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -3,7 +3,8 @@ import esphome.codegen as cg from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_URL, CONF_USERNAME -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import coroutine_with_priority +from esphome.coroutine import CoroPriority from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All( ) -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index a21ef9d97b..a84fe5a249 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -11,7 +11,8 @@ from esphome.const import ( CONF_SERVICES, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] @@ -72,7 +73,7 @@ def mdns_service( ) -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.NETWORK_SERVICES) async def to_code(config): if config[CONF_DISABLED] is True: return diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index cf814fb1ee..eec39668db 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -10,7 +10,8 @@ from esphome.const import ( CONF_TRIGGER_ID, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority CODEOWNERS = ["@esphome/core"] AUTO_LOAD = ["md5", "safe_mode"] @@ -82,7 +83,7 @@ BASE_OTA_SCHEMA = cv.Schema( ) -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): cg.add_define("USE_OTA") diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 3ec7e65e1d..22e56639e1 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -3,7 +3,8 @@ from esphome.components.esp32 import add_idf_component from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network", "web_server_base"] @@ -22,7 +23,7 @@ CONFIG_SCHEMA = ( ) -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.WEB_SERVER_OTA) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 8add2f051f..2aff405036 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -1,7 +1,8 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.Schema( ) -@coroutine_with_priority(CoroPriority.COMMUNICATION) +@coroutine_with_priority(CoroPriority.WEB_SERVER_BASE) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/coroutine.py b/esphome/coroutine.py index 741a0c7c0c..0331c602c5 100644 --- a/esphome/coroutine.py +++ b/esphome/coroutine.py @@ -90,11 +90,30 @@ class CoroPriority(enum.IntEnum): # Examples: status_led (80) STATUS = 80 + # Web server infrastructure + # Examples: web_server_base (65) + WEB_SERVER_BASE = 65 + + # Network portal services + # Examples: captive_portal (64) + CAPTIVE_PORTAL = 64 + # Communication protocols and services - # Examples: web_server_base (65), captive_portal (64), wifi (60), ethernet (60), - # mdns (55), ota_updates (54), web_server_ota (52) + # Examples: wifi (60), ethernet (60) COMMUNICATION = 60 + # Network discovery and management services + # Examples: mdns (55) + NETWORK_SERVICES = 55 + + # OTA update services + # Examples: ota_updates (54) + OTA_UPDATES = 54 + + # Web-based OTA services + # Examples: web_server_ota (52) + WEB_SERVER_OTA = 52 + # Application-level services # Examples: safe_mode (50) APPLICATION = 50 diff --git a/tests/components/mdns/test-comprehensive.esp8266-ard.yaml b/tests/components/mdns/test-comprehensive.esp8266-ard.yaml new file mode 100644 index 0000000000..02767833a3 --- /dev/null +++ b/tests/components/mdns/test-comprehensive.esp8266-ard.yaml @@ -0,0 +1,42 @@ +# Comprehensive ESP8266 test for mdns with multiple network components +# Tests the complete priority chain: +# wifi (60) -> mdns (55) -> ota (54) -> web_server_ota (52) + +esphome: + name: mdns-comprehensive-test + +esp8266: + board: esp01_1m + +logger: + level: DEBUG + +wifi: + ssid: MySSID + password: password1 + +# web_server_base should run at priority 65 (before wifi) +web_server: + port: 80 + +# mdns should run at priority 55 (after wifi at 60) +mdns: + services: + - service: _http + protocol: _tcp + port: 80 + +# OTA should run at priority 54 (after mdns) +ota: + - platform: esphome + password: "otapassword" + +# Test status LED at priority 80 +status_led: + pin: + number: GPIO2 + inverted: true + +# Include API at priority 40 +api: + password: "apipassword" diff --git a/tests/unit_tests/test_coroutine.py b/tests/unit_tests/test_coroutine.py index 138b08edb5..e12c273294 100644 --- a/tests/unit_tests/test_coroutine.py +++ b/tests/unit_tests/test_coroutine.py @@ -13,7 +13,12 @@ def test_coro_priority_enum_values() -> None: assert CoroPriority.CORE == 100 assert CoroPriority.DIAGNOSTICS == 90 assert CoroPriority.STATUS == 80 + assert CoroPriority.WEB_SERVER_BASE == 65 + assert CoroPriority.CAPTIVE_PORTAL == 64 assert CoroPriority.COMMUNICATION == 60 + assert CoroPriority.NETWORK_SERVICES == 55 + assert CoroPriority.OTA_UPDATES == 54 + assert CoroPriority.WEB_SERVER_OTA == 52 assert CoroPriority.APPLICATION == 50 assert CoroPriority.WEB == 40 assert CoroPriority.AUTOMATION == 30 @@ -70,7 +75,12 @@ def test_float_and_enum_are_interchangeable() -> None: (CoroPriority.CORE, 100.0), (CoroPriority.DIAGNOSTICS, 90.0), (CoroPriority.STATUS, 80.0), + (CoroPriority.WEB_SERVER_BASE, 65.0), + (CoroPriority.CAPTIVE_PORTAL, 64.0), (CoroPriority.COMMUNICATION, 60.0), + (CoroPriority.NETWORK_SERVICES, 55.0), + (CoroPriority.OTA_UPDATES, 54.0), + (CoroPriority.WEB_SERVER_OTA, 52.0), (CoroPriority.APPLICATION, 50.0), (CoroPriority.WEB, 40.0), (CoroPriority.AUTOMATION, 30.0), @@ -164,8 +174,13 @@ def test_enum_priority_comparison() -> None: assert CoroPriority.NETWORK_TRANSPORT > CoroPriority.CORE assert CoroPriority.CORE > CoroPriority.DIAGNOSTICS assert CoroPriority.DIAGNOSTICS > CoroPriority.STATUS - assert CoroPriority.STATUS > CoroPriority.COMMUNICATION - assert CoroPriority.COMMUNICATION > CoroPriority.APPLICATION + assert CoroPriority.STATUS > CoroPriority.WEB_SERVER_BASE + assert CoroPriority.WEB_SERVER_BASE > CoroPriority.CAPTIVE_PORTAL + assert CoroPriority.CAPTIVE_PORTAL > CoroPriority.COMMUNICATION + assert CoroPriority.COMMUNICATION > CoroPriority.NETWORK_SERVICES + assert CoroPriority.NETWORK_SERVICES > CoroPriority.OTA_UPDATES + assert CoroPriority.OTA_UPDATES > CoroPriority.WEB_SERVER_OTA + assert CoroPriority.WEB_SERVER_OTA > CoroPriority.APPLICATION assert CoroPriority.APPLICATION > CoroPriority.WEB assert CoroPriority.WEB > CoroPriority.AUTOMATION assert CoroPriority.AUTOMATION > CoroPriority.BUS From a5a21f47d175c2eeffb0d9eed550c5c749321b5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 05:22:22 -0500 Subject: [PATCH 1222/1626] [gpio] Fix unused function warnings when compiling with log level below DEBUG (#10779) --- esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index 45544c185b..7a35596194 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -6,6 +6,7 @@ namespace gpio { static const char *const TAG = "gpio.binary_sensor"; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG static const LogString *interrupt_type_to_string(gpio::InterruptType type) { switch (type) { case gpio::INTERRUPT_RISING_EDGE: @@ -22,6 +23,7 @@ static const LogString *interrupt_type_to_string(gpio::InterruptType type) { static const LogString *gpio_mode_to_string(bool use_interrupt) { return use_interrupt ? LOG_STR("interrupt") : LOG_STR("polling"); } +#endif void IRAM_ATTR GPIOBinarySensorStore::gpio_intr(GPIOBinarySensorStore *arg) { bool new_state = arg->isr_pin_.digital_read(); From 961be7fd12375dfc7b33844378a1746b98f2e543 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:52:10 +1200 Subject: [PATCH 1223/1626] Bump version to 2025.9.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 9cbb8a3b9a..6ef1d86c74 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.9.0 +PROJECT_NUMBER = 2025.9.1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 44f0fb3dd6..0b36e26ec0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.9.0" +__version__ = "2025.9.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 9853a2e6ab115367e40a8bd997d966cc33bedcee Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:41:23 +1200 Subject: [PATCH 1224/1626] [ektf2232] Rename `rts_pin` to `reset_pin` (#10720) --- esphome/components/ektf2232/touchscreen/__init__.py | 13 ++++++++----- .../components/ektf2232/touchscreen/ektf2232.cpp | 8 ++++---- esphome/components/ektf2232/touchscreen/ektf2232.h | 4 ++-- tests/components/ektf2232/common.yaml | 4 ++-- tests/components/ektf2232/test.esp32-ard.yaml | 4 ++-- tests/components/ektf2232/test.esp32-c3-ard.yaml | 4 ++-- tests/components/ektf2232/test.esp32-c3-idf.yaml | 4 ++-- tests/components/ektf2232/test.esp32-idf.yaml | 4 ++-- tests/components/ektf2232/test.esp8266-ard.yaml | 4 ++-- tests/components/ektf2232/test.rp2040-ard.yaml | 4 ++-- 10 files changed, 28 insertions(+), 25 deletions(-) diff --git a/esphome/components/ektf2232/touchscreen/__init__.py b/esphome/components/ektf2232/touchscreen/__init__.py index 7d946fdcb9..123f03ca08 100644 --- a/esphome/components/ektf2232/touchscreen/__init__.py +++ b/esphome/components/ektf2232/touchscreen/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c, touchscreen import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERRUPT_PIN +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["i2c"] @@ -15,7 +15,7 @@ EKTF2232Touchscreen = ektf2232_ns.class_( ) CONF_EKTF2232_ID = "ektf2232_id" -CONF_RTS_PIN = "rts_pin" +CONF_RTS_PIN = "rts_pin" # To be removed before 2026.4.0 CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( cv.Schema( @@ -24,7 +24,10 @@ CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( cv.Required(CONF_INTERRUPT_PIN): cv.All( pins.internal_gpio_input_pin_schema ), - cv.Required(CONF_RTS_PIN): pins.gpio_output_pin_schema, + cv.Required(CONF_RESET_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_RTS_PIN): cv.invalid( + f"{CONF_RTS_PIN} has been renamed to {CONF_RESET_PIN}" + ), } ).extend(i2c.i2c_device_schema(0x15)) ) @@ -37,5 +40,5 @@ async def to_code(config): interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) cg.add(var.set_interrupt_pin(interrupt_pin)) - rts_pin = await cg.gpio_pin_expression(config[CONF_RTS_PIN]) - cg.add(var.set_rts_pin(rts_pin)) + reset_pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) + cg.add(var.set_reset_pin(reset_pin)) diff --git a/esphome/components/ektf2232/touchscreen/ektf2232.cpp b/esphome/components/ektf2232/touchscreen/ektf2232.cpp index 1dacee6a57..63ebb2166b 100644 --- a/esphome/components/ektf2232/touchscreen/ektf2232.cpp +++ b/esphome/components/ektf2232/touchscreen/ektf2232.cpp @@ -21,7 +21,7 @@ void EKTF2232Touchscreen::setup() { this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); - this->rts_pin_->setup(); + this->reset_pin_->setup(); this->hard_reset_(); if (!this->soft_reset_()) { @@ -98,9 +98,9 @@ bool EKTF2232Touchscreen::get_power_state() { } void EKTF2232Touchscreen::hard_reset_() { - this->rts_pin_->digital_write(false); + this->reset_pin_->digital_write(false); delay(15); - this->rts_pin_->digital_write(true); + this->reset_pin_->digital_write(true); delay(15); } @@ -127,7 +127,7 @@ void EKTF2232Touchscreen::dump_config() { ESP_LOGCONFIG(TAG, "EKT2232 Touchscreen:"); LOG_I2C_DEVICE(this); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); - LOG_PIN(" RTS Pin: ", this->rts_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); } } // namespace ektf2232 diff --git a/esphome/components/ektf2232/touchscreen/ektf2232.h b/esphome/components/ektf2232/touchscreen/ektf2232.h index e9288d0a27..2ddc60851f 100644 --- a/esphome/components/ektf2232/touchscreen/ektf2232.h +++ b/esphome/components/ektf2232/touchscreen/ektf2232.h @@ -17,7 +17,7 @@ class EKTF2232Touchscreen : public Touchscreen, public i2c::I2CDevice { void dump_config() override; void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } - void set_rts_pin(GPIOPin *pin) { this->rts_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } void set_power_state(bool enable); bool get_power_state(); @@ -28,7 +28,7 @@ class EKTF2232Touchscreen : public Touchscreen, public i2c::I2CDevice { void update_touches() override; InternalGPIOPin *interrupt_pin_; - GPIOPin *rts_pin_; + GPIOPin *reset_pin_; }; } // namespace ektf2232 diff --git a/tests/components/ektf2232/common.yaml b/tests/components/ektf2232/common.yaml index 3271839fd4..91f09b4710 100644 --- a/tests/components/ektf2232/common.yaml +++ b/tests/components/ektf2232/common.yaml @@ -7,7 +7,7 @@ display: - platform: ssd1306_i2c id: ssd1306_display model: SSD1306_128X64 - reset_pin: ${reset_pin} + reset_pin: ${display_reset_pin} pages: - id: page1 lambda: |- @@ -16,7 +16,7 @@ display: touchscreen: - platform: ektf2232 interrupt_pin: ${interrupt_pin} - rts_pin: ${rts_pin} + reset_pin: ${touch_reset_pin} display: ssd1306_display on_touch: - logger.log: diff --git a/tests/components/ektf2232/test.esp32-ard.yaml b/tests/components/ektf2232/test.esp32-ard.yaml index b8f491c0c3..7d3f2ca7a2 100644 --- a/tests/components/ektf2232/test.esp32-ard.yaml +++ b/tests/components/ektf2232/test.esp32-ard.yaml @@ -1,8 +1,8 @@ substitutions: scl_pin: GPIO16 sda_pin: GPIO17 - reset_pin: GPIO13 + display_reset_pin: GPIO13 interrupt_pin: GPIO14 - rts_pin: GPIO15 + touch_reset_pin: GPIO15 <<: !include common.yaml diff --git a/tests/components/ektf2232/test.esp32-c3-ard.yaml b/tests/components/ektf2232/test.esp32-c3-ard.yaml index 9f2149b9d7..4d793a3242 100644 --- a/tests/components/ektf2232/test.esp32-c3-ard.yaml +++ b/tests/components/ektf2232/test.esp32-c3-ard.yaml @@ -1,8 +1,8 @@ substitutions: scl_pin: GPIO5 sda_pin: GPIO4 - reset_pin: GPIO3 + display_reset_pin: GPIO3 interrupt_pin: GPIO6 - rts_pin: GPIO7 + touch_reset_pin: GPIO7 <<: !include common.yaml diff --git a/tests/components/ektf2232/test.esp32-c3-idf.yaml b/tests/components/ektf2232/test.esp32-c3-idf.yaml index 9f2149b9d7..4d793a3242 100644 --- a/tests/components/ektf2232/test.esp32-c3-idf.yaml +++ b/tests/components/ektf2232/test.esp32-c3-idf.yaml @@ -1,8 +1,8 @@ substitutions: scl_pin: GPIO5 sda_pin: GPIO4 - reset_pin: GPIO3 + display_reset_pin: GPIO3 interrupt_pin: GPIO6 - rts_pin: GPIO7 + touch_reset_pin: GPIO7 <<: !include common.yaml diff --git a/tests/components/ektf2232/test.esp32-idf.yaml b/tests/components/ektf2232/test.esp32-idf.yaml index b8f491c0c3..7d3f2ca7a2 100644 --- a/tests/components/ektf2232/test.esp32-idf.yaml +++ b/tests/components/ektf2232/test.esp32-idf.yaml @@ -1,8 +1,8 @@ substitutions: scl_pin: GPIO16 sda_pin: GPIO17 - reset_pin: GPIO13 + display_reset_pin: GPIO13 interrupt_pin: GPIO14 - rts_pin: GPIO15 + touch_reset_pin: GPIO15 <<: !include common.yaml diff --git a/tests/components/ektf2232/test.esp8266-ard.yaml b/tests/components/ektf2232/test.esp8266-ard.yaml index 6d91a6533f..a87e9dfd45 100644 --- a/tests/components/ektf2232/test.esp8266-ard.yaml +++ b/tests/components/ektf2232/test.esp8266-ard.yaml @@ -1,8 +1,8 @@ substitutions: scl_pin: GPIO5 sda_pin: GPIO4 - reset_pin: GPIO3 + display_reset_pin: GPIO3 interrupt_pin: GPIO12 - rts_pin: GPIO13 + touch_reset_pin: GPIO13 <<: !include common.yaml diff --git a/tests/components/ektf2232/test.rp2040-ard.yaml b/tests/components/ektf2232/test.rp2040-ard.yaml index 9f2149b9d7..4d793a3242 100644 --- a/tests/components/ektf2232/test.rp2040-ard.yaml +++ b/tests/components/ektf2232/test.rp2040-ard.yaml @@ -1,8 +1,8 @@ substitutions: scl_pin: GPIO5 sda_pin: GPIO4 - reset_pin: GPIO3 + display_reset_pin: GPIO3 interrupt_pin: GPIO6 - rts_pin: GPIO7 + touch_reset_pin: GPIO7 <<: !include common.yaml From 2bb64a189dc4a04eb90505298ee9281f4131e48d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 20:13:13 -0500 Subject: [PATCH 1225/1626] [dashboard] Transfer DNS/mDNS cache from dashboard to CLI to avoid blocking (#10685) --- esphome/__main__.py | 33 ++- esphome/address_cache.py | 142 ++++++++++++ esphome/core/__init__.py | 5 + esphome/dashboard/dns.py | 15 ++ esphome/dashboard/status/mdns.py | 27 +++ esphome/dashboard/web_server.py | 119 ++++++---- esphome/espota2.py | 6 +- esphome/helpers.py | 118 ++++++---- tests/dashboard/conftest.py | 21 ++ tests/dashboard/status/__init__.py | 0 tests/dashboard/status/test_dns.py | 121 ++++++++++ tests/dashboard/status/test_mdns.py | 168 ++++++++++++++ tests/dashboard/test_web_server.py | 80 +++++++ tests/unit_tests/test_address_cache.py | 305 +++++++++++++++++++++++++ tests/unit_tests/test_helpers.py | 82 +++++++ 15 files changed, 1155 insertions(+), 87 deletions(-) create mode 100644 esphome/address_cache.py create mode 100644 tests/dashboard/conftest.py create mode 100644 tests/dashboard/status/__init__.py create mode 100644 tests/dashboard/status/test_dns.py create mode 100644 tests/dashboard/status/test_mdns.py create mode 100644 tests/unit_tests/test_address_cache.py diff --git a/esphome/__main__.py b/esphome/__main__.py index f54fa8e3c6..07cd267c96 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -114,6 +114,14 @@ class Purpose(StrEnum): LOGGING = "logging" +def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]: + """Resolve an address using cache if available, otherwise return the address itself.""" + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(address)): + _LOGGER.debug("Using cached addresses for %s: %s", purpose.value, cached) + return cached + return [address] + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, @@ -142,7 +150,7 @@ def choose_upload_log_host( (purpose == Purpose.LOGGING and has_api()) or (purpose == Purpose.UPLOADING and has_ota()) ): - resolved.append(CORE.address) + resolved.extend(_resolve_with_cache(CORE.address, purpose)) if purpose == Purpose.LOGGING: if has_api() and has_mqtt_ip_lookup(): @@ -152,15 +160,14 @@ def choose_upload_log_host( resolved.append("MQTT") if has_api() and has_non_ip_address(): - resolved.append(CORE.address) + resolved.extend(_resolve_with_cache(CORE.address, purpose)) elif purpose == Purpose.UPLOADING: if has_ota() and has_mqtt_ip_lookup(): resolved.append("MQTTIP") if has_ota() and has_non_ip_address(): - resolved.append(CORE.address) - + resolved.extend(_resolve_with_cache(CORE.address, purpose)) else: resolved.append(device) if not resolved: @@ -965,6 +972,18 @@ def parse_args(argv): help="Add a substitution", metavar=("key", "value"), ) + options_parser.add_argument( + "--mdns-address-cache", + help="mDNS address cache mapping in format 'hostname=ip1,ip2'", + action="append", + default=[], + ) + options_parser.add_argument( + "--dns-address-cache", + help="DNS address cache mapping in format 'hostname=ip1,ip2'", + action="append", + default=[], + ) parser = argparse.ArgumentParser( description=f"ESPHome {const.__version__}", parents=[options_parser] @@ -1212,9 +1231,15 @@ def parse_args(argv): def run_esphome(argv): + from esphome.address_cache import AddressCache + args = parse_args(argv) CORE.dashboard = args.dashboard + # Create address cache from command-line arguments + CORE.address_cache = AddressCache.from_cli_args( + args.mdns_address_cache, args.dns_address_cache + ) # Override log level if verbose is set if args.verbose: args.log_level = "DEBUG" diff --git a/esphome/address_cache.py b/esphome/address_cache.py new file mode 100644 index 0000000000..7c20be90f0 --- /dev/null +++ b/esphome/address_cache.py @@ -0,0 +1,142 @@ +"""Address cache for DNS and mDNS lookups.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + +_LOGGER = logging.getLogger(__name__) + + +def normalize_hostname(hostname: str) -> str: + """Normalize hostname for cache lookups. + + Removes trailing dots and converts to lowercase. + """ + return hostname.rstrip(".").lower() + + +class AddressCache: + """Cache for DNS and mDNS address lookups. + + This cache stores pre-resolved addresses from command-line arguments + to avoid slow DNS/mDNS lookups during builds. + """ + + def __init__( + self, + mdns_cache: dict[str, list[str]] | None = None, + dns_cache: dict[str, list[str]] | None = None, + ) -> None: + """Initialize the address cache. + + Args: + mdns_cache: Pre-populated mDNS addresses (hostname -> IPs) + dns_cache: Pre-populated DNS addresses (hostname -> IPs) + """ + self.mdns_cache = mdns_cache or {} + self.dns_cache = dns_cache or {} + + def _get_cached_addresses( + self, hostname: str, cache: dict[str, list[str]], cache_type: str + ) -> list[str] | None: + """Get cached addresses from a specific cache. + + Args: + hostname: The hostname to look up + cache: The cache dictionary to check + cache_type: Type of cache for logging ("mDNS" or "DNS") + + Returns: + List of IP addresses if found in cache, None otherwise + """ + normalized = normalize_hostname(hostname) + if addresses := cache.get(normalized): + _LOGGER.debug("Using %s cache for %s: %s", cache_type, hostname, addresses) + return addresses + return None + + def get_mdns_addresses(self, hostname: str) -> list[str] | None: + """Get cached mDNS addresses for a hostname. + + Args: + hostname: The hostname to look up (should end with .local) + + Returns: + List of IP addresses if found in cache, None otherwise + """ + return self._get_cached_addresses(hostname, self.mdns_cache, "mDNS") + + def get_dns_addresses(self, hostname: str) -> list[str] | None: + """Get cached DNS addresses for a hostname. + + Args: + hostname: The hostname to look up + + Returns: + List of IP addresses if found in cache, None otherwise + """ + return self._get_cached_addresses(hostname, self.dns_cache, "DNS") + + def get_addresses(self, hostname: str) -> list[str] | None: + """Get cached addresses for a hostname. + + Checks mDNS cache for .local domains, DNS cache otherwise. + + Args: + hostname: The hostname to look up + + Returns: + List of IP addresses if found in cache, None otherwise + """ + normalized = normalize_hostname(hostname) + if normalized.endswith(".local"): + return self.get_mdns_addresses(hostname) + return self.get_dns_addresses(hostname) + + def has_cache(self) -> bool: + """Check if any cache entries exist.""" + return bool(self.mdns_cache or self.dns_cache) + + @classmethod + def from_cli_args( + cls, mdns_args: Iterable[str], dns_args: Iterable[str] + ) -> AddressCache: + """Create cache from command-line arguments. + + Args: + mdns_args: List of mDNS cache entries like ['host=ip1,ip2'] + dns_args: List of DNS cache entries like ['host=ip1,ip2'] + + Returns: + Configured AddressCache instance + """ + mdns_cache = cls._parse_cache_args(mdns_args) + dns_cache = cls._parse_cache_args(dns_args) + return cls(mdns_cache=mdns_cache, dns_cache=dns_cache) + + @staticmethod + def _parse_cache_args(cache_args: Iterable[str]) -> dict[str, list[str]]: + """Parse cache arguments into a dictionary. + + Args: + cache_args: List of cache mappings like ['host1=ip1,ip2', 'host2=ip3'] + + Returns: + Dictionary mapping normalized hostnames to list of IP addresses + """ + cache: dict[str, list[str]] = {} + for arg in cache_args: + if "=" not in arg: + _LOGGER.warning( + "Invalid cache format: %s (expected 'hostname=ip1,ip2')", arg + ) + continue + hostname, ips = arg.split("=", 1) + # Normalize hostname for consistent lookups + normalized = normalize_hostname(hostname) + cache[normalized] = [ip.strip() for ip in ips.split(",")] + return cache diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 89e3eff7d8..242a6854df 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -39,6 +39,8 @@ from esphome.helpers import ensure_unique_string, get_str_env, is_ha_addon from esphome.util import OrderedDict if TYPE_CHECKING: + from esphome.address_cache import AddressCache + from ..cpp_generator import MockObj, MockObjClass, Statement from ..types import ConfigType, EntityMetadata @@ -583,6 +585,8 @@ class EsphomeCore: self.id_classes = {} # The current component being processed during validation self.current_component: str | None = None + # Address cache for DNS and mDNS lookups from command line arguments + self.address_cache: AddressCache | None = None def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -610,6 +614,7 @@ class EsphomeCore: self.platform_counts = defaultdict(int) self.unique_ids = {} self.current_component = None + self.address_cache = None PIN_SCHEMA_REGISTRY.reset() @contextmanager diff --git a/esphome/dashboard/dns.py b/esphome/dashboard/dns.py index 98134062f4..58867f7bc1 100644 --- a/esphome/dashboard/dns.py +++ b/esphome/dashboard/dns.py @@ -28,6 +28,21 @@ class DNSCache: self._cache: dict[str, tuple[float, list[str] | Exception]] = {} self._ttl = ttl + def get_cached_addresses( + self, hostname: str, now_monotonic: float + ) -> list[str] | None: + """Get cached addresses without triggering resolution. + + Returns None if not in cache, list of addresses if found. + """ + # Normalize hostname for consistent lookups + normalized = hostname.rstrip(".").lower() + if expire_time_addresses := self._cache.get(normalized): + expire_time, addresses = expire_time_addresses + if expire_time > now_monotonic and not isinstance(addresses, Exception): + return addresses + return None + async def async_resolve( self, hostname: str, now_monotonic: float ) -> list[str] | Exception: diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index f9ac7b4289..989517e1c3 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -4,6 +4,9 @@ import asyncio import logging import typing +from zeroconf import AddressResolver, IPVersion + +from esphome.address_cache import normalize_hostname from esphome.zeroconf import ( ESPHOME_SERVICE_TYPE, AsyncEsphomeZeroconf, @@ -50,6 +53,30 @@ class MDNSStatus: return await aiozc.async_resolve_host(host_name) return None + def get_cached_addresses(self, host_name: str) -> list[str] | None: + """Get cached addresses for a host without triggering resolution. + + Returns None if not in cache or no zeroconf available. + """ + if not self.aiozc: + _LOGGER.debug("No zeroconf instance available for %s", host_name) + return None + + # Normalize hostname and get the base name + normalized = normalize_hostname(host_name) + base_name = normalized.partition(".")[0] + + # Try to load from zeroconf cache without triggering resolution + resolver_name = f"{base_name}.local." + info = AddressResolver(resolver_name) + # Let zeroconf use its own current time for cache checking + if info.load_from_cache(self.aiozc.zeroconf): + addresses = info.parsed_scoped_addresses(IPVersion.All) + _LOGGER.debug("Found %s in zeroconf cache: %s", resolver_name, addresses) + return addresses + _LOGGER.debug("Not found in zeroconf cache: %s", resolver_name) + return None + async def async_refresh_hosts(self) -> None: """Refresh the hosts to track.""" dashboard = self.dashboard diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index e6c5fd3d84..24595eb942 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -50,8 +50,8 @@ from esphome.util import get_serial_ports, shlex_quote from esphome.yaml_util import FastestAvailableSafeLoader from .const import DASHBOARD_COMMAND -from .core import DASHBOARD -from .entries import UNKNOWN_STATE, entry_state_to_bool +from .core import DASHBOARD, ESPHomeDashboard +from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool from .util.file import write_file from .util.subprocess import async_run_system_command from .util.text import friendly_name_slugify @@ -314,6 +314,73 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): raise NotImplementedError +def build_cache_arguments( + entry: DashboardEntry | None, + dashboard: ESPHomeDashboard, + now: float, +) -> list[str]: + """Build cache arguments for passing to CLI. + + Args: + entry: Dashboard entry for the configuration + dashboard: Dashboard instance with cache access + now: Current monotonic time for DNS cache expiry checks + + Returns: + List of cache arguments to pass to CLI + """ + cache_args: list[str] = [] + + if not entry: + return cache_args + + _LOGGER.debug( + "Building cache for entry (address=%s, name=%s)", + entry.address, + entry.name, + ) + + def add_cache_entry(hostname: str, addresses: list[str], cache_type: str) -> None: + """Add a cache entry to the command arguments.""" + if not addresses: + return + normalized = hostname.rstrip(".").lower() + cache_args.extend( + [ + f"--{cache_type}-address-cache", + f"{normalized}={','.join(sort_ip_addresses(addresses))}", + ] + ) + + # Check entry.address for cached addresses + if use_address := entry.address: + if use_address.endswith(".local"): + # mDNS cache for .local addresses + if (mdns := dashboard.mdns_status) and ( + cached := mdns.get_cached_addresses(use_address) + ): + _LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached) + add_cache_entry(use_address, cached, "mdns") + # DNS cache for non-.local addresses + elif cached := dashboard.dns_cache.get_cached_addresses(use_address, now): + _LOGGER.debug("DNS cache hit for %s: %s", use_address, cached) + add_cache_entry(use_address, cached, "dns") + + # Check entry.name if we haven't already cached via address + # For mDNS devices, entry.name typically doesn't have .local suffix + if entry.name and not use_address: + mdns_name = ( + f"{entry.name}.local" if not entry.name.endswith(".local") else entry.name + ) + if (mdns := dashboard.mdns_status) and ( + cached := mdns.get_cached_addresses(mdns_name) + ): + _LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached) + add_cache_entry(mdns_name, cached, "mdns") + + return cache_args + + class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): """Base class for commands that require a port.""" @@ -326,52 +393,22 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): configuration = json_message["configuration"] config_file = settings.rel_path(configuration) port = json_message["port"] - addresses: list[str] = [] + + # Build cache arguments to pass to CLI + cache_args: list[str] = [] + if ( port == "OTA" # pylint: disable=too-many-boolean-expressions and (entry := entries.get(config_file)) and entry.loaded_integrations and "api" in entry.loaded_integrations ): - # First priority: entry.address AKA use_address - if ( - (use_address := entry.address) - and ( - address_list := await dashboard.dns_cache.async_resolve( - use_address, time.monotonic() - ) - ) - and not isinstance(address_list, Exception) - ): - addresses.extend(sort_ip_addresses(address_list)) + cache_args = build_cache_arguments(entry, dashboard, time.monotonic()) - # Second priority: mDNS - if ( - (mdns := dashboard.mdns_status) - and (address_list := await mdns.async_resolve_host(entry.name)) - and ( - new_addresses := [ - addr for addr in address_list if addr not in addresses - ] - ) - ): - # Use the IP address if available but only - # if the API is loaded and the device is online - # since MQTT logging will not work otherwise - addresses.extend(sort_ip_addresses(new_addresses)) - - if not addresses: - # If no address was found, use the port directly - # as otherwise they will get the chooser which - # does not work with the dashboard as there is no - # interactive way to get keyboard input - addresses = [port] - - device_args: list[str] = [ - arg for address in addresses for arg in ("--device", address) - ] - - return [*DASHBOARD_COMMAND, *args, config_file, *device_args] + # Cache arguments must come before the subcommand + cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port] + _LOGGER.debug("Built command: %s", cmd) + return cmd class EsphomeLogsHandler(EsphomePortCommandWebSocket): diff --git a/esphome/espota2.py b/esphome/espota2.py index 3d25af985b..99c91d94e2 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -311,10 +311,14 @@ def perform_ota( def run_ota_impl_( remote_host: str | list[str], remote_port: int, password: str, filename: str ) -> tuple[int, str | None]: + from esphome.core import CORE + # Handle both single host and list of hosts try: # Resolve all hosts at once for parallel DNS resolution - res = resolve_ip_address(remote_host, remote_port) + res = resolve_ip_address( + remote_host, remote_port, address_cache=CORE.address_cache + ) except EsphomeError as err: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", diff --git a/esphome/helpers.py b/esphome/helpers.py index 6beaa24a96..2b7221355c 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -9,10 +9,14 @@ from pathlib import Path import platform import re import tempfile +from typing import TYPE_CHECKING from urllib.parse import urlparse from esphome.const import __version__ as ESPHOME_VERSION +if TYPE_CHECKING: + from esphome.address_cache import AddressCache + # Type aliases for socket address information AddrInfo = tuple[ int, # family (AF_INET, AF_INET6, etc.) @@ -173,7 +177,24 @@ def addr_preference_(res: AddrInfo) -> int: return 1 -def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: +def _add_ip_addresses_to_addrinfo( + addresses: list[str], port: int, res: list[AddrInfo] +) -> None: + """Helper to add IP addresses to addrinfo results with error handling.""" + import socket + + for addr in addresses: + try: + res += socket.getaddrinfo( + addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST + ) + except OSError: + _LOGGER.debug("Failed to parse IP address '%s'", addr) + + +def resolve_ip_address( + host: str | list[str], port: int, address_cache: AddressCache | None = None +) -> list[AddrInfo]: import socket # There are five cases here. The host argument could be one of: @@ -194,47 +215,69 @@ def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]: hosts = [host] res: list[AddrInfo] = [] + + # Fast path: if all hosts are already IP addresses if all(is_ip_address(h) for h in hosts): - # Fast path: all are IP addresses, use socket.getaddrinfo with AI_NUMERICHOST - for addr in hosts: - try: - res += socket.getaddrinfo( - addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST - ) - except OSError: - _LOGGER.debug("Failed to parse IP address '%s'", addr) + _add_ip_addresses_to_addrinfo(hosts, port, res) # Sort by preference res.sort(key=addr_preference_) return res - from esphome.resolver import AsyncResolver + # Process hosts + cached_addresses: list[str] = [] + uncached_hosts: list[str] = [] + has_cache = address_cache is not None - resolver = AsyncResolver(hosts, port) - addr_infos = resolver.resolve() - # Convert aioesphomeapi AddrInfo to our format - for addr_info in addr_infos: - sockaddr = addr_info.sockaddr - if addr_info.family == socket.AF_INET6: - # IPv6 - sockaddr_tuple = ( - sockaddr.address, - sockaddr.port, - sockaddr.flowinfo, - sockaddr.scope_id, - ) + for h in hosts: + if is_ip_address(h): + if has_cache: + # If we have a cache, treat IPs as cached + cached_addresses.append(h) + else: + # If no cache, pass IPs through to resolver with hostnames + uncached_hosts.append(h) + elif address_cache and (cached := address_cache.get_addresses(h)): + # Found in cache + cached_addresses.extend(cached) else: - # IPv4 - sockaddr_tuple = (sockaddr.address, sockaddr.port) + # Not cached, need to resolve + if address_cache and address_cache.has_cache(): + _LOGGER.info("Host %s not in cache, will need to resolve", h) + uncached_hosts.append(h) - res.append( - ( - addr_info.family, - addr_info.type, - addr_info.proto, - "", # canonname - sockaddr_tuple, + # Process cached addresses (includes direct IPs and cached lookups) + _add_ip_addresses_to_addrinfo(cached_addresses, port, res) + + # If we have uncached hosts (only non-IP hostnames), resolve them + if uncached_hosts: + from esphome.resolver import AsyncResolver + + resolver = AsyncResolver(uncached_hosts, port) + addr_infos = resolver.resolve() + # Convert aioesphomeapi AddrInfo to our format + for addr_info in addr_infos: + sockaddr = addr_info.sockaddr + if addr_info.family == socket.AF_INET6: + # IPv6 + sockaddr_tuple = ( + sockaddr.address, + sockaddr.port, + sockaddr.flowinfo, + sockaddr.scope_id, + ) + else: + # IPv4 + sockaddr_tuple = (sockaddr.address, sockaddr.port) + + res.append( + ( + addr_info.family, + addr_info.type, + addr_info.proto, + "", # canonname + sockaddr_tuple, + ) ) - ) # Sort by preference res.sort(key=addr_preference_) @@ -256,14 +299,7 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]: # First "resolve" all the IP addresses to getaddrinfo() tuples of the form # (family, type, proto, canonname, sockaddr) res: list[AddrInfo] = [] - for addr in address_list: - # This should always work as these are supposed to be IP addresses - try: - res += socket.getaddrinfo( - addr, 0, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST - ) - except OSError: - _LOGGER.info("Failed to parse IP address '%s'", addr) + _add_ip_addresses_to_addrinfo(address_list, 0, res) # Now use that information to sort them. res.sort(key=addr_preference_) diff --git a/tests/dashboard/conftest.py b/tests/dashboard/conftest.py new file mode 100644 index 0000000000..358be1bf5d --- /dev/null +++ b/tests/dashboard/conftest.py @@ -0,0 +1,21 @@ +"""Common fixtures for dashboard tests.""" + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from esphome.dashboard.core import ESPHomeDashboard + + +@pytest.fixture +def mock_dashboard() -> Mock: + """Create a mock dashboard.""" + dashboard = Mock(spec=ESPHomeDashboard) + dashboard.entries = Mock() + dashboard.entries.async_all.return_value = [] + dashboard.stop_event = Mock() + dashboard.stop_event.is_set.return_value = True + dashboard.ping_request = Mock() + return dashboard diff --git a/tests/dashboard/status/__init__.py b/tests/dashboard/status/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/status/test_dns.py b/tests/dashboard/status/test_dns.py new file mode 100644 index 0000000000..9ca48ba2d8 --- /dev/null +++ b/tests/dashboard/status/test_dns.py @@ -0,0 +1,121 @@ +"""Unit tests for esphome.dashboard.dns module.""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +import pytest + +from esphome.dashboard.dns import DNSCache + + +@pytest.fixture +def dns_cache_fixture() -> DNSCache: + """Create a DNSCache instance.""" + return DNSCache() + + +def test_get_cached_addresses_not_in_cache(dns_cache_fixture: DNSCache) -> None: + """Test get_cached_addresses when hostname is not in cache.""" + now = time.monotonic() + result = dns_cache_fixture.get_cached_addresses("unknown.example.com", now) + assert result is None + + +def test_get_cached_addresses_expired(dns_cache_fixture: DNSCache) -> None: + """Test get_cached_addresses when cache entry is expired.""" + now = time.monotonic() + # Add entry that's already expired + dns_cache_fixture._cache["example.com"] = (now - 1, ["192.168.1.10"]) + + result = dns_cache_fixture.get_cached_addresses("example.com", now) + assert result is None + # Expired entry should still be in cache (not removed by get_cached_addresses) + assert "example.com" in dns_cache_fixture._cache + + +def test_get_cached_addresses_valid(dns_cache_fixture: DNSCache) -> None: + """Test get_cached_addresses with valid cache entry.""" + now = time.monotonic() + # Add entry that expires in 60 seconds + dns_cache_fixture._cache["example.com"] = ( + now + 60, + ["192.168.1.10", "192.168.1.11"], + ) + + result = dns_cache_fixture.get_cached_addresses("example.com", now) + assert result == ["192.168.1.10", "192.168.1.11"] + # Entry should still be in cache + assert "example.com" in dns_cache_fixture._cache + + +def test_get_cached_addresses_hostname_normalization( + dns_cache_fixture: DNSCache, +) -> None: + """Test get_cached_addresses normalizes hostname.""" + now = time.monotonic() + # Add entry with lowercase hostname + dns_cache_fixture._cache["example.com"] = (now + 60, ["192.168.1.10"]) + + # Test with various forms + assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM", now) == [ + "192.168.1.10" + ] + assert dns_cache_fixture.get_cached_addresses("example.com.", now) == [ + "192.168.1.10" + ] + assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM.", now) == [ + "192.168.1.10" + ] + + +def test_get_cached_addresses_ipv6(dns_cache_fixture: DNSCache) -> None: + """Test get_cached_addresses with IPv6 addresses.""" + now = time.monotonic() + dns_cache_fixture._cache["example.com"] = (now + 60, ["2001:db8::1", "fe80::1"]) + + result = dns_cache_fixture.get_cached_addresses("example.com", now) + assert result == ["2001:db8::1", "fe80::1"] + + +def test_get_cached_addresses_empty_list(dns_cache_fixture: DNSCache) -> None: + """Test get_cached_addresses with empty address list.""" + now = time.monotonic() + dns_cache_fixture._cache["example.com"] = (now + 60, []) + + result = dns_cache_fixture.get_cached_addresses("example.com", now) + assert result == [] + + +def test_get_cached_addresses_exception_in_cache(dns_cache_fixture: DNSCache) -> None: + """Test get_cached_addresses when cache contains an exception.""" + now = time.monotonic() + # Store an exception (from failed resolution) + dns_cache_fixture._cache["example.com"] = (now + 60, OSError("Resolution failed")) + + result = dns_cache_fixture.get_cached_addresses("example.com", now) + assert result is None # Should return None for exceptions + + +def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None: + """Test that get_cached_addresses never calls async_resolve.""" + now = time.monotonic() + + with patch.object(dns_cache_fixture, "async_resolve") as mock_resolve: + # Test non-cached + result = dns_cache_fixture.get_cached_addresses("uncached.com", now) + assert result is None + mock_resolve.assert_not_called() + + # Test expired + dns_cache_fixture._cache["expired.com"] = (now - 1, ["192.168.1.10"]) + result = dns_cache_fixture.get_cached_addresses("expired.com", now) + assert result is None + mock_resolve.assert_not_called() + + # Test valid + dns_cache_fixture._cache["valid.com"] = (now + 60, ["192.168.1.10"]) + result = dns_cache_fixture.get_cached_addresses("valid.com", now) + assert result == ["192.168.1.10"] + mock_resolve.assert_not_called() diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py new file mode 100644 index 0000000000..7130c2c73a --- /dev/null +++ b/tests/dashboard/status/test_mdns.py @@ -0,0 +1,168 @@ +"""Unit tests for esphome.dashboard.status.mdns module.""" + +from __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest +import pytest_asyncio +from zeroconf import AddressResolver, IPVersion + +from esphome.dashboard.status.mdns import MDNSStatus + + +@pytest_asyncio.fixture +async def mdns_status(mock_dashboard: Mock) -> MDNSStatus: + """Create an MDNSStatus instance in async context.""" + # We're in an async context so get_running_loop will work + return MDNSStatus(mock_dashboard) + + +@pytest.mark.asyncio +async def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses when no zeroconf instance is available.""" + mdns_status.aiozc = None + result = mdns_status.get_cached_addresses("device.local") + assert result is None + + +@pytest.mark.asyncio +async def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses when address is not in cache.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = False + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local") + assert result is None + mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) + + +@pytest.mark.asyncio +async def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses when address is found in cache.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10", "fe80::1"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local") + assert result == ["192.168.1.10", "fe80::1"] + mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) + mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All) + + +@pytest.mark.asyncio +async def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses with hostname having trailing dot.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local.") + assert result == ["192.168.1.10"] + # Should normalize to device.local. for zeroconf + mock_resolver.assert_called_once_with("device.local.") + + +@pytest.mark.asyncio +async def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses with uppercase hostname.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("DEVICE.LOCAL") + assert result == ["192.168.1.10"] + # Should normalize to device.local. for zeroconf + mock_resolver.assert_called_once_with("device.local.") + + +@pytest.mark.asyncio +async def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses with simple hostname (no domain).""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device") + assert result == ["192.168.1.10"] + # Should append .local. for zeroconf + mock_resolver.assert_called_once_with("device.local.") + + +@pytest.mark.asyncio +async def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses returning only IPv6 addresses.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = ["fe80::1", "2001:db8::1"] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local") + assert result == ["fe80::1", "2001:db8::1"] + + +@pytest.mark.asyncio +async def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: + """Test get_cached_addresses returning empty list from cache.""" + mdns_status.aiozc = Mock() + mdns_status.aiozc.zeroconf = Mock() + + with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: + mock_info = Mock(spec=AddressResolver) + mock_info.load_from_cache.return_value = True + mock_info.parsed_scoped_addresses.return_value = [] + mock_resolver.return_value = mock_info + + result = mdns_status.get_cached_addresses("device.local") + assert result == [] + + +@pytest.mark.asyncio +async def test_async_setup_success(mock_dashboard: Mock) -> None: + """Test successful async_setup.""" + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.return_value = Mock() + result = mdns_status.async_setup() + assert result is True + assert mdns_status.aiozc is not None + + +@pytest.mark.asyncio +async def test_async_setup_failure(mock_dashboard: Mock) -> None: + """Test async_setup with OSError.""" + mdns_status = MDNSStatus(mock_dashboard) + with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: + mock_zc.side_effect = OSError("Network error") + result = mdns_status.async_setup() + assert result is False + assert mdns_status.aiozc is None diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 1938617f20..605df4e02c 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -730,3 +730,83 @@ def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: mock_server_class.assert_called_once_with(app) mock_bind.assert_called_once_with(str(socket_path), mode=0o666) server.add_socket.assert_called_once() + + +def test_build_cache_arguments_no_entry(mock_dashboard: Mock) -> None: + """Test with no entry returns empty list.""" + result = web_server.build_cache_arguments(None, mock_dashboard, 0.0) + assert result == [] + + +def test_build_cache_arguments_no_address_no_name(mock_dashboard: Mock) -> None: + """Test with entry but no address or name.""" + entry = Mock(spec=web_server.DashboardEntry) + entry.address = None + entry.name = None + result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) + assert result == [] + + +def test_build_cache_arguments_mdns_address_cached(mock_dashboard: Mock) -> None: + """Test with .local address that has cached mDNS results.""" + entry = Mock(spec=web_server.DashboardEntry) + entry.address = "device.local" + entry.name = None + mock_dashboard.mdns_status = Mock() + mock_dashboard.mdns_status.get_cached_addresses.return_value = [ + "192.168.1.10", + "fe80::1", + ] + + result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) + + assert result == [ + "--mdns-address-cache", + "device.local=192.168.1.10,fe80::1", + ] + mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( + "device.local" + ) + + +def test_build_cache_arguments_dns_address_cached(mock_dashboard: Mock) -> None: + """Test with non-.local address that has cached DNS results.""" + entry = Mock(spec=web_server.DashboardEntry) + entry.address = "example.com" + entry.name = None + mock_dashboard.dns_cache = Mock() + mock_dashboard.dns_cache.get_cached_addresses.return_value = [ + "93.184.216.34", + "2606:2800:220:1:248:1893:25c8:1946", + ] + + now = 100.0 + result = web_server.build_cache_arguments(entry, mock_dashboard, now) + + # IPv6 addresses are sorted before IPv4 + assert result == [ + "--dns-address-cache", + "example.com=2606:2800:220:1:248:1893:25c8:1946,93.184.216.34", + ] + mock_dashboard.dns_cache.get_cached_addresses.assert_called_once_with( + "example.com", now + ) + + +def test_build_cache_arguments_name_without_address(mock_dashboard: Mock) -> None: + """Test with name but no address - should check mDNS with .local suffix.""" + entry = Mock(spec=web_server.DashboardEntry) + entry.name = "my-device" + entry.address = None + mock_dashboard.mdns_status = Mock() + mock_dashboard.mdns_status.get_cached_addresses.return_value = ["192.168.1.20"] + + result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) + + assert result == [ + "--mdns-address-cache", + "my-device.local=192.168.1.20", + ] + mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( + "my-device.local" + ) diff --git a/tests/unit_tests/test_address_cache.py b/tests/unit_tests/test_address_cache.py new file mode 100644 index 0000000000..de43830d53 --- /dev/null +++ b/tests/unit_tests/test_address_cache.py @@ -0,0 +1,305 @@ +"""Tests for the address_cache module.""" + +from __future__ import annotations + +import logging + +import pytest +from pytest import LogCaptureFixture + +from esphome.address_cache import AddressCache, normalize_hostname + + +def test_normalize_simple_hostname() -> None: + """Test normalizing a simple hostname.""" + assert normalize_hostname("device") == "device" + assert normalize_hostname("device.local") == "device.local" + assert normalize_hostname("server.example.com") == "server.example.com" + + +def test_normalize_removes_trailing_dots() -> None: + """Test that trailing dots are removed.""" + assert normalize_hostname("device.") == "device" + assert normalize_hostname("device.local.") == "device.local" + assert normalize_hostname("server.example.com.") == "server.example.com" + assert normalize_hostname("device...") == "device" + + +def test_normalize_converts_to_lowercase() -> None: + """Test that hostnames are converted to lowercase.""" + assert normalize_hostname("DEVICE") == "device" + assert normalize_hostname("Device.Local") == "device.local" + assert normalize_hostname("Server.Example.COM") == "server.example.com" + + +def test_normalize_combined() -> None: + """Test combination of trailing dots and case conversion.""" + assert normalize_hostname("DEVICE.LOCAL.") == "device.local" + assert normalize_hostname("Server.Example.COM...") == "server.example.com" + + +def test_init_empty() -> None: + """Test initialization with empty caches.""" + cache = AddressCache() + assert cache.mdns_cache == {} + assert cache.dns_cache == {} + assert not cache.has_cache() + + +def test_init_with_caches() -> None: + """Test initialization with provided caches.""" + mdns_cache: dict[str, list[str]] = {"device.local": ["192.168.1.10"]} + dns_cache: dict[str, list[str]] = {"server.com": ["10.0.0.1"]} + cache = AddressCache(mdns_cache=mdns_cache, dns_cache=dns_cache) + assert cache.mdns_cache == mdns_cache + assert cache.dns_cache == dns_cache + assert cache.has_cache() + + +def test_get_mdns_addresses() -> None: + """Test getting mDNS addresses.""" + cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10", "192.168.1.11"]}) + + # Direct lookup + assert cache.get_mdns_addresses("device.local") == [ + "192.168.1.10", + "192.168.1.11", + ] + + # Case insensitive lookup + assert cache.get_mdns_addresses("Device.Local") == [ + "192.168.1.10", + "192.168.1.11", + ] + + # With trailing dot + assert cache.get_mdns_addresses("device.local.") == [ + "192.168.1.10", + "192.168.1.11", + ] + + # Not found + assert cache.get_mdns_addresses("unknown.local") is None + + +def test_get_dns_addresses() -> None: + """Test getting DNS addresses.""" + cache = AddressCache(dns_cache={"server.com": ["10.0.0.1", "10.0.0.2"]}) + + # Direct lookup + assert cache.get_dns_addresses("server.com") == ["10.0.0.1", "10.0.0.2"] + + # Case insensitive lookup + assert cache.get_dns_addresses("Server.COM") == ["10.0.0.1", "10.0.0.2"] + + # With trailing dot + assert cache.get_dns_addresses("server.com.") == ["10.0.0.1", "10.0.0.2"] + + # Not found + assert cache.get_dns_addresses("unknown.com") is None + + +def test_get_addresses_auto_detection() -> None: + """Test automatic cache selection based on hostname.""" + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + + # Should use mDNS cache for .local domains + assert cache.get_addresses("device.local") == ["192.168.1.10"] + assert cache.get_addresses("device.local.") == ["192.168.1.10"] + assert cache.get_addresses("Device.Local") == ["192.168.1.10"] + + # Should use DNS cache for non-.local domains + assert cache.get_addresses("server.com") == ["10.0.0.1"] + assert cache.get_addresses("server.com.") == ["10.0.0.1"] + assert cache.get_addresses("Server.COM") == ["10.0.0.1"] + + # Not found + assert cache.get_addresses("unknown.local") is None + assert cache.get_addresses("unknown.com") is None + + +def test_has_cache() -> None: + """Test checking if cache has entries.""" + # Empty cache + cache = AddressCache() + assert not cache.has_cache() + + # Only mDNS cache + cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10"]}) + assert cache.has_cache() + + # Only DNS cache + cache = AddressCache(dns_cache={"server.com": ["10.0.0.1"]}) + assert cache.has_cache() + + # Both caches + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + assert cache.has_cache() + + +def test_from_cli_args_empty() -> None: + """Test creating cache from empty CLI arguments.""" + cache = AddressCache.from_cli_args([], []) + assert cache.mdns_cache == {} + assert cache.dns_cache == {} + + +def test_from_cli_args_single_entry() -> None: + """Test creating cache from single CLI argument.""" + mdns_args: list[str] = ["device.local=192.168.1.10"] + dns_args: list[str] = ["server.com=10.0.0.1"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1"]} + + +def test_from_cli_args_multiple_ips() -> None: + """Test creating cache with multiple IPs per host.""" + mdns_args: list[str] = ["device.local=192.168.1.10,192.168.1.11"] + dns_args: list[str] = ["server.com=10.0.0.1,10.0.0.2,10.0.0.3"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2", "10.0.0.3"]} + + +def test_from_cli_args_multiple_entries() -> None: + """Test creating cache with multiple host entries.""" + mdns_args: list[str] = [ + "device1.local=192.168.1.10", + "device2.local=192.168.1.20,192.168.1.21", + ] + dns_args: list[str] = ["server1.com=10.0.0.1", "server2.com=10.0.0.2"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == { + "device1.local": ["192.168.1.10"], + "device2.local": ["192.168.1.20", "192.168.1.21"], + } + assert cache.dns_cache == { + "server1.com": ["10.0.0.1"], + "server2.com": ["10.0.0.2"], + } + + +def test_from_cli_args_normalization() -> None: + """Test that CLI arguments are normalized.""" + mdns_args: list[str] = ["Device1.Local.=192.168.1.10", "DEVICE2.LOCAL=192.168.1.20"] + dns_args: list[str] = ["Server1.COM.=10.0.0.1", "SERVER2.com=10.0.0.2"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + # Hostnames should be normalized (lowercase, no trailing dots) + assert cache.mdns_cache == { + "device1.local": ["192.168.1.10"], + "device2.local": ["192.168.1.20"], + } + assert cache.dns_cache == { + "server1.com": ["10.0.0.1"], + "server2.com": ["10.0.0.2"], + } + + +def test_from_cli_args_whitespace_handling() -> None: + """Test that whitespace in IPs is handled.""" + mdns_args: list[str] = ["device.local= 192.168.1.10 , 192.168.1.11 "] + dns_args: list[str] = ["server.com= 10.0.0.1 , 10.0.0.2 "] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2"]} + + +def test_from_cli_args_invalid_format(caplog: LogCaptureFixture) -> None: + """Test handling of invalid argument format.""" + mdns_args: list[str] = ["invalid_format", "device.local=192.168.1.10"] + dns_args: list[str] = ["server.com=10.0.0.1", "also_invalid"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + # Valid entries should still be processed + assert cache.mdns_cache == {"device.local": ["192.168.1.10"]} + assert cache.dns_cache == {"server.com": ["10.0.0.1"]} + + # Check that warnings were logged for invalid entries + assert "Invalid cache format: invalid_format" in caplog.text + assert "Invalid cache format: also_invalid" in caplog.text + + +def test_from_cli_args_ipv6() -> None: + """Test handling of IPv6 addresses.""" + mdns_args: list[str] = ["device.local=fe80::1,2001:db8::1"] + dns_args: list[str] = ["server.com=2001:db8::2,::1"] + + cache = AddressCache.from_cli_args(mdns_args, dns_args) + + assert cache.mdns_cache == {"device.local": ["fe80::1", "2001:db8::1"]} + assert cache.dns_cache == {"server.com": ["2001:db8::2", "::1"]} + + +def test_logging_output(caplog: LogCaptureFixture) -> None: + """Test that appropriate debug logging occurs.""" + caplog.set_level(logging.DEBUG) + + cache = AddressCache( + mdns_cache={"device.local": ["192.168.1.10"]}, + dns_cache={"server.com": ["10.0.0.1"]}, + ) + + # Test successful lookups log at debug level + result: list[str] | None = cache.get_mdns_addresses("device.local") + assert result == ["192.168.1.10"] + assert "Using mDNS cache for device.local" in caplog.text + + caplog.clear() + result = cache.get_dns_addresses("server.com") + assert result == ["10.0.0.1"] + assert "Using DNS cache for server.com" in caplog.text + + # Test that failed lookups don't log + caplog.clear() + result = cache.get_mdns_addresses("unknown.local") + assert result is None + assert "Using mDNS cache" not in caplog.text + + +@pytest.mark.parametrize( + "hostname,expected", + [ + ("test.local", "test.local"), + ("Test.Local.", "test.local"), + ("TEST.LOCAL...", "test.local"), + ("example.com", "example.com"), + ("EXAMPLE.COM.", "example.com"), + ], +) +def test_normalize_hostname_parametrized(hostname: str, expected: str) -> None: + """Test hostname normalization with various inputs.""" + assert normalize_hostname(hostname) == expected + + +@pytest.mark.parametrize( + "mdns_arg,expected", + [ + ("host=1.2.3.4", {"host": ["1.2.3.4"]}), + ("Host.Local=1.2.3.4,5.6.7.8", {"host.local": ["1.2.3.4", "5.6.7.8"]}), + ("HOST.LOCAL.=::1", {"host.local": ["::1"]}), + ], +) +def test_parse_cache_args_parametrized( + mdns_arg: str, expected: dict[str, list[str]] +) -> None: + """Test parsing of cache arguments with various formats.""" + cache = AddressCache.from_cli_args([mdns_arg], []) + assert cache.mdns_cache == expected diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index cc65d9747e..b49e5797c1 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -11,6 +11,7 @@ from hypothesis.strategies import ip_addresses import pytest from esphome import helpers +from esphome.address_cache import AddressCache from esphome.core import EsphomeError @@ -830,3 +831,84 @@ def test_resolve_ip_address_sorting() -> None: assert result[0][4][0] == "2001:db8::1" # IPv6 (preference 1) assert result[1][4][0] == "192.168.1.100" # IPv4 (preference 2) assert result[2][4][0] == "fe80::1" # Link-local no scope (preference 3) + + +def test_resolve_ip_address_with_cache() -> None: + """Test that the cache is used when provided.""" + cache = AddressCache( + mdns_cache={"test.local": ["192.168.1.100", "192.168.1.101"]}, + dns_cache={ + "example.com": ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"] + }, + ) + + # Test mDNS cache hit + result = helpers.resolve_ip_address("test.local", 6053, address_cache=cache) + + # Should return cached addresses without calling resolver + assert len(result) == 2 + assert result[0][4][0] == "192.168.1.100" + assert result[1][4][0] == "192.168.1.101" + + # Test DNS cache hit + result = helpers.resolve_ip_address("example.com", 6053, address_cache=cache) + + # Should return cached addresses with IPv6 first due to preference + assert len(result) == 2 + assert result[0][4][0] == "2606:2800:220:1:248:1893:25c8:1946" # IPv6 first + assert result[1][4][0] == "93.184.216.34" # IPv4 second + + +def test_resolve_ip_address_cache_miss() -> None: + """Test that resolver is called when not in cache.""" + cache = AddressCache(mdns_cache={"other.local": ["192.168.1.200"]}) + + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address("test.local", 6053, address_cache=cache) + + # Should call resolver since test.local is not in cache + MockResolver.assert_called_once_with(["test.local"], 6053) + assert len(result) == 1 + assert result[0][4][0] == "192.168.1.100" + + +def test_resolve_ip_address_mixed_cached_uncached() -> None: + """Test resolution with mix of cached and uncached hosts.""" + cache = AddressCache(mdns_cache={"cached.local": ["192.168.1.50"]}) + + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.return_value = [mock_addr_info] + + # Pass a list with cached IP, cached hostname, and uncached hostname + result = helpers.resolve_ip_address( + ["192.168.1.10", "cached.local", "uncached.local"], + 6053, + address_cache=cache, + ) + + # Should only resolve uncached.local + MockResolver.assert_called_once_with(["uncached.local"], 6053) + + # Results should include all addresses + addresses = [r[4][0] for r in result] + assert "192.168.1.10" in addresses # Direct IP + assert "192.168.1.50" in addresses # From cache + assert "192.168.1.100" in addresses # From resolver From 9c201afe763f15a19f6dcaa5fe22992857e6056f Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 18 Sep 2025 20:55:45 -0500 Subject: [PATCH 1226/1626] [api_protobuf.py] Use type appropriate for `estimated_size` (#10797) --- script/api_protobuf/api_protobuf.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 81653c3302..05567629b2 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1750,13 +1750,16 @@ def build_message_type( # Add estimated size constant estimated_size = calculate_message_estimated_size(desc) - # Validate that estimated_size fits in uint8_t - if estimated_size > 255: - raise ValueError( - f"Estimated size {estimated_size} for {desc.name} exceeds uint8_t maximum (255)" - ) + # Use a type appropriate for estimated_size + estimated_size_type = ( + "uint8_t" + if estimated_size <= 255 + else "uint16_t" + if estimated_size <= 65535 + else "size_t" + ) public_content.append( - f"static constexpr uint8_t ESTIMATED_SIZE = {estimated_size};" + f"static constexpr {estimated_size_type} ESTIMATED_SIZE = {estimated_size};" ) # Add message_name method inline in header From 9ea3643b740dd5cf8fdd0663dc1eb4e34e6be5d8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 20 Sep 2025 00:59:48 +1200 Subject: [PATCH 1227/1626] [core] os.path -> Path (#10654) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/__main__.py | 38 ++-- esphome/build_gen/platformio.py | 4 +- esphome/components/esp32/__init__.py | 29 ++- esphome/components/esp32_hosted/__init__.py | 3 +- esphome/components/esp8266/__init__.py | 6 +- esphome/components/font/__init__.py | 10 +- esphome/components/libretiny/__init__.py | 12 +- esphome/components/rp2040/__init__.py | 22 +-- esphome/config_validation.py | 28 ++- esphome/core/__init__.py | 48 ++--- esphome/core/config.py | 30 +-- esphome/dashboard/core.py | 5 +- esphome/dashboard/entries.py | 12 +- esphome/dashboard/settings.py | 14 +- esphome/dashboard/util/file.py | 63 ------- esphome/dashboard/web_server.py | 48 +++-- esphome/espota2.py | 7 +- esphome/external_files.py | 13 +- esphome/helpers.py | 79 ++++---- esphome/platformio_api.py | 38 ++-- esphome/storage_json.py | 47 ++--- esphome/util.py | 25 ++- esphome/vscode.py | 16 +- esphome/wizard.py | 20 +- esphome/writer.py | 32 ++-- esphome/yaml_util.py | 52 +++--- script/api_protobuf/api_protobuf.py | 5 +- script/build_codeowners.py | 2 +- script/build_language_schema.py | 35 ++-- script/ci-custom.py | 29 ++- script/helpers.py | 8 +- script/list-components.py | 2 +- tests/component_tests/conftest.py | 6 +- tests/dashboard/test_entries.py | 68 +++---- tests/dashboard/test_settings.py | 31 ++- tests/dashboard/test_web_server.py | 34 ++-- tests/dashboard/test_web_server_paths.py | 79 ++++---- tests/dashboard/util/test_file.py | 56 ------ tests/integration/conftest.py | 2 +- tests/unit_tests/build_gen/test_platformio.py | 2 +- tests/unit_tests/conftest.py | 2 +- tests/unit_tests/core/common.py | 4 +- tests/unit_tests/core/conftest.py | 6 +- tests/unit_tests/core/test_config.py | 58 +++--- .../test_config_validation_paths.py | 32 ++-- tests/unit_tests/test_core.py | 37 ++-- tests/unit_tests/test_external_files.py | 30 +-- tests/unit_tests/test_helpers.py | 42 +---- tests/unit_tests/test_main.py | 18 +- tests/unit_tests/test_platformio_api.py | 40 ++-- tests/unit_tests/test_storage_json.py | 44 ++--- tests/unit_tests/test_substitutions.py | 24 +-- tests/unit_tests/test_util.py | 176 +++++++++--------- tests/unit_tests/test_vscode.py | 12 +- tests/unit_tests/test_wizard.py | 75 ++++---- tests/unit_tests/test_writer.py | 56 +++--- tests/unit_tests/test_yaml_util.py | 30 +-- 57 files changed, 808 insertions(+), 938 deletions(-) delete mode 100644 esphome/dashboard/util/file.py delete mode 100644 tests/dashboard/util/test_file.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 07cd267c96..830a7567c2 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -6,6 +6,7 @@ import getpass import importlib import logging import os +from pathlib import Path import re import sys import time @@ -538,7 +539,10 @@ def upload_program( remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD, "") - binary = args.file if getattr(args, "file", None) is not None else CORE.firmware_bin + if getattr(args, "file", None) is not None: + binary = Path(args.file) + else: + binary = CORE.firmware_bin # MQTT address resolution if get_port_type(host) in ("MQTT", "MQTTIP"): @@ -605,7 +609,7 @@ def clean_mqtt(config: ConfigType, args: ArgsProtocol) -> int | None: def command_wizard(args: ArgsProtocol) -> int | None: from esphome import wizard - return wizard.wizard(args.configuration) + return wizard.wizard(Path(args.configuration)) def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: @@ -818,7 +822,8 @@ def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: - for c in args.name: + new_name = args.name + for c in new_name: if c not in ALLOWED_NAME_CHARS: print( color( @@ -829,8 +834,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) return 1 # Load existing yaml file - with open(CORE.config_path, mode="r+", encoding="utf-8") as raw_file: - raw_contents = raw_file.read() + raw_contents = CORE.config_path.read_text(encoding="utf-8") yaml = yaml_util.load_yaml(CORE.config_path) if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]: @@ -845,7 +849,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: if match is None: new_raw = re.sub( rf"name:\s+[\"']?{old_name}[\"']?", - f'name: "{args.name}"', + f'name: "{new_name}"', raw_contents, ) else: @@ -865,29 +869,28 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_raw = re.sub( rf"^(\s+{match.group(1)}):\s+[\"']?{old_name}[\"']?", - f'\\1: "{args.name}"', + f'\\1: "{new_name}"', raw_contents, flags=re.MULTILINE, ) - new_path = os.path.join(CORE.config_dir, args.name + ".yaml") + new_path: Path = CORE.config_dir / (new_name + ".yaml") print( - f"Updating {color(AnsiFore.CYAN, CORE.config_path)} to {color(AnsiFore.CYAN, new_path)}" + f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" ) print() - with open(new_path, mode="w", encoding="utf-8") as new_file: - new_file.write(new_raw) + new_path.write_text(new_raw, encoding="utf-8") - rc = run_external_process("esphome", "config", new_path) + rc = run_external_process("esphome", "config", str(new_path)) if rc != 0: print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) - os.remove(new_path) + new_path.unlink() return 1 cli_args = [ "run", - new_path, + str(new_path), "--no-logs", "--device", CORE.address, @@ -901,11 +904,11 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: except KeyboardInterrupt: rc = 1 if rc != 0: - os.remove(new_path) + new_path.unlink() return 1 if CORE.config_path != new_path: - os.remove(CORE.config_path) + CORE.config_path.unlink() print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) print() @@ -1262,7 +1265,8 @@ def run_esphome(argv): _LOGGER.info("ESPHome %s", const.__version__) for conf_path in args.configuration: - if any(os.path.basename(conf_path) == x for x in SECRETS_FILES): + conf_path = Path(conf_path) + if any(conf_path.name == x for x in SECRETS_FILES): _LOGGER.warning("Skipping secrets file %s", conf_path) continue diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index 9bbe86694b..30dbb69d86 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -1,5 +1,3 @@ -import os - from esphome.const import __version__ from esphome.core import CORE from esphome.helpers import mkdir_p, read_file, write_file_if_changed @@ -63,7 +61,7 @@ def write_ini(content): update_storage_json() path = CORE.relative_build_path("platformio.ini") - if os.path.isfile(path): + if path.is_file(): text = read_file(path) content_format = find_begin_end( text, INI_AUTO_GENERATE_BEGIN, INI_AUTO_GENERATE_END diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1a2cbd9daf..f5eda52cae 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -37,7 +37,7 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt, TimePeriod import esphome.final_validate as fv -from esphome.helpers import copy_file_if_changed, mkdir_p, write_file_if_changed +from esphome.helpers import copy_file_if_changed, write_file_if_changed from esphome.types import ConfigType from esphome.writer import clean_cmake_cache @@ -272,14 +272,14 @@ def add_idf_component( } -def add_extra_script(stage: str, filename: str, path: str): +def add_extra_script(stage: str, filename: str, path: Path): """Add an extra script to the project.""" key = f"{stage}:{filename}" if add_extra_build_file(filename, path): cg.add_platformio_option("extra_scripts", [key]) -def add_extra_build_file(filename: str, path: str) -> bool: +def add_extra_build_file(filename: str, path: Path) -> bool: """Add an extra build file to the project.""" if filename not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]: CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES][filename] = { @@ -818,7 +818,7 @@ async def to_code(config): add_extra_script( "post", "post_build.py", - os.path.join(os.path.dirname(__file__), "post_build.py.script"), + Path(__file__).parent / "post_build.py.script", ) if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: @@ -1040,7 +1040,7 @@ def _write_sdkconfig(): def _write_idf_component_yml(): - yml_path = Path(CORE.relative_build_path("src/idf_component.yml")) + yml_path = CORE.relative_build_path("src/idf_component.yml") if CORE.data[KEY_ESP32][KEY_COMPONENTS]: components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS] dependencies = {} @@ -1058,8 +1058,8 @@ def _write_idf_component_yml(): contents = "" if write_file_if_changed(yml_path, contents): dependencies_lock = CORE.relative_build_path("dependencies.lock") - if os.path.isfile(dependencies_lock): - os.remove(dependencies_lock) + if dependencies_lock.is_file(): + dependencies_lock.unlink() clean_cmake_cache() @@ -1093,14 +1093,13 @@ def copy_files(): ) for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values(): - if file[KEY_PATH].startswith("http"): + name: str = file[KEY_NAME] + path: Path = file[KEY_PATH] + if str(path).startswith("http"): import requests - mkdir_p(CORE.relative_build_path(os.path.dirname(file[KEY_NAME]))) - with open(CORE.relative_build_path(file[KEY_NAME]), "wb") as f: - f.write(requests.get(file[KEY_PATH], timeout=30).content) + CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True) + content = requests.get(path, timeout=30).content + CORE.relative_build_path(name).write_bytes(content) else: - copy_file_if_changed( - file[KEY_PATH], - CORE.relative_build_path(file[KEY_NAME]), - ) + copy_file_if_changed(path, CORE.relative_build_path(name)) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 330800df12..9cea02c322 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -1,4 +1,5 @@ import os +from pathlib import Path from esphome import pins from esphome.components import esp32 @@ -97,5 +98,5 @@ async def to_code(config): esp32.add_extra_script( "post", "esp32_hosted.py", - os.path.join(os.path.dirname(__file__), "esp32_hosted.py.script"), + Path(__file__).parent / "esp32_hosted.py.script", ) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index b85314214e..8a7fbbcb0a 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -1,5 +1,5 @@ import logging -import os +from pathlib import Path import esphome.codegen as cg import esphome.config_validation as cv @@ -259,8 +259,8 @@ async def to_code(config): # Called by writer.py def copy_files(): - dir = os.path.dirname(__file__) - post_build_file = os.path.join(dir, "post_build.py.script") + dir = Path(__file__).parent + post_build_file = dir / "post_build.py.script" copy_file_if_changed( post_build_file, CORE.relative_build_path("post_build.py"), diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 4ecc76c561..ddcee14635 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -3,7 +3,6 @@ import functools import hashlib from itertools import accumulate import logging -import os from pathlib import Path import re @@ -38,6 +37,7 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.helpers import cpp_string_escape +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -253,11 +253,11 @@ def validate_truetype_file(value): return CORE.relative_config_path(cv.file_(value)) -def add_local_file(value): +def add_local_file(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value - path = value[CONF_PATH] - if not os.path.isfile(path): + path = Path(value[CONF_PATH]) + if not path.is_file(): raise cv.Invalid(f"File '{path}' not found.") FONT_CACHE[value] = path return value @@ -318,7 +318,7 @@ def download_gfont(value): external_files.compute_local_file_dir(DOMAIN) / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf" ) - if not external_files.is_file_recent(str(path), value[CONF_REFRESH]): + if not external_files.is_file_recent(path, value[CONF_REFRESH]): _LOGGER.debug("download_gfont: path=%s", path) try: req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 178660cb40..c63d6d7faa 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -1,6 +1,5 @@ import json import logging -from os.path import dirname, isfile, join import esphome.codegen as cg import esphome.config_validation as cv @@ -24,6 +23,7 @@ from esphome.const import ( __version__, ) from esphome.core import CORE +from esphome.storage_json import StorageJSON from . import gpio # noqa from .const import ( @@ -129,7 +129,7 @@ def only_on_family(*, supported=None, unsupported=None): return validator_ -def get_download_types(storage_json=None): +def get_download_types(storage_json: StorageJSON = None): types = [ { "title": "UF2 package (recommended)", @@ -139,11 +139,11 @@ def get_download_types(storage_json=None): }, ] - build_dir = dirname(storage_json.firmware_bin_path) - outputs = join(build_dir, "firmware.json") - if not isfile(outputs): + build_dir = storage_json.firmware_bin_path.parent + outputs = build_dir / "firmware.json" + if not outputs.is_file(): return types - with open(outputs, encoding="utf-8") as f: + with outputs.open(encoding="utf-8") as f: outputs = json.load(f) for output in outputs: if not output["public"]: diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 1ec38e0159..3a1ea16fa3 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -1,5 +1,5 @@ import logging -import os +from pathlib import Path from string import ascii_letters, digits import esphome.codegen as cg @@ -19,7 +19,7 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority -from esphome.helpers import copy_file_if_changed, mkdir_p, read_file, write_file +from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed from .const import KEY_BOARD, KEY_PIO_FILES, KEY_RP2040, rp2040_ns @@ -221,18 +221,18 @@ def generate_pio_files() -> bool: if not files: return False for key, data in files.items(): - pio_path = CORE.relative_build_path(f"src/pio/{key}.pio") - mkdir_p(os.path.dirname(pio_path)) - write_file(pio_path, data) + pio_path = CORE.build_path / "src" / "pio" / f"{key}.pio" + pio_path.parent.mkdir(parents=True, exist_ok=True) + write_file_if_changed(pio_path, data) includes.append(f"pio/{key}.pio.h") - write_file( + write_file_if_changed( CORE.relative_build_path("src/pio_includes.h"), "#pragma once\n" + "\n".join([f'#include "{include}"' for include in includes]), ) - dir = os.path.dirname(__file__) - build_pio_file = os.path.join(dir, "build_pio.py.script") + dir = Path(__file__).parent + build_pio_file = dir / "build_pio.py.script" copy_file_if_changed( build_pio_file, CORE.relative_build_path("build_pio.py"), @@ -243,8 +243,8 @@ def generate_pio_files() -> bool: # Called by writer.py def copy_files(): - dir = os.path.dirname(__file__) - post_build_file = os.path.join(dir, "post_build.py.script") + dir = Path(__file__).parent + post_build_file = dir / "post_build.py.script" copy_file_if_changed( post_build_file, CORE.relative_build_path("post_build.py"), @@ -252,4 +252,4 @@ def copy_files(): if generate_pio_files(): path = CORE.relative_src_path("esphome.h") content = read_file(path).rstrip("\n") - write_file(path, content + '\n#include "pio_includes.h"\n') + write_file_if_changed(path, content + '\n#include "pio_includes.h"\n') diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 866ed4f8aa..7aaba886e3 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -15,7 +15,7 @@ from ipaddress import ( ip_network, ) import logging -import os +from pathlib import Path import re from string import ascii_letters, digits import uuid as uuid_ @@ -1609,34 +1609,32 @@ def dimensions(value): return dimensions([match.group(1), match.group(2)]) -def directory(value): +def directory(value: object) -> Path: value = string(value) path = CORE.relative_config_path(value) - if not os.path.exists(path): + if not path.exists(): raise Invalid( - f"Could not find directory '{path}'. Please make sure it exists (full path: {os.path.abspath(path)})." + f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." ) - if not os.path.isdir(path): + if not path.is_dir(): raise Invalid( - f"Path '{path}' is not a directory (full path: {os.path.abspath(path)})." + f"Path '{path}' is not a directory (full path: {path.resolve()})." ) - return value + return path -def file_(value): +def file_(value: object) -> Path: value = string(value) path = CORE.relative_config_path(value) - if not os.path.exists(path): + if not path.exists(): raise Invalid( - f"Could not find file '{path}'. Please make sure it exists (full path: {os.path.abspath(path)})." + f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." ) - if not os.path.isfile(path): - raise Invalid( - f"Path '{path}' is not a file (full path: {os.path.abspath(path)})." - ) - return value + if not path.is_file(): + raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).") + return path ENTITY_ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 242a6854df..7ab8a3ba71 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -3,6 +3,7 @@ from contextlib import contextmanager import logging import math import os +from pathlib import Path import re from typing import TYPE_CHECKING @@ -383,7 +384,7 @@ class DocumentLocation: @classmethod def from_mark(cls, mark): - return cls(mark.name, mark.line, mark.column) + return cls(str(mark.name), mark.line, mark.column) def __str__(self): return f"{self.document} {self.line}:{self.column}" @@ -538,9 +539,9 @@ class EsphomeCore: # The first key to this dict should always be the integration name self.data = {} # The relative path to the configuration YAML - self.config_path: str | None = None + self.config_path: Path | None = None # The relative path to where all build files are stored - self.build_path: str | None = None + self.build_path: Path | None = None # The validated configuration, this is None until the config has been validated self.config: ConfigType | None = None # The pending tasks in the task queue (mostly for C++ generation) @@ -664,43 +665,46 @@ class EsphomeCore: return None @property - def config_dir(self): - return os.path.abspath(os.path.dirname(self.config_path)) + def config_dir(self) -> Path: + if self.config_path.is_dir(): + return self.config_path.absolute() + return self.config_path.absolute().parent @property - def data_dir(self): + def data_dir(self) -> Path: if is_ha_addon(): - return os.path.join("/data") + return Path("/data") if "ESPHOME_DATA_DIR" in os.environ: - return get_str_env("ESPHOME_DATA_DIR", None) + return Path(get_str_env("ESPHOME_DATA_DIR", None)) return self.relative_config_path(".esphome") @property - def config_filename(self): - return os.path.basename(self.config_path) + def config_filename(self) -> str: + return self.config_path.name - def relative_config_path(self, *path): - path_ = os.path.expanduser(os.path.join(*path)) - return os.path.join(self.config_dir, path_) + def relative_config_path(self, *path: str | Path) -> Path: + path_ = Path(*path).expanduser() + return self.config_dir / path_ - def relative_internal_path(self, *path: str) -> str: - return os.path.join(self.data_dir, *path) + def relative_internal_path(self, *path: str | Path) -> Path: + path_ = Path(*path).expanduser() + return self.data_dir / path_ - def relative_build_path(self, *path): - path_ = os.path.expanduser(os.path.join(*path)) - return os.path.join(self.build_path, path_) + def relative_build_path(self, *path: str | Path) -> Path: + path_ = Path(*path).expanduser() + return self.build_path / path_ - def relative_src_path(self, *path): + def relative_src_path(self, *path: str | Path) -> Path: return self.relative_build_path("src", *path) - def relative_pioenvs_path(self, *path): + def relative_pioenvs_path(self, *path: str | Path) -> Path: return self.relative_build_path(".pioenvs", *path) - def relative_piolibdeps_path(self, *path): + def relative_piolibdeps_path(self, *path: str | Path) -> Path: return self.relative_build_path(".piolibdeps", *path) @property - def firmware_bin(self): + def firmware_bin(self) -> Path: if self.is_libretiny: return self.relative_pioenvs_path(self.name, "firmware.uf2") return self.relative_pioenvs_path(self.name, "firmware.bin") diff --git a/esphome/core/config.py b/esphome/core/config.py index 96b9e23861..6d4f5af692 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -136,21 +136,21 @@ def validate_ids_and_references(config: ConfigType) -> ConfigType: return config -def valid_include(value): +def valid_include(value: str) -> str: # Look for "<...>" includes if value.startswith("<") and value.endswith(">"): return value try: - return cv.directory(value) + return str(cv.directory(value)) except cv.Invalid: pass - value = cv.file_(value) - _, ext = os.path.splitext(value) + path = cv.file_(value) + ext = path.suffix if ext not in VALID_INCLUDE_EXTS: raise cv.Invalid( f"Include has invalid file extension {ext} - valid extensions are {', '.join(VALID_INCLUDE_EXTS)}" ) - return value + return str(path) def valid_project_name(value: str): @@ -311,9 +311,9 @@ def preload_core_config(config, result) -> str: CORE.data[KEY_CORE] = {} if CONF_BUILD_PATH not in conf: - build_path = get_str_env("ESPHOME_BUILD_PATH", "build") - conf[CONF_BUILD_PATH] = os.path.join(build_path, CORE.name) - CORE.build_path = CORE.relative_internal_path(conf[CONF_BUILD_PATH]) + build_path = Path(get_str_env("ESPHOME_BUILD_PATH", "build")) + conf[CONF_BUILD_PATH] = str(build_path / CORE.name) + CORE.build_path = CORE.data_dir / conf[CONF_BUILD_PATH] target_platforms = [] @@ -339,12 +339,12 @@ def preload_core_config(config, result) -> str: return target_platforms[0] -def include_file(path, basename): - parts = basename.split(os.path.sep) +def include_file(path: Path, basename: Path): + parts = basename.parts dst = CORE.relative_src_path(*parts) copy_file_if_changed(path, dst) - _, ext = os.path.splitext(path) + ext = path.suffix if ext in [".h", ".hpp", ".tcc"]: # Header, add include statement cg.add_global(cg.RawStatement(f'#include "{basename}"')) @@ -377,18 +377,18 @@ async def add_arduino_global_workaround(): @coroutine_with_priority(CoroPriority.FINAL) -async def add_includes(includes): +async def add_includes(includes: list[str]) -> None: # Add includes at the very end, so that the included files can access global variables for include in includes: path = CORE.relative_config_path(include) - if os.path.isdir(path): + if path.is_dir(): # Directory, copy tree for p in walk_files(path): - basename = os.path.relpath(p, os.path.dirname(path)) + basename = p.relative_to(path.parent) include_file(p, basename) else: # Copy file - basename = os.path.basename(path) + basename = Path(path.name) include_file(path, basename) diff --git a/esphome/dashboard/core.py b/esphome/dashboard/core.py index 410ef0c29d..11aa4f3cb5 100644 --- a/esphome/dashboard/core.py +++ b/esphome/dashboard/core.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from functools import partial import json import logging -from pathlib import Path import threading from typing import Any @@ -108,7 +107,7 @@ class ESPHomeDashboard: await self.loop.run_in_executor(None, self.load_ignored_devices) def load_ignored_devices(self) -> None: - storage_path = Path(ignored_devices_storage_path()) + storage_path = ignored_devices_storage_path() try: with storage_path.open("r", encoding="utf-8") as f_handle: data = json.load(f_handle) @@ -117,7 +116,7 @@ class ESPHomeDashboard: pass def save_ignored_devices(self) -> None: - storage_path = Path(ignored_devices_storage_path()) + storage_path = ignored_devices_storage_path() with storage_path.open("w", encoding="utf-8") as f_handle: json.dump( {"ignored_devices": sorted(self.ignored_devices)}, indent=2, fp=f_handle diff --git a/esphome/dashboard/entries.py b/esphome/dashboard/entries.py index b138cfd272..de868fbf2b 100644 --- a/esphome/dashboard/entries.py +++ b/esphome/dashboard/entries.py @@ -5,7 +5,7 @@ from collections import defaultdict from dataclasses import dataclass from functools import lru_cache import logging -import os +from pathlib import Path from typing import TYPE_CHECKING, Any from esphome import const, util @@ -287,12 +287,12 @@ class DashboardEntries: for file in util.list_yaml_files([self._config_dir]): try: # Prefer the json storage path if it exists - stat = os.stat(ext_storage_path(os.path.basename(file))) + stat = ext_storage_path(file.name).stat() except OSError: try: # Fallback to the yaml file if the storage # file does not exist or could not be generated - stat = os.stat(file) + stat = file.stat() except OSError: # File was deleted, ignore continue @@ -329,10 +329,10 @@ class DashboardEntry: "_to_dict", ) - def __init__(self, path: str, cache_key: DashboardCacheKeyType) -> None: + def __init__(self, path: Path, cache_key: DashboardCacheKeyType) -> None: """Initialize the DashboardEntry.""" self.path = path - self.filename: str = os.path.basename(path) + self.filename: str = path.name self._storage_path = ext_storage_path(self.filename) self.cache_key = cache_key self.storage: StorageJSON | None = None @@ -365,7 +365,7 @@ class DashboardEntry: "loaded_integrations": sorted(self.loaded_integrations), "deployed_version": self.update_old, "current_version": self.update_new, - "path": self.path, + "path": str(self.path), "comment": self.comment, "address": self.address, "web_port": self.web_port, diff --git a/esphome/dashboard/settings.py b/esphome/dashboard/settings.py index fa39b55016..35b67c0d23 100644 --- a/esphome/dashboard/settings.py +++ b/esphome/dashboard/settings.py @@ -27,7 +27,7 @@ class DashboardSettings: def __init__(self) -> None: """Initialize the dashboard settings.""" - self.config_dir: str = "" + self.config_dir: Path = None self.password_hash: str = "" self.username: str = "" self.using_password: bool = False @@ -45,10 +45,10 @@ class DashboardSettings: self.using_password = bool(password) if self.using_password: self.password_hash = password_hash(password) - self.config_dir = args.configuration - self.absolute_config_dir = Path(self.config_dir).resolve() + self.config_dir = Path(args.configuration) + self.absolute_config_dir = self.config_dir.resolve() self.verbose = args.verbose - CORE.config_path = os.path.join(self.config_dir, ".") + CORE.config_path = self.config_dir / "." @property def relative_url(self) -> str: @@ -81,9 +81,9 @@ class DashboardSettings: # Compare password in constant running time (to prevent timing attacks) return hmac.compare_digest(self.password_hash, password_hash(password)) - def rel_path(self, *args: Any) -> str: + def rel_path(self, *args: Any) -> Path: """Return a path relative to the ESPHome config folder.""" - joined_path = os.path.join(self.config_dir, *args) + joined_path = self.config_dir / Path(*args) # Raises ValueError if not relative to ESPHome config folder - Path(joined_path).resolve().relative_to(self.absolute_config_dir) + joined_path.resolve().relative_to(self.absolute_config_dir) return joined_path diff --git a/esphome/dashboard/util/file.py b/esphome/dashboard/util/file.py deleted file mode 100644 index bb263f9ad7..0000000000 --- a/esphome/dashboard/util/file.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging -import os -from pathlib import Path -import tempfile - -_LOGGER = logging.getLogger(__name__) - - -def write_utf8_file( - filename: Path, - utf8_str: str, - private: bool = False, -) -> None: - """Write a file and rename it into place. - - Writes all or nothing. - """ - write_file(filename, utf8_str.encode("utf-8"), private) - - -# from https://github.com/home-assistant/core/blob/dev/homeassistant/util/file.py -def write_file( - filename: Path, - utf8_data: bytes, - private: bool = False, -) -> None: - """Write a file and rename it into place. - - Writes all or nothing. - """ - - tmp_filename = "" - missing_fchmod = False - try: - # Modern versions of Python tempfile create this file with mode 0o600 - with tempfile.NamedTemporaryFile( - mode="wb", dir=os.path.dirname(filename), delete=False - ) as fdesc: - fdesc.write(utf8_data) - tmp_filename = fdesc.name - if not private: - try: - os.fchmod(fdesc.fileno(), 0o644) - except AttributeError: - # os.fchmod is not available on Windows - missing_fchmod = True - - os.replace(tmp_filename, filename) - if missing_fchmod: - os.chmod(filename, 0o644) - finally: - if os.path.exists(tmp_filename): - try: - os.remove(tmp_filename) - except OSError as err: - # If we are cleaning up then something else went wrong, so - # we should suppress likely follow-on errors in the cleanup - _LOGGER.error( - "File replacement cleanup failed for %s while saving %s: %s", - tmp_filename, - filename, - err, - ) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 24595eb942..7b6e6b4507 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -49,10 +49,10 @@ from esphome.storage_json import ( from esphome.util import get_serial_ports, shlex_quote from esphome.yaml_util import FastestAvailableSafeLoader +from ..helpers import write_file from .const import DASHBOARD_COMMAND from .core import DASHBOARD, ESPHomeDashboard from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool -from .util.file import write_file from .util.subprocess import async_run_system_command from .util.text import friendly_name_slugify @@ -581,7 +581,7 @@ class WizardRequestHandler(BaseHandler): destination = settings.rel_path(filename) # Check if destination file already exists - if os.path.exists(destination): + if destination.exists(): self.set_status(409) # Conflict status code self.set_header("content-type", "application/json") self.write( @@ -798,10 +798,9 @@ class DownloadBinaryRequestHandler(BaseHandler): "download", f"{storage_json.name}-{file_name}", ) - path = os.path.dirname(storage_json.firmware_bin_path) - path = os.path.join(path, file_name) + path = storage_json.firmware_bin_path.with_name(file_name) - if not Path(path).is_file(): + if not path.is_file(): args = ["esphome", "idedata", settings.rel_path(configuration)] rc, stdout, _ = await async_run_system_command(args) @@ -1016,7 +1015,7 @@ class EditRequestHandler(BaseHandler): return filename = settings.rel_path(configuration) - if Path(filename).resolve().parent != settings.absolute_config_dir: + if filename.resolve().parent != settings.absolute_config_dir: self.send_error(404) return @@ -1039,10 +1038,6 @@ class EditRequestHandler(BaseHandler): self.set_status(404) return None - def _write_file(self, filename: str, content: bytes) -> None: - """Write a file with the given content.""" - write_file(filename, content) - @authenticated @bind_config async def post(self, configuration: str | None = None) -> None: @@ -1052,12 +1047,12 @@ class EditRequestHandler(BaseHandler): return filename = settings.rel_path(configuration) - if Path(filename).resolve().parent != settings.absolute_config_dir: + if filename.resolve().parent != settings.absolute_config_dir: self.send_error(404) return loop = asyncio.get_running_loop() - await loop.run_in_executor(None, self._write_file, filename, self.request.body) + await loop.run_in_executor(None, write_file, filename, self.request.body) # Ensure the StorageJSON is updated as well DASHBOARD.entries.async_schedule_storage_json_update(filename) self.set_status(200) @@ -1072,7 +1067,7 @@ class ArchiveRequestHandler(BaseHandler): archive_path = archive_storage_path() mkdir_p(archive_path) - shutil.move(config_file, os.path.join(archive_path, configuration)) + shutil.move(config_file, archive_path / configuration) storage_json = StorageJSON.load(storage_path) if storage_json is not None and storage_json.build_path: @@ -1086,7 +1081,7 @@ class UnArchiveRequestHandler(BaseHandler): def post(self, configuration: str | None = None) -> None: config_file = settings.rel_path(configuration) archive_path = archive_storage_path() - shutil.move(os.path.join(archive_path, configuration), config_file) + shutil.move(archive_path / configuration, config_file) class LoginHandler(BaseHandler): @@ -1173,7 +1168,7 @@ class SecretKeysRequestHandler(BaseHandler): for secret_filename in const.SECRETS_FILES: relative_filename = settings.rel_path(secret_filename) - if os.path.isfile(relative_filename): + if relative_filename.is_file(): filename = relative_filename break @@ -1206,16 +1201,17 @@ class JsonConfigRequestHandler(BaseHandler): @bind_config async def get(self, configuration: str | None = None) -> None: filename = settings.rel_path(configuration) - if not os.path.isfile(filename): + if not filename.is_file(): self.send_error(404) return - args = ["esphome", "config", filename, "--show-secrets"] + args = ["esphome", "config", str(filename), "--show-secrets"] - rc, stdout, _ = await async_run_system_command(args) + rc, stdout, stderr = await async_run_system_command(args) if rc != 0: - self.send_error(422) + self.set_status(422) + self.write(stderr) return data = yaml.load(stdout, Loader=SafeLoaderIgnoreUnknown) @@ -1224,7 +1220,7 @@ class JsonConfigRequestHandler(BaseHandler): self.finish() -def get_base_frontend_path() -> str: +def get_base_frontend_path() -> Path: if ENV_DEV not in os.environ: import esphome_dashboard @@ -1235,11 +1231,12 @@ def get_base_frontend_path() -> str: static_path += "/" # This path can be relative, so resolve against the root or else templates don't work - return os.path.abspath(os.path.join(os.getcwd(), static_path, "esphome_dashboard")) + path = Path(os.getcwd()) / static_path / "esphome_dashboard" + return path.resolve() -def get_static_path(*args: Iterable[str]) -> str: - return os.path.join(get_base_frontend_path(), "static", *args) +def get_static_path(*args: Iterable[str]) -> Path: + return get_base_frontend_path() / "static" / Path(*args) @functools.cache @@ -1256,8 +1253,7 @@ def get_static_file_url(name: str) -> str: return base.replace("index.js", esphome_dashboard.entrypoint()) path = get_static_path(name) - with open(path, "rb") as f_handle: - hash_ = hashlib.md5(f_handle.read()).hexdigest()[:8] + hash_ = hashlib.md5(path.read_bytes()).hexdigest()[:8] return f"{base}?hash={hash_}" @@ -1357,7 +1353,7 @@ def start_web_server( """Start the web server listener.""" trash_path = trash_storage_path() - if os.path.exists(trash_path): + if trash_path.is_dir() and trash_path.exists(): _LOGGER.info("Renaming 'trash' folder to 'archive'") archive_path = archive_storage_path() shutil.move(trash_path, archive_path) diff --git a/esphome/espota2.py b/esphome/espota2.py index 99c91d94e2..84f53377e7 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -4,6 +4,7 @@ import gzip import hashlib import io import logging +from pathlib import Path import random import socket import sys @@ -191,7 +192,7 @@ def send_check(sock, data, msg): def perform_ota( - sock: socket.socket, password: str, file_handle: io.IOBase, filename: str + sock: socket.socket, password: str, file_handle: io.IOBase, filename: Path ) -> None: file_contents = file_handle.read() file_size = len(file_contents) @@ -309,7 +310,7 @@ def perform_ota( def run_ota_impl_( - remote_host: str | list[str], remote_port: int, password: str, filename: str + remote_host: str | list[str], remote_port: int, password: str, filename: Path ) -> tuple[int, str | None]: from esphome.core import CORE @@ -360,7 +361,7 @@ def run_ota_impl_( def run_ota( - remote_host: str | list[str], remote_port: int, password: str, filename: str + remote_host: str | list[str], remote_port: int, password: str, filename: Path ) -> tuple[int, str | None]: try: return run_ota_impl_(remote_host, remote_port, password, filename) diff --git a/esphome/external_files.py b/esphome/external_files.py index 057ff52f3f..80b54ebb2f 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime import logging -import os from pathlib import Path import requests @@ -23,11 +22,11 @@ CONTENT_DISPOSITION = "content-disposition" TEMP_DIR = "temp" -def has_remote_file_changed(url, local_file_path): - if os.path.exists(local_file_path): +def has_remote_file_changed(url: str, local_file_path: Path) -> bool: + if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) try: - local_modification_time = os.path.getmtime(local_file_path) + local_modification_time = local_file_path.stat().st_mtime local_modification_time_str = datetime.utcfromtimestamp( local_modification_time ).strftime("%a, %d %b %Y %H:%M:%S GMT") @@ -65,9 +64,9 @@ def has_remote_file_changed(url, local_file_path): return True -def is_file_recent(file_path: str, refresh: TimePeriodSeconds) -> bool: - if os.path.exists(file_path): - creation_time = os.path.getctime(file_path) +def is_file_recent(file_path: Path, refresh: TimePeriodSeconds) -> bool: + if file_path.exists(): + creation_time = file_path.stat().st_ctime current_time = datetime.now().timestamp() return current_time - creation_time <= refresh.total_seconds return False diff --git a/esphome/helpers.py b/esphome/helpers.py index 2b7221355c..fb7b71775d 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,6 +1,5 @@ from __future__ import annotations -import codecs from contextlib import suppress import ipaddress import logging @@ -8,6 +7,7 @@ import os from pathlib import Path import platform import re +import shutil import tempfile from typing import TYPE_CHECKING from urllib.parse import urlparse @@ -140,16 +140,16 @@ def run_system_command(*args): return rc, stdout, stderr -def mkdir_p(path): +def mkdir_p(path: Path): if not path: # Empty path - means create current dir return try: - os.makedirs(path) + path.mkdir(parents=True, exist_ok=True) except OSError as err: import errno - if err.errno == errno.EEXIST and os.path.isdir(path): + if err.errno == errno.EEXIST and path.is_dir(): pass else: from esphome.core import EsphomeError @@ -331,16 +331,15 @@ def is_ha_addon(): return get_bool_env("ESPHOME_IS_HA_ADDON") -def walk_files(path): +def walk_files(path: Path): for root, _, files in os.walk(path): for name in files: - yield os.path.join(root, name) + yield Path(root) / name -def read_file(path): +def read_file(path: Path) -> str: try: - with codecs.open(path, "r", encoding="utf-8") as f_handle: - return f_handle.read() + return path.read_text(encoding="utf-8") except OSError as err: from esphome.core import EsphomeError @@ -351,13 +350,15 @@ def read_file(path): raise EsphomeError(f"Error reading file {path}: {err}") from err -def _write_file(path: Path | str, text: str | bytes): +def _write_file( + path: Path, + text: str | bytes, + private: bool = False, +) -> None: """Atomically writes `text` to the given path. Automatically creates all parent directories. """ - if not isinstance(path, Path): - path = Path(path) data = text if isinstance(text, str): data = text.encode() @@ -365,42 +366,54 @@ def _write_file(path: Path | str, text: str | bytes): directory = path.parent directory.mkdir(exist_ok=True, parents=True) - tmp_path = None + tmp_filename: Path | None = None + missing_fchmod = False try: + # Modern versions of Python tempfile create this file with mode 0o600 with tempfile.NamedTemporaryFile( mode="wb", dir=directory, delete=False ) as f_handle: - tmp_path = f_handle.name f_handle.write(data) - # Newer tempfile implementations create the file with mode 0o600 - os.chmod(tmp_path, 0o644) - # If destination exists, will be overwritten - os.replace(tmp_path, path) + tmp_filename = Path(f_handle.name) + + if not private: + try: + os.fchmod(f_handle.fileno(), 0o644) + except AttributeError: + # os.fchmod is not available on Windows + missing_fchmod = True + shutil.move(tmp_filename, path) + if missing_fchmod: + path.chmod(0o644) finally: - if tmp_path is not None and os.path.exists(tmp_path): + if tmp_filename and tmp_filename.exists(): try: - os.remove(tmp_path) + tmp_filename.unlink() except OSError as err: - _LOGGER.error("Write file cleanup failed: %s", err) + # If we are cleaning up then something else went wrong, so + # we should suppress likely follow-on errors in the cleanup + _LOGGER.error( + "File replacement cleanup failed for %s while saving %s: %s", + tmp_filename, + path, + err, + ) -def write_file(path: Path | str, text: str): +def write_file(path: Path, text: str | bytes, private: bool = False) -> None: try: - _write_file(path, text) + _write_file(path, text, private=private) except OSError as err: from esphome.core import EsphomeError raise EsphomeError(f"Could not write file at {path}") from err -def write_file_if_changed(path: Path | str, text: str) -> bool: +def write_file_if_changed(path: Path, text: str) -> bool: """Write text to the given path, but not if the contents match already. Returns true if the file was changed. """ - if not isinstance(path, Path): - path = Path(path) - src_content = None if path.is_file(): src_content = read_file(path) @@ -410,12 +423,10 @@ def write_file_if_changed(path: Path | str, text: str) -> bool: return True -def copy_file_if_changed(src: os.PathLike, dst: os.PathLike) -> None: - import shutil - +def copy_file_if_changed(src: Path, dst: Path) -> None: if file_compare(src, dst): return - mkdir_p(os.path.dirname(dst)) + dst.parent.mkdir(parents=True, exist_ok=True) try: shutil.copyfile(src, dst) except OSError as err: @@ -440,12 +451,12 @@ def list_starts_with(list_, sub): return len(sub) <= len(list_) and all(list_[i] == x for i, x in enumerate(sub)) -def file_compare(path1: os.PathLike, path2: os.PathLike) -> bool: +def file_compare(path1: Path, path2: Path) -> bool: """Return True if the files path1 and path2 have the same contents.""" import stat try: - stat1, stat2 = os.stat(path1), os.stat(path2) + stat1, stat2 = path1.stat(), path2.stat() except OSError: # File doesn't exist or another error -> not equal return False @@ -462,7 +473,7 @@ def file_compare(path1: os.PathLike, path2: os.PathLike) -> bool: bufsize = 8 * 1024 # Read files in blocks until a mismatch is found - with open(path1, "rb") as fh1, open(path2, "rb") as fh2: + with path1.open("rb") as fh1, path2.open("rb") as fh2: while True: blob1, blob2 = fh1.read(bufsize), fh2.read(bufsize) if blob1 != blob2: diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index d8c8611884..8c2e147020 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -18,23 +18,25 @@ def patch_structhash(): # removed/added. This might have unintended consequences, but this improves compile # times greatly when adding/removing components and a simple clean build solves # all issues - from os import makedirs - from os.path import getmtime, isdir, join - from platformio.run import cli, helpers def patched_clean_build_dir(build_dir, *args): from platformio import fs from platformio.project.helpers import get_project_dir - platformio_ini = join(get_project_dir(), "platformio.ini") + platformio_ini = Path(get_project_dir()) / "platformio.ini" + + build_dir = Path(build_dir) # if project's config is modified - if isdir(build_dir) and getmtime(platformio_ini) > getmtime(build_dir): + if ( + build_dir.is_dir() + and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime + ): fs.rmtree(build_dir) - if not isdir(build_dir): - makedirs(build_dir) + if not build_dir.is_dir(): + build_dir.mkdir(parents=True) helpers.clean_build_dir = patched_clean_build_dir cli.clean_build_dir = patched_clean_build_dir @@ -77,9 +79,9 @@ FILTER_PLATFORMIO_LINES = [ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PLATFORMIO_FORCE_COLOR"] = "true" - os.environ["PLATFORMIO_BUILD_DIR"] = os.path.abspath(CORE.relative_pioenvs_path()) + os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute()) os.environ.setdefault( - "PLATFORMIO_LIBDEPS_DIR", os.path.abspath(CORE.relative_piolibdeps_path()) + "PLATFORMIO_LIBDEPS_DIR", str(CORE.relative_piolibdeps_path().absolute()) ) # Suppress Python syntax warnings from third-party scripts during compilation os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") @@ -98,7 +100,7 @@ def run_platformio_cli(*args, **kwargs) -> str | int: def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int: - command = ["run", "-d", CORE.build_path] + command = ["run", "-d", str(CORE.build_path)] if verbose: command += ["-v"] command += list(args) @@ -130,8 +132,8 @@ def _run_idedata(config): def _load_idedata(config): - platformio_ini = Path(CORE.relative_build_path("platformio.ini")) - temp_idedata = Path(CORE.relative_internal_path("idedata", f"{CORE.name}.json")) + platformio_ini = CORE.relative_build_path("platformio.ini") + temp_idedata = CORE.relative_internal_path("idedata", f"{CORE.name}.json") changed = False if ( @@ -301,7 +303,7 @@ def process_stacktrace(config, line, backtrace_state): @dataclass class FlashImage: - path: str + path: Path offset: str @@ -310,17 +312,17 @@ class IDEData: self.raw = raw @property - def firmware_elf_path(self): - return self.raw["prog_path"] + def firmware_elf_path(self) -> Path: + return Path(self.raw["prog_path"]) @property - def firmware_bin_path(self) -> str: - return str(Path(self.firmware_elf_path).with_suffix(".bin")) + def firmware_bin_path(self) -> Path: + return self.firmware_elf_path.with_suffix(".bin") @property def extra_flash_images(self) -> list[FlashImage]: return [ - FlashImage(path=entry["path"], offset=entry["offset"]) + FlashImage(path=Path(entry["path"]), offset=entry["offset"]) for entry in self.raw["extra"]["flash_images"] ] diff --git a/esphome/storage_json.py b/esphome/storage_json.py index b69dc2dd3f..d5423ab1c7 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -1,11 +1,11 @@ from __future__ import annotations import binascii -import codecs from datetime import datetime import json import logging import os +from pathlib import Path from esphome import const from esphome.const import CONF_DISABLED, CONF_MDNS @@ -16,30 +16,35 @@ from esphome.types import CoreType _LOGGER = logging.getLogger(__name__) -def storage_path() -> str: - return os.path.join(CORE.data_dir, "storage", f"{CORE.config_filename}.json") +def storage_path() -> Path: + return CORE.data_dir / "storage" / f"{CORE.config_filename}.json" -def ext_storage_path(config_filename: str) -> str: - return os.path.join(CORE.data_dir, "storage", f"{config_filename}.json") +def ext_storage_path(config_filename: str) -> Path: + return CORE.data_dir / "storage" / f"{config_filename}.json" -def esphome_storage_path() -> str: - return os.path.join(CORE.data_dir, "esphome.json") +def esphome_storage_path() -> Path: + return CORE.data_dir / "esphome.json" -def ignored_devices_storage_path() -> str: - return os.path.join(CORE.data_dir, "ignored-devices.json") +def ignored_devices_storage_path() -> Path: + return CORE.data_dir / "ignored-devices.json" -def trash_storage_path() -> str: +def trash_storage_path() -> Path: return CORE.relative_config_path("trash") -def archive_storage_path() -> str: +def archive_storage_path() -> Path: return CORE.relative_config_path("archive") +def _to_path_if_not_none(value: str | None) -> Path | None: + """Convert a string to Path if it's not None.""" + return Path(value) if value is not None else None + + class StorageJSON: def __init__( self, @@ -52,8 +57,8 @@ class StorageJSON: address: str, web_port: int | None, target_platform: str, - build_path: str | None, - firmware_bin_path: str | None, + build_path: Path | None, + firmware_bin_path: Path | None, loaded_integrations: set[str], loaded_platforms: set[str], no_mdns: bool, @@ -107,8 +112,8 @@ class StorageJSON: "address": self.address, "web_port": self.web_port, "esp_platform": self.target_platform, - "build_path": self.build_path, - "firmware_bin_path": self.firmware_bin_path, + "build_path": str(self.build_path), + "firmware_bin_path": str(self.firmware_bin_path), "loaded_integrations": sorted(self.loaded_integrations), "loaded_platforms": sorted(self.loaded_platforms), "no_mdns": self.no_mdns, @@ -176,8 +181,8 @@ class StorageJSON: ) @staticmethod - def _load_impl(path: str) -> StorageJSON | None: - with codecs.open(path, "r", encoding="utf-8") as f_handle: + def _load_impl(path: Path) -> StorageJSON | None: + with path.open("r", encoding="utf-8") as f_handle: storage = json.load(f_handle) storage_version = storage["storage_version"] name = storage.get("name") @@ -190,8 +195,8 @@ class StorageJSON: address = storage.get("address") web_port = storage.get("web_port") esp_platform = storage.get("esp_platform") - build_path = storage.get("build_path") - firmware_bin_path = storage.get("firmware_bin_path") + build_path = _to_path_if_not_none(storage.get("build_path")) + firmware_bin_path = _to_path_if_not_none(storage.get("firmware_bin_path")) loaded_integrations = set(storage.get("loaded_integrations", [])) loaded_platforms = set(storage.get("loaded_platforms", [])) no_mdns = storage.get("no_mdns", False) @@ -217,7 +222,7 @@ class StorageJSON: ) @staticmethod - def load(path: str) -> StorageJSON | None: + def load(path: Path) -> StorageJSON | None: try: return StorageJSON._load_impl(path) except Exception: # pylint: disable=broad-except @@ -268,7 +273,7 @@ class EsphomeStorageJSON: @staticmethod def _load_impl(path: str) -> EsphomeStorageJSON | None: - with codecs.open(path, "r", encoding="utf-8") as f_handle: + with Path(path).open("r", encoding="utf-8") as f_handle: storage = json.load(f_handle) storage_version = storage["storage_version"] cookie_secret = storage.get("cookie_secret") diff --git a/esphome/util.py b/esphome/util.py index 23a66be4eb..3bf3248cb3 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -1,7 +1,6 @@ import collections import io import logging -import os from pathlib import Path import re import subprocess @@ -86,7 +85,10 @@ def safe_input(prompt=""): return input() -def shlex_quote(s): +def shlex_quote(s: str | Path) -> str: + # Convert Path objects to strings + if isinstance(s, Path): + s = str(s) if not s: return "''" if re.search(r"[^\w@%+=:,./-]", s) is None: @@ -272,25 +274,28 @@ class OrderedDict(collections.OrderedDict): return dict(self).__repr__() -def list_yaml_files(configs: list[str]) -> list[str]: - files: list[str] = [] +def list_yaml_files(configs: list[str | Path]) -> list[Path]: + files: list[Path] = [] for config in configs: - if os.path.isfile(config): + config = Path(config) + if not config.exists(): + raise FileNotFoundError(f"Config path '{config}' does not exist!") + if config.is_file(): files.append(config) else: - files.extend(os.path.join(config, p) for p in os.listdir(config)) + files.extend(config.glob("*")) files = filter_yaml_files(files) return sorted(files) -def filter_yaml_files(files: list[str]) -> list[str]: +def filter_yaml_files(files: list[Path]) -> list[Path]: return [ f for f in files if ( - os.path.splitext(f)[1] in (".yaml", ".yml") - and os.path.basename(f) not in ("secrets.yaml", "secrets.yml") - and not os.path.basename(f).startswith(".") + f.suffix in (".yaml", ".yml") + and f.name not in ("secrets.yaml", "secrets.yml") + and not f.name.startswith(".") ) ] diff --git a/esphome/vscode.py b/esphome/vscode.py index f5e2a20b97..53bb339a8e 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -2,7 +2,7 @@ from __future__ import annotations from io import StringIO import json -import os +from pathlib import Path from typing import Any from esphome.config import Config, _format_vol_invalid, validate_config @@ -67,24 +67,24 @@ def _read_file_content_from_json_on_stdin() -> str: return data["content"] -def _print_file_read_event(path: str) -> None: +def _print_file_read_event(path: Path) -> None: """Print a file read event.""" print( json.dumps( { "type": "read_file", - "path": path, + "path": str(path), } ) ) -def _request_and_get_stream_on_stdin(fname: str) -> StringIO: +def _request_and_get_stream_on_stdin(fname: Path) -> StringIO: _print_file_read_event(fname) return StringIO(_read_file_content_from_json_on_stdin()) -def _vscode_loader(fname: str) -> dict[str, Any]: +def _vscode_loader(fname: Path) -> dict[str, Any]: raw_yaml_stream = _request_and_get_stream_on_stdin(fname) # it is required to set the name on StringIO so document on start_mark # is set properly. Otherwise it is initialized with "" @@ -92,7 +92,7 @@ def _vscode_loader(fname: str) -> dict[str, Any]: return parse_yaml(fname, raw_yaml_stream, _vscode_loader) -def _ace_loader(fname: str) -> dict[str, Any]: +def _ace_loader(fname: Path) -> dict[str, Any]: raw_yaml_stream = _request_and_get_stream_on_stdin(fname) return parse_yaml(fname, raw_yaml_stream) @@ -120,10 +120,10 @@ def read_config(args): return CORE.vscode = True if args.ace: # Running from ESPHome Compiler dashboard, not vscode - CORE.config_path = os.path.join(args.configuration, data["file"]) + CORE.config_path = Path(args.configuration) / data["file"] loader = _ace_loader else: - CORE.config_path = data["file"] + CORE.config_path = Path(data["file"]) loader = _vscode_loader file_name = CORE.config_path diff --git a/esphome/wizard.py b/esphome/wizard.py index 3edf519816..97343eea99 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path import random import string from typing import Literal, NotRequired, TypedDict, Unpack @@ -213,7 +213,7 @@ class WizardWriteKwargs(TypedDict): file_text: NotRequired[str] -def wizard_write(path: str, **kwargs: Unpack[WizardWriteKwargs]) -> bool: +def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool: from esphome.components.bk72xx import boards as bk72xx_boards from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards @@ -256,13 +256,13 @@ def wizard_write(path: str, **kwargs: Unpack[WizardWriteKwargs]) -> bool: file_text = wizard_file(**kwargs) # Check if file already exists to prevent overwriting - if os.path.exists(path) and os.path.isfile(path): + if path.exists() and path.is_file(): safe_print(color(AnsiFore.RED, f'The file "{path}" already exists.')) return False write_file(path, file_text) storage = StorageJSON.from_wizard(name, name, f"{name}.local", hardware) - storage_path = ext_storage_path(os.path.basename(path)) + storage_path = ext_storage_path(path.name) storage.save(storage_path) return True @@ -301,7 +301,7 @@ def strip_accents(value: str) -> str: ) -def wizard(path: str) -> int: +def wizard(path: Path) -> int: from esphome.components.bk72xx import boards as bk72xx_boards from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards @@ -309,14 +309,14 @@ def wizard(path: str) -> int: from esphome.components.rp2040 import boards as rp2040_boards from esphome.components.rtl87xx import boards as rtl87xx_boards - if not path.endswith(".yaml") and not path.endswith(".yml"): + if path.suffix not in (".yaml", ".yml"): safe_print( - f"Please make your configuration file {color(AnsiFore.CYAN, path)} have the extension .yaml or .yml" + f"Please make your configuration file {color(AnsiFore.CYAN, str(path))} have the extension .yaml or .yml" ) return 1 - if os.path.exists(path): + if path.exists(): safe_print( - f"Uh oh, it seems like {color(AnsiFore.CYAN, path)} already exists, please delete that file first or chose another configuration file." + f"Uh oh, it seems like {color(AnsiFore.CYAN, str(path))} already exists, please delete that file first or chose another configuration file." ) return 2 @@ -549,7 +549,7 @@ def wizard(path: str) -> int: safe_print() safe_print( color(AnsiFore.CYAN, "DONE! I've now written a new configuration file to ") - + color(AnsiFore.BOLD_CYAN, path) + + color(AnsiFore.BOLD_CYAN, str(path)) ) safe_print() safe_print("Next steps:") diff --git a/esphome/writer.py b/esphome/writer.py index 2a9c6a770d..c0d4379b3a 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -1,6 +1,5 @@ import importlib import logging -import os from pathlib import Path import re @@ -266,7 +265,7 @@ def generate_version_h(): def write_cpp(code_s): path = CORE.relative_src_path("main.cpp") - if os.path.isfile(path): + if path.is_file(): text = read_file(path) code_format = find_begin_end( text, CPP_AUTO_GENERATE_BEGIN, CPP_AUTO_GENERATE_END @@ -292,28 +291,28 @@ def write_cpp(code_s): def clean_cmake_cache(): pioenvs = CORE.relative_pioenvs_path() - if os.path.isdir(pioenvs): - pioenvs_cmake_path = CORE.relative_pioenvs_path(CORE.name, "CMakeCache.txt") - if os.path.isfile(pioenvs_cmake_path): + if pioenvs.is_dir(): + pioenvs_cmake_path = pioenvs / CORE.name / "CMakeCache.txt" + if pioenvs_cmake_path.is_file(): _LOGGER.info("Deleting %s", pioenvs_cmake_path) - os.remove(pioenvs_cmake_path) + pioenvs_cmake_path.unlink() def clean_build(): import shutil pioenvs = CORE.relative_pioenvs_path() - if os.path.isdir(pioenvs): + if pioenvs.is_dir(): _LOGGER.info("Deleting %s", pioenvs) shutil.rmtree(pioenvs) piolibdeps = CORE.relative_piolibdeps_path() - if os.path.isdir(piolibdeps): + if piolibdeps.is_dir(): _LOGGER.info("Deleting %s", piolibdeps) shutil.rmtree(piolibdeps) dependencies_lock = CORE.relative_build_path("dependencies.lock") - if os.path.isfile(dependencies_lock): + if dependencies_lock.is_file(): _LOGGER.info("Deleting %s", dependencies_lock) - os.remove(dependencies_lock) + dependencies_lock.unlink() # Clean PlatformIO cache to resolve CMake compiler detection issues # This helps when toolchain paths change or get corrupted @@ -324,9 +323,11 @@ def clean_build(): pass else: cache_dir = get_project_cache_dir() - if cache_dir and cache_dir.strip() and os.path.isdir(cache_dir): - _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) - shutil.rmtree(cache_dir) + if cache_dir and cache_dir.strip(): + cache_path = Path(cache_dir) + if cache_path.is_dir(): + _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) + shutil.rmtree(cache_dir) GITIGNORE_CONTENT = """# Gitignore settings for ESPHome @@ -339,6 +340,5 @@ GITIGNORE_CONTENT = """# Gitignore settings for ESPHome def write_gitignore(): path = CORE.relative_config_path(".gitignore") - if not os.path.isfile(path): - with open(file=path, mode="w", encoding="utf-8") as f: - f.write(GITIGNORE_CONTENT) + if not path.is_file(): + path.write_text(GITIGNORE_CONTENT, encoding="utf-8") diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index f26bc0502d..f430fa22df 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Callable -import fnmatch import functools import inspect from io import BytesIO, TextIOBase, TextIOWrapper @@ -9,6 +8,7 @@ from ipaddress import _BaseAddress, _BaseNetwork import logging import math import os +from pathlib import Path from typing import Any import uuid @@ -109,7 +109,9 @@ def _add_data_ref(fn): class ESPHomeLoaderMixin: """Loader class that keeps track of line numbers.""" - def __init__(self, name: str, yaml_loader: Callable[[str], dict[str, Any]]) -> None: + def __init__( + self, name: Path, yaml_loader: Callable[[Path], dict[str, Any]] + ) -> None: """Initialize the loader.""" self.name = name self.yaml_loader = yaml_loader @@ -254,12 +256,8 @@ class ESPHomeLoaderMixin: f"Environment variable '{node.value}' not defined", node.start_mark ) - @property - def _directory(self) -> str: - return os.path.dirname(self.name) - - def _rel_path(self, *args: str) -> str: - return os.path.join(self._directory, *args) + def _rel_path(self, *args: str) -> Path: + return self.name.parent / Path(*args) @_add_data_ref def construct_secret(self, node: yaml.Node) -> str: @@ -269,8 +267,8 @@ class ESPHomeLoaderMixin: if self.name == CORE.config_path: raise e try: - main_config_dir = os.path.dirname(CORE.config_path) - main_secret_yml = os.path.join(main_config_dir, SECRET_YAML) + main_config_dir = CORE.config_path.parent + main_secret_yml = main_config_dir / SECRET_YAML secrets = self.yaml_loader(main_secret_yml) except EsphomeError as er: raise EsphomeError(f"{e}\n{er}") from er @@ -329,7 +327,7 @@ class ESPHomeLoaderMixin: files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) mapping = OrderedDict() for fname in files: - filename = os.path.splitext(os.path.basename(fname))[0] + filename = fname.stem mapping[filename] = self.yaml_loader(fname) return mapping @@ -369,8 +367,8 @@ class ESPHomeLoader(ESPHomeLoaderMixin, FastestAvailableSafeLoader): def __init__( self, stream: TextIOBase | BytesIO, - name: str, - yaml_loader: Callable[[str], dict[str, Any]], + name: Path, + yaml_loader: Callable[[Path], dict[str, Any]], ) -> None: FastestAvailableSafeLoader.__init__(self, stream) ESPHomeLoaderMixin.__init__(self, name, yaml_loader) @@ -382,8 +380,8 @@ class ESPHomePurePythonLoader(ESPHomeLoaderMixin, PurePythonLoader): def __init__( self, stream: TextIOBase | BytesIO, - name: str, - yaml_loader: Callable[[str], dict[str, Any]], + name: Path, + yaml_loader: Callable[[Path], dict[str, Any]], ) -> None: PurePythonLoader.__init__(self, stream) ESPHomeLoaderMixin.__init__(self, name, yaml_loader) @@ -414,24 +412,24 @@ for _loader in (ESPHomeLoader, ESPHomePurePythonLoader): _loader.add_constructor("!remove", _loader.construct_remove) -def load_yaml(fname: str, clear_secrets: bool = True) -> Any: +def load_yaml(fname: Path, clear_secrets: bool = True) -> Any: if clear_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() return _load_yaml_internal(fname) -def _load_yaml_internal(fname: str) -> Any: +def _load_yaml_internal(fname: Path) -> Any: """Load a YAML file.""" try: - with open(fname, encoding="utf-8") as f_handle: + with fname.open(encoding="utf-8") as f_handle: return parse_yaml(fname, f_handle) except (UnicodeDecodeError, OSError) as err: raise EsphomeError(f"Error reading file {fname}: {err}") from err def parse_yaml( - file_name: str, file_handle: TextIOWrapper, yaml_loader=_load_yaml_internal + file_name: Path, file_handle: TextIOWrapper, yaml_loader=_load_yaml_internal ) -> Any: """Parse a YAML file.""" try: @@ -483,9 +481,9 @@ def substitute_vars(config, vars): def _load_yaml_internal_with_type( loader_type: type[ESPHomeLoader] | type[ESPHomePurePythonLoader], - fname: str, + fname: Path, content: TextIOWrapper, - yaml_loader: Any, + yaml_loader: Callable[[Path], dict[str, Any]], ) -> Any: """Load a YAML file.""" loader = loader_type(content, fname, yaml_loader) @@ -512,13 +510,14 @@ def _is_file_valid(name: str) -> bool: return not name.startswith(".") -def _find_files(directory, pattern): +def _find_files(directory: Path, pattern): """Recursively load files in a directory.""" - for root, dirs, files in os.walk(directory, topdown=True): + for root, dirs, files in os.walk(directory): dirs[:] = [d for d in dirs if _is_file_valid(d)] - for basename in files: - if _is_file_valid(basename) and fnmatch.fnmatch(basename, pattern): - filename = os.path.join(root, basename) + for f in files: + filename = Path(f) + if _is_file_valid(f) and filename.match(pattern): + filename = Path(root) / filename yield filename @@ -627,3 +626,4 @@ ESPHomeDumper.add_multi_representer(TimePeriod, ESPHomeDumper.represent_stringif ESPHomeDumper.add_multi_representer(Lambda, ESPHomeDumper.represent_lambda) ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id) ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify) +ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify) diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 05567629b2..fa04222c5d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import ABC, abstractmethod from enum import IntEnum -import os from pathlib import Path import re from subprocess import call @@ -2704,8 +2703,8 @@ static const char *const TAG = "api.service"; import clang_format def exec_clang_format(path: Path) -> None: - clang_format_path = os.path.join( - os.path.dirname(clang_format.__file__), "data", "bin", "clang-format" + clang_format_path = ( + Path(clang_format.__file__).parent / "data" / "bin" / "clang-format" ) call([clang_format_path, "-i", path]) diff --git a/script/build_codeowners.py b/script/build_codeowners.py index 27ea82611b..10ca1295b7 100755 --- a/script/build_codeowners.py +++ b/script/build_codeowners.py @@ -39,7 +39,7 @@ esphome/core/* @esphome/core parts = [BASE] # Fake some directory so that get_component works -CORE.config_path = str(root) +CORE.config_path = root CORE.data[KEY_CORE] = {KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None} codeowners = defaultdict(list) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index ff6e898902..1ffe3c2873 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 import argparse -import glob import inspect import json import os +from pathlib import Path import re import voluptuous as vol @@ -70,14 +70,14 @@ def get_component_names(): component_names = ["esphome", "sensor", "esp32", "esp8266"] skip_components = [] - for d in os.listdir(CORE_COMPONENTS_PATH): + for d in CORE_COMPONENTS_PATH.iterdir(): if ( - not d.startswith("__") - and os.path.isdir(os.path.join(CORE_COMPONENTS_PATH, d)) - and d not in component_names - and d not in skip_components + not d.name.startswith("__") + and d.is_dir() + and d.name not in component_names + and d.name not in skip_components ): - component_names.append(d) + component_names.append(d.name) return sorted(component_names) @@ -121,7 +121,7 @@ from esphome.util import Registry # noqa: E402 def write_file(name, obj): - full_path = os.path.join(args.output_path, name + ".json") + full_path = Path(args.output_path) / f"{name}.json" if JSON_DUMP_PRETTY: json_str = json.dumps(obj, indent=2) else: @@ -131,9 +131,10 @@ def write_file(name, obj): def delete_extra_files(keep_names): - for d in os.listdir(args.output_path): - if d.endswith(".json") and d[:-5] not in keep_names: - os.remove(os.path.join(args.output_path, d)) + output_path = Path(args.output_path) + for d in output_path.iterdir(): + if d.suffix == ".json" and d.stem not in keep_names: + d.unlink() print(f"Deleted {d}") @@ -367,13 +368,11 @@ def get_logger_tags(): "scheduler", "api.service", ] - for x in os.walk(CORE_COMPONENTS_PATH): - for y in glob.glob(os.path.join(x[0], "*.cpp")): - with open(y, encoding="utf-8") as file: - data = file.read() - match = pattern.search(data) - if match: - tags.append(match.group(1)) + for file in CORE_COMPONENTS_PATH.rglob("*.cpp"): + data = file.read_text() + match = pattern.search(data) + if match: + tags.append(match.group(1)) return tags diff --git a/script/ci-custom.py b/script/ci-custom.py index 61081608d5..bc1ebda93b 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -6,6 +6,7 @@ import collections import fnmatch import functools import os.path +from pathlib import Path import re import sys import time @@ -75,12 +76,12 @@ ignore_types = ( LINT_FILE_CHECKS = [] LINT_CONTENT_CHECKS = [] LINT_POST_CHECKS = [] -EXECUTABLE_BIT = {} +EXECUTABLE_BIT: dict[str, int] = {} -errors = collections.defaultdict(list) +errors: collections.defaultdict[Path, list] = collections.defaultdict(list) -def add_errors(fname, errs): +def add_errors(fname: Path, errs: list[tuple[int, int, str] | None]) -> None: if not isinstance(errs, list): errs = [errs] for err in errs: @@ -246,8 +247,8 @@ def lint_ext_check(fname): ".github/copilot-instructions.md", ] ) -def lint_executable_bit(fname): - ex = EXECUTABLE_BIT[fname] +def lint_executable_bit(fname: Path) -> str | None: + ex = EXECUTABLE_BIT[str(fname)] if ex != 100644: return ( f"File has invalid executable bit {ex}. If running from a windows machine please " @@ -506,8 +507,8 @@ def lint_constants_usage(): return errs -def relative_cpp_search_text(fname, content): - parts = fname.split("/") +def relative_cpp_search_text(fname: Path, content) -> str: + parts = fname.parts integration = parts[2] return f'#include "esphome/components/{integration}' @@ -524,8 +525,8 @@ def lint_relative_cpp_import(fname, line, col, content): ) -def relative_py_search_text(fname, content): - parts = fname.split("/") +def relative_py_search_text(fname: Path, content: str) -> str: + parts = fname.parts integration = parts[2] return f"esphome.components.{integration}" @@ -591,10 +592,8 @@ def lint_relative_py_import(fname, line, col, content): "esphome/components/http_request/httplib.h", ], ) -def lint_namespace(fname, content): - expected_name = re.match( - r"^esphome/components/([^/]+)/.*", fname.replace(os.path.sep, "/") - ).group(1) +def lint_namespace(fname: Path, content: str) -> str | None: + expected_name = fname.parts[2] # Check for both old style and C++17 nested namespace syntax search_old = f"namespace {expected_name}" search_new = f"namespace esphome::{expected_name}" @@ -733,9 +732,9 @@ def main(): files.sort() for fname in files: - _, ext = os.path.splitext(fname) + fname = Path(fname) run_checks(LINT_FILE_CHECKS, fname, fname) - if ext in ignore_types: + if fname.suffix in ignore_types: continue try: with codecs.open(fname, "r", encoding="utf-8") as f_handle: diff --git a/script/helpers.py b/script/helpers.py index 2c2f44a513..38e6fcbd1e 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -52,10 +52,10 @@ def styled(color: str | tuple[str, ...], msg: str, reset: bool = True) -> str: return prefix + msg + suffix -def print_error_for_file(file: str, body: str | None) -> None: +def print_error_for_file(file: str | Path, body: str | None) -> None: print( styled(colorama.Fore.GREEN, "### File ") - + styled((colorama.Fore.GREEN, colorama.Style.BRIGHT), file) + + styled((colorama.Fore.GREEN, colorama.Style.BRIGHT), str(file)) ) print() if body is not None: @@ -513,7 +513,7 @@ def get_all_dependencies(component_names: set[str]) -> set[str]: # Set up fake config path for component loading root = Path(__file__).parent.parent - CORE.config_path = str(root) + CORE.config_path = root CORE.data[KEY_CORE] = {} # Keep finding dependencies until no new ones are found @@ -553,7 +553,7 @@ def get_components_from_integration_fixtures() -> set[str]: fixtures_dir = Path(__file__).parent.parent / "tests" / "integration" / "fixtures" for yaml_file in fixtures_dir.glob("*.yaml"): - config: dict[str, any] | None = yaml_util.load_yaml(str(yaml_file)) + config: dict[str, any] | None = yaml_util.load_yaml(yaml_file) if not config: continue diff --git a/script/list-components.py b/script/list-components.py index 66212f44e7..ef02aecdf6 100755 --- a/script/list-components.py +++ b/script/list-components.py @@ -50,7 +50,7 @@ def create_components_graph(): root = Path(__file__).parent.parent components_dir = root / "esphome" / "components" # Fake some directory so that get_component works - CORE.config_path = str(root) + CORE.config_path = root # Various configuration to capture different outcomes used by `AUTO_LOAD` function. TARGET_CONFIGURATIONS = [ {KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None}, diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 2045b03502..79b4151c55 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -40,9 +40,9 @@ def config_path(request: pytest.FixtureRequest) -> Generator[None]: if config_dir.exists(): # Set config_path to a dummy yaml file in the config directory # This ensures CORE.config_dir points to the config directory - CORE.config_path = str(config_dir / "dummy.yaml") + CORE.config_path = config_dir / "dummy.yaml" else: - CORE.config_path = str(Path(request.fspath).parent / "dummy.yaml") + CORE.config_path = Path(request.fspath).parent / "dummy.yaml" yield CORE.config_path = original_path @@ -129,7 +129,7 @@ def generate_main() -> Generator[Callable[[str | Path], str]]: """Generates the C++ main.cpp from a given yaml file and returns it in string form.""" def generator(path: str | Path) -> str: - CORE.config_path = str(path) + CORE.config_path = Path(path) CORE.config = read_config({}) generate_cpp_contents(CORE.config) return CORE.cpp_main_section diff --git a/tests/dashboard/test_entries.py b/tests/dashboard/test_entries.py index a86c33a16f..2f6d5667b7 100644 --- a/tests/dashboard/test_entries.py +++ b/tests/dashboard/test_entries.py @@ -22,7 +22,7 @@ def create_cache_key() -> tuple[int, int, float, int]: def setup_core(): """Set up CORE for testing.""" with tempfile.TemporaryDirectory() as tmpdir: - CORE.config_path = str(Path(tmpdir) / "test.yaml") + CORE.config_path = Path(tmpdir) / "test.yaml" yield CORE.reset() @@ -44,7 +44,7 @@ async def dashboard_entries(mock_settings: MagicMock) -> DashboardEntries: def test_dashboard_entry_path_initialization() -> None: """Test DashboardEntry initializes with path correctly.""" - test_path = "/test/config/device.yaml" + test_path = Path("/test/config/device.yaml") cache_key = create_cache_key() entry = DashboardEntry(test_path, cache_key) @@ -59,21 +59,21 @@ def test_dashboard_entry_path_with_absolute_path() -> None: test_path = Path.cwd() / "absolute" / "path" / "to" / "config.yaml" cache_key = create_cache_key() - entry = DashboardEntry(str(test_path), cache_key) + entry = DashboardEntry(test_path, cache_key) - assert entry.path == str(test_path) - assert Path(entry.path).is_absolute() + assert entry.path == test_path + assert entry.path.is_absolute() def test_dashboard_entry_path_with_relative_path() -> None: """Test DashboardEntry handles relative paths.""" - test_path = "configs/device.yaml" + test_path = Path("configs/device.yaml") cache_key = create_cache_key() entry = DashboardEntry(test_path, cache_key) assert entry.path == test_path - assert not Path(entry.path).is_absolute() + assert not entry.path.is_absolute() @pytest.mark.asyncio @@ -81,12 +81,12 @@ async def test_dashboard_entries_get_by_path( dashboard_entries: DashboardEntries, ) -> None: """Test getting entry by path.""" - test_path = "/test/config/device.yaml" + test_path = Path("/test/config/device.yaml") entry = DashboardEntry(test_path, create_cache_key()) - dashboard_entries._entries[test_path] = entry + dashboard_entries._entries[str(test_path)] = entry - result = dashboard_entries.get(test_path) + result = dashboard_entries.get(str(test_path)) assert result == entry @@ -104,12 +104,12 @@ async def test_dashboard_entries_path_normalization( dashboard_entries: DashboardEntries, ) -> None: """Test that paths are handled consistently.""" - path1 = "/test/config/device.yaml" + path1 = Path("/test/config/device.yaml") entry = DashboardEntry(path1, create_cache_key()) - dashboard_entries._entries[path1] = entry + dashboard_entries._entries[str(path1)] = entry - result = dashboard_entries.get(path1) + result = dashboard_entries.get(str(path1)) assert result == entry @@ -118,12 +118,12 @@ async def test_dashboard_entries_path_with_spaces( dashboard_entries: DashboardEntries, ) -> None: """Test handling paths with spaces.""" - test_path = "/test/config/my device.yaml" + test_path = Path("/test/config/my device.yaml") entry = DashboardEntry(test_path, create_cache_key()) - dashboard_entries._entries[test_path] = entry + dashboard_entries._entries[str(test_path)] = entry - result = dashboard_entries.get(test_path) + result = dashboard_entries.get(str(test_path)) assert result == entry assert result.path == test_path @@ -133,18 +133,18 @@ async def test_dashboard_entries_path_with_special_chars( dashboard_entries: DashboardEntries, ) -> None: """Test handling paths with special characters.""" - test_path = "/test/config/device-01_test.yaml" + test_path = Path("/test/config/device-01_test.yaml") entry = DashboardEntry(test_path, create_cache_key()) - dashboard_entries._entries[test_path] = entry + dashboard_entries._entries[str(test_path)] = entry - result = dashboard_entries.get(test_path) + result = dashboard_entries.get(str(test_path)) assert result == entry def test_dashboard_entries_windows_path() -> None: """Test handling Windows-style paths.""" - test_path = r"C:\Users\test\esphome\device.yaml" + test_path = Path(r"C:\Users\test\esphome\device.yaml") cache_key = create_cache_key() entry = DashboardEntry(test_path, cache_key) @@ -157,28 +157,28 @@ async def test_dashboard_entries_path_to_cache_key_mapping( dashboard_entries: DashboardEntries, ) -> None: """Test internal entries storage with paths and cache keys.""" - path1 = "/test/config/device1.yaml" - path2 = "/test/config/device2.yaml" + path1 = Path("/test/config/device1.yaml") + path2 = Path("/test/config/device2.yaml") entry1 = DashboardEntry(path1, create_cache_key()) entry2 = DashboardEntry(path2, (1, 1, 1.0, 1)) - dashboard_entries._entries[path1] = entry1 - dashboard_entries._entries[path2] = entry2 + dashboard_entries._entries[str(path1)] = entry1 + dashboard_entries._entries[str(path2)] = entry2 - assert path1 in dashboard_entries._entries - assert path2 in dashboard_entries._entries - assert dashboard_entries._entries[path1].cache_key == create_cache_key() - assert dashboard_entries._entries[path2].cache_key == (1, 1, 1.0, 1) + assert str(path1) in dashboard_entries._entries + assert str(path2) in dashboard_entries._entries + assert dashboard_entries._entries[str(path1)].cache_key == create_cache_key() + assert dashboard_entries._entries[str(path2)].cache_key == (1, 1, 1.0, 1) def test_dashboard_entry_path_property() -> None: """Test that path property returns expected value.""" - test_path = "/test/config/device.yaml" + test_path = Path("/test/config/device.yaml") entry = DashboardEntry(test_path, create_cache_key()) assert entry.path == test_path - assert isinstance(entry.path, str) + assert isinstance(entry.path, Path) @pytest.mark.asyncio @@ -187,14 +187,14 @@ async def test_dashboard_entries_all_returns_entries_with_paths( ) -> None: """Test that all() returns entries with their paths intact.""" paths = [ - "/test/config/device1.yaml", - "/test/config/device2.yaml", - "/test/config/subfolder/device3.yaml", + Path("/test/config/device1.yaml"), + Path("/test/config/device2.yaml"), + Path("/test/config/subfolder/device3.yaml"), ] for path in paths: entry = DashboardEntry(path, create_cache_key()) - dashboard_entries._entries[path] = entry + dashboard_entries._entries[str(path)] = entry all_entries = dashboard_entries.async_all() diff --git a/tests/dashboard/test_settings.py b/tests/dashboard/test_settings.py index 90a79ac0f8..c9097fe5e2 100644 --- a/tests/dashboard/test_settings.py +++ b/tests/dashboard/test_settings.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from pathlib import Path import tempfile @@ -17,7 +16,7 @@ def dashboard_settings(tmp_path: Path) -> DashboardSettings: settings = DashboardSettings() # Resolve symlinks to ensure paths match resolved_dir = tmp_path.resolve() - settings.config_dir = str(resolved_dir) + settings.config_dir = resolved_dir settings.absolute_config_dir = resolved_dir return settings @@ -26,7 +25,7 @@ def test_rel_path_simple(dashboard_settings: DashboardSettings) -> None: """Test rel_path with simple relative path.""" result = dashboard_settings.rel_path("config.yaml") - expected = str(Path(dashboard_settings.config_dir) / "config.yaml") + expected = dashboard_settings.config_dir / "config.yaml" assert result == expected @@ -34,9 +33,7 @@ def test_rel_path_multiple_components(dashboard_settings: DashboardSettings) -> """Test rel_path with multiple path components.""" result = dashboard_settings.rel_path("subfolder", "device", "config.yaml") - expected = str( - Path(dashboard_settings.config_dir) / "subfolder" / "device" / "config.yaml" - ) + expected = dashboard_settings.config_dir / "subfolder" / "device" / "config.yaml" assert result == expected @@ -55,7 +52,7 @@ def test_rel_path_absolute_path_within_config( internal_path.touch() result = dashboard_settings.rel_path("internal.yaml") - expected = str(Path(dashboard_settings.config_dir) / "internal.yaml") + expected = dashboard_settings.config_dir / "internal.yaml" assert result == expected @@ -80,7 +77,7 @@ def test_rel_path_with_pathlib_path(dashboard_settings: DashboardSettings) -> No path_obj = Path("subfolder") / "config.yaml" result = dashboard_settings.rel_path(path_obj) - expected = str(Path(dashboard_settings.config_dir) / "subfolder" / "config.yaml") + expected = dashboard_settings.config_dir / "subfolder" / "config.yaml" assert result == expected @@ -93,9 +90,7 @@ def test_rel_path_normalizes_slashes(dashboard_settings: DashboardSettings) -> N assert result1 == result2 # Also test that the result is as expected - expected = os.path.join( - dashboard_settings.config_dir, "folder", "subfolder", "file.yaml" - ) + expected = dashboard_settings.config_dir / "folder" / "subfolder" / "file.yaml" assert result1 == expected @@ -103,7 +98,7 @@ def test_rel_path_handles_spaces(dashboard_settings: DashboardSettings) -> None: """Test rel_path handles paths with spaces.""" result = dashboard_settings.rel_path("my folder", "my config.yaml") - expected = str(Path(dashboard_settings.config_dir) / "my folder" / "my config.yaml") + expected = dashboard_settings.config_dir / "my folder" / "my config.yaml" assert result == expected @@ -111,15 +106,13 @@ def test_rel_path_handles_special_chars(dashboard_settings: DashboardSettings) - """Test rel_path handles paths with special characters.""" result = dashboard_settings.rel_path("device-01_test", "config.yaml") - expected = str( - Path(dashboard_settings.config_dir) / "device-01_test" / "config.yaml" - ) + expected = dashboard_settings.config_dir / "device-01_test" / "config.yaml" assert result == expected def test_config_dir_as_path_property(dashboard_settings: DashboardSettings) -> None: """Test that config_dir can be accessed and used with Path operations.""" - config_path = Path(dashboard_settings.config_dir) + config_path = dashboard_settings.config_dir assert config_path.exists() assert config_path.is_dir() @@ -141,7 +134,7 @@ def test_rel_path_symlink_inside_config(dashboard_settings: DashboardSettings) - symlink = dashboard_settings.absolute_config_dir / "link.yaml" symlink.symlink_to(target) result = dashboard_settings.rel_path("link.yaml") - expected = str(Path(dashboard_settings.config_dir) / "link.yaml") + expected = dashboard_settings.config_dir / "link.yaml" assert result == expected @@ -157,12 +150,12 @@ def test_rel_path_symlink_outside_config(dashboard_settings: DashboardSettings) def test_rel_path_with_none_arg(dashboard_settings: DashboardSettings) -> None: """Test rel_path handles None arguments gracefully.""" result = dashboard_settings.rel_path("None") - expected = str(Path(dashboard_settings.config_dir) / "None") + expected = dashboard_settings.config_dir / "None" assert result == expected def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> None: """Test rel_path handles numeric arguments.""" result = dashboard_settings.rel_path("123", "456.789") - expected = str(Path(dashboard_settings.config_dir) / "123" / "456.789") + expected = dashboard_settings.config_dir / "123" / "456.789" assert result == expected diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 605df4e02c..14a7d7b136 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -49,7 +49,7 @@ def mock_trash_storage_path(tmp_path: Path) -> Generator[MagicMock]: """Fixture to mock trash_storage_path.""" trash_dir = tmp_path / "trash" with patch( - "esphome.dashboard.web_server.trash_storage_path", return_value=str(trash_dir) + "esphome.dashboard.web_server.trash_storage_path", return_value=trash_dir ) as mock: yield mock @@ -60,7 +60,7 @@ def mock_archive_storage_path(tmp_path: Path) -> Generator[MagicMock]: archive_dir = tmp_path / "archive" with patch( "esphome.dashboard.web_server.archive_storage_path", - return_value=str(archive_dir), + return_value=archive_dir, ) as mock: yield mock @@ -257,7 +257,7 @@ async def test_download_binary_handler_with_file( # Mock storage JSON mock_storage = Mock() mock_storage.name = "test_device" - mock_storage.firmware_bin_path = str(firmware_file) + mock_storage.firmware_bin_path = firmware_file mock_storage_json.load.return_value = mock_storage response = await dashboard.fetch( @@ -289,7 +289,7 @@ async def test_download_binary_handler_compressed( # Mock storage JSON mock_storage = Mock() mock_storage.name = "test_device" - mock_storage.firmware_bin_path = str(firmware_file) + mock_storage.firmware_bin_path = firmware_file mock_storage_json.load.return_value = mock_storage response = await dashboard.fetch( @@ -321,7 +321,7 @@ async def test_download_binary_handler_custom_download_name( # Mock storage JSON mock_storage = Mock() mock_storage.name = "test_device" - mock_storage.firmware_bin_path = str(firmware_file) + mock_storage.firmware_bin_path = firmware_file mock_storage_json.load.return_value = mock_storage response = await dashboard.fetch( @@ -355,7 +355,7 @@ async def test_download_binary_handler_idedata_fallback( # Mock storage JSON mock_storage = Mock() mock_storage.name = "test_device" - mock_storage.firmware_bin_path = str(firmware_file) + mock_storage.firmware_bin_path = firmware_file mock_storage_json.load.return_value = mock_storage # Mock idedata response @@ -402,7 +402,7 @@ async def test_edit_request_handler_post_existing( test_file.write_text("esphome:\n name: original\n") # Configure the mock settings - mock_dashboard_settings.rel_path.return_value = str(test_file) + mock_dashboard_settings.rel_path.return_value = test_file mock_dashboard_settings.absolute_config_dir = test_file.parent new_content = "esphome:\n name: modified\n" @@ -426,7 +426,7 @@ async def test_unarchive_request_handler( ) -> None: """Test the UnArchiveRequestHandler.post method.""" # Set up an archived file - archive_dir = Path(mock_archive_storage_path.return_value) + archive_dir = mock_archive_storage_path.return_value archive_dir.mkdir(parents=True, exist_ok=True) archived_file = archive_dir / "archived.yaml" archived_file.write_text("test content") @@ -435,7 +435,7 @@ async def test_unarchive_request_handler( config_dir = tmp_path / "config" config_dir.mkdir(parents=True, exist_ok=True) destination_file = config_dir / "archived.yaml" - mock_dashboard_settings.rel_path.return_value = str(destination_file) + mock_dashboard_settings.rel_path.return_value = destination_file response = await dashboard.fetch( "/unarchive?configuration=archived.yaml", @@ -474,7 +474,7 @@ async def test_secret_keys_handler_with_file( # Configure mock to return our temp secrets file # Since the file actually exists, os.path.isfile will return True naturally - mock_dashboard_settings.rel_path.return_value = str(secrets_file) + mock_dashboard_settings.rel_path.return_value = secrets_file response = await dashboard.fetch("/secret_keys", method="GET") assert response.code == 200 @@ -538,8 +538,8 @@ def test_start_web_server_with_address_port( ) -> None: """Test the start_web_server function with address and port.""" app = Mock() - trash_dir = Path(mock_trash_storage_path.return_value) - archive_dir = Path(mock_archive_storage_path.return_value) + trash_dir = mock_trash_storage_path.return_value + archive_dir = mock_archive_storage_path.return_value # Create trash dir to test migration trash_dir.mkdir() @@ -643,12 +643,12 @@ async def test_archive_handler_with_build_folder( (build_folder / ".pioenvs").mkdir() mock_dashboard_settings.config_dir = str(config_dir) - mock_dashboard_settings.rel_path.return_value = str(test_config) - mock_archive_storage_path.return_value = str(archive_dir) + mock_dashboard_settings.rel_path.return_value = test_config + mock_archive_storage_path.return_value = archive_dir mock_storage = MagicMock() mock_storage.name = "test_device" - mock_storage.build_path = str(build_folder) + mock_storage.build_path = build_folder mock_storage_json.load.return_value = mock_storage response = await dashboard.fetch( @@ -686,8 +686,8 @@ async def test_archive_handler_no_build_folder( test_config.write_text("esphome:\n name: test_device\n") mock_dashboard_settings.config_dir = str(config_dir) - mock_dashboard_settings.rel_path.return_value = str(test_config) - mock_archive_storage_path.return_value = str(archive_dir) + mock_dashboard_settings.rel_path.return_value = test_config + mock_archive_storage_path.return_value = archive_dir mock_storage = MagicMock() mock_storage.name = "test_device" diff --git a/tests/dashboard/test_web_server_paths.py b/tests/dashboard/test_web_server_paths.py index f66e6a7ec2..b596ebb581 100644 --- a/tests/dashboard/test_web_server_paths.py +++ b/tests/dashboard/test_web_server_paths.py @@ -13,14 +13,14 @@ from esphome.dashboard import web_server def test_get_base_frontend_path_production() -> None: """Test get_base_frontend_path in production mode.""" mock_module = MagicMock() - mock_module.where.return_value = "/usr/local/lib/esphome_dashboard" + mock_module.where.return_value = Path("/usr/local/lib/esphome_dashboard") with ( patch.dict(os.environ, {}, clear=True), patch.dict("sys.modules", {"esphome_dashboard": mock_module}), ): result = web_server.get_base_frontend_path() - assert result == "/usr/local/lib/esphome_dashboard" + assert result == Path("/usr/local/lib/esphome_dashboard") mock_module.where.assert_called_once() @@ -31,13 +31,12 @@ def test_get_base_frontend_path_dev_mode() -> None: with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): result = web_server.get_base_frontend_path() - # The function uses os.path.abspath which doesn't resolve symlinks - # We need to match that behavior + # The function uses Path.resolve() which resolves symlinks # The actual function adds "/" to the path, so we simulate that test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = os.path.abspath( - os.path.join(os.getcwd(), test_path_with_slash, "esphome_dashboard") - ) + expected = ( + Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard" + ).resolve() assert result == expected @@ -48,8 +47,8 @@ def test_get_base_frontend_path_dev_mode_with_trailing_slash() -> None: with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): result = web_server.get_base_frontend_path() - # The function uses os.path.abspath which doesn't resolve symlinks - expected = os.path.abspath(str(Path.cwd() / test_path / "esphome_dashboard")) + # The function uses Path.resolve() which resolves symlinks + expected = (Path.cwd() / test_path / "esphome_dashboard").resolve() assert result == expected @@ -60,76 +59,72 @@ def test_get_base_frontend_path_dev_mode_relative_path() -> None: with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): result = web_server.get_base_frontend_path() - # The function uses os.path.abspath which doesn't resolve symlinks - # We need to match that behavior + # The function uses Path.resolve() which resolves symlinks # The actual function adds "/" to the path, so we simulate that test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = os.path.abspath( - os.path.join(os.getcwd(), test_path_with_slash, "esphome_dashboard") - ) + expected = ( + Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard" + ).resolve() assert result == expected - assert Path(result).is_absolute() + assert result.is_absolute() def test_get_static_path_single_component() -> None: """Test get_static_path with single path component.""" with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = "/base/frontend" + mock_base.return_value = Path("/base/frontend") result = web_server.get_static_path("file.js") - assert result == os.path.join("/base/frontend", "static", "file.js") + assert result == Path("/base/frontend") / "static" / "file.js" def test_get_static_path_multiple_components() -> None: """Test get_static_path with multiple path components.""" with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = "/base/frontend" + mock_base.return_value = Path("/base/frontend") result = web_server.get_static_path("js", "esphome", "index.js") - assert result == os.path.join( - "/base/frontend", "static", "js", "esphome", "index.js" + assert ( + result == Path("/base/frontend") / "static" / "js" / "esphome" / "index.js" ) def test_get_static_path_empty_args() -> None: """Test get_static_path with no arguments.""" with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = "/base/frontend" + mock_base.return_value = Path("/base/frontend") result = web_server.get_static_path() - assert result == os.path.join("/base/frontend", "static") + assert result == Path("/base/frontend") / "static" def test_get_static_path_with_pathlib_path() -> None: """Test get_static_path with Path objects.""" with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = "/base/frontend" + mock_base.return_value = Path("/base/frontend") path_obj = Path("js") / "app.js" result = web_server.get_static_path(str(path_obj)) - assert result == os.path.join("/base/frontend", "static", "js", "app.js") + assert result == Path("/base/frontend") / "static" / "js" / "app.js" def test_get_static_file_url_production() -> None: """Test get_static_file_url in production mode.""" web_server.get_static_file_url.cache_clear() mock_module = MagicMock() - mock_file = MagicMock() - mock_file.read.return_value = b"test content" - mock_file.__enter__ = MagicMock(return_value=mock_file) - mock_file.__exit__ = MagicMock(return_value=None) + mock_path = MagicMock(spec=Path) + mock_path.read_bytes.return_value = b"test content" with ( patch.dict(os.environ, {}, clear=True), patch.dict("sys.modules", {"esphome_dashboard": mock_module}), patch("esphome.dashboard.web_server.get_static_path") as mock_get_path, - patch("esphome.dashboard.web_server.open", create=True, return_value=mock_file), ): - mock_get_path.return_value = "/fake/path/js/app.js" + mock_get_path.return_value = mock_path result = web_server.get_static_file_url("js/app.js") assert result.startswith("./static/js/app.js?hash=") @@ -182,26 +177,26 @@ def test_load_file_compressed_path(tmp_path: Path) -> None: def test_path_normalization_in_static_path() -> None: """Test that paths are normalized correctly.""" with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = "/base/frontend" + mock_base.return_value = Path("/base/frontend") # Test with separate components result1 = web_server.get_static_path("js", "app.js") result2 = web_server.get_static_path("js", "app.js") assert result1 == result2 - assert result1 == os.path.join("/base/frontend", "static", "js", "app.js") + assert result1 == Path("/base/frontend") / "static" / "js" / "app.js" def test_windows_path_handling() -> None: """Test handling of Windows-style paths.""" with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = r"C:\Program Files\esphome\frontend" + mock_base.return_value = Path(r"C:\Program Files\esphome\frontend") result = web_server.get_static_path("js", "app.js") - # os.path.join should handle this correctly on the platform - expected = os.path.join( - r"C:\Program Files\esphome\frontend", "static", "js", "app.js" + # Path should handle this correctly on the platform + expected = ( + Path(r"C:\Program Files\esphome\frontend") / "static" / "js" / "app.js" ) assert result == expected @@ -209,22 +204,20 @@ def test_windows_path_handling() -> None: def test_path_with_special_characters() -> None: """Test paths with special characters.""" with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = "/base/frontend" + mock_base.return_value = Path("/base/frontend") result = web_server.get_static_path("js-modules", "app_v1.0.js") - assert result == os.path.join( - "/base/frontend", "static", "js-modules", "app_v1.0.js" + assert ( + result == Path("/base/frontend") / "static" / "js-modules" / "app_v1.0.js" ) def test_path_with_spaces() -> None: """Test paths with spaces.""" with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = "/base/my frontend" + mock_base.return_value = Path("/base/my frontend") result = web_server.get_static_path("my js", "my app.js") - assert result == os.path.join( - "/base/my frontend", "static", "my js", "my app.js" - ) + assert result == Path("/base/my frontend") / "static" / "my js" / "my app.js" diff --git a/tests/dashboard/util/test_file.py b/tests/dashboard/util/test_file.py deleted file mode 100644 index 51ba10b328..0000000000 --- a/tests/dashboard/util/test_file.py +++ /dev/null @@ -1,56 +0,0 @@ -import os -from pathlib import Path -from unittest.mock import patch - -import py -import pytest - -from esphome.dashboard.util.file import write_file, write_utf8_file - - -def test_write_utf8_file(tmp_path: Path) -> None: - write_utf8_file(tmp_path.joinpath("foo.txt"), "foo") - assert tmp_path.joinpath("foo.txt").read_text() == "foo" - - with pytest.raises(OSError): - write_utf8_file(Path("/dev/not-writable"), "bar") - - -def test_write_file(tmp_path: Path) -> None: - write_file(tmp_path.joinpath("foo.txt"), b"foo") - assert tmp_path.joinpath("foo.txt").read_text() == "foo" - - -def test_write_utf8_file_fails_at_rename( - tmpdir: py.path.local, caplog: pytest.LogCaptureFixture -) -> None: - """Test that if rename fails not not remove, we do not log the failed cleanup.""" - test_dir = tmpdir.mkdir("files") - test_file = Path(test_dir / "test.json") - - with ( - pytest.raises(OSError), - patch("esphome.dashboard.util.file.os.replace", side_effect=OSError), - ): - write_utf8_file(test_file, '{"some":"data"}', False) - - assert not os.path.exists(test_file) - - assert "File replacement cleanup failed" not in caplog.text - - -def test_write_utf8_file_fails_at_rename_and_remove( - tmpdir: py.path.local, caplog: pytest.LogCaptureFixture -) -> None: - """Test that if rename and remove both fail, we log the failed cleanup.""" - test_dir = tmpdir.mkdir("files") - test_file = Path(test_dir / "test.json") - - with ( - pytest.raises(OSError), - patch("esphome.dashboard.util.file.os.remove", side_effect=OSError), - patch("esphome.dashboard.util.file.os.replace", side_effect=OSError), - ): - write_utf8_file(test_file, '{"some":"data"}', False) - - assert "File replacement cleanup failed" in caplog.text diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 0530752551..94632f8439 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -271,7 +271,7 @@ async def compile_esphome( def _read_config_and_get_binary(): CORE.reset() # Reset CORE state between test runs - CORE.config_path = str(config_path) + CORE.config_path = config_path config = esphome.config.read_config( {"command": "compile", "config": str(config_path)} ) diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index 04b7381141..a124dbc128 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -172,7 +172,7 @@ def test_write_ini_no_change_when_content_same( # write_file_if_changed should be called with the same content mock_write_file_if_changed.assert_called_once() call_args = mock_write_file_if_changed.call_args[0] - assert call_args[0] == str(ini_file) + assert call_args[0] == ini_file assert content in call_args[1] diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 06d06d0506..0151bce00c 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -43,7 +43,7 @@ def fixture_path() -> Path: @pytest.fixture def setup_core(tmp_path: Path) -> Path: """Set up CORE with test paths.""" - CORE.config_path = str(tmp_path / "test.yaml") + CORE.config_path = tmp_path / "test.yaml" return tmp_path diff --git a/tests/unit_tests/core/common.py b/tests/unit_tests/core/common.py index 1848d5397b..daa429dc96 100644 --- a/tests/unit_tests/core/common.py +++ b/tests/unit_tests/core/common.py @@ -10,7 +10,7 @@ from esphome.core import CORE def load_config_from_yaml( - yaml_file: Callable[[str], str], yaml_content: str + yaml_file: Callable[[str], Path], yaml_content: str ) -> Config | None: """Load configuration from YAML content.""" yaml_path = yaml_file(yaml_content) @@ -25,7 +25,7 @@ def load_config_from_yaml( def load_config_from_fixture( - yaml_file: Callable[[str], str], fixture_name: str, fixtures_dir: Path + yaml_file: Callable[[str], Path], fixture_name: str, fixtures_dir: Path ) -> Config | None: """Load configuration from a fixture file.""" fixture_path = fixtures_dir / fixture_name diff --git a/tests/unit_tests/core/conftest.py b/tests/unit_tests/core/conftest.py index 60d6738ce9..42e59c15e6 100644 --- a/tests/unit_tests/core/conftest.py +++ b/tests/unit_tests/core/conftest.py @@ -7,12 +7,12 @@ import pytest @pytest.fixture -def yaml_file(tmp_path: Path) -> Callable[[str], str]: +def yaml_file(tmp_path: Path) -> Callable[[str], Path]: """Create a temporary YAML file for testing.""" - def _yaml_file(content: str) -> str: + def _yaml_file(content: str) -> Path: yaml_path = tmp_path / "test.yaml" yaml_path.write_text(content) - return str(yaml_path) + return yaml_path return _yaml_file diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 7d3b90794b..4fddfc9678 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -289,7 +289,7 @@ def test_valid_include_with_angle_brackets() -> None: def test_valid_include_with_valid_file(tmp_path: Path) -> None: """Test valid_include accepts valid include files.""" - CORE.config_path = str(tmp_path / "test.yaml") + CORE.config_path = tmp_path / "test.yaml" include_file = tmp_path / "include.h" include_file.touch() @@ -298,7 +298,7 @@ def test_valid_include_with_valid_file(tmp_path: Path) -> None: def test_valid_include_with_valid_directory(tmp_path: Path) -> None: """Test valid_include accepts valid directories.""" - CORE.config_path = str(tmp_path / "test.yaml") + CORE.config_path = tmp_path / "test.yaml" include_dir = tmp_path / "includes" include_dir.mkdir() @@ -307,7 +307,7 @@ def test_valid_include_with_valid_directory(tmp_path: Path) -> None: def test_valid_include_invalid_extension(tmp_path: Path) -> None: """Test valid_include rejects files with invalid extensions.""" - CORE.config_path = str(tmp_path / "test.yaml") + CORE.config_path = tmp_path / "test.yaml" invalid_file = tmp_path / "file.txt" invalid_file.touch() @@ -481,7 +481,7 @@ def test_include_file_header(tmp_path: Path, mock_copy_file_if_changed: Mock) -> src_file = tmp_path / "source.h" src_file.write_text("// Header content") - CORE.build_path = str(tmp_path / "build") + CORE.build_path = tmp_path / "build" with patch("esphome.core.config.cg") as mock_cg: # Mock RawStatement to capture the text @@ -494,7 +494,7 @@ def test_include_file_header(tmp_path: Path, mock_copy_file_if_changed: Mock) -> mock_cg.RawStatement.side_effect = raw_statement_side_effect - config.include_file(str(src_file), "test.h") + config.include_file(src_file, Path("test.h")) mock_copy_file_if_changed.assert_called_once() mock_cg.add_global.assert_called_once() @@ -507,10 +507,10 @@ def test_include_file_cpp(tmp_path: Path, mock_copy_file_if_changed: Mock) -> No src_file = tmp_path / "source.cpp" src_file.write_text("// CPP content") - CORE.build_path = str(tmp_path / "build") + CORE.build_path = tmp_path / "build" with patch("esphome.core.config.cg") as mock_cg: - config.include_file(str(src_file), "test.cpp") + config.include_file(src_file, Path("test.cpp")) mock_copy_file_if_changed.assert_called_once() # Should not add include statement for .cpp files @@ -602,8 +602,8 @@ async def test_add_includes_with_single_file( mock_cg_with_include_capture: tuple[Mock, list[str]], ) -> None: """Test add_includes copies a single header file to build directory.""" - CORE.config_path = str(tmp_path / "config.yaml") - CORE.build_path = str(tmp_path / "build") + CORE.config_path = tmp_path / "config.yaml" + CORE.build_path = tmp_path / "build" os.makedirs(CORE.build_path, exist_ok=True) # Create include file @@ -617,7 +617,7 @@ async def test_add_includes_with_single_file( # Verify copy_file_if_changed was called to copy the file # Note: add_includes adds files to a src/ subdirectory mock_copy_file_if_changed.assert_called_once_with( - str(include_file), str(Path(CORE.build_path) / "src" / "my_header.h") + include_file, CORE.build_path / "src" / "my_header.h" ) # Verify include statement was added @@ -632,8 +632,8 @@ async def test_add_includes_with_directory_unix( mock_cg_with_include_capture: tuple[Mock, list[str]], ) -> None: """Test add_includes copies all files from a directory on Unix.""" - CORE.config_path = str(tmp_path / "config.yaml") - CORE.build_path = str(tmp_path / "build") + CORE.config_path = tmp_path / "config.yaml" + CORE.build_path = tmp_path / "build" os.makedirs(CORE.build_path, exist_ok=True) # Create include directory with files @@ -677,8 +677,8 @@ async def test_add_includes_with_directory_windows( mock_cg_with_include_capture: tuple[Mock, list[str]], ) -> None: """Test add_includes copies all files from a directory on Windows.""" - CORE.config_path = str(tmp_path / "config.yaml") - CORE.build_path = str(tmp_path / "build") + CORE.config_path = tmp_path / "config.yaml" + CORE.build_path = tmp_path / "build" os.makedirs(CORE.build_path, exist_ok=True) # Create include directory with files @@ -719,8 +719,8 @@ async def test_add_includes_with_multiple_sources( tmp_path: Path, mock_copy_file_if_changed: Mock ) -> None: """Test add_includes with multiple files and directories.""" - CORE.config_path = str(tmp_path / "config.yaml") - CORE.build_path = str(tmp_path / "build") + CORE.config_path = tmp_path / "config.yaml" + CORE.build_path = tmp_path / "build" os.makedirs(CORE.build_path, exist_ok=True) # Create various include sources @@ -747,8 +747,8 @@ async def test_add_includes_empty_directory( tmp_path: Path, mock_copy_file_if_changed: Mock ) -> None: """Test add_includes with an empty directory doesn't fail.""" - CORE.config_path = str(tmp_path / "config.yaml") - CORE.build_path = str(tmp_path / "build") + CORE.config_path = tmp_path / "config.yaml" + CORE.build_path = tmp_path / "build" os.makedirs(CORE.build_path, exist_ok=True) # Create empty directory @@ -769,8 +769,8 @@ async def test_add_includes_preserves_directory_structure_unix( tmp_path: Path, mock_copy_file_if_changed: Mock ) -> None: """Test that add_includes preserves relative directory structure on Unix.""" - CORE.config_path = str(tmp_path / "config.yaml") - CORE.build_path = str(tmp_path / "build") + CORE.config_path = tmp_path / "config.yaml" + CORE.build_path = tmp_path / "build" os.makedirs(CORE.build_path, exist_ok=True) # Create nested directory structure @@ -793,8 +793,8 @@ async def test_add_includes_preserves_directory_structure_unix( dest_paths = [call[0][1] for call in calls] # Check that relative paths are preserved - assert any("lib/src/core.h" in path for path in dest_paths) - assert any("lib/utils/helper.h" in path for path in dest_paths) + assert any("lib/src/core.h" in str(path) for path in dest_paths) + assert any("lib/utils/helper.h" in str(path) for path in dest_paths) @pytest.mark.asyncio @@ -803,8 +803,8 @@ async def test_add_includes_preserves_directory_structure_windows( tmp_path: Path, mock_copy_file_if_changed: Mock ) -> None: """Test that add_includes preserves relative directory structure on Windows.""" - CORE.config_path = str(tmp_path / "config.yaml") - CORE.build_path = str(tmp_path / "build") + CORE.config_path = tmp_path / "config.yaml" + CORE.build_path = tmp_path / "build" os.makedirs(CORE.build_path, exist_ok=True) # Create nested directory structure @@ -827,8 +827,8 @@ async def test_add_includes_preserves_directory_structure_windows( dest_paths = [call[0][1] for call in calls] # Check that relative paths are preserved - assert any("lib\\src\\core.h" in path for path in dest_paths) - assert any("lib\\utils\\helper.h" in path for path in dest_paths) + assert any("lib\\src\\core.h" in str(path) for path in dest_paths) + assert any("lib\\utils\\helper.h" in str(path) for path in dest_paths) @pytest.mark.asyncio @@ -836,8 +836,8 @@ async def test_add_includes_overwrites_existing_files( tmp_path: Path, mock_copy_file_if_changed: Mock ) -> None: """Test that add_includes overwrites existing files in build directory.""" - CORE.config_path = str(tmp_path / "config.yaml") - CORE.build_path = str(tmp_path / "build") + CORE.config_path = tmp_path / "config.yaml" + CORE.build_path = tmp_path / "build" os.makedirs(CORE.build_path, exist_ok=True) # Create include file @@ -850,5 +850,5 @@ async def test_add_includes_overwrites_existing_files( # Verify copy_file_if_changed was called (it handles overwriting) # Note: add_includes adds files to a src/ subdirectory mock_copy_file_if_changed.assert_called_once_with( - str(include_file), str(Path(CORE.build_path) / "src" / "header.h") + include_file, CORE.build_path / "src" / "header.h" ) diff --git a/tests/unit_tests/test_config_validation_paths.py b/tests/unit_tests/test_config_validation_paths.py index f8f038390e..f327e9c443 100644 --- a/tests/unit_tests/test_config_validation_paths.py +++ b/tests/unit_tests/test_config_validation_paths.py @@ -15,7 +15,7 @@ def test_directory_valid_path(setup_core: Path) -> None: result = cv.directory("test_directory") - assert result == "test_directory" + assert result == test_dir def test_directory_absolute_path(setup_core: Path) -> None: @@ -25,7 +25,7 @@ def test_directory_absolute_path(setup_core: Path) -> None: result = cv.directory(str(test_dir)) - assert result == str(test_dir) + assert result == test_dir def test_directory_nonexistent_path(setup_core: Path) -> None: @@ -52,7 +52,7 @@ def test_directory_with_parent_directory(setup_core: Path) -> None: result = cv.directory("parent/child/grandchild") - assert result == "parent/child/grandchild" + assert result == nested_dir def test_file_valid_path(setup_core: Path) -> None: @@ -62,7 +62,7 @@ def test_file_valid_path(setup_core: Path) -> None: result = cv.file_("test_file.yaml") - assert result == "test_file.yaml" + assert result == test_file def test_file_absolute_path(setup_core: Path) -> None: @@ -72,7 +72,7 @@ def test_file_absolute_path(setup_core: Path) -> None: result = cv.file_(str(test_file)) - assert result == str(test_file) + assert result == test_file def test_file_nonexistent_path(setup_core: Path) -> None: @@ -99,7 +99,7 @@ def test_file_with_parent_directory(setup_core: Path) -> None: result = cv.file_("configs/sensors/temperature.yaml") - assert result == "configs/sensors/temperature.yaml" + assert result == test_file def test_directory_handles_trailing_slash(setup_core: Path) -> None: @@ -108,29 +108,29 @@ def test_directory_handles_trailing_slash(setup_core: Path) -> None: test_dir.mkdir() result = cv.directory("test_dir/") - assert result == "test_dir/" + assert result == test_dir result = cv.directory("test_dir") - assert result == "test_dir" + assert result == test_dir def test_file_handles_various_extensions(setup_core: Path) -> None: """Test file_ validator works with different file extensions.""" yaml_file = setup_core / "config.yaml" yaml_file.write_text("yaml content") - assert cv.file_("config.yaml") == "config.yaml" + assert cv.file_("config.yaml") == yaml_file yml_file = setup_core / "config.yml" yml_file.write_text("yml content") - assert cv.file_("config.yml") == "config.yml" + assert cv.file_("config.yml") == yml_file txt_file = setup_core / "readme.txt" txt_file.write_text("text content") - assert cv.file_("readme.txt") == "readme.txt" + assert cv.file_("readme.txt") == txt_file no_ext_file = setup_core / "LICENSE" no_ext_file.write_text("license content") - assert cv.file_("LICENSE") == "LICENSE" + assert cv.file_("LICENSE") == no_ext_file def test_directory_with_symlink(setup_core: Path) -> None: @@ -142,7 +142,7 @@ def test_directory_with_symlink(setup_core: Path) -> None: symlink_dir.symlink_to(actual_dir) result = cv.directory("symlink_directory") - assert result == "symlink_directory" + assert result == symlink_dir def test_file_with_symlink(setup_core: Path) -> None: @@ -154,7 +154,7 @@ def test_file_with_symlink(setup_core: Path) -> None: symlink_file.symlink_to(actual_file) result = cv.file_("symlink_file.txt") - assert result == "symlink_file.txt" + assert result == symlink_file def test_directory_error_shows_full_path(setup_core: Path) -> None: @@ -175,7 +175,7 @@ def test_directory_with_spaces_in_name(setup_core: Path) -> None: dir_with_spaces.mkdir() result = cv.directory("my test directory") - assert result == "my test directory" + assert result == dir_with_spaces def test_file_with_spaces_in_name(setup_core: Path) -> None: @@ -184,4 +184,4 @@ def test_file_with_spaces_in_name(setup_core: Path) -> None: file_with_spaces.write_text("content") result = cv.file_("my test file.yaml") - assert result == "my test file.yaml" + assert result == file_with_spaces diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 9a69329e80..48eae06ea6 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -1,4 +1,5 @@ import os +from pathlib import Path from unittest.mock import patch from hypothesis import given @@ -536,8 +537,8 @@ class TestEsphomeCore: @pytest.fixture def target(self, fixture_path): target = core.EsphomeCore() - target.build_path = "foo/build" - target.config_path = "foo/config" + target.build_path = Path("foo/build") + target.config_path = Path("foo/config") return target def test_reset(self, target): @@ -584,33 +585,33 @@ class TestEsphomeCore: @pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") def test_data_dir_default_unix(self, target): """Test data_dir returns .esphome in config directory by default on Unix.""" - target.config_path = "/home/user/config.yaml" - assert target.data_dir == "/home/user/.esphome" + target.config_path = Path("/home/user/config.yaml") + assert target.data_dir == Path("/home/user/.esphome") @pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") def test_data_dir_default_windows(self, target): """Test data_dir returns .esphome in config directory by default on Windows.""" - target.config_path = "D:\\home\\user\\config.yaml" - assert target.data_dir == "D:\\home\\user\\.esphome" + target.config_path = Path("D:\\home\\user\\config.yaml") + assert target.data_dir == Path("D:\\home\\user\\.esphome") def test_data_dir_ha_addon(self, target): """Test data_dir returns /data when running as Home Assistant addon.""" - target.config_path = "/config/test.yaml" + target.config_path = Path("/config/test.yaml") with patch.dict(os.environ, {"ESPHOME_IS_HA_ADDON": "true"}): - assert target.data_dir == "/data" + assert target.data_dir == Path("/data") def test_data_dir_env_override(self, target): """Test data_dir uses ESPHOME_DATA_DIR environment variable when set.""" - target.config_path = "/home/user/config.yaml" + target.config_path = Path("/home/user/config.yaml") with patch.dict(os.environ, {"ESPHOME_DATA_DIR": "/custom/data/path"}): - assert target.data_dir == "/custom/data/path" + assert target.data_dir == Path("/custom/data/path") @pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") def test_data_dir_priority_unix(self, target): """Test data_dir priority on Unix: HA addon > env var > default.""" - target.config_path = "/config/test.yaml" + target.config_path = Path("/config/test.yaml") expected_default = "/config/.esphome" # Test HA addon takes priority over env var @@ -618,26 +619,26 @@ class TestEsphomeCore: os.environ, {"ESPHOME_IS_HA_ADDON": "true", "ESPHOME_DATA_DIR": "/custom/path"}, ): - assert target.data_dir == "/data" + assert target.data_dir == Path("/data") # Test env var is used when not HA addon with patch.dict( os.environ, {"ESPHOME_IS_HA_ADDON": "false", "ESPHOME_DATA_DIR": "/custom/path"}, ): - assert target.data_dir == "/custom/path" + assert target.data_dir == Path("/custom/path") # Test default when neither is set with patch.dict(os.environ, {}, clear=True): # Ensure these env vars are not set os.environ.pop("ESPHOME_IS_HA_ADDON", None) os.environ.pop("ESPHOME_DATA_DIR", None) - assert target.data_dir == expected_default + assert target.data_dir == Path(expected_default) @pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") def test_data_dir_priority_windows(self, target): """Test data_dir priority on Windows: HA addon > env var > default.""" - target.config_path = "D:\\config\\test.yaml" + target.config_path = Path("D:\\config\\test.yaml") expected_default = "D:\\config\\.esphome" # Test HA addon takes priority over env var @@ -645,18 +646,18 @@ class TestEsphomeCore: os.environ, {"ESPHOME_IS_HA_ADDON": "true", "ESPHOME_DATA_DIR": "/custom/path"}, ): - assert target.data_dir == "/data" + assert target.data_dir == Path("/data") # Test env var is used when not HA addon with patch.dict( os.environ, {"ESPHOME_IS_HA_ADDON": "false", "ESPHOME_DATA_DIR": "/custom/path"}, ): - assert target.data_dir == "/custom/path" + assert target.data_dir == Path("/custom/path") # Test default when neither is set with patch.dict(os.environ, {}, clear=True): # Ensure these env vars are not set os.environ.pop("ESPHOME_IS_HA_ADDON", None) os.environ.pop("ESPHOME_DATA_DIR", None) - assert target.data_dir == expected_default + assert target.data_dir == Path(expected_default) diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 3fa7de2f64..05e0bd3523 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -42,7 +42,7 @@ def test_is_file_recent_with_recent_file(setup_core: Path) -> None: refresh = TimePeriod(seconds=3600) - result = external_files.is_file_recent(str(test_file), refresh) + result = external_files.is_file_recent(test_file, refresh) assert result is True @@ -53,11 +53,13 @@ def test_is_file_recent_with_old_file(setup_core: Path) -> None: test_file.write_text("content") old_time = time.time() - 7200 + mock_stat = MagicMock() + mock_stat.st_ctime = old_time - with patch("os.path.getctime", return_value=old_time): + with patch.object(Path, "stat", return_value=mock_stat): refresh = TimePeriod(seconds=3600) - result = external_files.is_file_recent(str(test_file), refresh) + result = external_files.is_file_recent(test_file, refresh) assert result is False @@ -67,7 +69,7 @@ def test_is_file_recent_nonexistent_file(setup_core: Path) -> None: test_file = setup_core / "nonexistent.txt" refresh = TimePeriod(seconds=3600) - result = external_files.is_file_recent(str(test_file), refresh) + result = external_files.is_file_recent(test_file, refresh) assert result is False @@ -77,10 +79,12 @@ def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None: test_file = setup_core / "test.txt" test_file.write_text("content") - # Mock getctime to return a time 10 seconds ago - with patch("os.path.getctime", return_value=time.time() - 10): + # Mock stat to return a time 10 seconds ago + mock_stat = MagicMock() + mock_stat.st_ctime = time.time() - 10 + with patch.object(Path, "stat", return_value=mock_stat): refresh = TimePeriod(seconds=0) - result = external_files.is_file_recent(str(test_file), refresh) + result = external_files.is_file_recent(test_file, refresh) assert result is False @@ -97,7 +101,7 @@ def test_has_remote_file_changed_not_modified( mock_head.return_value = mock_response url = "https://example.com/file.txt" - result = external_files.has_remote_file_changed(url, str(test_file)) + result = external_files.has_remote_file_changed(url, test_file) assert result is False mock_head.assert_called_once() @@ -121,7 +125,7 @@ def test_has_remote_file_changed_modified( mock_head.return_value = mock_response url = "https://example.com/file.txt" - result = external_files.has_remote_file_changed(url, str(test_file)) + result = external_files.has_remote_file_changed(url, test_file) assert result is True @@ -131,7 +135,7 @@ def test_has_remote_file_changed_no_local_file(setup_core: Path) -> None: test_file = setup_core / "nonexistent.txt" url = "https://example.com/file.txt" - result = external_files.has_remote_file_changed(url, str(test_file)) + result = external_files.has_remote_file_changed(url, test_file) assert result is True @@ -149,7 +153,7 @@ def test_has_remote_file_changed_network_error( url = "https://example.com/file.txt" with pytest.raises(Invalid, match="Could not check if.*Network error"): - external_files.has_remote_file_changed(url, str(test_file)) + external_files.has_remote_file_changed(url, test_file) @patch("esphome.external_files.requests.head") @@ -165,7 +169,7 @@ def test_has_remote_file_changed_timeout( mock_head.return_value = mock_response url = "https://example.com/file.txt" - external_files.has_remote_file_changed(url, str(test_file)) + external_files.has_remote_file_changed(url, test_file) call_args = mock_head.call_args assert call_args[1]["timeout"] == external_files.NETWORK_TIMEOUT @@ -191,6 +195,6 @@ def test_is_file_recent_handles_float_seconds(setup_core: Path) -> None: refresh = TimePeriod(seconds=3600.5) - result = external_files.is_file_recent(str(test_file), refresh) + result = external_files.is_file_recent(test_file, refresh) assert result is True diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index b49e5797c1..87ed901ecb 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -154,11 +154,11 @@ def test_walk_files(fixture_path): actual = list(helpers.walk_files(path)) # Ensure paths start with the root - assert all(p.startswith(str(path)) for p in actual) + assert all(p.is_relative_to(path) for p in actual) class Test_write_file_if_changed: - def test_src_and_dst_match(self, tmp_path): + def test_src_and_dst_match(self, tmp_path: Path): text = "A files are unique.\n" initial = text dst = tmp_path / "file-a.txt" @@ -168,7 +168,7 @@ class Test_write_file_if_changed: assert dst.read_text() == text - def test_src_and_dst_do_not_match(self, tmp_path): + def test_src_and_dst_do_not_match(self, tmp_path: Path): text = "A files are unique.\n" initial = "B files are unique.\n" dst = tmp_path / "file-a.txt" @@ -178,7 +178,7 @@ class Test_write_file_if_changed: assert dst.read_text() == text - def test_dst_does_not_exist(self, tmp_path): + def test_dst_does_not_exist(self, tmp_path: Path): text = "A files are unique.\n" dst = tmp_path / "file-a.txt" @@ -188,7 +188,7 @@ class Test_write_file_if_changed: class Test_copy_file_if_changed: - def test_src_and_dst_match(self, tmp_path, fixture_path): + def test_src_and_dst_match(self, tmp_path: Path, fixture_path: Path): src = fixture_path / "helpers" / "file-a.txt" initial = fixture_path / "helpers" / "file-a.txt" dst = tmp_path / "file-a.txt" @@ -197,7 +197,7 @@ class Test_copy_file_if_changed: helpers.copy_file_if_changed(src, dst) - def test_src_and_dst_do_not_match(self, tmp_path, fixture_path): + def test_src_and_dst_do_not_match(self, tmp_path: Path, fixture_path: Path): src = fixture_path / "helpers" / "file-a.txt" initial = fixture_path / "helpers" / "file-c.txt" dst = tmp_path / "file-a.txt" @@ -208,7 +208,7 @@ class Test_copy_file_if_changed: assert src.read_text() == dst.read_text() - def test_dst_does_not_exist(self, tmp_path, fixture_path): + def test_dst_does_not_exist(self, tmp_path: Path, fixture_path: Path): src = fixture_path / "helpers" / "file-a.txt" dst = tmp_path / "file-a.txt" @@ -604,9 +604,8 @@ def test_mkdir_p_with_existing_file_raises_error(tmp_path: Path) -> None: helpers.mkdir_p(dir_path) -@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") -def test_read_file_unix(tmp_path: Path) -> None: - """Test read_file reads file content correctly on Unix.""" +def test_read_file(tmp_path: Path) -> None: + """Test read_file reads file content correctly.""" # Test reading regular file test_file = tmp_path / "test.txt" expected_content = "Test content\nLine 2\n" @@ -624,31 +623,10 @@ def test_read_file_unix(tmp_path: Path) -> None: assert content == utf8_content -@pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") -def test_read_file_windows(tmp_path: Path) -> None: - """Test read_file reads file content correctly on Windows.""" - # Test reading regular file - test_file = tmp_path / "test.txt" - expected_content = "Test content\nLine 2\n" - test_file.write_text(expected_content) - - content = helpers.read_file(test_file) - # On Windows, text mode reading converts \n to \r\n - assert content == expected_content.replace("\n", "\r\n") - - # Test reading file with UTF-8 characters - utf8_file = tmp_path / "utf8.txt" - utf8_content = "Hello 世界 🌍" - utf8_file.write_text(utf8_content, encoding="utf-8") - - content = helpers.read_file(utf8_file) - assert content == utf8_content - - def test_read_file_not_found() -> None: """Test read_file raises error for non-existent file.""" with pytest.raises(EsphomeError, match=r"Error reading file"): - helpers.read_file("/nonexistent/file.txt") + helpers.read_file(Path("/nonexistent/file.txt")) def test_read_file_unicode_decode_error(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index fff0b2cd48..e805ecb2eb 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -885,7 +885,7 @@ def test_upload_program_ota_success( assert exit_code == 0 assert host == "192.168.1.100" - expected_firmware = str( + expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( @@ -919,7 +919,9 @@ def test_upload_program_ota_with_file_arg( assert exit_code == 0 assert host == "192.168.1.100" - mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", "custom.bin") + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], 3232, "", Path("custom.bin") + ) def test_upload_program_ota_no_config( @@ -972,7 +974,7 @@ def test_upload_program_ota_with_mqtt_resolution( assert exit_code == 0 assert host == "192.168.1.100" mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") - expected_firmware = str( + expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", expected_firmware) @@ -1382,7 +1384,7 @@ def test_command_wizard(tmp_path: Path) -> None: result = command_wizard(args) assert result == 0 - mock_wizard.assert_called_once_with(str(config_file)) + mock_wizard.assert_called_once_with(config_file) def test_command_rename_invalid_characters( @@ -1407,7 +1409,7 @@ def test_command_rename_complex_yaml( config_file = tmp_path / "test.yaml" config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n") setup_core(tmp_path=tmp_path) - CORE.config_path = str(config_file) + CORE.config_path = config_file args = MockArgs(name="newname") result = command_rename(args, {}) @@ -1436,7 +1438,7 @@ wifi: password: "test1234" """) setup_core(tmp_path=tmp_path) - CORE.config_path = str(config_file) + CORE.config_path = config_file # Set up CORE.config to avoid ValueError when accessing CORE.address CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} @@ -1486,7 +1488,7 @@ esp32: board: nodemcu-32s """) setup_core(tmp_path=tmp_path) - CORE.config_path = str(config_file) + CORE.config_path = config_file # Set up CORE.config to avoid ValueError when accessing CORE.address CORE.config = { @@ -1523,7 +1525,7 @@ esp32: board: nodemcu-32s """) setup_core(tmp_path=tmp_path) - CORE.config_path = str(config_file) + CORE.config_path = config_file args = MockArgs(name="newname", dashboard=False) diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 7c7883d391..07948cc6ad 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -15,45 +15,45 @@ from esphome.core import CORE, EsphomeError def test_idedata_firmware_elf_path(setup_core: Path) -> None: """Test IDEData.firmware_elf_path returns correct path.""" - CORE.build_path = str(setup_core / "build" / "test") + CORE.build_path = setup_core / "build" / "test" CORE.name = "test" raw_data = {"prog_path": "/path/to/firmware.elf"} idedata = platformio_api.IDEData(raw_data) - assert idedata.firmware_elf_path == "/path/to/firmware.elf" + assert idedata.firmware_elf_path == Path("/path/to/firmware.elf") def test_idedata_firmware_bin_path(setup_core: Path) -> None: """Test IDEData.firmware_bin_path returns Path with .bin extension.""" - CORE.build_path = str(setup_core / "build" / "test") + CORE.build_path = setup_core / "build" / "test" CORE.name = "test" prog_path = str(Path("/path/to/firmware.elf")) raw_data = {"prog_path": prog_path} idedata = platformio_api.IDEData(raw_data) result = idedata.firmware_bin_path - assert isinstance(result, str) - expected = str(Path("/path/to/firmware.bin")) + assert isinstance(result, Path) + expected = Path("/path/to/firmware.bin") assert result == expected - assert result.endswith(".bin") + assert str(result).endswith(".bin") def test_idedata_firmware_bin_path_preserves_directory(setup_core: Path) -> None: """Test firmware_bin_path preserves the directory structure.""" - CORE.build_path = str(setup_core / "build" / "test") + CORE.build_path = setup_core / "build" / "test" CORE.name = "test" prog_path = str(Path("/complex/path/to/build/firmware.elf")) raw_data = {"prog_path": prog_path} idedata = platformio_api.IDEData(raw_data) result = idedata.firmware_bin_path - expected = str(Path("/complex/path/to/build/firmware.bin")) + expected = Path("/complex/path/to/build/firmware.bin") assert result == expected def test_idedata_extra_flash_images(setup_core: Path) -> None: """Test IDEData.extra_flash_images returns list of FlashImage objects.""" - CORE.build_path = str(setup_core / "build" / "test") + CORE.build_path = setup_core / "build" / "test" CORE.name = "test" raw_data = { "prog_path": "/path/to/firmware.elf", @@ -69,15 +69,15 @@ def test_idedata_extra_flash_images(setup_core: Path) -> None: images = idedata.extra_flash_images assert len(images) == 2 assert all(isinstance(img, platformio_api.FlashImage) for img in images) - assert images[0].path == "/path/to/bootloader.bin" + assert images[0].path == Path("/path/to/bootloader.bin") assert images[0].offset == "0x1000" - assert images[1].path == "/path/to/partition.bin" + assert images[1].path == Path("/path/to/partition.bin") assert images[1].offset == "0x8000" def test_idedata_extra_flash_images_empty(setup_core: Path) -> None: """Test extra_flash_images returns empty list when no extra images.""" - CORE.build_path = str(setup_core / "build" / "test") + CORE.build_path = setup_core / "build" / "test" CORE.name = "test" raw_data = {"prog_path": "/path/to/firmware.elf", "extra": {"flash_images": []}} idedata = platformio_api.IDEData(raw_data) @@ -88,7 +88,7 @@ def test_idedata_extra_flash_images_empty(setup_core: Path) -> None: def test_idedata_cc_path(setup_core: Path) -> None: """Test IDEData.cc_path returns compiler path.""" - CORE.build_path = str(setup_core / "build" / "test") + CORE.build_path = setup_core / "build" / "test" CORE.name = "test" raw_data = { "prog_path": "/path/to/firmware.elf", @@ -104,9 +104,9 @@ def test_idedata_cc_path(setup_core: Path) -> None: def test_flash_image_dataclass() -> None: """Test FlashImage dataclass stores path and offset correctly.""" - image = platformio_api.FlashImage(path="/path/to/image.bin", offset="0x10000") + image = platformio_api.FlashImage(path=Path("/path/to/image.bin"), offset="0x10000") - assert image.path == "/path/to/image.bin" + assert image.path == Path("/path/to/image.bin") assert image.offset == "0x10000" @@ -114,7 +114,7 @@ def test_load_idedata_returns_dict( setup_core: Path, mock_run_platformio_cli_run ) -> None: """Test _load_idedata returns parsed idedata dict when successful.""" - CORE.build_path = str(setup_core / "build" / "test") + CORE.build_path = setup_core / "build" / "test" CORE.name = "test" # Create required files @@ -366,7 +366,7 @@ def test_get_idedata_caches_result( assert result1 is result2 assert isinstance(result1, platformio_api.IDEData) - assert result1.firmware_elf_path == "/test/firmware.elf" + assert result1.firmware_elf_path == Path("/test/firmware.elf") def test_idedata_addr2line_path_windows(setup_core: Path) -> None: @@ -434,9 +434,9 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None: os.utime(platformio_ini, (build_mtime + 1, build_mtime + 1)) # Track if directory was removed - removed_paths: list[str] = [] + removed_paths: list[Path] = [] - def track_rmtree(path: str) -> None: + def track_rmtree(path: Path) -> None: removed_paths.append(path) shutil.rmtree(path) @@ -466,7 +466,7 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None: # Verify directory was removed and recreated assert len(removed_paths) == 1 - assert removed_paths[0] == str(build_dir) + assert removed_paths[0] == build_dir assert build_dir.exists() # makedirs recreated it diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index e1abe565b1..a3a38960e7 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -15,12 +15,12 @@ from esphome.core import CORE def test_storage_path(setup_core: Path) -> None: """Test storage_path returns correct path for current config.""" - CORE.config_path = str(setup_core / "my_device.yaml") + CORE.config_path = setup_core / "my_device.yaml" result = storage_json.storage_path() data_dir = Path(CORE.data_dir) - expected = str(data_dir / "storage" / "my_device.yaml.json") + expected = data_dir / "storage" / "my_device.yaml.json" assert result == expected @@ -29,20 +29,20 @@ def test_ext_storage_path(setup_core: Path) -> None: result = storage_json.ext_storage_path("other_device.yaml") data_dir = Path(CORE.data_dir) - expected = str(data_dir / "storage" / "other_device.yaml.json") + expected = data_dir / "storage" / "other_device.yaml.json" assert result == expected def test_ext_storage_path_handles_various_extensions(setup_core: Path) -> None: """Test ext_storage_path works with different file extensions.""" result_yml = storage_json.ext_storage_path("device.yml") - assert result_yml.endswith("device.yml.json") + assert str(result_yml).endswith("device.yml.json") result_no_ext = storage_json.ext_storage_path("device") - assert result_no_ext.endswith("device.json") + assert str(result_no_ext).endswith("device.json") result_path = storage_json.ext_storage_path("my/device.yaml") - assert result_path.endswith("device.yaml.json") + assert str(result_path).endswith("device.yaml.json") def test_esphome_storage_path(setup_core: Path) -> None: @@ -50,7 +50,7 @@ def test_esphome_storage_path(setup_core: Path) -> None: result = storage_json.esphome_storage_path() data_dir = Path(CORE.data_dir) - expected = str(data_dir / "esphome.json") + expected = data_dir / "esphome.json" assert result == expected @@ -59,27 +59,27 @@ def test_ignored_devices_storage_path(setup_core: Path) -> None: result = storage_json.ignored_devices_storage_path() data_dir = Path(CORE.data_dir) - expected = str(data_dir / "ignored-devices.json") + expected = data_dir / "ignored-devices.json" assert result == expected def test_trash_storage_path(setup_core: Path) -> None: """Test trash_storage_path returns correct path.""" - CORE.config_path = str(setup_core / "configs" / "device.yaml") + CORE.config_path = setup_core / "configs" / "device.yaml" result = storage_json.trash_storage_path() - expected = str(setup_core / "configs" / "trash") + expected = setup_core / "configs" / "trash" assert result == expected def test_archive_storage_path(setup_core: Path) -> None: """Test archive_storage_path returns correct path.""" - CORE.config_path = str(setup_core / "configs" / "device.yaml") + CORE.config_path = setup_core / "configs" / "device.yaml" result = storage_json.archive_storage_path() - expected = str(setup_core / "configs" / "archive") + expected = setup_core / "configs" / "archive" assert result == expected @@ -87,12 +87,12 @@ def test_storage_path_with_subdirectory(setup_core: Path) -> None: """Test storage paths work correctly when config is in subdirectory.""" subdir = setup_core / "configs" / "basement" subdir.mkdir(parents=True, exist_ok=True) - CORE.config_path = str(subdir / "sensor.yaml") + CORE.config_path = subdir / "sensor.yaml" result = storage_json.storage_path() data_dir = Path(CORE.data_dir) - expected = str(data_dir / "storage" / "sensor.yaml.json") + expected = data_dir / "storage" / "sensor.yaml.json" assert result == expected @@ -173,16 +173,16 @@ def test_storage_paths_with_ha_addon(mock_is_ha_addon: bool, tmp_path: Path) -> """Test storage paths when running as Home Assistant addon.""" mock_is_ha_addon.return_value = True - CORE.config_path = str(tmp_path / "test.yaml") + CORE.config_path = tmp_path / "test.yaml" result = storage_json.storage_path() # When is_ha_addon is True, CORE.data_dir returns "/data" # This is the standard mount point for HA addon containers - expected = str(Path("/data") / "storage" / "test.yaml.json") + expected = Path("/data") / "storage" / "test.yaml.json" assert result == expected result = storage_json.esphome_storage_path() - expected = str(Path("/data") / "esphome.json") + expected = Path("/data") / "esphome.json" assert result == expected @@ -375,7 +375,7 @@ def test_storage_json_load_valid_file(tmp_path: Path) -> None: file_path = tmp_path / "storage.json" file_path.write_text(json.dumps(storage_data)) - result = storage_json.StorageJSON.load(str(file_path)) + result = storage_json.StorageJSON.load(file_path) assert result is not None assert result.name == "loaded_device" @@ -386,8 +386,8 @@ def test_storage_json_load_valid_file(tmp_path: Path) -> None: assert result.address == "10.0.0.1" assert result.web_port == 8080 assert result.target_platform == "ESP32" - assert result.build_path == "/loaded/build" - assert result.firmware_bin_path == "/loaded/firmware.bin" + assert result.build_path == Path("/loaded/build") + assert result.firmware_bin_path == Path("/loaded/firmware.bin") assert result.loaded_integrations == {"wifi", "api"} assert result.loaded_platforms == {"sensor"} assert result.no_mdns is True @@ -400,7 +400,7 @@ def test_storage_json_load_invalid_file(tmp_path: Path) -> None: file_path = tmp_path / "invalid.json" file_path.write_text("not valid json{") - result = storage_json.StorageJSON.load(str(file_path)) + result = storage_json.StorageJSON.load(file_path) assert result is None @@ -654,7 +654,7 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: file_path = tmp_path / "legacy.json" file_path.write_text(json.dumps(storage_data)) - result = storage_json.StorageJSON.load(str(file_path)) + result = storage_json.StorageJSON.load(file_path) assert result is not None assert result.esphome_version == "1.14.0" # Should map to esphome_version diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index b2b7cb1ea4..dd419aba9c 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -1,6 +1,6 @@ import glob import logging -import os +from pathlib import Path from esphome import yaml_util from esphome.components import substitutions @@ -52,9 +52,8 @@ def dict_diff(a, b, path=""): return diffs -def write_yaml(path, data): - with open(path, "w", encoding="utf-8") as f: - f.write(yaml_util.dump(data)) +def write_yaml(path: Path, data: dict) -> None: + path.write_text(yaml_util.dump(data), encoding="utf-8") def test_substitutions_fixtures(fixture_path): @@ -64,11 +63,10 @@ def test_substitutions_fixtures(fixture_path): failures = [] for source_path in sources: + source_path = Path(source_path) try: - expected_path = source_path.replace(".input.yaml", ".approved.yaml") - test_case = os.path.splitext(os.path.basename(source_path))[0].replace( - ".input", "" - ) + expected_path = source_path.with_suffix("").with_suffix(".approved.yaml") + test_case = source_path.with_suffix("").stem # Load using ESPHome's YAML loader config = yaml_util.load_yaml(source_path) @@ -81,12 +79,12 @@ def test_substitutions_fixtures(fixture_path): substitutions.do_substitution_pass(config, None) # Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE - if os.path.isfile(expected_path): + if expected_path.is_file(): expected = yaml_util.load_yaml(expected_path) elif DEV_MODE: expected = {} else: - assert os.path.isfile(expected_path), ( + assert expected_path.is_file(), ( f"Expected file missing: {expected_path}" ) @@ -97,16 +95,14 @@ def test_substitutions_fixtures(fixture_path): if got_sorted != expected_sorted: diff = "\n".join(dict_diff(got_sorted, expected_sorted)) msg = ( - f"Substitution result mismatch for {os.path.basename(source_path)}\n" + f"Substitution result mismatch for {source_path.name}\n" f"Diff:\n{diff}\n\n" f"Got: {got_sorted}\n" f"Expected: {expected_sorted}" ) # Write out the received file when test fails if DEV_MODE: - received_path = os.path.join( - os.path.dirname(source_path), f"{test_case}.received.yaml" - ) + received_path = source_path.with_name(f"{test_case}.received.yaml") write_yaml(received_path, config) print(msg) failures.append(msg) diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 6feda854b7..85873caea8 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -32,21 +32,21 @@ def test_list_yaml_files_with_files_and_directories(tmp_path: Path) -> None: # Test with mixed input (directories and files) configs = [ - str(dir1), - str(standalone1), - str(dir2), - str(standalone2), + dir1, + standalone1, + dir2, + standalone2, ] result = util.list_yaml_files(configs) # Should include all YAML files but not the .txt file assert set(result) == { - str(dir1 / "config1.yaml"), - str(dir1 / "config2.yml"), - str(dir2 / "config3.yaml"), - str(standalone1), - str(standalone2), + dir1 / "config1.yaml", + dir1 / "config2.yml", + dir2 / "config3.yaml", + standalone1, + standalone2, } # Check that results are sorted assert result == sorted(result) @@ -63,12 +63,12 @@ def test_list_yaml_files_only_directories(tmp_path: Path) -> None: (dir1 / "b.yml").write_text("test: b") (dir2 / "c.yaml").write_text("test: c") - result = util.list_yaml_files([str(dir1), str(dir2)]) + result = util.list_yaml_files([dir1, dir2]) assert set(result) == { - str(dir1 / "a.yaml"), - str(dir1 / "b.yml"), - str(dir2 / "c.yaml"), + dir1 / "a.yaml", + dir1 / "b.yml", + dir2 / "c.yaml", } assert result == sorted(result) @@ -88,17 +88,17 @@ def test_list_yaml_files_only_files(tmp_path: Path) -> None: # Include a non-YAML file to test filtering result = util.list_yaml_files( [ - str(file1), - str(file2), - str(file3), - str(non_yaml), + file1, + file2, + file3, + non_yaml, ] ) assert set(result) == { - str(file1), - str(file2), - str(file3), + file1, + file2, + file3, } assert result == sorted(result) @@ -108,7 +108,7 @@ def test_list_yaml_files_empty_directory(tmp_path: Path) -> None: empty_dir = tmp_path / "empty" empty_dir.mkdir() - result = util.list_yaml_files([str(empty_dir)]) + result = util.list_yaml_files([empty_dir]) assert result == [] @@ -121,7 +121,7 @@ def test_list_yaml_files_nonexistent_path(tmp_path: Path) -> None: # Should raise an error for non-existent directory with pytest.raises(FileNotFoundError): - util.list_yaml_files([str(nonexistent), str(existing)]) + util.list_yaml_files([nonexistent, existing]) def test_list_yaml_files_mixed_extensions(tmp_path: Path) -> None: @@ -137,11 +137,11 @@ def test_list_yaml_files_mixed_extensions(tmp_path: Path) -> None: yml_file.write_text("test: yml") other_file.write_text("test: txt") - result = util.list_yaml_files([str(dir1)]) + result = util.list_yaml_files([dir1]) assert set(result) == { - str(yaml_file), - str(yml_file), + yaml_file, + yml_file, } @@ -174,17 +174,18 @@ def test_list_yaml_files_does_not_recurse_into_subdirectories(tmp_path: Path) -> assert len(result) == 3 # Check that only root-level files are found - assert str(root / "config1.yaml") in result - assert str(root / "config2.yml") in result - assert str(root / "device.yaml") in result + assert root / "config1.yaml" in result + assert root / "config2.yml" in result + assert root / "device.yaml" in result # Ensure nested files are NOT found for r in result: - assert "subdir" not in r - assert "deeper" not in r - assert "nested1.yaml" not in r - assert "nested2.yml" not in r - assert "very_nested.yaml" not in r + r_str = str(r) + assert "subdir" not in r_str + assert "deeper" not in r_str + assert "nested1.yaml" not in r_str + assert "nested2.yml" not in r_str + assert "very_nested.yaml" not in r_str def test_list_yaml_files_excludes_secrets(tmp_path: Path) -> None: @@ -202,10 +203,10 @@ def test_list_yaml_files_excludes_secrets(tmp_path: Path) -> None: # Should find 2 files (config.yaml and device.yaml), not secrets assert len(result) == 2 - assert str(root / "config.yaml") in result - assert str(root / "device.yaml") in result - assert str(root / "secrets.yaml") not in result - assert str(root / "secrets.yml") not in result + assert root / "config.yaml" in result + assert root / "device.yaml" in result + assert root / "secrets.yaml" not in result + assert root / "secrets.yml" not in result def test_list_yaml_files_excludes_hidden_files(tmp_path: Path) -> None: @@ -223,93 +224,102 @@ def test_list_yaml_files_excludes_hidden_files(tmp_path: Path) -> None: # Should find only non-hidden files assert len(result) == 2 - assert str(root / "config.yaml") in result - assert str(root / "device.yaml") in result - assert str(root / ".hidden.yaml") not in result - assert str(root / ".backup.yml") not in result + assert root / "config.yaml" in result + assert root / "device.yaml" in result + assert root / ".hidden.yaml" not in result + assert root / ".backup.yml" not in result def test_filter_yaml_files_basic() -> None: """Test filter_yaml_files function.""" files = [ - "/path/to/config.yaml", - "/path/to/device.yml", - "/path/to/readme.txt", - "/path/to/script.py", - "/path/to/data.json", - "/path/to/another.yaml", + Path("/path/to/config.yaml"), + Path("/path/to/device.yml"), + Path("/path/to/readme.txt"), + Path("/path/to/script.py"), + Path("/path/to/data.json"), + Path("/path/to/another.yaml"), ] result = util.filter_yaml_files(files) assert len(result) == 3 - assert "/path/to/config.yaml" in result - assert "/path/to/device.yml" in result - assert "/path/to/another.yaml" in result - assert "/path/to/readme.txt" not in result - assert "/path/to/script.py" not in result - assert "/path/to/data.json" not in result + assert Path("/path/to/config.yaml") in result + assert Path("/path/to/device.yml") in result + assert Path("/path/to/another.yaml") in result + assert Path("/path/to/readme.txt") not in result + assert Path("/path/to/script.py") not in result + assert Path("/path/to/data.json") not in result def test_filter_yaml_files_excludes_secrets() -> None: """Test that filter_yaml_files excludes secrets files.""" files = [ - "/path/to/config.yaml", - "/path/to/secrets.yaml", - "/path/to/secrets.yml", - "/path/to/device.yaml", - "/some/dir/secrets.yaml", + Path("/path/to/config.yaml"), + Path("/path/to/secrets.yaml"), + Path("/path/to/secrets.yml"), + Path("/path/to/device.yaml"), + Path("/some/dir/secrets.yaml"), ] result = util.filter_yaml_files(files) assert len(result) == 2 - assert "/path/to/config.yaml" in result - assert "/path/to/device.yaml" in result - assert "/path/to/secrets.yaml" not in result - assert "/path/to/secrets.yml" not in result - assert "/some/dir/secrets.yaml" not in result + assert Path("/path/to/config.yaml") in result + assert Path("/path/to/device.yaml") in result + assert Path("/path/to/secrets.yaml") not in result + assert Path("/path/to/secrets.yml") not in result + assert Path("/some/dir/secrets.yaml") not in result def test_filter_yaml_files_excludes_hidden() -> None: """Test that filter_yaml_files excludes hidden files.""" files = [ - "/path/to/config.yaml", - "/path/to/.hidden.yaml", - "/path/to/.backup.yml", - "/path/to/device.yaml", - "/some/dir/.config.yaml", + Path("/path/to/config.yaml"), + Path("/path/to/.hidden.yaml"), + Path("/path/to/.backup.yml"), + Path("/path/to/device.yaml"), + Path("/some/dir/.config.yaml"), ] result = util.filter_yaml_files(files) assert len(result) == 2 - assert "/path/to/config.yaml" in result - assert "/path/to/device.yaml" in result - assert "/path/to/.hidden.yaml" not in result - assert "/path/to/.backup.yml" not in result - assert "/some/dir/.config.yaml" not in result + assert Path("/path/to/config.yaml") in result + assert Path("/path/to/device.yaml") in result + assert Path("/path/to/.hidden.yaml") not in result + assert Path("/path/to/.backup.yml") not in result + assert Path("/some/dir/.config.yaml") not in result def test_filter_yaml_files_case_sensitive() -> None: """Test that filter_yaml_files is case-sensitive for extensions.""" files = [ - "/path/to/config.yaml", - "/path/to/config.YAML", - "/path/to/config.YML", - "/path/to/config.Yaml", - "/path/to/config.yml", + Path("/path/to/config.yaml"), + Path("/path/to/config.YAML"), + Path("/path/to/config.YML"), + Path("/path/to/config.Yaml"), + Path("/path/to/config.yml"), ] result = util.filter_yaml_files(files) # Should only match lowercase .yaml and .yml assert len(result) == 2 - assert "/path/to/config.yaml" in result - assert "/path/to/config.yml" in result - assert "/path/to/config.YAML" not in result - assert "/path/to/config.YML" not in result - assert "/path/to/config.Yaml" not in result + + # Check the actual suffixes to ensure case-sensitive filtering + result_suffixes = [p.suffix for p in result] + assert ".yaml" in result_suffixes + assert ".yml" in result_suffixes + + # Verify the filtered files have the expected names + result_names = [p.name for p in result] + assert "config.yaml" in result_names + assert "config.yml" in result_names + # Ensure uppercase extensions are NOT included + assert "config.YAML" not in result_names + assert "config.YML" not in result_names + assert "config.Yaml" not in result_names @pytest.mark.parametrize( diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 4b28a2215b..63bdf3e255 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -1,5 +1,5 @@ import json -import os +from pathlib import Path from unittest.mock import Mock, patch from esphome import vscode @@ -45,7 +45,7 @@ RESULT_NO_ERROR = '{"type": "result", "yaml_errors": [], "validation_errors": [] def test_multi_file(): - source_path = os.path.join("dir_path", "x.yaml") + source_path = str(Path("dir_path", "x.yaml")) output_lines = _run_repl_test( [ _validate(source_path), @@ -62,7 +62,7 @@ esp8266: expected_lines = [ _read_file(source_path), - _read_file(os.path.join("dir_path", "secrets.yaml")), + _read_file(str(Path("dir_path", "secrets.yaml"))), RESULT_NO_ERROR, ] @@ -70,7 +70,7 @@ esp8266: def test_shows_correct_range_error(): - source_path = os.path.join("dir_path", "x.yaml") + source_path = str(Path("dir_path", "x.yaml")) output_lines = _run_repl_test( [ _validate(source_path), @@ -98,7 +98,7 @@ esp8266: def test_shows_correct_loaded_file_error(): - source_path = os.path.join("dir_path", "x.yaml") + source_path = str(Path("dir_path", "x.yaml")) output_lines = _run_repl_test( [ _validate(source_path), @@ -121,7 +121,7 @@ packages: validation_error = error["validation_errors"][0] assert validation_error["message"].startswith("[broad] is an invalid option for") range = validation_error["range"] - assert range["document"] == os.path.join("dir_path", ".pkg.esp8266.yaml") + assert range["document"] == str(Path("dir_path", ".pkg.esp8266.yaml")) assert range["start_line"] == 1 assert range["start_col"] == 2 assert range["end_line"] == 1 diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index 7af4db813a..fd53a0b0b7 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -1,6 +1,5 @@ """Tests for the wizard.py file.""" -import os from pathlib import Path from typing import Any from unittest.mock import MagicMock @@ -127,7 +126,7 @@ def test_wizard_write_sets_platform( # Given del default_config["platform"] monkeypatch.setattr(wz, "write_file", MagicMock()) - monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) # When wz.wizard_write(tmp_path, **default_config) @@ -147,7 +146,7 @@ def test_wizard_empty_config(tmp_path: Path, monkeypatch: MonkeyPatch): "name": "test-empty", } monkeypatch.setattr(wz, "write_file", MagicMock()) - monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) # When wz.wizard_write(tmp_path, **empty_config) @@ -168,7 +167,7 @@ def test_wizard_upload_config(tmp_path: Path, monkeypatch: MonkeyPatch): "file_text": "# imported file 📁\n\n", } monkeypatch.setattr(wz, "write_file", MagicMock()) - monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) # When wz.wizard_write(tmp_path, **empty_config) @@ -189,7 +188,7 @@ def test_wizard_write_defaults_platform_from_board_esp8266( default_config["board"] = [*ESP8266_BOARD_PINS][0] monkeypatch.setattr(wz, "write_file", MagicMock()) - monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) # When wz.wizard_write(tmp_path, **default_config) @@ -210,7 +209,7 @@ def test_wizard_write_defaults_platform_from_board_esp32( default_config["board"] = [*ESP32_BOARD_PINS][0] monkeypatch.setattr(wz, "write_file", MagicMock()) - monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) # When wz.wizard_write(tmp_path, **default_config) @@ -231,7 +230,7 @@ def test_wizard_write_defaults_platform_from_board_bk72xx( default_config["board"] = [*BK72XX_BOARD_PINS][0] monkeypatch.setattr(wz, "write_file", MagicMock()) - monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) # When wz.wizard_write(tmp_path, **default_config) @@ -252,7 +251,7 @@ def test_wizard_write_defaults_platform_from_board_ln882x( default_config["board"] = [*LN882X_BOARD_PINS][0] monkeypatch.setattr(wz, "write_file", MagicMock()) - monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) # When wz.wizard_write(tmp_path, **default_config) @@ -273,7 +272,7 @@ def test_wizard_write_defaults_platform_from_board_rtl87xx( default_config["board"] = [*RTL87XX_BOARD_PINS][0] monkeypatch.setattr(wz, "write_file", MagicMock()) - monkeypatch.setattr(CORE, "config_path", os.path.dirname(tmp_path)) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) # When wz.wizard_write(tmp_path, **default_config) @@ -362,7 +361,7 @@ def test_wizard_rejects_path_with_invalid_extension(): """ # Given - config_file = "test.json" + config_file = Path("test.json") # When retval = wz.wizard(config_file) @@ -371,31 +370,31 @@ def test_wizard_rejects_path_with_invalid_extension(): assert retval == 1 -def test_wizard_rejects_existing_files(tmpdir): +def test_wizard_rejects_existing_files(tmp_path): """ The wizard should reject any configuration file that already exists """ # Given - config_file = tmpdir.join("test.yaml") - config_file.write("") + config_file = tmp_path / "test.yaml" + config_file.write_text("") # When - retval = wz.wizard(str(config_file)) + retval = wz.wizard(config_file) # Then assert retval == 2 def test_wizard_accepts_default_answers_esp8266( - tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] ): """ The wizard should accept the given default answers for esp8266 """ # Given - config_file = tmpdir.join("test.yaml") + config_file = tmp_path / "test.yaml" input_mock = MagicMock(side_effect=wizard_answers) monkeypatch.setattr("builtins.input", input_mock) monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) @@ -403,14 +402,14 @@ def test_wizard_accepts_default_answers_esp8266( monkeypatch.setattr(wz, "wizard_write", MagicMock()) # When - retval = wz.wizard(str(config_file)) + retval = wz.wizard(config_file) # Then assert retval == 0 def test_wizard_accepts_default_answers_esp32( - tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] ): """ The wizard should accept the given default answers for esp32 @@ -419,7 +418,7 @@ def test_wizard_accepts_default_answers_esp32( # Given wizard_answers[1] = "ESP32" wizard_answers[2] = "nodemcu-32s" - config_file = tmpdir.join("test.yaml") + config_file = tmp_path / "test.yaml" input_mock = MagicMock(side_effect=wizard_answers) monkeypatch.setattr("builtins.input", input_mock) monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) @@ -427,14 +426,14 @@ def test_wizard_accepts_default_answers_esp32( monkeypatch.setattr(wz, "wizard_write", MagicMock()) # When - retval = wz.wizard(str(config_file)) + retval = wz.wizard(config_file) # Then assert retval == 0 def test_wizard_offers_better_node_name( - tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] ): """ When the node name does not conform, a better alternative is offered @@ -451,7 +450,7 @@ def test_wizard_offers_better_node_name( wz, "default_input", MagicMock(side_effect=lambda _, default: default) ) - config_file = tmpdir.join("test.yaml") + config_file = tmp_path / "test.yaml" input_mock = MagicMock(side_effect=wizard_answers) monkeypatch.setattr("builtins.input", input_mock) monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) @@ -459,7 +458,7 @@ def test_wizard_offers_better_node_name( monkeypatch.setattr(wz, "wizard_write", MagicMock()) # When - retval = wz.wizard(str(config_file)) + retval = wz.wizard(config_file) # Then assert retval == 0 @@ -467,7 +466,7 @@ def test_wizard_offers_better_node_name( def test_wizard_requires_correct_platform( - tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] ): """ When the platform is not either esp32 or esp8266, the wizard should reject it @@ -476,7 +475,7 @@ def test_wizard_requires_correct_platform( # Given wizard_answers.insert(1, "foobar") # add invalid entry for platform - config_file = tmpdir.join("test.yaml") + config_file = tmp_path / "test.yaml" input_mock = MagicMock(side_effect=wizard_answers) monkeypatch.setattr("builtins.input", input_mock) monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) @@ -484,14 +483,14 @@ def test_wizard_requires_correct_platform( monkeypatch.setattr(wz, "wizard_write", MagicMock()) # When - retval = wz.wizard(str(config_file)) + retval = wz.wizard(config_file) # Then assert retval == 0 def test_wizard_requires_correct_board( - tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] ): """ When the board is not a valid esp8266 board, the wizard should reject it @@ -500,7 +499,7 @@ def test_wizard_requires_correct_board( # Given wizard_answers.insert(2, "foobar") # add an invalid entry for board - config_file = tmpdir.join("test.yaml") + config_file = tmp_path / "test.yaml" input_mock = MagicMock(side_effect=wizard_answers) monkeypatch.setattr("builtins.input", input_mock) monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) @@ -508,14 +507,14 @@ def test_wizard_requires_correct_board( monkeypatch.setattr(wz, "wizard_write", MagicMock()) # When - retval = wz.wizard(str(config_file)) + retval = wz.wizard(config_file) # Then assert retval == 0 def test_wizard_requires_valid_ssid( - tmpdir, monkeypatch: MonkeyPatch, wizard_answers: list[str] + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] ): """ When the board is not a valid esp8266 board, the wizard should reject it @@ -524,7 +523,7 @@ def test_wizard_requires_valid_ssid( # Given wizard_answers.insert(3, "") # add an invalid entry for ssid - config_file = tmpdir.join("test.yaml") + config_file = tmp_path / "test.yaml" input_mock = MagicMock(side_effect=wizard_answers) monkeypatch.setattr("builtins.input", input_mock) monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) @@ -532,28 +531,28 @@ def test_wizard_requires_valid_ssid( monkeypatch.setattr(wz, "wizard_write", MagicMock()) # When - retval = wz.wizard(str(config_file)) + retval = wz.wizard(config_file) # Then assert retval == 0 def test_wizard_write_protects_existing_config( - tmpdir, default_config: dict[str, Any], monkeypatch: MonkeyPatch + tmp_path: Path, default_config: dict[str, Any], monkeypatch: MonkeyPatch ): """ The wizard_write function should not overwrite existing config files and return False """ # Given - config_file = tmpdir.join("test.yaml") + config_file = tmp_path / "test.yaml" original_content = "# Original config content\n" - config_file.write(original_content) + config_file.write_text(original_content) - monkeypatch.setattr(CORE, "config_path", str(tmpdir)) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) # When - result = wz.wizard_write(str(config_file), **default_config) + result = wz.wizard_write(config_file, **default_config) # Then assert result is False # Should return False when file exists - assert config_file.read() == original_content + assert config_file.read_text() == original_content diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 970e0fada6..ba309f2406 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -257,10 +257,7 @@ def test_clean_cmake_cache( cmake_cache_file.write_text("# CMake cache file") # Setup mocks - mock_core.relative_pioenvs_path.side_effect = [ - str(pioenvs_dir), # First call for directory check - str(cmake_cache_file), # Second call for file path - ] + mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.name = "test_device" # Verify file exists before @@ -288,7 +285,7 @@ def test_clean_cmake_cache_no_pioenvs_dir( pioenvs_dir = tmp_path / ".pioenvs" # Setup mocks - mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) + mock_core.relative_pioenvs_path.return_value = pioenvs_dir # Verify directory doesn't exist assert not pioenvs_dir.exists() @@ -314,10 +311,7 @@ def test_clean_cmake_cache_no_cmake_file( cmake_cache_file = device_dir / "CMakeCache.txt" # Setup mocks - mock_core.relative_pioenvs_path.side_effect = [ - str(pioenvs_dir), # First call for directory check - str(cmake_cache_file), # Second call for file path - ] + mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.name = "test_device" # Verify file doesn't exist @@ -358,9 +352,9 @@ def test_clean_build( (platformio_cache_dir / "downloads" / "package.tar.gz").write_text("package") # Setup mocks - mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) - mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) - mock_core.relative_build_path.return_value = str(dependencies_lock) + mock_core.relative_pioenvs_path.return_value = pioenvs_dir + mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir + mock_core.relative_build_path.return_value = dependencies_lock # Verify all exist before assert pioenvs_dir.exists() @@ -408,9 +402,9 @@ def test_clean_build_partial_exists( dependencies_lock = tmp_path / "dependencies.lock" # Setup mocks - mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) - mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) - mock_core.relative_build_path.return_value = str(dependencies_lock) + mock_core.relative_pioenvs_path.return_value = pioenvs_dir + mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir + mock_core.relative_build_path.return_value = dependencies_lock # Verify only pioenvs exists assert pioenvs_dir.exists() @@ -445,9 +439,9 @@ def test_clean_build_nothing_exists( dependencies_lock = tmp_path / "dependencies.lock" # Setup mocks - mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) - mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) - mock_core.relative_build_path.return_value = str(dependencies_lock) + mock_core.relative_pioenvs_path.return_value = pioenvs_dir + mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir + mock_core.relative_build_path.return_value = dependencies_lock # Verify nothing exists assert not pioenvs_dir.exists() @@ -481,9 +475,9 @@ def test_clean_build_platformio_not_available( dependencies_lock.write_text("lock file") # Setup mocks - mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) - mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) - mock_core.relative_build_path.return_value = str(dependencies_lock) + mock_core.relative_pioenvs_path.return_value = pioenvs_dir + mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir + mock_core.relative_build_path.return_value = dependencies_lock # Verify all exist before assert pioenvs_dir.exists() @@ -519,9 +513,9 @@ def test_clean_build_empty_cache_dir( pioenvs_dir.mkdir() # Setup mocks - mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) - mock_core.relative_piolibdeps_path.return_value = str(tmp_path / ".piolibdeps") - mock_core.relative_build_path.return_value = str(tmp_path / "dependencies.lock") + mock_core.relative_pioenvs_path.return_value = pioenvs_dir + mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" + mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock" # Verify pioenvs exists before assert pioenvs_dir.exists() @@ -552,7 +546,7 @@ def test_write_gitignore_creates_new_file( gitignore_path = tmp_path / ".gitignore" # Setup mocks - mock_core.relative_config_path.return_value = str(gitignore_path) + mock_core.relative_config_path.return_value = gitignore_path # Verify file doesn't exist assert not gitignore_path.exists() @@ -576,7 +570,7 @@ def test_write_gitignore_skips_existing_file( gitignore_path.write_text(existing_content) # Setup mocks - mock_core.relative_config_path.return_value = str(gitignore_path) + mock_core.relative_config_path.return_value = gitignore_path # Verify file exists with custom content assert gitignore_path.exists() @@ -615,7 +609,7 @@ void loop() {{}}""" main_cpp.write_text(existing_content) # Setup mocks - mock_core.relative_src_path.return_value = str(main_cpp) + mock_core.relative_src_path.return_value = main_cpp mock_core.cpp_global_section = "// Global section" # Call the function @@ -652,7 +646,7 @@ def test_write_cpp_creates_new_file( main_cpp = tmp_path / "main.cpp" # Setup mocks - mock_core.relative_src_path.return_value = str(main_cpp) + mock_core.relative_src_path.return_value = main_cpp mock_core.cpp_global_section = "// Global section" # Verify file doesn't exist @@ -668,7 +662,7 @@ def test_write_cpp_creates_new_file( # Get the content that would be written mock_write_file.assert_called_once() written_path, written_content = mock_write_file.call_args[0] - assert written_path == str(main_cpp) + assert written_path == main_cpp # Check that all necessary parts are in the new file assert '#include "esphome.h"' in written_content @@ -698,7 +692,7 @@ def test_write_cpp_with_missing_end_marker( main_cpp.write_text(existing_content) # Setup mocks - mock_core.relative_src_path.return_value = str(main_cpp) + mock_core.relative_src_path.return_value = main_cpp # Call should raise an error with pytest.raises(EsphomeError, match="Could not find auto generated code end"): @@ -724,7 +718,7 @@ def test_write_cpp_with_duplicate_markers( main_cpp.write_text(existing_content) # Setup mocks - mock_core.relative_src_path.return_value = str(main_cpp) + mock_core.relative_src_path.return_value = main_cpp # Call should raise an error with pytest.raises(EsphomeError, match="Found multiple auto generate code begins"): diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index bc3c89a64d..eac0ceabb8 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -67,18 +67,18 @@ def test_parsing_with_custom_loader(fixture_path): """ yaml_file = fixture_path / "yaml_util" / "includetest.yaml" - loader_calls = [] + loader_calls: list[Path] = [] - def custom_loader(fname): + def custom_loader(fname: Path): loader_calls.append(fname) - with open(yaml_file, encoding="utf-8") as f_handle: + with yaml_file.open(encoding="utf-8") as f_handle: yaml_util.parse_yaml(yaml_file, f_handle, custom_loader) assert len(loader_calls) == 3 - assert loader_calls[0].endswith("includes/included.yaml") - assert loader_calls[1].endswith("includes/list.yaml") - assert loader_calls[2].endswith("includes/scalar.yaml") + assert loader_calls[0].parts[-2:] == ("includes", "included.yaml") + assert loader_calls[1].parts[-2:] == ("includes", "list.yaml") + assert loader_calls[2].parts[-2:] == ("includes", "scalar.yaml") def test_construct_secret_simple(fixture_path: Path) -> None: @@ -110,7 +110,7 @@ wifi: secrets_yaml.write_text("some_other_secret: value") with pytest.raises(EsphomeError, match="Secret 'nonexistent_secret' not defined"): - yaml_util.load_yaml(str(test_yaml)) + yaml_util.load_yaml(test_yaml) def test_construct_secret_no_secrets_file(tmp_path: Path) -> None: @@ -124,10 +124,10 @@ wifi: # Mock CORE.config_path to avoid NoneType error with ( - patch.object(core.CORE, "config_path", str(tmp_path / "main.yaml")), + patch.object(core.CORE, "config_path", tmp_path / "main.yaml"), pytest.raises(EsphomeError, match="secrets.yaml"), ): - yaml_util.load_yaml(str(test_yaml)) + yaml_util.load_yaml(test_yaml) def test_construct_secret_fallback_to_main_config_dir( @@ -149,8 +149,8 @@ wifi: main_secrets.write_text("test_secret: main_secret_value") # Mock CORE.config_path to point to main directory - with patch.object(core.CORE, "config_path", str(tmp_path / "main.yaml")): - actual = yaml_util.load_yaml(str(test_yaml)) + with patch.object(core.CORE, "config_path", tmp_path / "main.yaml"): + actual = yaml_util.load_yaml(test_yaml) assert actual["wifi"]["password"] == "main_secret_value" @@ -167,7 +167,7 @@ def test_construct_include_dir_named(fixture_path: Path, tmp_path: Path) -> None sensor: !include_dir_named named_dir """) - actual = yaml_util.load_yaml(str(test_yaml)) + actual = yaml_util.load_yaml(test_yaml) actual_sensor = actual["sensor"] # Check that files were loaded with their names as keys @@ -202,7 +202,7 @@ def test_construct_include_dir_named_empty_dir(tmp_path: Path) -> None: sensor: !include_dir_named empty_dir """) - actual = yaml_util.load_yaml(str(test_yaml)) + actual = yaml_util.load_yaml(test_yaml) # Should return empty OrderedDict assert isinstance(actual["sensor"], OrderedDict) @@ -234,7 +234,7 @@ def test_construct_include_dir_named_with_dots(tmp_path: Path) -> None: test: !include_dir_named test_dir """) - actual = yaml_util.load_yaml(str(test_yaml)) + actual = yaml_util.load_yaml(test_yaml) # Should only include visible file assert "visible" in actual["test"] @@ -258,7 +258,7 @@ def test_find_files_recursive(fixture_path: Path, tmp_path: Path) -> None: all_sensors: !include_dir_named named_dir """) - actual = yaml_util.load_yaml(str(test_yaml)) + actual = yaml_util.load_yaml(test_yaml) # Should find sensor1.yaml, sensor2.yaml, and subdir/sensor3.yaml (all flattened) assert len(actual["all_sensors"]) == 3 From b3a122de3c7634ed78d809a381eaa4c678c091d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 08:04:29 -0600 Subject: [PATCH 1228/1626] Bump ruff from 0.13.0 to 0.13.1 (#10802) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cda0384d54..cab433c7f9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.13.0 + rev: v0.13.1 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 2e2f8a1c0d..2c78eadf45 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.8 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.13.0 # also change in .pre-commit-config.yaml when updating +ruff==0.13.1 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From 1ccec6950a61bc1ea76cac5ee565503a8b7fb439 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 19 Sep 2025 09:52:54 -0500 Subject: [PATCH 1229/1626] [zwave_proxy] Send Home ID in DeviceInfoResponse (#10798) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: AlCalzone Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 1 + esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_pb2.cpp | 6 ++ esphome/components/api/api_pb2.h | 5 +- esphome/components/api/api_pb2_dump.cpp | 3 + .../components/zwave_proxy/zwave_proxy.cpp | 62 +++++++++++++++---- esphome/components/zwave_proxy/zwave_proxy.h | 18 ++++-- 7 files changed, 78 insertions(+), 18 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index fa64649df5..ad99de4b4a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -260,6 +260,7 @@ message DeviceInfoResponse { // Indicates if Z-Wave proxy support is available and features supported uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"]; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 697b02b915..a27adfe241 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1474,6 +1474,7 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { #endif #ifdef USE_ZWAVE_PROXY resp.zwave_proxy_feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags(); + resp.zwave_home_id = zwave_proxy::global_zwave_proxy->get_home_id(); #endif #ifdef USE_API_NOISE resp.api_encryption_supported = true; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 58a083ad06..245933724b 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -132,6 +132,9 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { #ifdef USE_ZWAVE_PROXY buffer.encode_uint32(23, this->zwave_proxy_feature_flags); #endif +#ifdef USE_ZWAVE_PROXY + buffer.encode_uint32(24, this->zwave_home_id); +#endif } void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_API_PASSWORD @@ -187,6 +190,9 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const { #ifdef USE_ZWAVE_PROXY size.add_uint32(2, this->zwave_proxy_feature_flags); #endif +#ifdef USE_ZWAVE_PROXY + size.add_uint32(2, this->zwave_home_id); +#endif } #ifdef USE_BINARY_SENSOR void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index d52cb9eab3..248a4b1f82 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -498,7 +498,7 @@ class DeviceInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint8_t ESTIMATED_SIZE = 252; + static constexpr uint16_t ESTIMATED_SIZE = 257; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "device_info_response"; } #endif @@ -561,6 +561,9 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_ZWAVE_PROXY uint32_t zwave_proxy_feature_flags{0}; +#endif +#ifdef USE_ZWAVE_PROXY + uint32_t zwave_home_id{0}; #endif void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index d24f9b3fdc..ac43af6d54 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -769,6 +769,9 @@ void DeviceInfoResponse::dump_to(std::string &out) const { #ifdef USE_ZWAVE_PROXY dump_field(out, "zwave_proxy_feature_flags", this->zwave_proxy_feature_flags); #endif +#ifdef USE_ZWAVE_PROXY + dump_field(out, "zwave_home_id", this->zwave_home_id); +#endif } void ListEntitiesRequest::dump_to(std::string &out) const { out.append("ListEntitiesRequest {}"); } void ListEntitiesDoneResponse::dump_to(std::string &out) const { out.append("ListEntitiesDoneResponse {}"); } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index e39f857743..12c4ee0c0d 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -8,8 +8,26 @@ namespace zwave_proxy { static const char *const TAG = "zwave_proxy"; +static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20; +// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] +static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value +static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 9; // TYPE + CMD + HOME_ID(4) + NODE_ID + checksum + +static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { + // Calculate Z-Wave frame checksum + // XOR all bytes between SOF and checksum position (exclusive) + // Initial value is 0xFF per Z-Wave protocol specification + uint8_t checksum = 0xFF; + for (uint8_t i = 1; i < length - 1; i++) { + checksum ^= data[i]; + } + return checksum; +} + ZWaveProxy::ZWaveProxy() { global_zwave_proxy = this; } +void ZWaveProxy::setup() { this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS); } + void ZWaveProxy::loop() { if (this->response_handler_()) { ESP_LOGV(TAG, "Handled late response"); @@ -26,6 +44,21 @@ void ZWaveProxy::loop() { return; } if (this->parse_byte_(byte)) { + // Check if this is a GET_NETWORK_IDS response frame + // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] + // We verify: + // - buffer_[0]: Start of frame marker (0x01) + // - buffer_[1]: Length field must be >= 9 to contain all required data + // - buffer_[2]: Command type (0x01 for response) + // - buffer_[3]: Command ID (0x20 for GET_NETWORK_IDS) + if (this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS && this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && + this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && this->buffer_[0] == ZWAVE_FRAME_TYPE_START) { + // Extract the 4-byte Home ID starting at offset 4 + // The frame parser has already validated the checksum and ensured all bytes are present + std::memcpy(this->home_id_.data(), this->buffer_.data() + 4, this->home_id_.size()); + ESP_LOGI(TAG, "Home ID: %s", + format_hex_pretty(this->home_id_.data(), this->home_id_.size(), ':', false).c_str()); + } ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); if (this->api_connection_ != nullptr) { // minimize copying to reduce CPU overhead @@ -35,7 +68,7 @@ void ZWaveProxy::loop() { // If this is a data frame, use frame length indicator + 2 (for SoF + checksum), else assume 1 for ACK/NAK/CAN this->outgoing_proto_msg_.data_len = this->buffer_[0] == ZWAVE_FRAME_TYPE_START ? this->buffer_[1] + 2 : 1; } - std::memcpy(this->outgoing_proto_msg_.data, this->buffer_, this->outgoing_proto_msg_.data_len); + std::memcpy(this->outgoing_proto_msg_.data, this->buffer_.data(), this->outgoing_proto_msg_.data_len); this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE); } } @@ -77,6 +110,15 @@ void ZWaveProxy::send_frame(const uint8_t *data, size_t length) { this->write_array(data, length); } +void ZWaveProxy::send_simple_command_(const uint8_t command_id) { + // Send a simple Z-Wave command with no parameters + // Frame format: [SOF][LENGTH][TYPE][CMD][CHECKSUM] + // Where LENGTH=0x03 (3 bytes: TYPE + CMD + CHECKSUM) + uint8_t cmd[] = {0x01, 0x03, 0x00, command_id, 0x00}; + cmd[4] = calculate_frame_checksum(cmd, sizeof(cmd)); + this->send_frame(cmd, sizeof(cmd)); +} + bool ZWaveProxy::parse_byte_(uint8_t byte) { bool frame_completed = false; // Basic parsing logic for received frames @@ -94,43 +136,40 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { this->end_frame_after_ = this->buffer_index_ + byte; ESP_LOGVV(TAG, "Calculated EOF: %u", this->end_frame_after_); this->buffer_[this->buffer_index_++] = byte; - this->checksum_ ^= byte; this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_TYPE; break; case ZWAVE_PARSING_STATE_WAIT_TYPE: this->buffer_[this->buffer_index_++] = byte; ESP_LOGVV(TAG, "Received TYPE: 0x%02X", byte); - this->checksum_ ^= byte; this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_COMMAND_ID; break; case ZWAVE_PARSING_STATE_WAIT_COMMAND_ID: this->buffer_[this->buffer_index_++] = byte; ESP_LOGVV(TAG, "Received COMMAND ID: 0x%02X", byte); - this->checksum_ ^= byte; this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_PAYLOAD; break; case ZWAVE_PARSING_STATE_WAIT_PAYLOAD: this->buffer_[this->buffer_index_++] = byte; - this->checksum_ ^= byte; ESP_LOGVV(TAG, "Received PAYLOAD: 0x%02X", byte); if (this->buffer_index_ >= this->end_frame_after_) { this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_CHECKSUM; } break; - case ZWAVE_PARSING_STATE_WAIT_CHECKSUM: + case ZWAVE_PARSING_STATE_WAIT_CHECKSUM: { this->buffer_[this->buffer_index_++] = byte; - ESP_LOGVV(TAG, "Received CHECKSUM: 0x%02X", byte); - ESP_LOGV(TAG, "Calculated CHECKSUM: 0x%02X", this->checksum_); - if (this->checksum_ != byte) { - ESP_LOGW(TAG, "Bad checksum: expected 0x%02X, got 0x%02X", this->checksum_, byte); + auto checksum = calculate_frame_checksum(this->buffer_.data(), this->buffer_index_); + ESP_LOGVV(TAG, "CHECKSUM Received: 0x%02X - Calculated: 0x%02X", byte, checksum); + if (checksum != byte) { + ESP_LOGW(TAG, "Bad checksum: expected 0x%02X, got 0x%02X", checksum, byte); this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; } else { this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_ACK; - ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(this->buffer_, this->buffer_index_).c_str()); + ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(this->buffer_.data(), this->buffer_index_).c_str()); frame_completed = true; } this->response_handler_(); break; + } case ZWAVE_PARSING_STATE_READ_BL_MENU: this->buffer_[this->buffer_index_++] = byte; if (!byte) { @@ -151,7 +190,6 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { void ZWaveProxy::parse_start_(uint8_t byte) { this->buffer_index_ = 0; - this->checksum_ = 0xFF; this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; switch (byte) { case ZWAVE_FRAME_TYPE_START: diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index a0f25849e4..5d908b328c 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -3,8 +3,11 @@ #include "esphome/components/api/api_connection.h" #include "esphome/components/api/api_pb2.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/uart/uart.h" +#include + namespace esphome { namespace zwave_proxy { @@ -38,6 +41,7 @@ class ZWaveProxy : public uart::UARTDevice, public Component { public: ZWaveProxy(); + void setup() override; void loop() override; void dump_config() override; @@ -45,21 +49,25 @@ class ZWaveProxy : public uart::UARTDevice, public Component { api::APIConnection *get_api_connection() { return this->api_connection_; } uint32_t get_feature_flags() const { return ZWaveProxyFeature::FEATURE_ZWAVE_PROXY_ENABLED; } + uint32_t get_home_id() { + return encode_uint32(this->home_id_[0], this->home_id_[1], this->home_id_[2], this->home_id_[3]); + } void send_frame(const uint8_t *data, size_t length); protected: + void send_simple_command_(uint8_t command_id); bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) void parse_start_(uint8_t byte); bool response_handler_(); api::APIConnection *api_connection_{nullptr}; // Current subscribed client - uint8_t buffer_[sizeof(api::ZWaveProxyFrame::data)]; // Fixed buffer for incoming data - uint8_t buffer_index_{0}; // Index for populating the data buffer - uint8_t checksum_{0}; // Checksum of the frame being parsed - uint8_t end_frame_after_{0}; // Payload reception ends after this index - uint8_t last_response_{0}; // Last response type sent + std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID + std::array buffer_; // Fixed buffer for incoming data + uint8_t buffer_index_{0}; // Index for populating the data buffer + uint8_t end_frame_after_{0}; // Payload reception ends after this index + uint8_t last_response_{0}; // Last response type sent ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode From 26df5420360d20c8f5d96e4f91f1c6b7d77b8c8b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 19 Sep 2025 16:36:46 -0400 Subject: [PATCH 1230/1626] Fix esphome run (#10807) --- esphome/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 830a7567c2..fff66bcd50 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -453,7 +453,7 @@ def upload_using_esptool( "detect", ] for img in flash_images: - cmd += [img.offset, img.path] + cmd += [img.offset, str(img.path)] if os.environ.get("ESPHOME_USE_SUBPROCESS") is None: import esptool From 923e7049f119c1fb968bfe6548c6bbf164a1d759 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 15:04:43 -0600 Subject: [PATCH 1231/1626] Bump aioesphomeapi from 41.1.0 to 41.4.0 (#10805) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e29a926a7c..7e81f89187 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==41.1.0 +aioesphomeapi==41.4.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From a96798ef98d0a0837c7877624831d0cc3f49c590 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 15:13:47 -0600 Subject: [PATCH 1232/1626] Bump esptool from 5.0.2 to 5.1.0 (#10758) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7e81f89187..2424db1639 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile -esptool==5.0.2 +esptool==5.1.0 click==8.1.7 esphome-dashboard==20250904.0 aioesphomeapi==41.4.0 From 46f05b34e5722c7f749e5fa801b88a691f91279f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 15:45:38 -0600 Subject: [PATCH 1233/1626] preen --- esphome/components/esphome/ota/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 3134fadf26..370f50fcbc 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -27,10 +27,15 @@ CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] +def supports_sha256() -> bool: + """Check if the current platform supports SHA256 for OTA authentication.""" + return bool(CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny) + + def AUTO_LOAD(): """Conditionally auto-load sha256 only on platforms that support it.""" base_components = ["md5", "socket"] - if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny: + if supports_sha256(): return base_components + ["sha256"] return base_components @@ -136,7 +141,7 @@ async def to_code(config): cg.add(var.set_port(config[CONF_PORT])) # Only include SHA256 support on platforms that have it - if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny: + if supports_sha256(): cg.add_define("USE_OTA_SHA256") if CONF_PASSWORD in config: From 6215199c1aec0c2d11c7dbd9316841cf8ead3284 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 15:51:52 -0600 Subject: [PATCH 1234/1626] codeowners --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/CODEOWNERS b/CODEOWNERS index e91116795a..77a837df0d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -407,6 +407,7 @@ esphome/components/sensor/* @esphome/core esphome/components/sfa30/* @ghsensdev esphome/components/sgp40/* @SenexCrenshaw esphome/components/sgp4x/* @martgras @SenexCrenshaw +esphome/components/sha256/* @esphome/core esphome/components/shelly_dimmer/* @edge90 @rnauber esphome/components/sht3xd/* @mrtoy-me esphome/components/sht4x/* @sjtrny From e721e8c2037f1205b4c8b674d75606e7ab6b5dcf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 15:54:17 -0600 Subject: [PATCH 1235/1626] preen --- esphome/components/esphome/ota/__init__.py | 2 +- esphome/components/esphome/ota/ota_esphome.cpp | 2 -- esphome/espota2.py | 9 ++++++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 370f50fcbc..72a690b926 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -32,7 +32,7 @@ def supports_sha256() -> bool: return bool(CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny) -def AUTO_LOAD(): +def AUTO_LOAD() -> list[str]: """Conditionally auto-load sha256 only on platforms that support it.""" base_components = ["md5", "socket"] if supports_sha256(): diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 06fd119c07..8b6235e247 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -102,7 +102,6 @@ static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; #endif -// Template traits for hash algorithms template struct HashTraits; template<> struct HashTraits { @@ -121,7 +120,6 @@ template<> struct HashTraits { }; #endif -// Template helper for hash-based authentication template bool perform_hash_auth(ESPHomeOTAComponent *ota, const std::string &password) { using Traits = HashTraits; diff --git a/esphome/espota2.py b/esphome/espota2.py index 8215c14cb3..33176cc35f 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -9,6 +9,7 @@ import random import socket import sys import time +from typing import Any from esphome.core import EsphomeError from esphome.helpers import resolve_ip_address @@ -228,7 +229,13 @@ def perform_ota( else: upload_contents = file_contents - def perform_auth(sock, password, hash_func, nonce_size, hash_name): + def perform_auth( + sock: socket.socket, + password: str, + hash_func: Any, + nonce_size: int, + hash_name: str, + ) -> None: """Perform challenge-response authentication using specified hash algorithm.""" if not password: raise OTAError("ESP requests password, but no password given!") From 0919669fc6eae29d0c271392362593e1ee25ebf7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 15:56:21 -0600 Subject: [PATCH 1236/1626] preen --- esphome/espota2.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 33176cc35f..dc4fa7237b 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable import gzip import hashlib import io @@ -55,6 +56,12 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 _LOGGER = logging.getLogger(__name__) +# Authentication method lookup table: response -> (hash_func, nonce_size, name) +_AUTH_METHODS: dict[int, tuple[Callable[[], Any], int, str]] = { + RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), + RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), +} + class ProgressBar: def __init__(self): @@ -262,18 +269,24 @@ def perform_ota( send_check(sock, result, "auth result") receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK) + # Authentication method lookup table + auth_methods = { + RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), + RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), + } + (auth,) = receive_exactly( sock, 1, "auth", [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], ) - if auth == RESPONSE_REQUEST_SHA256_AUTH: - # SHA256 authentication - perform_auth(sock, password, hashlib.sha256, 64, "SHA256") - elif auth == RESPONSE_REQUEST_AUTH: - # MD5 authentication (backward compatibility) - perform_auth(sock, password, hashlib.md5, 32, "MD5") + + if auth in auth_methods: + hash_func, nonce_size, hash_name = auth_methods[auth] + perform_auth(sock, password, hash_func, nonce_size, hash_name) + elif auth != RESPONSE_AUTH_OK: + raise OTAError(f"Unknown authentication method requested: 0x{auth:02X}") # Set higher timeout during upload sock.settimeout(30.0) From 4b6fbc2a1e28b95d6272ac9a37dcaa5061833a4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 15:56:40 -0600 Subject: [PATCH 1237/1626] preen --- esphome/espota2.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index dc4fa7237b..5f906e4d08 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -269,12 +269,6 @@ def perform_ota( send_check(sock, result, "auth result") receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK) - # Authentication method lookup table - auth_methods = { - RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), - RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), - } - (auth,) = receive_exactly( sock, 1, @@ -282,8 +276,8 @@ def perform_ota( [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], ) - if auth in auth_methods: - hash_func, nonce_size, hash_name = auth_methods[auth] + if auth in _AUTH_METHODS: + hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) elif auth != RESPONSE_AUTH_OK: raise OTAError(f"Unknown authentication method requested: 0x{auth:02X}") From e41ca7e888d3747ef1ae8dc0bcec74add66d3c10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 16:31:58 -0600 Subject: [PATCH 1238/1626] tidy --- .../components/esphome/ota/ota_esphome.cpp | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 8b6235e247..d638b030e9 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -105,18 +105,18 @@ static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; template struct HashTraits; template<> struct HashTraits { - static constexpr int nonce_size = 8; - static constexpr int hex_size = 32; - static constexpr const char *name = "MD5"; - static constexpr ota::OTAResponseTypes auth_request = ota::OTA_RESPONSE_REQUEST_AUTH; + static constexpr int NONCE_SIZE = 8; + static constexpr int HEX_SIZE = 32; + static constexpr const char *NAME = "MD5"; + static constexpr ota::OTAResponseTypes AUTH_REQUEST = ota::OTA_RESPONSE_REQUEST_AUTH; }; #ifdef USE_OTA_SHA256 template<> struct HashTraits { - static constexpr int nonce_size = 16; - static constexpr int hex_size = 64; - static constexpr const char *name = "SHA256"; - static constexpr ota::OTAResponseTypes auth_request = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; + static constexpr int NONCE_SIZE = 16; + static constexpr int HEX_SIZE = 64; + static constexpr const char *NAME = "SHA256"; + static constexpr ota::OTAResponseTypes AUTH_REQUEST = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; }; #endif @@ -125,7 +125,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co // Minimize stack usage by reusing buffers // We only need 2 buffers at most at the same time - constexpr size_t hex_buffer_size = Traits::hex_size + 1; + constexpr size_t hex_buffer_size = Traits::HEX_SIZE + 1; // These two buffers are reused throughout the function char hex_buffer1[hex_buffer_size]; // Used for: nonce -> expected result @@ -136,7 +136,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) // Send auth request type - buf[0] = Traits::auth_request; + buf[0] = Traits::AUTH_REQUEST; ota->writeall_(buf, 1); HashClass hasher; @@ -150,7 +150,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co nonce_bytes[2] = (r1 >> 8) & 0xFF; nonce_bytes[3] = r1 & 0xFF; - if (Traits::nonce_size == 8) { + if (Traits::NONCE_SIZE == 8) { // MD5: 8 chars = "%08x" format = 4 bytes from one random uint32 hasher.add(nonce_bytes, 4); } @@ -169,50 +169,50 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co // Use hex_buffer1 for nonce hasher.get_hex(hex_buffer1); - hex_buffer1[Traits::hex_size] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::name, hex_buffer1); + hex_buffer1[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); // Send nonce - if (!ota->writeall_(reinterpret_cast(hex_buffer1), Traits::hex_size)) { - ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::name); + if (!ota->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { + ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::NAME); return false; } // Prepare challenge hasher.init(); hasher.add(password.c_str(), password.length()); - hasher.add(hex_buffer1, Traits::hex_size); // Add nonce + hasher.add(hex_buffer1, Traits::HEX_SIZE); // Add nonce // Receive cnonce into hex_buffer2 - if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::hex_size)) { - ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::name); + if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { + ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::NAME); return false; } - hex_buffer2[Traits::hex_size] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s CNonce is %s", Traits::name, hex_buffer2); + hex_buffer2[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s CNonce is %s", Traits::NAME, hex_buffer2); // Add cnonce to hash - hasher.add(hex_buffer2, Traits::hex_size); + hasher.add(hex_buffer2, Traits::HEX_SIZE); // Calculate result - reuse hex_buffer1 for expected hasher.calculate(); hasher.get_hex(hex_buffer1); - hex_buffer1[Traits::hex_size] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::name, hex_buffer1); + hex_buffer1[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::NAME, hex_buffer1); // Receive response - reuse hex_buffer2 - if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::hex_size)) { - ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::name); + if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { + ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::NAME); return false; } - hex_buffer2[Traits::hex_size] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Response is %s", Traits::name, hex_buffer2); + hex_buffer2[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Response is %s", Traits::NAME, hex_buffer2); // Compare - bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::hex_size) == 0; + bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::HEX_SIZE) == 0; if (!matches) { - ESP_LOGW("esphome.ota", "Auth failed! %s passwords do not match", Traits::name); + ESP_LOGW("esphome.ota", "Auth failed! %s passwords do not match", Traits::NAME); } return matches; From acb561633405ad3976bc425133f25bdaafbb496c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 17:00:03 -0600 Subject: [PATCH 1239/1626] make member --- esphome/components/esphome/ota/ota_esphome.cpp | 10 +++++----- esphome/components/esphome/ota/ota_esphome.h | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index d638b030e9..0eef0f0a57 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -120,7 +120,7 @@ template<> struct HashTraits { }; #endif -template bool perform_hash_auth(ESPHomeOTAComponent *ota, const std::string &password) { +template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &password) { using Traits = HashTraits; // Minimize stack usage by reusing buffers @@ -137,7 +137,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co // Send auth request type buf[0] = Traits::AUTH_REQUEST; - ota->writeall_(buf, 1); + this->writeall_(buf, 1); HashClass hasher; hasher.init(); @@ -173,7 +173,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); // Send nonce - if (!ota->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { + if (!this->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::NAME); return false; } @@ -184,7 +184,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co hasher.add(hex_buffer1, Traits::HEX_SIZE); // Add nonce // Receive cnonce into hex_buffer2 - if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { + if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::NAME); return false; } @@ -201,7 +201,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::NAME, hex_buffer1); // Receive response - reuse hex_buffer2 - if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { + if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::NAME); return false; } diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index f5a3e43ae3..8bfb1658b2 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -30,6 +30,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { protected: void handle_handshake_(); void handle_data_(); + template bool perform_hash_auth_(const std::string &password); bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); void log_socket_error_(const LogString *msg); From 110b364c1f1d9eeb9d2dc5206a3b8b3d6e089802 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 17:00:23 -0600 Subject: [PATCH 1240/1626] make member --- esphome/components/esphome/ota/ota_esphome.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 0eef0f0a57..cf845dd5c6 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -355,12 +355,12 @@ void ESPHomeOTAComponent::handle_data_() { if (use_sha256) { // Use SHA256 for authentication - auth_success = perform_hash_auth(this, this->password_); + auth_success = this->perform_hash_auth_(this->password_); } else #endif // USE_OTA_SHA256 { // Fall back to MD5 for backward compatibility (or when SHA256 is not available) - auth_success = perform_hash_auth(this, this->password_); + auth_success = this->perform_hash_auth_(this->password_); } if (!auth_success) { From 73e939dbbcc9cb12c578e092ec7990e93747322c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 19:09:32 -0500 Subject: [PATCH 1241/1626] [zephyr] Fix compilation after Path migration (#10811) --- esphome/components/zephyr/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index c698122030..ff4644163e 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path from typing import TypedDict import esphome.codegen as cg @@ -48,7 +48,7 @@ class ZephyrData(TypedDict): bootloader: str prj_conf: dict[str, tuple[PrjConfValueType, bool]] overlay: str - extra_build_files: dict[str, str] + extra_build_files: dict[str, Path] pm_static: list[Section] user: dict[str, list[str]] @@ -93,7 +93,7 @@ def zephyr_add_overlay(content): zephyr_data()[KEY_OVERLAY] += content -def add_extra_build_file(filename: str, path: str) -> bool: +def add_extra_build_file(filename: str, path: Path) -> bool: """Add an extra build file to the project.""" extra_build_files = zephyr_data()[KEY_EXTRA_BUILD_FILES] if filename not in extra_build_files: @@ -102,7 +102,7 @@ def add_extra_build_file(filename: str, path: str) -> bool: return False -def add_extra_script(stage: str, filename: str, path: str): +def add_extra_script(stage: str, filename: str, path: Path) -> None: """Add an extra script to the project.""" key = f"{stage}:{filename}" if add_extra_build_file(filename, path): @@ -144,7 +144,7 @@ def zephyr_to_code(config): add_extra_script( "pre", "pre_build.py", - os.path.join(os.path.dirname(__file__), "pre_build.py.script"), + Path(__file__).parent / "pre_build.py.script", ) From 971522574dd3bc45cd30ce1d699f2de59ef0717f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 19:10:02 -0500 Subject: [PATCH 1242/1626] [http_request] Fix Path object passed to C++ codegen (#10812) --- esphome/components/http_request/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 146458f53b..4884085099 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -194,7 +194,7 @@ async def to_code(config): cg.add_define("CPPHTTPLIB_OPENSSL_SUPPORT") elif path := config.get(CONF_CA_CERTIFICATE_PATH): cg.add_define("CPPHTTPLIB_OPENSSL_SUPPORT") - cg.add(var.set_ca_path(path)) + cg.add(var.set_ca_path(str(path))) cg.add_build_flag("-lssl") cg.add_build_flag("-lcrypto") From 6810e87fa7471027f02b3489ca10e95df5ec84ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 22:35:18 -0600 Subject: [PATCH 1243/1626] reorder --- .../components/esphome/ota/ota_esphome.cpp | 203 +++++++++--------- 1 file changed, 105 insertions(+), 98 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index cf845dd5c6..62cfe8d388 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -120,104 +120,6 @@ template<> struct HashTraits { }; #endif -template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &password) { - using Traits = HashTraits; - - // Minimize stack usage by reusing buffers - // We only need 2 buffers at most at the same time - constexpr size_t hex_buffer_size = Traits::HEX_SIZE + 1; - - // These two buffers are reused throughout the function - char hex_buffer1[hex_buffer_size]; // Used for: nonce -> expected result - char hex_buffer2[hex_buffer_size]; // Used for: cnonce -> response - - // Small stack buffer for auth request and nonce seed bytes - uint8_t buf[1]; - uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) - - // Send auth request type - buf[0] = Traits::AUTH_REQUEST; - this->writeall_(buf, 1); - - HashClass hasher; - hasher.init(); - - // Generate nonce seed bytes - uint32_t r1 = random_uint32(); - // Convert first uint32 to bytes (always needed for MD5) - nonce_bytes[0] = (r1 >> 24) & 0xFF; - nonce_bytes[1] = (r1 >> 16) & 0xFF; - nonce_bytes[2] = (r1 >> 8) & 0xFF; - nonce_bytes[3] = r1 & 0xFF; - - if (Traits::NONCE_SIZE == 8) { - // MD5: 8 chars = "%08x" format = 4 bytes from one random uint32 - hasher.add(nonce_bytes, 4); - } -#ifdef USE_OTA_SHA256 - else { - // SHA256: 16 chars = "%08x%08x" format = 8 bytes from two random uint32s - uint32_t r2 = random_uint32(); - nonce_bytes[4] = (r2 >> 24) & 0xFF; - nonce_bytes[5] = (r2 >> 16) & 0xFF; - nonce_bytes[6] = (r2 >> 8) & 0xFF; - nonce_bytes[7] = r2 & 0xFF; - hasher.add(nonce_bytes, 8); - } -#endif - hasher.calculate(); - - // Use hex_buffer1 for nonce - hasher.get_hex(hex_buffer1); - hex_buffer1[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); - - // Send nonce - if (!this->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { - ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::NAME); - return false; - } - - // Prepare challenge - hasher.init(); - hasher.add(password.c_str(), password.length()); - hasher.add(hex_buffer1, Traits::HEX_SIZE); // Add nonce - - // Receive cnonce into hex_buffer2 - if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { - ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::NAME); - return false; - } - hex_buffer2[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s CNonce is %s", Traits::NAME, hex_buffer2); - - // Add cnonce to hash - hasher.add(hex_buffer2, Traits::HEX_SIZE); - - // Calculate result - reuse hex_buffer1 for expected - hasher.calculate(); - hasher.get_hex(hex_buffer1); - hex_buffer1[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::NAME, hex_buffer1); - - // Receive response - reuse hex_buffer2 - if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { - ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::NAME); - return false; - } - hex_buffer2[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Response is %s", Traits::NAME, hex_buffer2); - - // Compare - bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::HEX_SIZE) == 0; - - if (!matches) { - ESP_LOGW("esphome.ota", "Auth failed! %s passwords do not match", Traits::NAME); - } - - return matches; -} - void ESPHomeOTAComponent::handle_handshake_() { /// Handle the initial OTA handshake. /// @@ -587,5 +489,110 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { delay(1); } +// Template function definition - placed at end to ensure all types are complete +template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &password) { + using Traits = HashTraits; + + // Minimize stack usage by reusing buffers + // We only need 2 buffers at most at the same time + constexpr size_t hex_buffer_size = Traits::HEX_SIZE + 1; + + // These two buffers are reused throughout the function + char hex_buffer1[hex_buffer_size]; // Used for: nonce -> expected result + char hex_buffer2[hex_buffer_size]; // Used for: cnonce -> response + + // Small stack buffer for auth request and nonce seed bytes + uint8_t buf[1]; + uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) + + // Send auth request type + buf[0] = Traits::AUTH_REQUEST; + this->writeall_(buf, 1); + + HashClass hasher; + hasher.init(); + + // Generate nonce seed bytes + uint32_t r1 = random_uint32(); + // Convert first uint32 to bytes (always needed for MD5) + nonce_bytes[0] = (r1 >> 24) & 0xFF; + nonce_bytes[1] = (r1 >> 16) & 0xFF; + nonce_bytes[2] = (r1 >> 8) & 0xFF; + nonce_bytes[3] = r1 & 0xFF; + + if (Traits::NONCE_SIZE == 8) { + // MD5: 8 chars = "%08x" format = 4 bytes from one random uint32 + hasher.add(nonce_bytes, 4); + } +#ifdef USE_OTA_SHA256 + else { + // SHA256: 16 chars = "%08x%08x" format = 8 bytes from two random uint32s + uint32_t r2 = random_uint32(); + nonce_bytes[4] = (r2 >> 24) & 0xFF; + nonce_bytes[5] = (r2 >> 16) & 0xFF; + nonce_bytes[6] = (r2 >> 8) & 0xFF; + nonce_bytes[7] = r2 & 0xFF; + hasher.add(nonce_bytes, 8); + } +#endif + hasher.calculate(); + + // Use hex_buffer1 for nonce + hasher.get_hex(hex_buffer1); + hex_buffer1[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); + + // Send nonce + if (!this->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { + ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::NAME); + return false; + } + + // Prepare challenge + hasher.init(); + hasher.add(password.c_str(), password.length()); + hasher.add(hex_buffer1, Traits::HEX_SIZE); // Add nonce + + // Receive cnonce into hex_buffer2 + if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { + ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::NAME); + return false; + } + hex_buffer2[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s CNonce is %s", Traits::NAME, hex_buffer2); + + // Add cnonce to hash + hasher.add(hex_buffer2, Traits::HEX_SIZE); + + // Calculate result - reuse hex_buffer1 for expected + hasher.calculate(); + hasher.get_hex(hex_buffer1); + hex_buffer1[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::NAME, hex_buffer1); + + // Receive response - reuse hex_buffer2 + if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { + ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::NAME); + return false; + } + hex_buffer2[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Response is %s", Traits::NAME, hex_buffer2); + + // Compare + bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::HEX_SIZE) == 0; + + if (!matches) { + ESP_LOGW("esphome.ota", "Auth failed! %s passwords do not match", Traits::NAME); + } + + return matches; +} + +// Explicit template instantiations +template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &); +#ifdef USE_OTA_SHA256 +template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &); +#endif + } // namespace esphome #endif From e49cbac46a627a5867f949a3334f745a5707f4cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 22:51:14 -0600 Subject: [PATCH 1244/1626] optimize --- esphome/components/sha256/sha256.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 699579251e..94f623f2fa 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -98,7 +98,9 @@ void SHA256::get_hex(char *output) { return; } for (size_t i = 0; i < 32; i++) { - sprintf(output + i * 2, "%02x", this->ctx_->hash[i]); + uint8_t byte = this->ctx_->hash[i]; + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); } } From dfc161b618eb7572a6abdab666ed88540e1c34cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 22:54:36 -0600 Subject: [PATCH 1245/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 62cfe8d388..fc1db50c05 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -540,11 +540,11 @@ template bool ESPHomeOTAComponent::perform_hash_auth_(const // Use hex_buffer1 for nonce hasher.get_hex(hex_buffer1); hex_buffer1[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); + ESP_LOGV(TAG, "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); // Send nonce if (!this->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { - ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::NAME); + ESP_LOGW(TAG, "Auth: Writing %s nonce failed", Traits::NAME); return false; } @@ -555,11 +555,11 @@ template bool ESPHomeOTAComponent::perform_hash_auth_(const // Receive cnonce into hex_buffer2 if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { - ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::NAME); + ESP_LOGW(TAG, "Auth: Reading %s cnonce failed", Traits::NAME); return false; } hex_buffer2[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s CNonce is %s", Traits::NAME, hex_buffer2); + ESP_LOGV(TAG, "Auth: %s CNonce is %s", Traits::NAME, hex_buffer2); // Add cnonce to hash hasher.add(hex_buffer2, Traits::HEX_SIZE); @@ -568,21 +568,21 @@ template bool ESPHomeOTAComponent::perform_hash_auth_(const hasher.calculate(); hasher.get_hex(hex_buffer1); hex_buffer1[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::NAME, hex_buffer1); + ESP_LOGV(TAG, "Auth: %s Result is %s", Traits::NAME, hex_buffer1); // Receive response - reuse hex_buffer2 if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { - ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::NAME); + ESP_LOGW(TAG, "Auth: Reading %s response failed", Traits::NAME); return false; } hex_buffer2[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Response is %s", Traits::NAME, hex_buffer2); + ESP_LOGV(TAG, "Auth: %s Response is %s", Traits::NAME, hex_buffer2); // Compare bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::HEX_SIZE) == 0; if (!matches) { - ESP_LOGW("esphome.ota", "Auth failed! %s passwords do not match", Traits::NAME); + ESP_LOGW(TAG, "Auth failed! %s passwords do not match", Traits::NAME); } return matches; From f171afca62dd9f4fee81427df5b36c99103320d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Sep 2025 07:03:48 -0600 Subject: [PATCH 1246/1626] move context to .h --- esphome/components/sha256/sha256.cpp | 16 ---------------- esphome/components/sha256/sha256.h | 21 ++++++++++++++++++++- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 94f623f2fa..a3e06cae2f 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -2,19 +2,9 @@ #include "esphome/core/helpers.h" #include -#ifdef USE_ESP32 -#include "mbedtls/sha256.h" -#elif defined(USE_ARDUINO) -#include -#endif - namespace esphome::sha256 { #ifdef USE_ESP32 -struct SHA256::SHA256Context { - mbedtls_sha256_context ctx; - uint8_t hash[32]; -}; SHA256::~SHA256() { if (this->ctx_) { @@ -46,12 +36,6 @@ void SHA256::calculate() { #elif defined(USE_ARDUINO) -struct SHA256::SHA256Context { - ::SHA256 sha; - uint8_t hash[32]; - bool calculated{false}; -}; - SHA256::~SHA256() = default; void SHA256::init() { diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index dd1742ea0d..5917f68572 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -5,6 +5,12 @@ #include #include +#ifdef USE_ESP32 +#include "mbedtls/sha256.h" +#elif defined(USE_ARDUINO) +#include +#endif + namespace esphome::sha256 { class SHA256 { @@ -27,7 +33,20 @@ class SHA256 { bool equals_hex(const char *expected); protected: - struct SHA256Context; +#ifdef USE_ESP32 + struct SHA256Context { + mbedtls_sha256_context ctx; + uint8_t hash[32]; + }; +#elif defined(USE_ARDUINO) + struct SHA256Context { + ::SHA256 sha; + uint8_t hash[32]; + bool calculated{false}; + }; +#else +#error "SHA256 not supported on this platform" +#endif std::unique_ptr ctx_; }; From ebdcb3e4d96bf0ce41796a37255145ff8cbac3ca Mon Sep 17 00:00:00 2001 From: esphomebot Date: Sun, 21 Sep 2025 01:09:21 +1200 Subject: [PATCH 1247/1626] Synchronise Device Classes from Home Assistant (#10803) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/number/__init__.py | 2 ++ esphome/components/sensor/__init__.py | 2 ++ esphome/const.py | 1 + 3 files changed, 5 insertions(+) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index c2cad2f7f1..76a7b05ea1 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -51,6 +51,7 @@ from esphome.const import ( DEVICE_CLASS_OZONE, DEVICE_CLASS_PH, DEVICE_CLASS_PM1, + DEVICE_CLASS_PM4, DEVICE_CLASS_PM10, DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, @@ -116,6 +117,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PM1, DEVICE_CLASS_PM10, DEVICE_CLASS_PM25, + DEVICE_CLASS_PM4, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_PRECIPITATION, diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index fe9822b3ca..2b99f68ac0 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -74,6 +74,7 @@ from esphome.const import ( DEVICE_CLASS_OZONE, DEVICE_CLASS_PH, DEVICE_CLASS_PM1, + DEVICE_CLASS_PM4, DEVICE_CLASS_PM10, DEVICE_CLASS_PM25, DEVICE_CLASS_POWER, @@ -143,6 +144,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PM1, DEVICE_CLASS_PM10, DEVICE_CLASS_PM25, + DEVICE_CLASS_PM4, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_PRECIPITATION, diff --git a/esphome/const.py b/esphome/const.py index c91c81f484..3e93200f14 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1269,6 +1269,7 @@ DEVICE_CLASS_PLUG = "plug" DEVICE_CLASS_PM1 = "pm1" DEVICE_CLASS_PM10 = "pm10" DEVICE_CLASS_PM25 = "pm25" +DEVICE_CLASS_PM4 = "pm4" DEVICE_CLASS_POWER = "power" DEVICE_CLASS_POWER_FACTOR = "power_factor" DEVICE_CLASS_PRECIPITATION = "precipitation" From d7245ebde6370084d67064be5ddbe58586c84463 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Sep 2025 07:55:53 -0600 Subject: [PATCH 1248/1626] try to make it work on 8266 --- esphome/components/sha256/sha256.cpp | 29 ++++++++++++++++++++++++++++ esphome/components/sha256/sha256.h | 8 ++++++++ 2 files changed, 37 insertions(+) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index a3e06cae2f..b1a949b504 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -34,6 +34,35 @@ void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_->ctx, this->ctx_->hash); } +#elif defined(USE_ESP8266) + +SHA256::~SHA256() = default; + +void SHA256::init() { + if (!this->ctx_) { + this->ctx_ = std::make_unique(); + } + br_sha256_init(&this->ctx_->ctx); + this->ctx_->calculated = false; +} + +void SHA256::add(const uint8_t *data, size_t len) { + if (!this->ctx_) { + this->init(); + } + br_sha256_update(&this->ctx_->ctx, data, len); +} + +void SHA256::calculate() { + if (!this->ctx_) { + this->init(); + } + if (!this->ctx_->calculated) { + br_sha256_out(&this->ctx_->ctx, this->ctx_->hash); + this->ctx_->calculated = true; + } +} + #elif defined(USE_ARDUINO) SHA256::~SHA256() = default; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 5917f68572..b047cb66e7 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -7,6 +7,8 @@ #ifdef USE_ESP32 #include "mbedtls/sha256.h" +#elif defined(USE_ESP8266) +#include #elif defined(USE_ARDUINO) #include #endif @@ -38,6 +40,12 @@ class SHA256 { mbedtls_sha256_context ctx; uint8_t hash[32]; }; +#elif defined(USE_ESP8266) + struct SHA256Context { + br_sha256_context ctx; + uint8_t hash[32]; + bool calculated{false}; + }; #elif defined(USE_ARDUINO) struct SHA256Context { ::SHA256 sha; From cebacfcc5931b3ab94e622671cad021602cb7bf9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Sep 2025 08:52:06 -0600 Subject: [PATCH 1249/1626] fix rp2040 --- esphome/components/sha256/sha256.cpp | 2 +- esphome/components/sha256/sha256.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index b1a949b504..cf3cfb1a30 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -34,7 +34,7 @@ void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_->ctx, this->ctx_->hash); } -#elif defined(USE_ESP8266) +#elif defined(USE_ESP8266) || defined(USE_RP2040) SHA256::~SHA256() = default; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index b047cb66e7..5f56d7542a 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -7,7 +7,7 @@ #ifdef USE_ESP32 #include "mbedtls/sha256.h" -#elif defined(USE_ESP8266) +#elif defined(USE_ESP8266) || defined(USE_RP2040) #include #elif defined(USE_ARDUINO) #include @@ -40,7 +40,7 @@ class SHA256 { mbedtls_sha256_context ctx; uint8_t hash[32]; }; -#elif defined(USE_ESP8266) +#elif defined(USE_ESP8266) || defined(USE_RP2040) struct SHA256Context { br_sha256_context ctx; uint8_t hash[32]; From 8da77059277c6f7859b41542acfb5dc49380e28a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Sep 2025 18:29:17 -0600 Subject: [PATCH 1250/1626] fix nrf52 --- esphome/components/sha256/sha256.cpp | 6 ++++++ esphome/components/sha256/sha256.h | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index cf3cfb1a30..71e4045499 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -1,4 +1,8 @@ #include "sha256.h" + +// Only compile SHA256 implementation on platforms that support it +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) + #include "esphome/core/helpers.h" #include @@ -142,3 +146,5 @@ bool SHA256::equals_hex(const char *expected) { } } // namespace esphome::sha256 + +#endif // Platform check diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 5f56d7542a..2a7aa72183 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -1,6 +1,10 @@ #pragma once #include "esphome/core/defines.h" + +// Only define SHA256 on platforms that support it +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) + #include #include #include @@ -59,3 +63,5 @@ class SHA256 { }; } // namespace esphome::sha256 + +#endif // Platform check From e3b64103cc2fb4c6365c838d39527f8cd439a87f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 20 Sep 2025 22:23:33 -0400 Subject: [PATCH 1251/1626] [sensirion] Fix warning (#10813) --- esphome/components/sensirion_common/i2c_sensirion.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/sensirion_common/i2c_sensirion.cpp b/esphome/components/sensirion_common/i2c_sensirion.cpp index 22c4b0e53c..9eac6b4525 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.cpp +++ b/esphome/components/sensirion_common/i2c_sensirion.cpp @@ -76,7 +76,8 @@ bool SensirionI2CDevice::write_command_(uint16_t command, CommandLen command_len temp[raw_idx++] = data[i] >> 8; #endif // Use MSB first since Sensirion devices use CRC-8 with MSB first - temp[raw_idx++] = crc8(&temp[raw_idx - 2], 2, 0xFF, CRC_POLYNOMIAL, true); + uint8_t crc = crc8(&temp[raw_idx - 2], 2, 0xFF, CRC_POLYNOMIAL, true); + temp[raw_idx++] = crc; } this->last_error_ = this->write(temp, raw_idx); return this->last_error_ == i2c::ERROR_OK; From a81985bfbaa515398e72a9d79435a6bf627e23a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:01:07 -0600 Subject: [PATCH 1252/1626] cleanup --- .../components/esphome/ota/ota_esphome.cpp | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index fc1db50c05..0ce7f18f96 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -252,19 +252,40 @@ void ESPHomeOTAComponent::handle_data_() { bool auth_success = false; #ifdef USE_OTA_SHA256 - // Check if client supports SHA256 auth - bool use_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + // SECURITY HARDENING: Enforce SHA256 authentication on platforms that support it. + // + // This is a hardening measure to prevent future downgrade attacks where an attacker + // could force the use of MD5 authentication by manipulating the feature flags. + // + // While MD5 is currently still acceptable for our OTA authentication use case + // (where the password is a shared secret and we're only authenticating, not + // encrypting), at some point in the future MD5 will likely become so weak that + // it could be practically attacked. + // + // We enforce SHA256 now on capable platforms because: + // 1. We can't retroactively update device firmware in the field + // 2. Clients (like esphome CLI) can always be updated to support SHA256 + // 3. This prevents any possibility of downgrade attacks in the future + // + // Devices that don't support SHA256 (due to platform limitations) will + // continue to use MD5 as their only option (see #else branch below). - if (use_sha256) { - // Use SHA256 for authentication - auth_success = this->perform_hash_auth_(this->password_); - } else -#endif // USE_OTA_SHA256 - { - // Fall back to MD5 for backward compatibility (or when SHA256 is not available) - auth_success = this->perform_hash_auth_(this->password_); + bool client_supports_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + + if (!client_supports_sha256) { + ESP_LOGW(TAG, "Client requires SHA256"); + error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) } + // Use SHA256 for authentication (mandatory on platforms that support it) + auth_success = this->perform_hash_auth_(this->password_); +#else + // Platform only supports MD5 - use it as the only available option + // This is not a security downgrade as the platform cannot support SHA256 + auth_success = this->perform_hash_auth_(this->password_); +#endif // USE_OTA_SHA256 + if (!auth_success) { error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; goto error; // NOLINT(cppcoreguidelines-avoid-goto) From 0ddd1037ca7fc6255cef82b5e6754cd73e961b0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:05:40 -0600 Subject: [PATCH 1253/1626] cleanup --- .../components/esphome/ota/ota_esphome.cpp | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 0ce7f18f96..8cd4152f6e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -102,6 +102,12 @@ static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; #endif +// Temporary flag to allow MD5 downgrade for ~3 versions (until 2026.1.0) +// This allows users to downgrade via OTA if they encounter issues after updating. +// Without this, users would need to do a serial flash to downgrade. +// TODO: Remove this flag and all associated code in 2026.1.0 +#define ALLOW_OTA_DOWNGRADE_MD5 + template struct HashTraits; template<> struct HashTraits { @@ -252,7 +258,7 @@ void ESPHomeOTAComponent::handle_data_() { bool auth_success = false; #ifdef USE_OTA_SHA256 - // SECURITY HARDENING: Enforce SHA256 authentication on platforms that support it. + // SECURITY HARDENING: Prefer SHA256 authentication on platforms that support it. // // This is a hardening measure to prevent future downgrade attacks where an attacker // could force the use of MD5 authentication by manipulating the feature flags. @@ -272,14 +278,25 @@ void ESPHomeOTAComponent::handle_data_() { bool client_supports_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; +#ifdef ALLOW_OTA_DOWNGRADE_MD5 + // Temporary compatibility mode: Allow MD5 for ~3 versions to enable OTA downgrades + // This prevents users from being locked out if they need to downgrade after updating + // TODO: Remove this entire ifdef block in 2026.1.0 + if (client_supports_sha256) { + auth_success = this->perform_hash_auth_(this->password_); + } else { + ESP_LOGW(TAG, "Using MD5 auth for compatibility (deprecated)"); + auth_success = this->perform_hash_auth_(this->password_); + } +#else + // Strict mode: SHA256 required on capable platforms (future default) if (!client_supports_sha256) { ESP_LOGW(TAG, "Client requires SHA256"); error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - - // Use SHA256 for authentication (mandatory on platforms that support it) auth_success = this->perform_hash_auth_(this->password_); +#endif // ALLOW_OTA_DOWNGRADE_MD5 #else // Platform only supports MD5 - use it as the only available option // This is not a security downgrade as the platform cannot support SHA256 From 139577f96a174db845c53503a6729f62ee6adedb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:48:56 -0600 Subject: [PATCH 1254/1626] cleanup --- tests/unit_tests/test_espota2.py | 537 +++++++++++++++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 tests/unit_tests/test_espota2.py diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py new file mode 100644 index 0000000000..80fb804bc8 --- /dev/null +++ b/tests/unit_tests/test_espota2.py @@ -0,0 +1,537 @@ +"""Unit tests for esphome.espota2 module.""" + +from __future__ import annotations + +import gzip +import hashlib +import io +import socket +from unittest.mock import MagicMock, Mock, call, patch + +import pytest + +from esphome import espota2 +from esphome.core import EsphomeError + + +def test_recv_decode_with_decode() -> None: + """Test recv_decode with decode=True returns list.""" + mock_socket = Mock() + mock_socket.recv.return_value = b"\x01\x02\x03" + + result = espota2.recv_decode(mock_socket, 3, decode=True) + + assert result == [1, 2, 3] + mock_socket.recv.assert_called_once_with(3) + + +def test_recv_decode_without_decode() -> None: + """Test recv_decode with decode=False returns bytes.""" + mock_socket = Mock() + mock_socket.recv.return_value = b"\x01\x02\x03" + + result = espota2.recv_decode(mock_socket, 3, decode=False) + + assert result == b"\x01\x02\x03" + mock_socket.recv.assert_called_once_with(3) + + +def test_receive_exactly_success() -> None: + """Test receive_exactly successfully receives expected data.""" + mock_socket = Mock() + mock_socket.recv.side_effect = [b"\x00", b"\x01\x02"] + + result = espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) + + assert result == [0, 1, 2] + assert mock_socket.recv.call_count == 2 + + +def test_receive_exactly_with_error_response() -> None: + """Test receive_exactly raises OTAError on error response.""" + mock_socket = Mock() + mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]) + mock_socket.close = Mock() + + with pytest.raises(espota2.OTAError, match="Error auth:.*Authentication invalid"): + espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) + + mock_socket.close.assert_called_once() + + +def test_receive_exactly_socket_error() -> None: + """Test receive_exactly handles socket errors.""" + mock_socket = Mock() + mock_socket.recv.side_effect = OSError("Connection reset") + + with pytest.raises(espota2.OTAError, match="Error receiving acknowledge test"): + espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) + + +@pytest.mark.parametrize( + ("error_code", "expected_msg"), + [ + (espota2.RESPONSE_ERROR_MAGIC, "Error: Invalid magic byte"), + (espota2.RESPONSE_ERROR_UPDATE_PREPARE, "Error: Couldn't prepare flash memory"), + (espota2.RESPONSE_ERROR_AUTH_INVALID, "Error: Authentication invalid"), + ( + espota2.RESPONSE_ERROR_WRITING_FLASH, + "Error: Wring OTA data to flash memory failed", + ), + (espota2.RESPONSE_ERROR_UPDATE_END, "Error: Finishing update failed"), + ( + espota2.RESPONSE_ERROR_INVALID_BOOTSTRAPPING, + "Error: Please press the reset button", + ), + ( + espota2.RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG, + "Error: ESP has been flashed with wrong flash size", + ), + ( + espota2.RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG, + "Error: ESP does not have the requested flash size", + ), + ( + espota2.RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE, + "Error: ESP does not have enough space", + ), + ( + espota2.RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE, + "Error: The OTA partition on the ESP is too small", + ), + ( + espota2.RESPONSE_ERROR_NO_UPDATE_PARTITION, + "Error: The OTA partition on the ESP couldn't be found", + ), + (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"), + (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), + ], +) +def test_check_error_with_various_errors(error_code: int, expected_msg: str) -> None: + """Test check_error raises appropriate errors for different error codes.""" + with pytest.raises(espota2.OTAError, match=expected_msg): + espota2.check_error([error_code], [espota2.RESPONSE_OK]) + + +def test_check_error_unexpected_response() -> None: + """Test check_error raises error for unexpected response.""" + with pytest.raises(espota2.OTAError, match="Unexpected response from ESP: 0x7F"): + espota2.check_error([0x7F], [espota2.RESPONSE_OK, espota2.RESPONSE_AUTH_OK]) + + +def test_send_check_with_various_data_types() -> None: + """Test send_check handles different data types.""" + mock_socket = Mock() + + # Test with list/tuple + espota2.send_check(mock_socket, [0x01, 0x02], "list") + mock_socket.sendall.assert_called_with(b"\x01\x02") + + # Test with int + espota2.send_check(mock_socket, 0x42, "int") + mock_socket.sendall.assert_called_with(b"\x42") + + # Test with string + espota2.send_check(mock_socket, "hello", "string") + mock_socket.sendall.assert_called_with(b"hello") + + # Test with bytes (should pass through) + espota2.send_check(mock_socket, b"\xaa\xbb", "bytes") + mock_socket.sendall.assert_called_with(b"\xaa\xbb") + + +def test_send_check_socket_error() -> None: + """Test send_check handles socket errors.""" + mock_socket = Mock() + mock_socket.sendall.side_effect = OSError("Broken pipe") + + with pytest.raises(espota2.OTAError, match="Error sending test"): + espota2.send_check(mock_socket, b"data", "test") + + +def test_perform_ota_successful_md5_auth() -> None: + """Test successful OTA with MD5 authentication.""" + mock_socket = Mock() + mock_file = io.BytesIO(b"firmware content here") + + # Mock random for predictable cnonce + with ( + patch("random.random", return_value=0.123456), + patch("time.sleep"), + patch("time.perf_counter", side_effect=[0, 1]), + ): + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request + b"12345678901234567890123456789012", # 32 char hex nonce + bytes([espota2.RESPONSE_AUTH_OK]), # Auth result + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + # Run OTA + espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) + + # Verify features were sent + assert mock_socket.sendall.call_args_list[1] == call( + bytes([espota2.FEATURE_SUPPORTS_COMPRESSION]) + ) + + # Verify cnonce was sent (MD5 of random.random()) + cnonce = hashlib.md5(b"0.123456").hexdigest() + assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) + + # Verify auth result was computed correctly + expected_hash = hashlib.md5() + expected_hash.update(b"testpass") + expected_hash.update(b"12345678901234567890123456789012") + expected_hash.update(cnonce.encode()) + expected_result = expected_hash.hexdigest() + assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) + + +def test_perform_ota_no_auth() -> None: + """Test OTA without authentication.""" + mock_socket = Mock() + mock_file = io.BytesIO(b"firmware") + + with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_1_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + # Should not send any auth-related data + auth_calls = [ + call + for call in mock_socket.sendall.call_args_list + if "cnonce" in str(call) or "result" in str(call) + ] + assert len(auth_calls) == 0 + + +def test_perform_ota_with_compression() -> None: + """Test OTA with compression support.""" + mock_socket = Mock() + original_content = b"firmware" * 100 # Repeating content for compression + mock_file = io.BytesIO(original_content) + + with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes( + [espota2.RESPONSE_SUPPORTS_COMPRESSION] + ), # Device supports compression + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + # Verify compressed content was sent + # Get the binary size that was sent (4 bytes after features) + size_bytes = mock_socket.sendall.call_args_list[2][0][0] + sent_size = ( + (size_bytes[0] << 24) + | (size_bytes[1] << 16) + | (size_bytes[2] << 8) + | size_bytes[3] + ) + + # Size should be less than original due to compression + assert sent_size < len(original_content) + + # Verify the content sent was gzipped + compressed = gzip.compress(original_content, compresslevel=9) + assert sent_size == len(compressed) + + +def test_perform_ota_auth_without_password() -> None: + """Test OTA fails when auth is required but no password provided.""" + mock_socket = Mock() + mock_file = io.BytesIO(b"firmware") + + responses = [ + bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_HEADER_OK]), + bytes([espota2.RESPONSE_REQUEST_AUTH]), + ] + + mock_socket.recv.side_effect = responses + + with pytest.raises( + espota2.OTAError, match="ESP requests password, but no password given" + ): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + +def test_perform_ota_unsupported_version() -> None: + """Test OTA fails with unsupported version.""" + mock_socket = Mock() + mock_file = io.BytesIO(b"firmware") + + responses = [ + bytes([espota2.RESPONSE_OK, 99]), # Unsupported version + ] + + mock_socket.recv.side_effect = responses + + with pytest.raises(espota2.OTAError, match="Device uses unsupported OTA version"): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + +def test_perform_ota_upload_error() -> None: + """Test OTA handles upload errors.""" + mock_socket = Mock() + mock_file = io.BytesIO(b"firmware") + + with patch("time.perf_counter", side_effect=[0, 1]): + # Setup responses - provide enough for the recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + # Add OSError to recv to simulate connection loss during chunk read + recv_responses.append(OSError("Connection lost")) + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises( + espota2.OTAError, match="Error receiving acknowledge chunk OK" + ): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + +def test_run_ota_impl_successful() -> None: + """Test run_ota_impl_ with successful upload.""" + mock_socket = Mock() + + with ( + patch("socket.socket", return_value=mock_socket), + patch("esphome.espota2.resolve_ip_address") as mock_resolve, + patch("builtins.open", create=True) as mock_open, + patch("esphome.espota2.perform_ota") as mock_perform, + ): + # Setup mocks + mock_resolve.return_value = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) + ] + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + + # Run OTA + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", "firmware.bin" + ) + + # Verify success + assert result_code == 0 + assert result_host == "192.168.1.100" + + # Verify socket was configured correctly + mock_socket.settimeout.assert_called_with(10.0) + mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232)) + mock_socket.close.assert_called_once() + + # Verify perform_ota was called + mock_perform.assert_called_once_with( + mock_socket, "password", mock_file, "firmware.bin" + ) + + +def test_run_ota_impl_connection_failed() -> None: + """Test run_ota_impl_ when connection fails.""" + mock_socket = Mock() + mock_socket.connect.side_effect = OSError("Connection refused") + + with ( + patch("socket.socket", return_value=mock_socket), + patch("esphome.espota2.resolve_ip_address") as mock_resolve, + ): + mock_resolve.return_value = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", "firmware.bin" + ) + + assert result_code == 1 + assert result_host is None + mock_socket.close.assert_called_once() + + +def test_run_ota_impl_resolve_failed() -> None: + """Test run_ota_impl_ when DNS resolution fails.""" + with patch("esphome.espota2.resolve_ip_address") as mock_resolve: + mock_resolve.side_effect = EsphomeError("DNS resolution failed") + + with pytest.raises(espota2.OTAError, match="DNS resolution failed"): + result_code, result_host = espota2.run_ota_impl_( + "unknown.host", 3232, "password", "firmware.bin" + ) + + +def test_run_ota_wrapper() -> None: + """Test run_ota wrapper function.""" + with patch("esphome.espota2.run_ota_impl_") as mock_impl: + # Test successful case + mock_impl.return_value = (0, "192.168.1.100") + result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") + assert result == (0, "192.168.1.100") + + # Test error case + mock_impl.side_effect = espota2.OTAError("Test error") + result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") + assert result == (1, None) + + +def test_progress_bar() -> None: + """Test ProgressBar functionality.""" + with ( + patch("sys.stderr.write") as mock_write, + patch("sys.stderr.flush"), + ): + progress = espota2.ProgressBar() + + # Test initial update + progress.update(0.0) + assert mock_write.called + assert "0%" in mock_write.call_args[0][0] + + # Test progress update + mock_write.reset_mock() + progress.update(0.5) + assert "50%" in mock_write.call_args[0][0] + + # Test completion + mock_write.reset_mock() + progress.update(1.0) + assert "100%" in mock_write.call_args[0][0] + assert "Done" in mock_write.call_args[0][0] + + # Test done method + mock_write.reset_mock() + progress.done() + assert mock_write.call_args[0][0] == "\n" + + # Test same progress doesn't update + mock_write.reset_mock() + progress.update(0.5) + progress.update(0.5) + assert mock_write.call_count == 1 # Only called once + + +# Tests for SHA256 authentication (for when PR is merged) +def test_perform_ota_successful_sha256_auth() -> None: + """Test successful OTA with SHA256 authentication (future support).""" + mock_socket = Mock() + + # Mock random for predictable cnonce + with patch("random.random", return_value=0.123456): + # Constants for SHA256 auth (when implemented) + RESPONSE_REQUEST_SHA256_AUTH = 0x02 # From PR + + # Setup socket responses + responses = [ + # Version handshake + bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), + # Features response + bytes([espota2.RESPONSE_HEADER_OK]), + # SHA256 Auth request + bytes([RESPONSE_REQUEST_SHA256_AUTH]), + # Nonce from device (64 chars for SHA256) + b"1234567890123456789012345678901234567890123456789012345678901234", + # Auth result + bytes([espota2.RESPONSE_AUTH_OK]), + # Binary size OK + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), + # MD5 checksum OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), + # Chunk OK + bytes([espota2.RESPONSE_CHUNK_OK]), + bytes([espota2.RESPONSE_CHUNK_OK]), + bytes([espota2.RESPONSE_CHUNK_OK]), + # Receive OK + bytes([espota2.RESPONSE_RECEIVE_OK]), + # Update end OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), + ] + + mock_socket.recv.side_effect = responses + + # When SHA256 is implemented, this test will verify: + # 1. Client sends FEATURE_SUPPORTS_SHA256_AUTH flag + # 2. Device responds with RESPONSE_REQUEST_SHA256_AUTH + # 3. Authentication uses SHA256 instead of MD5 + # 4. Nonce is 64 characters instead of 32 + + # For now, this would raise an error since SHA256 isn't implemented + # Once implemented, uncomment to test: + # espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + + +def test_perform_ota_sha256_fallback_to_md5() -> None: + """Test SHA256-capable client falls back to MD5 for compatibility.""" + # This test verifies the temporary backward compatibility + # where a SHA256-capable client can still authenticate with MD5 + # This compatibility will be removed in 2026.1.0 according to PR + pass # Implementation depends on final PR merge + + +def test_perform_ota_version_differences() -> None: + """Test OTA behavior differences between version 1.0 and 2.0.""" + mock_socket = Mock() + mock_file = io.BytesIO(b"firmware") + + with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): + # Test version 1.0 - no chunk acknowledgments + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_1_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + # No RESPONSE_CHUNK_OK for v1 + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + # Verify no chunk acknowledgments were expected + # (implementation detail - v1 doesn't wait for chunk OK) From 6c8b66df96146c5b1e5c30e337d9b02fb0aa337d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:52:39 -0600 Subject: [PATCH 1255/1626] cleanup --- tests/unit_tests/test_espota2.py | 64 ++++++++++++++++---------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 80fb804bc8..fd46ae3b49 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -14,9 +14,20 @@ from esphome import espota2 from esphome.core import EsphomeError -def test_recv_decode_with_decode() -> None: +@pytest.fixture +def mock_socket(): + """Create a mock socket for testing.""" + socket = Mock() + socket.close = Mock() + socket.recv = Mock() + socket.sendall = Mock() + socket.settimeout = Mock() + socket.connect = Mock() + return socket + + +def test_recv_decode_with_decode(mock_socket) -> None: """Test recv_decode with decode=True returns list.""" - mock_socket = Mock() mock_socket.recv.return_value = b"\x01\x02\x03" result = espota2.recv_decode(mock_socket, 3, decode=True) @@ -25,9 +36,8 @@ def test_recv_decode_with_decode() -> None: mock_socket.recv.assert_called_once_with(3) -def test_recv_decode_without_decode() -> None: +def test_recv_decode_without_decode(mock_socket) -> None: """Test recv_decode with decode=False returns bytes.""" - mock_socket = Mock() mock_socket.recv.return_value = b"\x01\x02\x03" result = espota2.recv_decode(mock_socket, 3, decode=False) @@ -36,9 +46,8 @@ def test_recv_decode_without_decode() -> None: mock_socket.recv.assert_called_once_with(3) -def test_receive_exactly_success() -> None: +def test_receive_exactly_success(mock_socket) -> None: """Test receive_exactly successfully receives expected data.""" - mock_socket = Mock() mock_socket.recv.side_effect = [b"\x00", b"\x01\x02"] result = espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) @@ -47,11 +56,9 @@ def test_receive_exactly_success() -> None: assert mock_socket.recv.call_count == 2 -def test_receive_exactly_with_error_response() -> None: +def test_receive_exactly_with_error_response(mock_socket) -> None: """Test receive_exactly raises OTAError on error response.""" - mock_socket = Mock() mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]) - mock_socket.close = Mock() with pytest.raises(espota2.OTAError, match="Error auth:.*Authentication invalid"): espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) @@ -59,9 +66,8 @@ def test_receive_exactly_with_error_response() -> None: mock_socket.close.assert_called_once() -def test_receive_exactly_socket_error() -> None: +def test_receive_exactly_socket_error(mock_socket) -> None: """Test receive_exactly handles socket errors.""" - mock_socket = Mock() mock_socket.recv.side_effect = OSError("Connection reset") with pytest.raises(espota2.OTAError, match="Error receiving acknowledge test"): @@ -119,9 +125,8 @@ def test_check_error_unexpected_response() -> None: espota2.check_error([0x7F], [espota2.RESPONSE_OK, espota2.RESPONSE_AUTH_OK]) -def test_send_check_with_various_data_types() -> None: +def test_send_check_with_various_data_types(mock_socket) -> None: """Test send_check handles different data types.""" - mock_socket = Mock() # Test with list/tuple espota2.send_check(mock_socket, [0x01, 0x02], "list") @@ -140,18 +145,16 @@ def test_send_check_with_various_data_types() -> None: mock_socket.sendall.assert_called_with(b"\xaa\xbb") -def test_send_check_socket_error() -> None: +def test_send_check_socket_error(mock_socket) -> None: """Test send_check handles socket errors.""" - mock_socket = Mock() mock_socket.sendall.side_effect = OSError("Broken pipe") with pytest.raises(espota2.OTAError, match="Error sending test"): espota2.send_check(mock_socket, b"data", "test") -def test_perform_ota_successful_md5_auth() -> None: +def test_perform_ota_successful_md5_auth(mock_socket) -> None: """Test successful OTA with MD5 authentication.""" - mock_socket = Mock() mock_file = io.BytesIO(b"firmware content here") # Mock random for predictable cnonce @@ -183,9 +186,14 @@ def test_perform_ota_successful_md5_auth() -> None: # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify features were sent + # Verify features were sent (compression + SHA256 support) assert mock_socket.sendall.call_args_list[1] == call( - bytes([espota2.FEATURE_SUPPORTS_COMPRESSION]) + bytes( + [ + espota2.FEATURE_SUPPORTS_COMPRESSION + | espota2.FEATURE_SUPPORTS_SHA256_AUTH + ] + ) ) # Verify cnonce was sent (MD5 of random.random()) @@ -201,9 +209,8 @@ def test_perform_ota_successful_md5_auth() -> None: assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) -def test_perform_ota_no_auth() -> None: +def test_perform_ota_no_auth(mock_socket) -> None: """Test OTA without authentication.""" - mock_socket = Mock() mock_file = io.BytesIO(b"firmware") with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): @@ -231,9 +238,8 @@ def test_perform_ota_no_auth() -> None: assert len(auth_calls) == 0 -def test_perform_ota_with_compression() -> None: +def test_perform_ota_with_compression(mock_socket) -> None: """Test OTA with compression support.""" - mock_socket = Mock() original_content = b"firmware" * 100 # Repeating content for compression mock_file = io.BytesIO(original_content) @@ -274,9 +280,8 @@ def test_perform_ota_with_compression() -> None: assert sent_size == len(compressed) -def test_perform_ota_auth_without_password() -> None: +def test_perform_ota_auth_without_password(mock_socket) -> None: """Test OTA fails when auth is required but no password provided.""" - mock_socket = Mock() mock_file = io.BytesIO(b"firmware") responses = [ @@ -293,9 +298,8 @@ def test_perform_ota_auth_without_password() -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_perform_ota_unsupported_version() -> None: +def test_perform_ota_unsupported_version(mock_socket) -> None: """Test OTA fails with unsupported version.""" - mock_socket = Mock() mock_file = io.BytesIO(b"firmware") responses = [ @@ -308,9 +312,8 @@ def test_perform_ota_unsupported_version() -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_perform_ota_upload_error() -> None: +def test_perform_ota_upload_error(mock_socket) -> None: """Test OTA handles upload errors.""" - mock_socket = Mock() mock_file = io.BytesIO(b"firmware") with patch("time.perf_counter", side_effect=[0, 1]): @@ -511,9 +514,8 @@ def test_perform_ota_sha256_fallback_to_md5() -> None: pass # Implementation depends on final PR merge -def test_perform_ota_version_differences() -> None: +def test_perform_ota_version_differences(mock_socket) -> None: """Test OTA behavior differences between version 1.0 and 2.0.""" - mock_socket = Mock() mock_file = io.BytesIO(b"firmware") with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): From 1d6c6c917af8ea98fafcb3f7c45c858dabf75082 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:54:17 -0600 Subject: [PATCH 1256/1626] cleanup --- tests/unit_tests/test_espota2.py | 61 +++++++++++++++++--------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index fd46ae3b49..119f024cbc 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -6,16 +6,21 @@ import gzip import hashlib import io import socket +from typing import TYPE_CHECKING from unittest.mock import MagicMock, Mock, call, patch import pytest +from pytest import CaptureFixture from esphome import espota2 from esphome.core import EsphomeError +if TYPE_CHECKING: + from unittest.mock import Mock as MockType + @pytest.fixture -def mock_socket(): +def mock_socket() -> MockType: """Create a mock socket for testing.""" socket = Mock() socket.close = Mock() @@ -421,40 +426,38 @@ def test_run_ota_wrapper() -> None: assert result == (1, None) -def test_progress_bar() -> None: +def test_progress_bar(capsys: CaptureFixture[str]) -> None: """Test ProgressBar functionality.""" - with ( - patch("sys.stderr.write") as mock_write, - patch("sys.stderr.flush"), - ): - progress = espota2.ProgressBar() + progress = espota2.ProgressBar() - # Test initial update - progress.update(0.0) - assert mock_write.called - assert "0%" in mock_write.call_args[0][0] + # Test initial update + progress.update(0.0) + captured = capsys.readouterr() + assert "0%" in captured.err + assert "[" in captured.err - # Test progress update - mock_write.reset_mock() - progress.update(0.5) - assert "50%" in mock_write.call_args[0][0] + # Test progress update + progress.update(0.5) + captured = capsys.readouterr() + assert "50%" in captured.err - # Test completion - mock_write.reset_mock() - progress.update(1.0) - assert "100%" in mock_write.call_args[0][0] - assert "Done" in mock_write.call_args[0][0] + # Test completion + progress.update(1.0) + captured = capsys.readouterr() + assert "100%" in captured.err + assert "Done" in captured.err - # Test done method - mock_write.reset_mock() - progress.done() - assert mock_write.call_args[0][0] == "\n" + # Test done method + progress.done() + captured = capsys.readouterr() + assert captured.err == "\n" - # Test same progress doesn't update - mock_write.reset_mock() - progress.update(0.5) - progress.update(0.5) - assert mock_write.call_count == 1 # Only called once + # Test same progress doesn't update + progress.update(0.5) + progress.update(0.5) + captured = capsys.readouterr() + # Should only see one update (second call shouldn't write) + assert captured.err.count("50%") == 1 # Tests for SHA256 authentication (for when PR is merged) From e2fd5190c2226cbfde398cead3865cf698ca4be3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:55:01 -0600 Subject: [PATCH 1257/1626] cleanup --- tests/unit_tests/test_espota2.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 119f024cbc..e1b3de6f97 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -31,6 +31,29 @@ def mock_socket() -> MockType: return socket +@pytest.fixture +def mock_file() -> io.BytesIO: + """Create a mock firmware file for testing.""" + return io.BytesIO(b"firmware content here") + + +@pytest.fixture +def mock_time(): + """Mock time-related functions for consistent testing.""" + with ( + patch("time.sleep"), + patch("time.perf_counter", side_effect=[0, 1]), + ) as mocks: + yield mocks + + +@pytest.fixture +def mock_random(): + """Mock random for predictable test values.""" + with patch("random.random", return_value=0.123456) as mock_rand: + yield mock_rand + + def test_recv_decode_with_decode(mock_socket) -> None: """Test recv_decode with decode=True returns list.""" mock_socket.recv.return_value = b"\x01\x02\x03" From 0b0eb5d4bf8447442b67672c38851d0337cac186 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:56:22 -0600 Subject: [PATCH 1258/1626] cleanup --- tests/unit_tests/test_espota2.py | 79 ++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 18 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index e1b3de6f97..6bfd20a0b2 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -54,6 +54,40 @@ def mock_random(): yield mock_rand +@pytest.fixture +def mock_resolve_ip(): + """Mock resolve_ip_address for testing.""" + with patch("esphome.espota2.resolve_ip_address") as mock: + mock.return_value = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) + ] + yield mock + + +@pytest.fixture +def mock_perform_ota(): + """Mock perform_ota function for testing.""" + with patch("esphome.espota2.perform_ota") as mock: + yield mock + + +@pytest.fixture +def mock_run_ota_impl(): + """Mock run_ota_impl_ function for testing.""" + with patch("esphome.espota2.run_ota_impl_") as mock: + mock.return_value = (0, "192.168.1.100") + yield mock + + +@pytest.fixture +def mock_open_file(): + """Mock file opening for testing.""" + with patch("builtins.open", create=True) as mock_open: + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + yield mock_open, mock_file + + def test_recv_decode_with_decode(mock_socket) -> None: """Test recv_decode with decode=True returns list.""" mock_socket.recv.return_value = b"\x01\x02\x03" @@ -365,26 +399,25 @@ def test_perform_ota_upload_error(mock_socket) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_run_ota_impl_successful() -> None: +def test_run_ota_impl_successful(mock_socket, tmp_path) -> None: """Test run_ota_impl_ with successful upload.""" - mock_socket = Mock() + # Create a real firmware file + firmware_file = tmp_path / "firmware.bin" + firmware_file.write_bytes(b"firmware content") with ( patch("socket.socket", return_value=mock_socket), patch("esphome.espota2.resolve_ip_address") as mock_resolve, - patch("builtins.open", create=True) as mock_open, patch("esphome.espota2.perform_ota") as mock_perform, ): # Setup mocks mock_resolve.return_value = [ (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) ] - mock_file = MagicMock() - mock_open.return_value.__enter__.return_value = mock_file - # Run OTA + # Run OTA with real file path result_code, result_host = espota2.run_ota_impl_( - "test.local", 3232, "password", "firmware.bin" + "test.local", 3232, "password", str(firmware_file) ) # Verify success @@ -396,17 +429,24 @@ def test_run_ota_impl_successful() -> None: mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232)) mock_socket.close.assert_called_once() - # Verify perform_ota was called - mock_perform.assert_called_once_with( - mock_socket, "password", mock_file, "firmware.bin" - ) + # Verify perform_ota was called with real file + mock_perform.assert_called_once() + call_args = mock_perform.call_args[0] + assert call_args[0] == mock_socket + assert call_args[1] == "password" + # The file object should be opened + assert hasattr(call_args[2], "read") + assert call_args[3] == str(firmware_file) -def test_run_ota_impl_connection_failed() -> None: +def test_run_ota_impl_connection_failed(mock_socket, tmp_path) -> None: """Test run_ota_impl_ when connection fails.""" - mock_socket = Mock() mock_socket.connect.side_effect = OSError("Connection refused") + # Create a real firmware file + firmware_file = tmp_path / "firmware.bin" + firmware_file.write_bytes(b"firmware content") + with ( patch("socket.socket", return_value=mock_socket), patch("esphome.espota2.resolve_ip_address") as mock_resolve, @@ -416,7 +456,7 @@ def test_run_ota_impl_connection_failed() -> None: ] result_code, result_host = espota2.run_ota_impl_( - "test.local", 3232, "password", "firmware.bin" + "test.local", 3232, "password", str(firmware_file) ) assert result_code == 1 @@ -424,14 +464,18 @@ def test_run_ota_impl_connection_failed() -> None: mock_socket.close.assert_called_once() -def test_run_ota_impl_resolve_failed() -> None: +def test_run_ota_impl_resolve_failed(tmp_path) -> None: """Test run_ota_impl_ when DNS resolution fails.""" + # Create a real firmware file + firmware_file = tmp_path / "firmware.bin" + firmware_file.write_bytes(b"firmware content") + with patch("esphome.espota2.resolve_ip_address") as mock_resolve: mock_resolve.side_effect = EsphomeError("DNS resolution failed") with pytest.raises(espota2.OTAError, match="DNS resolution failed"): result_code, result_host = espota2.run_ota_impl_( - "unknown.host", 3232, "password", "firmware.bin" + "unknown.host", 3232, "password", str(firmware_file) ) @@ -484,9 +528,8 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None: # Tests for SHA256 authentication (for when PR is merged) -def test_perform_ota_successful_sha256_auth() -> None: +def test_perform_ota_successful_sha256_auth(mock_socket) -> None: """Test successful OTA with SHA256 authentication (future support).""" - mock_socket = Mock() # Mock random for predictable cnonce with patch("random.random", return_value=0.123456): From 0d622fa268a0176158e50fa25172b0a2af3fdf3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:56:29 -0600 Subject: [PATCH 1259/1626] cleanup --- tests/unit_tests/test_espota2.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 6bfd20a0b2..5f9947afd7 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -5,6 +5,7 @@ from __future__ import annotations import gzip import hashlib import io +from pathlib import Path import socket from typing import TYPE_CHECKING from unittest.mock import MagicMock, Mock, call, patch @@ -399,7 +400,7 @@ def test_perform_ota_upload_error(mock_socket) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_run_ota_impl_successful(mock_socket, tmp_path) -> None: +def test_run_ota_impl_successful(mock_socket, tmp_path: Path) -> None: """Test run_ota_impl_ with successful upload.""" # Create a real firmware file firmware_file = tmp_path / "firmware.bin" @@ -439,7 +440,7 @@ def test_run_ota_impl_successful(mock_socket, tmp_path) -> None: assert call_args[3] == str(firmware_file) -def test_run_ota_impl_connection_failed(mock_socket, tmp_path) -> None: +def test_run_ota_impl_connection_failed(mock_socket, tmp_path: Path) -> None: """Test run_ota_impl_ when connection fails.""" mock_socket.connect.side_effect = OSError("Connection refused") @@ -464,7 +465,7 @@ def test_run_ota_impl_connection_failed(mock_socket, tmp_path) -> None: mock_socket.close.assert_called_once() -def test_run_ota_impl_resolve_failed(tmp_path) -> None: +def test_run_ota_impl_resolve_failed(tmp_path: Path) -> None: """Test run_ota_impl_ when DNS resolution fails.""" # Create a real firmware file firmware_file = tmp_path / "firmware.bin" From 594c60a4a46a75de312b8a4b026fb84937d7b75e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:07:01 -0600 Subject: [PATCH 1260/1626] preen --- tests/unit_tests/test_espota2.py | 459 ++++++++++++++++--------------- 1 file changed, 235 insertions(+), 224 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 5f9947afd7..cda5d8d222 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -216,131 +216,118 @@ def test_send_check_socket_error(mock_socket) -> None: espota2.send_check(mock_socket, b"data", "test") -def test_perform_ota_successful_md5_auth(mock_socket) -> None: +def test_perform_ota_successful_md5_auth( + mock_socket, mock_file, mock_time, mock_random +) -> None: """Test successful OTA with MD5 authentication.""" - mock_file = io.BytesIO(b"firmware content here") + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request + b"12345678901234567890123456789012", # 32 char hex nonce + bytes([espota2.RESPONSE_AUTH_OK]), # Auth result + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] - # Mock random for predictable cnonce - with ( - patch("random.random", return_value=0.123456), - patch("time.sleep"), - patch("time.perf_counter", side_effect=[0, 1]), - ): - # Setup socket responses for recv calls - recv_responses = [ - bytes([espota2.RESPONSE_OK]), # First byte of version response - bytes([espota2.OTA_VERSION_2_0]), # Version number - bytes([espota2.RESPONSE_HEADER_OK]), # Features response - bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request - b"12345678901234567890123456789012", # 32 char hex nonce - bytes([espota2.RESPONSE_AUTH_OK]), # Auth result - bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK - bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK - bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK - bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK - bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK - ] + mock_socket.recv.side_effect = recv_responses - mock_socket.recv.side_effect = recv_responses + # Run OTA + espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") - # Run OTA - espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify magic bytes were sent - assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - - # Verify features were sent (compression + SHA256 support) - assert mock_socket.sendall.call_args_list[1] == call( - bytes( - [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH - ] - ) + # Verify features were sent (compression + SHA256 support) + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.FEATURE_SUPPORTS_COMPRESSION + | espota2.FEATURE_SUPPORTS_SHA256_AUTH + ] ) + ) - # Verify cnonce was sent (MD5 of random.random()) - cnonce = hashlib.md5(b"0.123456").hexdigest() - assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) + # Verify cnonce was sent (MD5 of random.random()) + cnonce = hashlib.md5(b"0.123456").hexdigest() + assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) - # Verify auth result was computed correctly - expected_hash = hashlib.md5() - expected_hash.update(b"testpass") - expected_hash.update(b"12345678901234567890123456789012") - expected_hash.update(cnonce.encode()) - expected_result = expected_hash.hexdigest() - assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) + # Verify auth result was computed correctly + expected_hash = hashlib.md5() + expected_hash.update(b"testpass") + expected_hash.update(b"12345678901234567890123456789012") + expected_hash.update(cnonce.encode()) + expected_result = expected_hash.hexdigest() + assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) -def test_perform_ota_no_auth(mock_socket) -> None: +def test_perform_ota_no_auth(mock_socket, mock_file, mock_time) -> None: """Test OTA without authentication.""" - mock_file = io.BytesIO(b"firmware") + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_1_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] - with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): - recv_responses = [ - bytes([espota2.RESPONSE_OK]), # First byte of version response - bytes([espota2.OTA_VERSION_1_0]), # Version number - bytes([espota2.RESPONSE_HEADER_OK]), # Features response - bytes([espota2.RESPONSE_AUTH_OK]), # No auth required - bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK - bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK - bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK - bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK - ] + mock_socket.recv.side_effect = recv_responses - mock_socket.recv.side_effect = recv_responses + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") - - # Should not send any auth-related data - auth_calls = [ - call - for call in mock_socket.sendall.call_args_list - if "cnonce" in str(call) or "result" in str(call) - ] - assert len(auth_calls) == 0 + # Should not send any auth-related data + auth_calls = [ + call + for call in mock_socket.sendall.call_args_list + if "cnonce" in str(call) or "result" in str(call) + ] + assert len(auth_calls) == 0 -def test_perform_ota_with_compression(mock_socket) -> None: +def test_perform_ota_with_compression(mock_socket, mock_time) -> None: """Test OTA with compression support.""" original_content = b"firmware" * 100 # Repeating content for compression mock_file = io.BytesIO(original_content) + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_SUPPORTS_COMPRESSION]), # Device supports compression + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] - with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): - recv_responses = [ - bytes([espota2.RESPONSE_OK]), # First byte of version response - bytes([espota2.OTA_VERSION_2_0]), # Version number - bytes( - [espota2.RESPONSE_SUPPORTS_COMPRESSION] - ), # Device supports compression - bytes([espota2.RESPONSE_AUTH_OK]), # No auth required - bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK - bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK - bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK - bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK - bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK - ] + mock_socket.recv.side_effect = recv_responses - mock_socket.recv.side_effect = recv_responses + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + # Verify compressed content was sent + # Get the binary size that was sent (4 bytes after features) + size_bytes = mock_socket.sendall.call_args_list[2][0][0] + sent_size = ( + (size_bytes[0] << 24) + | (size_bytes[1] << 16) + | (size_bytes[2] << 8) + | size_bytes[3] + ) - # Verify compressed content was sent - # Get the binary size that was sent (4 bytes after features) - size_bytes = mock_socket.sendall.call_args_list[2][0][0] - sent_size = ( - (size_bytes[0] << 24) - | (size_bytes[1] << 16) - | (size_bytes[2] << 8) - | size_bytes[3] - ) + # Size should be less than original due to compression + assert sent_size < len(original_content) - # Size should be less than original due to compression - assert sent_size < len(original_content) - - # Verify the content sent was gzipped - compressed = gzip.compress(original_content, compresslevel=9) - assert sent_size == len(compressed) + # Verify the content sent was gzipped + compressed = gzip.compress(original_content, compresslevel=9) + assert sent_size == len(compressed) def test_perform_ota_auth_without_password(mock_socket) -> None: @@ -375,47 +362,35 @@ def test_perform_ota_unsupported_version(mock_socket) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_perform_ota_upload_error(mock_socket) -> None: +def test_perform_ota_upload_error(mock_socket, mock_file, mock_time) -> None: """Test OTA handles upload errors.""" - mock_file = io.BytesIO(b"firmware") + # Setup responses - provide enough for the recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + # Add OSError to recv to simulate connection loss during chunk read + recv_responses.append(OSError("Connection lost")) - with patch("time.perf_counter", side_effect=[0, 1]): - # Setup responses - provide enough for the recv calls - recv_responses = [ - bytes([espota2.RESPONSE_OK]), # First byte of version response - bytes([espota2.OTA_VERSION_2_0]), # Version number - bytes([espota2.RESPONSE_HEADER_OK]), # Features response - bytes([espota2.RESPONSE_AUTH_OK]), # No auth required - bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK - bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK - ] - # Add OSError to recv to simulate connection loss during chunk read - recv_responses.append(OSError("Connection lost")) + mock_socket.recv.side_effect = recv_responses - mock_socket.recv.side_effect = recv_responses - - with pytest.raises( - espota2.OTAError, match="Error receiving acknowledge chunk OK" - ): - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + with pytest.raises(espota2.OTAError, match="Error receiving acknowledge chunk OK"): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_run_ota_impl_successful(mock_socket, tmp_path: Path) -> None: +def test_run_ota_impl_successful( + mock_socket, tmp_path: Path, mock_resolve_ip, mock_perform_ota +) -> None: """Test run_ota_impl_ with successful upload.""" # Create a real firmware file firmware_file = tmp_path / "firmware.bin" firmware_file.write_bytes(b"firmware content") - with ( - patch("socket.socket", return_value=mock_socket), - patch("esphome.espota2.resolve_ip_address") as mock_resolve, - patch("esphome.espota2.perform_ota") as mock_perform, - ): - # Setup mocks - mock_resolve.return_value = [ - (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) - ] - + with patch("socket.socket", return_value=mock_socket): # Run OTA with real file path result_code, result_host = espota2.run_ota_impl_( "test.local", 3232, "password", str(firmware_file) @@ -431,8 +406,8 @@ def test_run_ota_impl_successful(mock_socket, tmp_path: Path) -> None: mock_socket.close.assert_called_once() # Verify perform_ota was called with real file - mock_perform.assert_called_once() - call_args = mock_perform.call_args[0] + mock_perform_ota.assert_called_once() + call_args = mock_perform_ota.call_args[0] assert call_args[0] == mock_socket assert call_args[1] == "password" # The file object should be opened @@ -440,7 +415,9 @@ def test_run_ota_impl_successful(mock_socket, tmp_path: Path) -> None: assert call_args[3] == str(firmware_file) -def test_run_ota_impl_connection_failed(mock_socket, tmp_path: Path) -> None: +def test_run_ota_impl_connection_failed( + mock_socket, tmp_path: Path, mock_resolve_ip +) -> None: """Test run_ota_impl_ when connection fails.""" mock_socket.connect.side_effect = OSError("Connection refused") @@ -448,14 +425,7 @@ def test_run_ota_impl_connection_failed(mock_socket, tmp_path: Path) -> None: firmware_file = tmp_path / "firmware.bin" firmware_file.write_bytes(b"firmware content") - with ( - patch("socket.socket", return_value=mock_socket), - patch("esphome.espota2.resolve_ip_address") as mock_resolve, - ): - mock_resolve.return_value = [ - (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) - ] - + with patch("socket.socket", return_value=mock_socket): result_code, result_host = espota2.run_ota_impl_( "test.local", 3232, "password", str(firmware_file) ) @@ -465,33 +435,31 @@ def test_run_ota_impl_connection_failed(mock_socket, tmp_path: Path) -> None: mock_socket.close.assert_called_once() -def test_run_ota_impl_resolve_failed(tmp_path: Path) -> None: +def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip) -> None: """Test run_ota_impl_ when DNS resolution fails.""" # Create a real firmware file firmware_file = tmp_path / "firmware.bin" firmware_file.write_bytes(b"firmware content") - with patch("esphome.espota2.resolve_ip_address") as mock_resolve: - mock_resolve.side_effect = EsphomeError("DNS resolution failed") + mock_resolve_ip.side_effect = EsphomeError("DNS resolution failed") - with pytest.raises(espota2.OTAError, match="DNS resolution failed"): - result_code, result_host = espota2.run_ota_impl_( - "unknown.host", 3232, "password", str(firmware_file) - ) + with pytest.raises(espota2.OTAError, match="DNS resolution failed"): + result_code, result_host = espota2.run_ota_impl_( + "unknown.host", 3232, "password", str(firmware_file) + ) -def test_run_ota_wrapper() -> None: +def test_run_ota_wrapper(mock_run_ota_impl) -> None: """Test run_ota wrapper function.""" - with patch("esphome.espota2.run_ota_impl_") as mock_impl: - # Test successful case - mock_impl.return_value = (0, "192.168.1.100") - result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") - assert result == (0, "192.168.1.100") + # Test successful case + mock_run_ota_impl.return_value = (0, "192.168.1.100") + result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") + assert result == (0, "192.168.1.100") - # Test error case - mock_impl.side_effect = espota2.OTAError("Test error") - result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") - assert result == (1, None) + # Test error case + mock_run_ota_impl.side_effect = espota2.OTAError("Test error") + result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") + assert result == (1, None) def test_progress_bar(capsys: CaptureFixture[str]) -> None: @@ -528,82 +496,125 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None: assert captured.err.count("50%") == 1 -# Tests for SHA256 authentication (for when PR is merged) -def test_perform_ota_successful_sha256_auth(mock_socket) -> None: - """Test successful OTA with SHA256 authentication (future support).""" +# Tests for SHA256 authentication +def test_perform_ota_successful_sha256_auth( + mock_socket, mock_file, mock_time, mock_random +) -> None: + """Test successful OTA with SHA256 authentication.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), # SHA256 Auth request + b"1234567890123456789012345678901234567890123456789012345678901234", # 64 char hex nonce + bytes([espota2.RESPONSE_AUTH_OK]), # Auth result + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] - # Mock random for predictable cnonce - with patch("random.random", return_value=0.123456): - # Constants for SHA256 auth (when implemented) - RESPONSE_REQUEST_SHA256_AUTH = 0x02 # From PR + mock_socket.recv.side_effect = recv_responses - # Setup socket responses - responses = [ - # Version handshake - bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), - # Features response - bytes([espota2.RESPONSE_HEADER_OK]), - # SHA256 Auth request - bytes([RESPONSE_REQUEST_SHA256_AUTH]), - # Nonce from device (64 chars for SHA256) - b"1234567890123456789012345678901234567890123456789012345678901234", - # Auth result - bytes([espota2.RESPONSE_AUTH_OK]), - # Binary size OK - bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), - # MD5 checksum OK - bytes([espota2.RESPONSE_BIN_MD5_OK]), - # Chunk OK - bytes([espota2.RESPONSE_CHUNK_OK]), - bytes([espota2.RESPONSE_CHUNK_OK]), - bytes([espota2.RESPONSE_CHUNK_OK]), - # Receive OK - bytes([espota2.RESPONSE_RECEIVE_OK]), - # Update end OK - bytes([espota2.RESPONSE_UPDATE_END_OK]), - ] + # Run OTA + espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") - mock_socket.recv.side_effect = responses + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # When SHA256 is implemented, this test will verify: - # 1. Client sends FEATURE_SUPPORTS_SHA256_AUTH flag - # 2. Device responds with RESPONSE_REQUEST_SHA256_AUTH - # 3. Authentication uses SHA256 instead of MD5 - # 4. Nonce is 64 characters instead of 32 + # Verify features were sent (compression + SHA256 support) + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.FEATURE_SUPPORTS_COMPRESSION + | espota2.FEATURE_SUPPORTS_SHA256_AUTH + ] + ) + ) - # For now, this would raise an error since SHA256 isn't implemented - # Once implemented, uncomment to test: - # espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + # Verify cnonce was sent (SHA256 of random.random()) + cnonce = hashlib.sha256(b"0.123456").hexdigest() + assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) + + # Verify auth result was computed correctly with SHA256 + expected_hash = hashlib.sha256() + expected_hash.update(b"testpass") + expected_hash.update( + b"1234567890123456789012345678901234567890123456789012345678901234" + ) + expected_hash.update(cnonce.encode()) + expected_result = expected_hash.hexdigest() + assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) -def test_perform_ota_sha256_fallback_to_md5() -> None: +def test_perform_ota_sha256_fallback_to_md5( + mock_socket, mock_file, mock_time, mock_random +) -> None: """Test SHA256-capable client falls back to MD5 for compatibility.""" # This test verifies the temporary backward compatibility # where a SHA256-capable client can still authenticate with MD5 - # This compatibility will be removed in 2026.1.0 according to PR - pass # Implementation depends on final PR merge + # This compatibility will be removed in 2026.1.0 + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes( + [espota2.RESPONSE_REQUEST_AUTH] + ), # MD5 Auth request (device doesn't support SHA256) + b"12345678901234567890123456789012", # 32 char hex nonce for MD5 + bytes([espota2.RESPONSE_AUTH_OK]), # Auth result + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + # Run OTA - should work even though device requested MD5 + espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + + # Verify client still advertised SHA256 support + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.FEATURE_SUPPORTS_COMPRESSION + | espota2.FEATURE_SUPPORTS_SHA256_AUTH + ] + ) + ) + + # But authentication was done with MD5 + cnonce = hashlib.md5(b"0.123456").hexdigest() + expected_hash = hashlib.md5() + expected_hash.update(b"testpass") + expected_hash.update(b"12345678901234567890123456789012") + expected_hash.update(cnonce.encode()) + expected_result = expected_hash.hexdigest() + assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) -def test_perform_ota_version_differences(mock_socket) -> None: +def test_perform_ota_version_differences(mock_socket, mock_file, mock_time) -> None: """Test OTA behavior differences between version 1.0 and 2.0.""" - mock_file = io.BytesIO(b"firmware") + # Test version 1.0 - no chunk acknowledgments + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_1_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + # No RESPONSE_CHUNK_OK for v1 + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] - with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): - # Test version 1.0 - no chunk acknowledgments - recv_responses = [ - bytes([espota2.RESPONSE_OK]), # First byte of version response - bytes([espota2.OTA_VERSION_1_0]), # Version number - bytes([espota2.RESPONSE_HEADER_OK]), # Features response - bytes([espota2.RESPONSE_AUTH_OK]), # No auth required - bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK - bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK - # No RESPONSE_CHUNK_OK for v1 - bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK - bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK - ] + mock_socket.recv.side_effect = recv_responses + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") - mock_socket.recv.side_effect = recv_responses - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") - - # Verify no chunk acknowledgments were expected - # (implementation detail - v1 doesn't wait for chunk OK) + # Verify no chunk acknowledgments were expected + # (implementation detail - v1 doesn't wait for chunk OK) + assert True # Placeholder assertion From 17704f712efd2ebce2178cae4901abaf44bb6527 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:07:27 -0600 Subject: [PATCH 1261/1626] preen --- tests/unit_tests/test_espota2.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index cda5d8d222..e76bc99211 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -41,11 +41,8 @@ def mock_file() -> io.BytesIO: @pytest.fixture def mock_time(): """Mock time-related functions for consistent testing.""" - with ( - patch("time.sleep"), - patch("time.perf_counter", side_effect=[0, 1]), - ) as mocks: - yield mocks + with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): + yield @pytest.fixture From eee8b111197f170144add82facca07187f55faa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:09:35 -0600 Subject: [PATCH 1262/1626] preen --- tests/unit_tests/test_espota2.py | 91 +++++++++++++++++--------------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index e76bc99211..60c34709d9 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -2,12 +2,13 @@ from __future__ import annotations +from collections.abc import Generator import gzip import hashlib import io from pathlib import Path import socket -from typing import TYPE_CHECKING +import struct from unittest.mock import MagicMock, Mock, call, patch import pytest @@ -16,20 +17,18 @@ from pytest import CaptureFixture from esphome import espota2 from esphome.core import EsphomeError -if TYPE_CHECKING: - from unittest.mock import Mock as MockType - @pytest.fixture -def mock_socket() -> MockType: +def mock_socket() -> Mock: """Create a mock socket for testing.""" - socket = Mock() - socket.close = Mock() - socket.recv = Mock() - socket.sendall = Mock() - socket.settimeout = Mock() - socket.connect = Mock() - return socket + socket_mock = Mock() + socket_mock.close = Mock() + socket_mock.recv = Mock() + socket_mock.sendall = Mock() + socket_mock.settimeout = Mock() + socket_mock.connect = Mock() + socket_mock.setsockopt = Mock() + return socket_mock @pytest.fixture @@ -39,21 +38,21 @@ def mock_file() -> io.BytesIO: @pytest.fixture -def mock_time(): +def mock_time() -> Generator[None]: """Mock time-related functions for consistent testing.""" with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): yield @pytest.fixture -def mock_random(): +def mock_random() -> Generator[Mock]: """Mock random for predictable test values.""" with patch("random.random", return_value=0.123456) as mock_rand: yield mock_rand @pytest.fixture -def mock_resolve_ip(): +def mock_resolve_ip() -> Generator[Mock]: """Mock resolve_ip_address for testing.""" with patch("esphome.espota2.resolve_ip_address") as mock: mock.return_value = [ @@ -63,14 +62,14 @@ def mock_resolve_ip(): @pytest.fixture -def mock_perform_ota(): +def mock_perform_ota() -> Generator[Mock]: """Mock perform_ota function for testing.""" with patch("esphome.espota2.perform_ota") as mock: yield mock @pytest.fixture -def mock_run_ota_impl(): +def mock_run_ota_impl() -> Generator[Mock]: """Mock run_ota_impl_ function for testing.""" with patch("esphome.espota2.run_ota_impl_") as mock: mock.return_value = (0, "192.168.1.100") @@ -78,7 +77,7 @@ def mock_run_ota_impl(): @pytest.fixture -def mock_open_file(): +def mock_open_file() -> Generator[tuple[Mock, MagicMock]]: """Mock file opening for testing.""" with patch("builtins.open", create=True) as mock_open: mock_file = MagicMock() @@ -86,7 +85,7 @@ def mock_open_file(): yield mock_open, mock_file -def test_recv_decode_with_decode(mock_socket) -> None: +def test_recv_decode_with_decode(mock_socket: Mock) -> None: """Test recv_decode with decode=True returns list.""" mock_socket.recv.return_value = b"\x01\x02\x03" @@ -96,7 +95,7 @@ def test_recv_decode_with_decode(mock_socket) -> None: mock_socket.recv.assert_called_once_with(3) -def test_recv_decode_without_decode(mock_socket) -> None: +def test_recv_decode_without_decode(mock_socket: Mock) -> None: """Test recv_decode with decode=False returns bytes.""" mock_socket.recv.return_value = b"\x01\x02\x03" @@ -106,7 +105,7 @@ def test_recv_decode_without_decode(mock_socket) -> None: mock_socket.recv.assert_called_once_with(3) -def test_receive_exactly_success(mock_socket) -> None: +def test_receive_exactly_success(mock_socket: Mock) -> None: """Test receive_exactly successfully receives expected data.""" mock_socket.recv.side_effect = [b"\x00", b"\x01\x02"] @@ -116,7 +115,7 @@ def test_receive_exactly_success(mock_socket) -> None: assert mock_socket.recv.call_count == 2 -def test_receive_exactly_with_error_response(mock_socket) -> None: +def test_receive_exactly_with_error_response(mock_socket: Mock) -> None: """Test receive_exactly raises OTAError on error response.""" mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]) @@ -126,7 +125,7 @@ def test_receive_exactly_with_error_response(mock_socket) -> None: mock_socket.close.assert_called_once() -def test_receive_exactly_socket_error(mock_socket) -> None: +def test_receive_exactly_socket_error(mock_socket: Mock) -> None: """Test receive_exactly handles socket errors.""" mock_socket.recv.side_effect = OSError("Connection reset") @@ -185,7 +184,7 @@ def test_check_error_unexpected_response() -> None: espota2.check_error([0x7F], [espota2.RESPONSE_OK, espota2.RESPONSE_AUTH_OK]) -def test_send_check_with_various_data_types(mock_socket) -> None: +def test_send_check_with_various_data_types(mock_socket: Mock) -> None: """Test send_check handles different data types.""" # Test with list/tuple @@ -205,7 +204,7 @@ def test_send_check_with_various_data_types(mock_socket) -> None: mock_socket.sendall.assert_called_with(b"\xaa\xbb") -def test_send_check_socket_error(mock_socket) -> None: +def test_send_check_socket_error(mock_socket: Mock) -> None: """Test send_check handles socket errors.""" mock_socket.sendall.side_effect = OSError("Broken pipe") @@ -213,8 +212,9 @@ def test_send_check_socket_error(mock_socket) -> None: espota2.send_check(mock_socket, b"data", "test") +@pytest.mark.usefixtures("mock_time") def test_perform_ota_successful_md5_auth( - mock_socket, mock_file, mock_time, mock_random + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock ) -> None: """Test successful OTA with MD5 authentication.""" # Setup socket responses for recv calls @@ -263,7 +263,8 @@ def test_perform_ota_successful_md5_auth( assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) -def test_perform_ota_no_auth(mock_socket, mock_file, mock_time) -> None: +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: """Test OTA without authentication.""" recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response @@ -289,7 +290,8 @@ def test_perform_ota_no_auth(mock_socket, mock_file, mock_time) -> None: assert len(auth_calls) == 0 -def test_perform_ota_with_compression(mock_socket, mock_time) -> None: +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_with_compression(mock_socket: Mock) -> None: """Test OTA with compression support.""" original_content = b"firmware" * 100 # Repeating content for compression mock_file = io.BytesIO(original_content) @@ -312,12 +314,7 @@ def test_perform_ota_with_compression(mock_socket, mock_time) -> None: # Verify compressed content was sent # Get the binary size that was sent (4 bytes after features) size_bytes = mock_socket.sendall.call_args_list[2][0][0] - sent_size = ( - (size_bytes[0] << 24) - | (size_bytes[1] << 16) - | (size_bytes[2] << 8) - | size_bytes[3] - ) + sent_size = struct.unpack(">I", size_bytes)[0] # Size should be less than original due to compression assert sent_size < len(original_content) @@ -327,7 +324,7 @@ def test_perform_ota_with_compression(mock_socket, mock_time) -> None: assert sent_size == len(compressed) -def test_perform_ota_auth_without_password(mock_socket) -> None: +def test_perform_ota_auth_without_password(mock_socket: Mock) -> None: """Test OTA fails when auth is required but no password provided.""" mock_file = io.BytesIO(b"firmware") @@ -345,7 +342,7 @@ def test_perform_ota_auth_without_password(mock_socket) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_perform_ota_unsupported_version(mock_socket) -> None: +def test_perform_ota_unsupported_version(mock_socket: Mock) -> None: """Test OTA fails with unsupported version.""" mock_file = io.BytesIO(b"firmware") @@ -359,7 +356,8 @@ def test_perform_ota_unsupported_version(mock_socket) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_perform_ota_upload_error(mock_socket, mock_file, mock_time) -> None: +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> None: """Test OTA handles upload errors.""" # Setup responses - provide enough for the recv calls recv_responses = [ @@ -380,7 +378,7 @@ def test_perform_ota_upload_error(mock_socket, mock_file, mock_time) -> None: def test_run_ota_impl_successful( - mock_socket, tmp_path: Path, mock_resolve_ip, mock_perform_ota + mock_socket: Mock, tmp_path: Path, mock_resolve_ip: Mock, mock_perform_ota: Mock ) -> None: """Test run_ota_impl_ with successful upload.""" # Create a real firmware file @@ -413,7 +411,7 @@ def test_run_ota_impl_successful( def test_run_ota_impl_connection_failed( - mock_socket, tmp_path: Path, mock_resolve_ip + mock_socket: Mock, tmp_path: Path, mock_resolve_ip: Mock ) -> None: """Test run_ota_impl_ when connection fails.""" mock_socket.connect.side_effect = OSError("Connection refused") @@ -432,7 +430,7 @@ def test_run_ota_impl_connection_failed( mock_socket.close.assert_called_once() -def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip) -> None: +def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: """Test run_ota_impl_ when DNS resolution fails.""" # Create a real firmware file firmware_file = tmp_path / "firmware.bin" @@ -446,7 +444,7 @@ def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip) -> None: ) -def test_run_ota_wrapper(mock_run_ota_impl) -> None: +def test_run_ota_wrapper(mock_run_ota_impl: Mock) -> None: """Test run_ota wrapper function.""" # Test successful case mock_run_ota_impl.return_value = (0, "192.168.1.100") @@ -494,8 +492,9 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None: # Tests for SHA256 authentication +@pytest.mark.usefixtures("mock_time") def test_perform_ota_successful_sha256_auth( - mock_socket, mock_file, mock_time, mock_random + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock ) -> None: """Test successful OTA with SHA256 authentication.""" # Setup socket responses for recv calls @@ -546,8 +545,9 @@ def test_perform_ota_successful_sha256_auth( assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) +@pytest.mark.usefixtures("mock_time") def test_perform_ota_sha256_fallback_to_md5( - mock_socket, mock_file, mock_time, mock_random + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock ) -> None: """Test SHA256-capable client falls back to MD5 for compatibility.""" # This test verifies the temporary backward compatibility @@ -594,7 +594,10 @@ def test_perform_ota_sha256_fallback_to_md5( assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) -def test_perform_ota_version_differences(mock_socket, mock_file, mock_time) -> None: +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_version_differences( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: """Test OTA behavior differences between version 1.0 and 2.0.""" # Test version 1.0 - no chunk acknowledgments recv_responses = [ From 0cae1f28b015b7d72736546e228575539ae151e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:12:48 -0600 Subject: [PATCH 1263/1626] preen --- tests/unit_tests/test_espota2.py | 65 ++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 60c34709d9..539f4ecc42 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -17,6 +17,12 @@ from pytest import CaptureFixture from esphome import espota2 from esphome.core import EsphomeError +# Test constants +MOCK_RANDOM_VALUE = 0.123456 +MOCK_RANDOM_BYTES = b"0.123456" +MOCK_MD5_NONCE = b"12345678901234567890123456789012" # 32 char nonce for MD5 +MOCK_SHA256_NONCE = b"1234567890123456789012345678901234567890123456789012345678901234" # 64 char nonce for SHA256 + @pytest.fixture def mock_socket() -> Mock: @@ -40,14 +46,18 @@ def mock_file() -> io.BytesIO: @pytest.fixture def mock_time() -> Generator[None]: """Mock time-related functions for consistent testing.""" - with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): + # Provide enough values for multiple calls (tests may call perform_ota multiple times) + with ( + patch("time.sleep"), + patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]), + ): yield @pytest.fixture def mock_random() -> Generator[Mock]: """Mock random for predictable test values.""" - with patch("random.random", return_value=0.123456) as mock_rand: + with patch("random.random", return_value=MOCK_RANDOM_VALUE) as mock_rand: yield mock_rand @@ -223,7 +233,7 @@ def test_perform_ota_successful_md5_auth( bytes([espota2.OTA_VERSION_2_0]), # Version number bytes([espota2.RESPONSE_HEADER_OK]), # Features response bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request - b"12345678901234567890123456789012", # 32 char hex nonce + MOCK_MD5_NONCE, # 32 char hex nonce bytes([espota2.RESPONSE_AUTH_OK]), # Auth result bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK @@ -251,13 +261,13 @@ def test_perform_ota_successful_md5_auth( ) # Verify cnonce was sent (MD5 of random.random()) - cnonce = hashlib.md5(b"0.123456").hexdigest() + cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest() assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) # Verify auth result was computed correctly expected_hash = hashlib.md5() expected_hash.update(b"testpass") - expected_hash.update(b"12345678901234567890123456789012") + expected_hash.update(MOCK_MD5_NONCE) expected_hash.update(cnonce.encode()) expected_result = expected_hash.hexdigest() assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) @@ -503,7 +513,7 @@ def test_perform_ota_successful_sha256_auth( bytes([espota2.OTA_VERSION_2_0]), # Version number bytes([espota2.RESPONSE_HEADER_OK]), # Features response bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), # SHA256 Auth request - b"1234567890123456789012345678901234567890123456789012345678901234", # 64 char hex nonce + MOCK_SHA256_NONCE, # 64 char hex nonce bytes([espota2.RESPONSE_AUTH_OK]), # Auth result bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK @@ -531,15 +541,13 @@ def test_perform_ota_successful_sha256_auth( ) # Verify cnonce was sent (SHA256 of random.random()) - cnonce = hashlib.sha256(b"0.123456").hexdigest() + cnonce = hashlib.sha256(MOCK_RANDOM_BYTES).hexdigest() assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) # Verify auth result was computed correctly with SHA256 expected_hash = hashlib.sha256() expected_hash.update(b"testpass") - expected_hash.update( - b"1234567890123456789012345678901234567890123456789012345678901234" - ) + expected_hash.update(MOCK_SHA256_NONCE) expected_hash.update(cnonce.encode()) expected_result = expected_hash.hexdigest() assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) @@ -560,7 +568,7 @@ def test_perform_ota_sha256_fallback_to_md5( bytes( [espota2.RESPONSE_REQUEST_AUTH] ), # MD5 Auth request (device doesn't support SHA256) - b"12345678901234567890123456789012", # 32 char hex nonce for MD5 + MOCK_MD5_NONCE, # 32 char hex nonce for MD5 bytes([espota2.RESPONSE_AUTH_OK]), # Auth result bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK @@ -585,10 +593,10 @@ def test_perform_ota_sha256_fallback_to_md5( ) # But authentication was done with MD5 - cnonce = hashlib.md5(b"0.123456").hexdigest() + cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest() expected_hash = hashlib.md5() expected_hash.update(b"testpass") - expected_hash.update(b"12345678901234567890123456789012") + expected_hash.update(MOCK_MD5_NONCE) expected_hash.update(cnonce.encode()) expected_result = expected_hash.hexdigest() assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) @@ -615,6 +623,31 @@ def test_perform_ota_version_differences( mock_socket.recv.side_effect = recv_responses espota2.perform_ota(mock_socket, "", mock_file, "test.bin") - # Verify no chunk acknowledgments were expected - # (implementation detail - v1 doesn't wait for chunk OK) - assert True # Placeholder assertion + # For v1.0, verify that we only get the expected number of recv calls + # v1.0 doesn't have chunk acknowledgments, so fewer recv calls + assert mock_socket.recv.call_count == 8 # v1.0 has 8 recv calls + + # Reset mock for v2.0 test + mock_socket.reset_mock() + + # Reset file position for second test + mock_file.seek(0) + + # Test version 2.0 - with chunk acknowledgments + recv_responses_v2 = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # v2.0 has chunk acknowledgment + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses_v2 + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + # For v2.0, verify more recv calls due to chunk acknowledgments + assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK) From 2aa0ebd1d2d2daecf2697e58a2d06703eb0b1360 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:14:40 -0600 Subject: [PATCH 1264/1626] preen --- tests/unit_tests/test_espota2.py | 67 +++++++++++++++++--------------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 539f4ecc42..24b6a57b63 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -95,6 +95,13 @@ def mock_open_file() -> Generator[tuple[Mock, MagicMock]]: yield mock_open, mock_file +@pytest.fixture +def mock_socket_constructor(mock_socket: Mock) -> Generator[Mock]: + """Mock socket.socket constructor to return our mock socket.""" + with patch("socket.socket", return_value=mock_socket) as mock_constructor: + yield mock_constructor + + def test_recv_decode_with_decode(mock_socket: Mock) -> None: """Test recv_decode with decode=True returns list.""" mock_socket.recv.return_value = b"\x01\x02\x03" @@ -387,42 +394,41 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N espota2.perform_ota(mock_socket, "", mock_file, "test.bin") +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_successful( - mock_socket: Mock, tmp_path: Path, mock_resolve_ip: Mock, mock_perform_ota: Mock + mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock ) -> None: """Test run_ota_impl_ with successful upload.""" # Create a real firmware file firmware_file = tmp_path / "firmware.bin" firmware_file.write_bytes(b"firmware content") - with patch("socket.socket", return_value=mock_socket): - # Run OTA with real file path - result_code, result_host = espota2.run_ota_impl_( - "test.local", 3232, "password", str(firmware_file) - ) + # Run OTA with real file path + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) - # Verify success - assert result_code == 0 - assert result_host == "192.168.1.100" + # Verify success + assert result_code == 0 + assert result_host == "192.168.1.100" - # Verify socket was configured correctly - mock_socket.settimeout.assert_called_with(10.0) - mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232)) - mock_socket.close.assert_called_once() + # Verify socket was configured correctly + mock_socket.settimeout.assert_called_with(10.0) + mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232)) + mock_socket.close.assert_called_once() - # Verify perform_ota was called with real file - mock_perform_ota.assert_called_once() - call_args = mock_perform_ota.call_args[0] - assert call_args[0] == mock_socket - assert call_args[1] == "password" - # The file object should be opened - assert hasattr(call_args[2], "read") - assert call_args[3] == str(firmware_file) + # Verify perform_ota was called with real file + mock_perform_ota.assert_called_once() + call_args = mock_perform_ota.call_args[0] + assert call_args[0] == mock_socket + assert call_args[1] == "password" + # The file object should be opened + assert hasattr(call_args[2], "read") + assert call_args[3] == str(firmware_file) -def test_run_ota_impl_connection_failed( - mock_socket: Mock, tmp_path: Path, mock_resolve_ip: Mock -) -> None: +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None: """Test run_ota_impl_ when connection fails.""" mock_socket.connect.side_effect = OSError("Connection refused") @@ -430,14 +436,13 @@ def test_run_ota_impl_connection_failed( firmware_file = tmp_path / "firmware.bin" firmware_file.write_bytes(b"firmware content") - with patch("socket.socket", return_value=mock_socket): - result_code, result_host = espota2.run_ota_impl_( - "test.local", 3232, "password", str(firmware_file) - ) + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) - assert result_code == 1 - assert result_host is None - mock_socket.close.assert_called_once() + assert result_code == 1 + assert result_host is None + mock_socket.close.assert_called_once() def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: From 69cad7b3c7fb1362fd4cb0d7fec5f74e15494067 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:15:22 -0600 Subject: [PATCH 1265/1626] preen --- tests/unit_tests/test_espota2.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 24b6a57b63..f7962ba5e7 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -9,7 +9,7 @@ import io from pathlib import Path import socket import struct -from unittest.mock import MagicMock, Mock, call, patch +from unittest.mock import Mock, call, patch import pytest from pytest import CaptureFixture @@ -86,15 +86,6 @@ def mock_run_ota_impl() -> Generator[Mock]: yield mock -@pytest.fixture -def mock_open_file() -> Generator[tuple[Mock, MagicMock]]: - """Mock file opening for testing.""" - with patch("builtins.open", create=True) as mock_open: - mock_file = MagicMock() - mock_open.return_value.__enter__.return_value = mock_file - yield mock_open, mock_file - - @pytest.fixture def mock_socket_constructor(mock_socket: Mock) -> Generator[Mock]: """Mock socket.socket constructor to return our mock socket.""" From 0e71662158124ba3457993378f3007d91e21c6a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:18:27 -0600 Subject: [PATCH 1266/1626] preen --- tests/unit_tests/test_espota2.py | 70 +++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f7962ba5e7..f74ca1e4e8 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -350,6 +350,72 @@ def test_perform_ota_auth_without_password(mock_socket: Mock) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_md5_auth_wrong_password( + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock +) -> None: + """Test OTA fails when MD5 authentication is rejected due to wrong password.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request + MOCK_MD5_NONCE, # 32 char hex nonce + bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]), # Auth rejected! + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"): + espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin") + + # Verify the socket was closed after auth failure + mock_socket.close.assert_called() + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_sha256_auth_wrong_password( + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock +) -> None: + """Test OTA fails when SHA256 authentication is rejected due to wrong password.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), # SHA256 Auth request + MOCK_SHA256_NONCE, # 64 char hex nonce + bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]), # Auth rejected! + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"): + espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin") + + # Verify the socket was closed after auth failure + mock_socket.close.assert_called() + + +def test_perform_ota_sha256_auth_without_password(mock_socket: Mock) -> None: + """Test OTA fails when SHA256 auth is required but no password provided.""" + mock_file = io.BytesIO(b"firmware") + + responses = [ + bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_HEADER_OK]), + bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), + ] + + mock_socket.recv.side_effect = responses + + with pytest.raises( + espota2.OTAError, match="ESP requests password, but no password given" + ): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + def test_perform_ota_unsupported_version(mock_socket: Mock) -> None: """Test OTA fails with unsupported version.""" mock_file = io.BytesIO(b"firmware") @@ -413,8 +479,8 @@ def test_run_ota_impl_successful( call_args = mock_perform_ota.call_args[0] assert call_args[0] == mock_socket assert call_args[1] == "password" - # The file object should be opened - assert hasattr(call_args[2], "read") + # Verify the file object is a proper file handle + assert isinstance(call_args[2], io.IOBase) assert call_args[3] == str(firmware_file) From 97bc627d41241eb0063c12d8aab1c7772922f17d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:25:48 -0600 Subject: [PATCH 1267/1626] preen --- esphome/espota2.py | 26 ++++++++++++++++++-------- tests/unit_tests/test_espota2.py | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 5f906e4d08..36eb4d68ea 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -91,18 +91,26 @@ class OTAError(EsphomeError): pass -def recv_decode(sock, amount, decode=True): +def recv_decode( + sock: socket.socket, amount: int, decode: bool = True +) -> bytes | list[int]: data = sock.recv(amount) if not decode: return data return list(data) -def receive_exactly(sock, amount, msg, expect, decode=True): - data = [] if decode else b"" +def receive_exactly( + sock: socket.socket, + amount: int, + msg: str, + expect: int | list[int] | None, + decode: bool = True, +) -> list[int] | bytes: + data: list[int] | bytes = [] if decode else b"" try: - data += recv_decode(sock, 1, decode=decode) + data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] except OSError as err: raise OTAError(f"Error receiving acknowledge {msg}: {err}") from err @@ -114,13 +122,13 @@ def receive_exactly(sock, amount, msg, expect, decode=True): while len(data) < amount: try: - data += recv_decode(sock, amount - len(data), decode=decode) + data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator] except OSError as err: raise OTAError(f"Error receiving {msg}: {err}") from err return data -def check_error(data, expect): +def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None: if not expect: return dat = data[0] @@ -187,7 +195,9 @@ def check_error(data, expect): raise OTAError(f"Unexpected response from ESP: 0x{data[0]:02X}") -def send_check(sock, data, msg): +def send_check( + sock: socket.socket, data: list[int] | tuple[int, ...] | int | str | bytes, msg: str +) -> None: try: if isinstance(data, (list, tuple)): data = bytes(data) @@ -239,7 +249,7 @@ def perform_ota( def perform_auth( sock: socket.socket, password: str, - hash_func: Any, + hash_func: Callable[[], Any], nonce_size: int, hash_name: str, ) -> None: diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f74ca1e4e8..c036a5de8e 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -416,6 +416,29 @@ def test_perform_ota_sha256_auth_without_password(mock_socket: Mock) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") +def test_perform_ota_unexpected_auth_response(mock_socket: Mock) -> None: + """Test OTA fails when device sends an unexpected auth response.""" + mock_file = io.BytesIO(b"firmware") + + # Use 0x03 which is not in the expected auth responses + # This will be caught by check_error and raise "Unexpected response from ESP" + UNKNOWN_AUTH_METHOD = 0x03 + + responses = [ + bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_HEADER_OK]), + bytes([UNKNOWN_AUTH_METHOD]), # Unknown auth method + ] + + mock_socket.recv.side_effect = responses + + # This will actually raise "Unexpected response from ESP" from check_error + with pytest.raises( + espota2.OTAError, match=r"Error auth: Unexpected response from ESP: 0x03" + ): + espota2.perform_ota(mock_socket, "password", mock_file, "test.bin") + + def test_perform_ota_unsupported_version(mock_socket: Mock) -> None: """Test OTA fails with unsupported version.""" mock_file = io.BytesIO(b"firmware") From 7d4a7d48ee9525b1875632af7f1541f070b5cdb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:26:52 -0600 Subject: [PATCH 1268/1626] remove unreachable code --- esphome/espota2.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 36eb4d68ea..ba47e6af0d 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -286,11 +286,8 @@ def perform_ota( [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], ) - if auth in _AUTH_METHODS: - hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] - perform_auth(sock, password, hash_func, nonce_size, hash_name) - elif auth != RESPONSE_AUTH_OK: - raise OTAError(f"Unknown authentication method requested: 0x{auth:02X}") + hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] + perform_auth(sock, password, hash_func, nonce_size, hash_name) # Set higher timeout during upload sock.settimeout(30.0) From 233cc08dc62ad0fd2ee92ac7c0777374d530a5b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:27:27 -0600 Subject: [PATCH 1269/1626] remove unreachable code --- esphome/espota2.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index ba47e6af0d..6e8936668b 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -286,8 +286,9 @@ def perform_ota( [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], ) - hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] - perform_auth(sock, password, hash_func, nonce_size, hash_name) + if auth != RESPONSE_AUTH_OK: + hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] + perform_auth(sock, password, hash_func, nonce_size, hash_name) # Set higher timeout during upload sock.settimeout(30.0) From e47cecc5f0240c52c8111e6b58c076d0df111c57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:28:09 -0600 Subject: [PATCH 1270/1626] remove unreachable code --- esphome/espota2.py | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 6e8936668b..2a4d21dc3e 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -57,7 +57,7 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 _LOGGER = logging.getLogger(__name__) # Authentication method lookup table: response -> (hash_func, nonce_size, name) -_AUTH_METHODS: dict[int, tuple[Callable[[], Any], int, str]] = { +_AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), } @@ -94,6 +94,13 @@ class OTAError(EsphomeError): def recv_decode( sock: socket.socket, amount: int, decode: bool = True ) -> bytes | list[int]: + """Receive data from socket and optionally decode to list of integers. + + :param sock: Socket to receive data from. + :param amount: Number of bytes to receive. + :param decode: If True, convert bytes to list of integers, otherwise return raw bytes. + :return: List of integers if decode=True, otherwise raw bytes. + """ data = sock.recv(amount) if not decode: return data @@ -107,6 +114,16 @@ def receive_exactly( expect: int | list[int] | None, decode: bool = True, ) -> list[int] | bytes: + """Receive exactly the specified amount of data from socket with error checking. + + :param sock: Socket to receive data from. + :param amount: Exact number of bytes to receive. + :param msg: Description of what is being received for error messages. + :param expect: Expected response code(s) for validation, None to skip validation. + :param decode: If True, return list of integers, otherwise return raw bytes. + :return: List of integers if decode=True, otherwise raw bytes. + :raises OTAError: If receiving fails or response doesn't match expected. + """ data: list[int] | bytes = [] if decode else b"" try: @@ -129,6 +146,12 @@ def receive_exactly( def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None: + """Check response data for error codes and validate against expected response. + + :param data: Response data from device (first byte is the response code). + :param expect: Expected response code(s), None to skip validation. + :raises OTAError: If an error code is detected or response doesn't match expected. + """ if not expect: return dat = data[0] @@ -198,6 +221,13 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None def send_check( sock: socket.socket, data: list[int] | tuple[int, ...] | int | str | bytes, msg: str ) -> None: + """Send data to socket with error handling. + + :param sock: Socket to send data to. + :param data: Data to send (can be list/tuple of ints, single int, string, or bytes). + :param msg: Description of what is being sent for error messages. + :raises OTAError: If sending fails. + """ try: if isinstance(data, (list, tuple)): data = bytes(data) @@ -249,7 +279,7 @@ def perform_ota( def perform_auth( sock: socket.socket, password: str, - hash_func: Callable[[], Any], + hash_func: Callable[..., Any], nonce_size: int, hash_name: str, ) -> None: @@ -257,9 +287,11 @@ def perform_ota( if not password: raise OTAError("ESP requests password, but no password given!") - nonce = receive_exactly( + nonce_bytes = receive_exactly( sock, nonce_size, f"{hash_name} authentication nonce", [], decode=False - ).decode() + ) + assert isinstance(nonce_bytes, bytes) + nonce = nonce_bytes.decode() _LOGGER.debug("Auth: %s Nonce is %s", hash_name, nonce) # Generate cnonce From 113fe6dfd5aed093118892fb5ce087e21ed759bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:35:20 -0600 Subject: [PATCH 1271/1626] sha256 for host --- esphome/components/sha256/__init__.py | 14 +++++++++ esphome/components/sha256/sha256.cpp | 31 ++++++++++++++++++- esphome/components/sha256/sha256.h | 10 +++++- tests/components/sha512/common.yaml | 5 +++ tests/components/sha512/test.bk72xx-ard.yaml | 2 ++ tests/components/sha512/test.esp32-idf.yaml | 1 + tests/components/sha512/test.esp8266-ard.yaml | 1 + tests/components/sha512/test.host.yaml | 1 + tests/components/sha512/test.rp2040-ard.yaml | 1 + 9 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/components/sha512/common.yaml create mode 100644 tests/components/sha512/test.bk72xx-ard.yaml create mode 100644 tests/components/sha512/test.esp32-idf.yaml create mode 100644 tests/components/sha512/test.esp8266-ard.yaml create mode 100644 tests/components/sha512/test.host.yaml create mode 100644 tests/components/sha512/test.rp2040-ard.yaml diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index e24da86e25..f77820744b 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -1,5 +1,19 @@ import esphome.codegen as cg +from esphome.core import CORE, IS_MACOS CODEOWNERS = ["@esphome/core"] sha256_ns = cg.esphome_ns.namespace("sha256") + + +async def to_code(config): + # Add OpenSSL library for host platform + if CORE.is_host: + if IS_MACOS: + # macOS needs special handling for Homebrew OpenSSL + cg.add_build_flag("-I/opt/homebrew/opt/openssl/include") + cg.add_build_flag("-L/opt/homebrew/opt/openssl/lib") + cg.add_build_flag("-lcrypto") + else: + # Linux and other Unix systems usually have OpenSSL in standard paths + cg.add_build_flag("-lcrypto") diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 71e4045499..6fa17bb7c0 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -1,7 +1,7 @@ #include "sha256.h" // Only compile SHA256 implementation on platforms that support it -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) #include "esphome/core/helpers.h" #include @@ -67,6 +67,35 @@ void SHA256::calculate() { } } +#elif defined(USE_HOST) + +SHA256::~SHA256() = default; + +void SHA256::init() { + if (!this->ctx_) { + this->ctx_ = std::make_unique(); + } + SHA256_Init(&this->ctx_->ctx); + this->ctx_->calculated = false; +} + +void SHA256::add(const uint8_t *data, size_t len) { + if (!this->ctx_) { + this->init(); + } + SHA256_Update(&this->ctx_->ctx, data, len); +} + +void SHA256::calculate() { + if (!this->ctx_) { + this->init(); + } + if (!this->ctx_->calculated) { + SHA256_Final(this->ctx_->hash, &this->ctx_->ctx); + this->ctx_->calculated = true; + } +} + #elif defined(USE_ARDUINO) SHA256::~SHA256() = default; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 2a7aa72183..246a7ca891 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -3,7 +3,7 @@ #include "esphome/core/defines.h" // Only define SHA256 on platforms that support it -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) #include #include @@ -13,6 +13,8 @@ #include "mbedtls/sha256.h" #elif defined(USE_ESP8266) || defined(USE_RP2040) #include +#elif defined(USE_HOST) +#include #elif defined(USE_ARDUINO) #include #endif @@ -50,6 +52,12 @@ class SHA256 { uint8_t hash[32]; bool calculated{false}; }; +#elif defined(USE_HOST) + struct SHA256Context { + SHA256_CTX ctx; + uint8_t hash[32]; + bool calculated{false}; + }; #elif defined(USE_ARDUINO) struct SHA256Context { ::SHA256 sha; diff --git a/tests/components/sha512/common.yaml b/tests/components/sha512/common.yaml new file mode 100644 index 0000000000..72adf30501 --- /dev/null +++ b/tests/components/sha512/common.yaml @@ -0,0 +1,5 @@ +wifi: + ssid: MySSID + password: password1 + +sha256: diff --git a/tests/components/sha512/test.bk72xx-ard.yaml b/tests/components/sha512/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/sha512/test.bk72xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/sha512/test.esp32-idf.yaml b/tests/components/sha512/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha512/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha512/test.esp8266-ard.yaml b/tests/components/sha512/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha512/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha512/test.host.yaml b/tests/components/sha512/test.host.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha512/test.host.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha512/test.rp2040-ard.yaml b/tests/components/sha512/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha512/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 93c444ee1520101f6712aa17707ed8a138c9bc57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:37:44 -0600 Subject: [PATCH 1272/1626] sha256 for host --- esphome/components/sha256/__init__.py | 6 +++++- tests/components/sha512/common.yaml | 4 ---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index f77820744b..045cbbe37e 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -1,10 +1,14 @@ import esphome.codegen as cg -from esphome.core import CORE, IS_MACOS +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.helpers import IS_MACOS CODEOWNERS = ["@esphome/core"] sha256_ns = cg.esphome_ns.namespace("sha256") +CONFIG_SCHEMA = cv.Schema({}) + async def to_code(config): # Add OpenSSL library for host platform diff --git a/tests/components/sha512/common.yaml b/tests/components/sha512/common.yaml index 72adf30501..2f254dbfc4 100644 --- a/tests/components/sha512/common.yaml +++ b/tests/components/sha512/common.yaml @@ -1,5 +1 @@ -wifi: - ssid: MySSID - password: password1 - sha256: From 4cdeb3f5470ac552dcc0b00fc1aea1a6dd4922dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:40:26 -0600 Subject: [PATCH 1273/1626] sha256 for host --- esphome/components/sha256/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index 045cbbe37e..91d4929a4f 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import IS_MACOS +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -10,7 +11,7 @@ sha256_ns = cg.esphome_ns.namespace("sha256") CONFIG_SCHEMA = cv.Schema({}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Add OpenSSL library for host platform if CORE.is_host: if IS_MACOS: From ee7e30eaa84f5cd927c8b807a1c7fb24c697b191 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:41:44 -0600 Subject: [PATCH 1274/1626] doh --- tests/components/sha256/common.yaml | 32 +++++++++++++++++++ .../test.bk72xx-ard.yaml} | 0 .../test.esp32-ard.yaml} | 0 .../test.esp32-idf.yaml} | 0 .../test.esp8266-ard.yaml} | 0 tests/components/sha256/test.host.yaml | 6 ++++ tests/components/sha256/test.rp2040-ard.yaml | 1 + tests/components/sha512/common.yaml | 1 - tests/components/sha512/test.bk72xx-ard.yaml | 2 -- 9 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 tests/components/sha256/common.yaml rename tests/components/{sha512/test.esp32-idf.yaml => sha256/test.bk72xx-ard.yaml} (100%) rename tests/components/{sha512/test.esp8266-ard.yaml => sha256/test.esp32-ard.yaml} (100%) rename tests/components/{sha512/test.host.yaml => sha256/test.esp32-idf.yaml} (100%) rename tests/components/{sha512/test.rp2040-ard.yaml => sha256/test.esp8266-ard.yaml} (100%) create mode 100644 tests/components/sha256/test.host.yaml create mode 100644 tests/components/sha256/test.rp2040-ard.yaml delete mode 100644 tests/components/sha512/common.yaml delete mode 100644 tests/components/sha512/test.bk72xx-ard.yaml diff --git a/tests/components/sha256/common.yaml b/tests/components/sha256/common.yaml new file mode 100644 index 0000000000..fa884c1958 --- /dev/null +++ b/tests/components/sha256/common.yaml @@ -0,0 +1,32 @@ +esphome: + on_boot: + - lambda: |- + // Test SHA256 functionality + #ifdef USE_SHA256 + using esphome::sha256::SHA256; + SHA256 hasher; + hasher.init(); + + // Test with "Hello World" - known SHA256 + const char* test_string = "Hello World"; + hasher.add(test_string, strlen(test_string)); + hasher.calculate(); + + char hex_output[65]; + hasher.get_hex(hex_output); + hex_output[64] = '\0'; + + ESP_LOGD("SHA256", "SHA256('Hello World') = %s", hex_output); + + // Expected: a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e + const char* expected = "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e"; + if (strcmp(hex_output, expected) == 0) { + ESP_LOGI("SHA256", "Test PASSED"); + } else { + ESP_LOGE("SHA256", "Test FAILED. Expected %s", expected); + } + #else + ESP_LOGW("SHA256", "SHA256 not available on this platform"); + #endif + +sha256: diff --git a/tests/components/sha512/test.esp32-idf.yaml b/tests/components/sha256/test.bk72xx-ard.yaml similarity index 100% rename from tests/components/sha512/test.esp32-idf.yaml rename to tests/components/sha256/test.bk72xx-ard.yaml diff --git a/tests/components/sha512/test.esp8266-ard.yaml b/tests/components/sha256/test.esp32-ard.yaml similarity index 100% rename from tests/components/sha512/test.esp8266-ard.yaml rename to tests/components/sha256/test.esp32-ard.yaml diff --git a/tests/components/sha512/test.host.yaml b/tests/components/sha256/test.esp32-idf.yaml similarity index 100% rename from tests/components/sha512/test.host.yaml rename to tests/components/sha256/test.esp32-idf.yaml diff --git a/tests/components/sha512/test.rp2040-ard.yaml b/tests/components/sha256/test.esp8266-ard.yaml similarity index 100% rename from tests/components/sha512/test.rp2040-ard.yaml rename to tests/components/sha256/test.esp8266-ard.yaml diff --git a/tests/components/sha256/test.host.yaml b/tests/components/sha256/test.host.yaml new file mode 100644 index 0000000000..1f50d9ea38 --- /dev/null +++ b/tests/components/sha256/test.host.yaml @@ -0,0 +1,6 @@ +# Host platform doesn't support OTA, so we can't test SHA256 indirectly +# The SHA256 component is tested via unit tests instead +esphome: + on_boot: + - lambda: |- + ESP_LOGI("SHA256", "SHA256 component available on host for library use"); diff --git a/tests/components/sha256/test.rp2040-ard.yaml b/tests/components/sha256/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha256/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha512/common.yaml b/tests/components/sha512/common.yaml deleted file mode 100644 index 2f254dbfc4..0000000000 --- a/tests/components/sha512/common.yaml +++ /dev/null @@ -1 +0,0 @@ -sha256: diff --git a/tests/components/sha512/test.bk72xx-ard.yaml b/tests/components/sha512/test.bk72xx-ard.yaml deleted file mode 100644 index 25cb37a0b4..0000000000 --- a/tests/components/sha512/test.bk72xx-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - common: !include common.yaml From d1fb3336f00c27884e999987759fd0a06ddb942f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:42:51 -0600 Subject: [PATCH 1275/1626] reen --- tests/components/sha256/test.host.yaml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/components/sha256/test.host.yaml b/tests/components/sha256/test.host.yaml index 1f50d9ea38..dade44d145 100644 --- a/tests/components/sha256/test.host.yaml +++ b/tests/components/sha256/test.host.yaml @@ -1,6 +1 @@ -# Host platform doesn't support OTA, so we can't test SHA256 indirectly -# The SHA256 component is tested via unit tests instead -esphome: - on_boot: - - lambda: |- - ESP_LOGI("SHA256", "SHA256 component available on host for library use"); +<<: !include common.yaml From ada1b00cad83e84bd78813bd8f1350388dec5aa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:44:09 -0600 Subject: [PATCH 1276/1626] use evp interface --- esphome/components/sha256/sha256.cpp | 18 ++++++++++++++---- esphome/components/sha256/sha256.h | 4 ++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 6fa17bb7c0..f3c0625a5a 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -69,13 +69,22 @@ void SHA256::calculate() { #elif defined(USE_HOST) -SHA256::~SHA256() = default; +SHA256::~SHA256() { + if (this->ctx_ && this->ctx_->ctx) { + EVP_MD_CTX_free(this->ctx_->ctx); + this->ctx_->ctx = nullptr; + } +} void SHA256::init() { if (!this->ctx_) { this->ctx_ = std::make_unique(); } - SHA256_Init(&this->ctx_->ctx); + if (this->ctx_->ctx) { + EVP_MD_CTX_free(this->ctx_->ctx); + } + this->ctx_->ctx = EVP_MD_CTX_new(); + EVP_DigestInit_ex(this->ctx_->ctx, EVP_sha256(), nullptr); this->ctx_->calculated = false; } @@ -83,7 +92,7 @@ void SHA256::add(const uint8_t *data, size_t len) { if (!this->ctx_) { this->init(); } - SHA256_Update(&this->ctx_->ctx, data, len); + EVP_DigestUpdate(this->ctx_->ctx, data, len); } void SHA256::calculate() { @@ -91,7 +100,8 @@ void SHA256::calculate() { this->init(); } if (!this->ctx_->calculated) { - SHA256_Final(this->ctx_->hash, &this->ctx_->ctx); + unsigned int len = 32; + EVP_DigestFinal_ex(this->ctx_->ctx, this->ctx_->hash, &len); this->ctx_->calculated = true; } } diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 246a7ca891..89b3218166 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -14,7 +14,7 @@ #elif defined(USE_ESP8266) || defined(USE_RP2040) #include #elif defined(USE_HOST) -#include +#include #elif defined(USE_ARDUINO) #include #endif @@ -54,7 +54,7 @@ class SHA256 { }; #elif defined(USE_HOST) struct SHA256Context { - SHA256_CTX ctx; + EVP_MD_CTX *ctx{nullptr}; uint8_t hash[32]; bool calculated{false}; }; From 3aa7da60e6c18515c8d78fa5c3ebc3b9e7938efe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:56:08 -0600 Subject: [PATCH 1277/1626] fix libretiny --- esphome/components/sha256/sha256.cpp | 31 +--------------------------- esphome/components/sha256/sha256.h | 14 ++++--------- esphome/core/helpers.h | 11 ++++++++++ 3 files changed, 16 insertions(+), 40 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index f3c0625a5a..62edb5aaa2 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -8,7 +8,7 @@ namespace esphome::sha256 { -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_LIBRETINY) SHA256::~SHA256() { if (this->ctx_) { @@ -106,35 +106,6 @@ void SHA256::calculate() { } } -#elif defined(USE_ARDUINO) - -SHA256::~SHA256() = default; - -void SHA256::init() { - if (!this->ctx_) { - this->ctx_ = std::make_unique(); - } - this->ctx_->sha.reset(); - this->ctx_->calculated = false; -} - -void SHA256::add(const uint8_t *data, size_t len) { - if (!this->ctx_) { - this->init(); - } - this->ctx_->sha.update(data, len); -} - -void SHA256::calculate() { - if (!this->ctx_) { - this->init(); - } - if (!this->ctx_->calculated) { - this->ctx_->sha.finalize(this->ctx_->hash, 32); - this->ctx_->calculated = true; - } -} - #else #error "SHA256 not supported on this platform" #endif diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 89b3218166..0cea4cdcef 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -9,14 +9,14 @@ #include #include -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_LIBRETINY) #include "mbedtls/sha256.h" #elif defined(USE_ESP8266) || defined(USE_RP2040) #include #elif defined(USE_HOST) #include -#elif defined(USE_ARDUINO) -#include +#else +#error "SHA256 not supported on this platform" #endif namespace esphome::sha256 { @@ -41,7 +41,7 @@ class SHA256 { bool equals_hex(const char *expected); protected: -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_LIBRETINY) struct SHA256Context { mbedtls_sha256_context ctx; uint8_t hash[32]; @@ -58,12 +58,6 @@ class SHA256 { uint8_t hash[32]; bool calculated{false}; }; -#elif defined(USE_ARDUINO) - struct SHA256Context { - ::SHA256 sha; - uint8_t hash[32]; - bool calculated{false}; - }; #else #error "SHA256 not supported on this platform" #endif diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 21aa159b25..a28718de5a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -82,6 +82,16 @@ template constexpr T byteswap(T n) { return m; } template<> constexpr uint8_t byteswap(uint8_t n) { return n; } +#ifdef USE_LIBRETINY +// LibreTiny's Beken framework redefines __builtin_bswap functions as non-constexpr +template<> inline uint16_t byteswap(uint16_t n) { return __builtin_bswap16(n); } +template<> inline uint32_t byteswap(uint32_t n) { return __builtin_bswap32(n); } +template<> inline uint64_t byteswap(uint64_t n) { return __builtin_bswap64(n); } +template<> inline int8_t byteswap(int8_t n) { return n; } +template<> inline int16_t byteswap(int16_t n) { return __builtin_bswap16(n); } +template<> inline int32_t byteswap(int32_t n) { return __builtin_bswap32(n); } +template<> inline int64_t byteswap(int64_t n) { return __builtin_bswap64(n); } +#else template<> constexpr uint16_t byteswap(uint16_t n) { return __builtin_bswap16(n); } template<> constexpr uint32_t byteswap(uint32_t n) { return __builtin_bswap32(n); } template<> constexpr uint64_t byteswap(uint64_t n) { return __builtin_bswap64(n); } @@ -89,6 +99,7 @@ template<> constexpr int8_t byteswap(int8_t n) { return n; } template<> constexpr int16_t byteswap(int16_t n) { return __builtin_bswap16(n); } template<> constexpr int32_t byteswap(int32_t n) { return __builtin_bswap32(n); } template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); } +#endif ///@} From f3ced331a6b1e64d0525a0965cf0097ebb67de37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:57:06 -0600 Subject: [PATCH 1278/1626] no esp32 ard needed --- tests/components/sha256/test.esp32-ard.yaml | 1 - 1 file changed, 1 deletion(-) delete mode 100644 tests/components/sha256/test.esp32-ard.yaml diff --git a/tests/components/sha256/test.esp32-ard.yaml b/tests/components/sha256/test.esp32-ard.yaml deleted file mode 100644 index dade44d145..0000000000 --- a/tests/components/sha256/test.esp32-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common.yaml From 7ea680a802b6b64ebe70b9c679cc5dfd74bee183 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 11:00:49 -0600 Subject: [PATCH 1279/1626] [core] Fix TypeError in update-all command after Path migration --- esphome/__main__.py | 10 +- tests/unit_tests/test_main.py | 244 ++++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 5 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index fff66bcd50..b63720d672 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -772,7 +772,7 @@ def command_update_all(args: ArgsProtocol) -> int | None: safe_print(f"{half_line}{middle_text}{half_line}") for f in files: - safe_print(f"Updating {color(AnsiFore.CYAN, f)}") + safe_print(f"Updating {color(AnsiFore.CYAN, str(f))}") safe_print("-" * twidth) safe_print() if CORE.dashboard: @@ -784,10 +784,10 @@ def command_update_all(args: ArgsProtocol) -> int | None: "esphome", "run", f, "--no-logs", "--device", "OTA" ) if rc == 0: - print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {f}") + print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}") success[f] = True else: - print_bar(f"[{color(AnsiFore.BOLD_RED, 'ERROR')}] {f}") + print_bar(f"[{color(AnsiFore.BOLD_RED, 'ERROR')}] {str(f)}") success[f] = False safe_print() @@ -798,9 +798,9 @@ def command_update_all(args: ArgsProtocol) -> int | None: failed = 0 for f in files: if success[f]: - safe_print(f" - {f}: {color(AnsiFore.GREEN, 'SUCCESS')}") + safe_print(f" - {str(f)}: {color(AnsiFore.GREEN, 'SUCCESS')}") else: - safe_print(f" - {f}: {color(AnsiFore.BOLD_RED, 'FAILED')}") + safe_print(f" - {str(f)}: {color(AnsiFore.BOLD_RED, 'FAILED')}") failed += 1 return failed diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e805ecb2eb..49cbd1d597 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass from pathlib import Path +import re from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -15,6 +16,7 @@ from esphome.__main__ import ( Purpose, choose_upload_log_host, command_rename, + command_update_all, command_wizard, get_port_type, has_ip_address, @@ -55,6 +57,17 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError +def strip_ansi_codes(text: str) -> str: + """Remove ANSI escape codes from text. + + This helps make test assertions cleaner by removing color codes and other + terminal formatting that can make tests brittle. + """ + # Pattern to match ANSI escape sequences + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + return ansi_escape.sub("", text) + + @dataclass class MockSerialPort: """Mock serial port for testing. @@ -1545,3 +1558,234 @@ esp32: captured = capfd.readouterr() assert "Rename failed" in captured.out + + +def test_command_update_all_path_string_conversion( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test that command_update_all properly converts Path objects to strings in output.""" + # Create test YAML files + yaml1 = tmp_path / "device1.yaml" + yaml1.write_text(""" +esphome: + name: device1 + +esp32: + board: nodemcu-32s +""") + + yaml2 = tmp_path / "device2.yaml" + yaml2.write_text(""" +esphome: + name: device2 + +esp8266: + board: nodemcuv2 +""") + + # Set up CORE + setup_core(tmp_path=tmp_path) + + # Mock successful updates + mock_run_external_process.return_value = 0 + + # Create args with the directory as configuration + args = MockArgs(configuration=[str(tmp_path)]) + + # Run command_update_all + result = command_update_all(args) + + # Should succeed + assert result == 0 + + # Capture output + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Check that Path objects were properly converted to strings + # The output should contain file paths without causing TypeError + assert "device1.yaml" in clean_output + assert "device2.yaml" in clean_output + assert "SUCCESS" in clean_output + assert "SUMMARY" in clean_output + + # Verify run_external_process was called for each file + assert mock_run_external_process.call_count == 2 + + +def test_command_update_all_with_failures( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test command_update_all handles mixed success/failure cases properly.""" + # Create test YAML files + yaml1 = tmp_path / "success_device.yaml" + yaml1.write_text(""" +esphome: + name: success_device + +esp32: + board: nodemcu-32s +""") + + yaml2 = tmp_path / "failed_device.yaml" + yaml2.write_text(""" +esphome: + name: failed_device + +esp8266: + board: nodemcuv2 +""") + + # Set up CORE + setup_core(tmp_path=tmp_path) + + # Mock mixed results - first succeeds, second fails + mock_run_external_process.side_effect = [0, 1] + + # Create args with the directory as configuration + args = MockArgs(configuration=[str(tmp_path)]) + + # Run command_update_all + result = command_update_all(args) + + # Should return 1 (failure) since one device failed + assert result == 1 + + # Capture output + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Check that both success and failure are properly displayed + assert "SUCCESS" in clean_output + assert "ERROR" in clean_output or "FAILED" in clean_output + assert "SUMMARY" in clean_output + + # Files are processed in alphabetical order, so we need to check which one succeeded/failed + # The mock_run_external_process.side_effect = [0, 1] applies to files in alphabetical order + # So "failed_device.yaml" gets 0 (success) and "success_device.yaml" gets 1 (failure) + assert "failed_device.yaml: SUCCESS" in clean_output + assert "success_device.yaml: FAILED" in clean_output + + +def test_command_update_all_empty_directory( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test command_update_all with an empty directory (no YAML files).""" + # Set up CORE with empty directory + setup_core(tmp_path=tmp_path) + + # Create args with the directory as configuration + args = MockArgs(configuration=[str(tmp_path)]) + + # Run command_update_all + result = command_update_all(args) + + # Should succeed with no updates + assert result == 0 + + # Should not have called run_external_process + mock_run_external_process.assert_not_called() + + # Capture output + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Should still show summary + assert "SUMMARY" in clean_output + + +def test_command_update_all_single_file( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test command_update_all with a single YAML file specified.""" + # Create test YAML file + yaml_file = tmp_path / "single_device.yaml" + yaml_file.write_text(""" +esphome: + name: single_device + +esp32: + board: nodemcu-32s +""") + + # Set up CORE + setup_core(tmp_path=tmp_path) + + # Mock successful update + mock_run_external_process.return_value = 0 + + # Create args with single file as configuration + args = MockArgs(configuration=[str(yaml_file)]) + + # Run command_update_all + result = command_update_all(args) + + # Should succeed + assert result == 0 + + # Capture output + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Check output + assert "single_device.yaml" in clean_output + assert "SUCCESS" in clean_output + + # Verify run_external_process was called once + mock_run_external_process.assert_called_once() + + +def test_command_update_all_path_formatting_in_color_calls( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test that Path objects are properly converted when passed to color() function.""" + # Create a test YAML file with special characters in name + yaml_file = tmp_path / "test-device_123.yaml" + yaml_file.write_text(""" +esphome: + name: test-device_123 + +esp32: + board: nodemcu-32s +""") + + # Set up CORE + setup_core(tmp_path=tmp_path) + + # Mock successful update + mock_run_external_process.return_value = 0 + + # Create args + args = MockArgs(configuration=[str(tmp_path)]) + + # Run command_update_all + result = command_update_all(args) + + # Should succeed + assert result == 0 + + # Capture output + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # The file path should appear in the output without causing TypeError + assert "test-device_123.yaml" in clean_output + + # Check that output contains expected content + assert "Updating" in clean_output + assert "SUCCESS" in clean_output + assert "SUMMARY" in clean_output + + # Should not have any Python error messages + assert "TypeError" not in clean_output + assert "can only concatenate str" not in clean_output From 56be0dfc905d5b0ae8fba797ae2734398f32d393 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 11:04:02 -0600 Subject: [PATCH 1280/1626] preen --- tests/unit_tests/test_main.py | 73 +++-------------------------------- 1 file changed, 5 insertions(+), 68 deletions(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 49cbd1d597..da280b1fd8 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1566,7 +1566,6 @@ def test_command_update_all_path_string_conversion( capfd: CaptureFixture[str], ) -> None: """Test that command_update_all properly converts Path objects to strings in output.""" - # Create test YAML files yaml1 = tmp_path / "device1.yaml" yaml1.write_text(""" esphome: @@ -1585,22 +1584,11 @@ esp8266: board: nodemcuv2 """) - # Set up CORE setup_core(tmp_path=tmp_path) - - # Mock successful updates mock_run_external_process.return_value = 0 - # Create args with the directory as configuration - args = MockArgs(configuration=[str(tmp_path)]) + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0 - # Run command_update_all - result = command_update_all(args) - - # Should succeed - assert result == 0 - - # Capture output captured = capfd.readouterr() clean_output = strip_ansi_codes(captured.out) @@ -1621,7 +1609,6 @@ def test_command_update_all_with_failures( capfd: CaptureFixture[str], ) -> None: """Test command_update_all handles mixed success/failure cases properly.""" - # Create test YAML files yaml1 = tmp_path / "success_device.yaml" yaml1.write_text(""" esphome: @@ -1640,22 +1627,14 @@ esp8266: board: nodemcuv2 """) - # Set up CORE setup_core(tmp_path=tmp_path) # Mock mixed results - first succeeds, second fails mock_run_external_process.side_effect = [0, 1] - # Create args with the directory as configuration - args = MockArgs(configuration=[str(tmp_path)]) - - # Run command_update_all - result = command_update_all(args) - # Should return 1 (failure) since one device failed - assert result == 1 + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 1 - # Capture output captured = capfd.readouterr() clean_output = strip_ansi_codes(captured.out) @@ -1677,26 +1656,14 @@ def test_command_update_all_empty_directory( capfd: CaptureFixture[str], ) -> None: """Test command_update_all with an empty directory (no YAML files).""" - # Set up CORE with empty directory setup_core(tmp_path=tmp_path) - # Create args with the directory as configuration - args = MockArgs(configuration=[str(tmp_path)]) - - # Run command_update_all - result = command_update_all(args) - - # Should succeed with no updates - assert result == 0 - - # Should not have called run_external_process + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0 mock_run_external_process.assert_not_called() - # Capture output captured = capfd.readouterr() clean_output = strip_ansi_codes(captured.out) - # Should still show summary assert "SUMMARY" in clean_output @@ -1706,7 +1673,6 @@ def test_command_update_all_single_file( capfd: CaptureFixture[str], ) -> None: """Test command_update_all with a single YAML file specified.""" - # Create test YAML file yaml_file = tmp_path / "single_device.yaml" yaml_file.write_text(""" esphome: @@ -1716,30 +1682,16 @@ esp32: board: nodemcu-32s """) - # Set up CORE setup_core(tmp_path=tmp_path) - - # Mock successful update mock_run_external_process.return_value = 0 - # Create args with single file as configuration - args = MockArgs(configuration=[str(yaml_file)]) + assert command_update_all(MockArgs(configuration=[str(yaml_file)])) == 0 - # Run command_update_all - result = command_update_all(args) - - # Should succeed - assert result == 0 - - # Capture output captured = capfd.readouterr() clean_output = strip_ansi_codes(captured.out) - # Check output assert "single_device.yaml" in clean_output assert "SUCCESS" in clean_output - - # Verify run_external_process was called once mock_run_external_process.assert_called_once() @@ -1749,7 +1701,6 @@ def test_command_update_all_path_formatting_in_color_calls( capfd: CaptureFixture[str], ) -> None: """Test that Path objects are properly converted when passed to color() function.""" - # Create a test YAML file with special characters in name yaml_file = tmp_path / "test-device_123.yaml" yaml_file.write_text(""" esphome: @@ -1759,29 +1710,15 @@ esp32: board: nodemcu-32s """) - # Set up CORE setup_core(tmp_path=tmp_path) - - # Mock successful update mock_run_external_process.return_value = 0 - # Create args - args = MockArgs(configuration=[str(tmp_path)]) + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0 - # Run command_update_all - result = command_update_all(args) - - # Should succeed - assert result == 0 - - # Capture output captured = capfd.readouterr() clean_output = strip_ansi_codes(captured.out) - # The file path should appear in the output without causing TypeError assert "test-device_123.yaml" in clean_output - - # Check that output contains expected content assert "Updating" in clean_output assert "SUCCESS" in clean_output assert "SUMMARY" in clean_output From 4729bc87fa9d32ee5333798536a51a32029a7b45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 11:07:27 -0600 Subject: [PATCH 1281/1626] [core] Fix TypeError in update-all command after Path migration (#10821) --- esphome/__main__.py | 10 +- tests/unit_tests/test_main.py | 181 ++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 5 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index fff66bcd50..b63720d672 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -772,7 +772,7 @@ def command_update_all(args: ArgsProtocol) -> int | None: safe_print(f"{half_line}{middle_text}{half_line}") for f in files: - safe_print(f"Updating {color(AnsiFore.CYAN, f)}") + safe_print(f"Updating {color(AnsiFore.CYAN, str(f))}") safe_print("-" * twidth) safe_print() if CORE.dashboard: @@ -784,10 +784,10 @@ def command_update_all(args: ArgsProtocol) -> int | None: "esphome", "run", f, "--no-logs", "--device", "OTA" ) if rc == 0: - print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {f}") + print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}") success[f] = True else: - print_bar(f"[{color(AnsiFore.BOLD_RED, 'ERROR')}] {f}") + print_bar(f"[{color(AnsiFore.BOLD_RED, 'ERROR')}] {str(f)}") success[f] = False safe_print() @@ -798,9 +798,9 @@ def command_update_all(args: ArgsProtocol) -> int | None: failed = 0 for f in files: if success[f]: - safe_print(f" - {f}: {color(AnsiFore.GREEN, 'SUCCESS')}") + safe_print(f" - {str(f)}: {color(AnsiFore.GREEN, 'SUCCESS')}") else: - safe_print(f" - {f}: {color(AnsiFore.BOLD_RED, 'FAILED')}") + safe_print(f" - {str(f)}: {color(AnsiFore.BOLD_RED, 'FAILED')}") failed += 1 return failed diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e805ecb2eb..da280b1fd8 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass from pathlib import Path +import re from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -15,6 +16,7 @@ from esphome.__main__ import ( Purpose, choose_upload_log_host, command_rename, + command_update_all, command_wizard, get_port_type, has_ip_address, @@ -55,6 +57,17 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError +def strip_ansi_codes(text: str) -> str: + """Remove ANSI escape codes from text. + + This helps make test assertions cleaner by removing color codes and other + terminal formatting that can make tests brittle. + """ + # Pattern to match ANSI escape sequences + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + return ansi_escape.sub("", text) + + @dataclass class MockSerialPort: """Mock serial port for testing. @@ -1545,3 +1558,171 @@ esp32: captured = capfd.readouterr() assert "Rename failed" in captured.out + + +def test_command_update_all_path_string_conversion( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test that command_update_all properly converts Path objects to strings in output.""" + yaml1 = tmp_path / "device1.yaml" + yaml1.write_text(""" +esphome: + name: device1 + +esp32: + board: nodemcu-32s +""") + + yaml2 = tmp_path / "device2.yaml" + yaml2.write_text(""" +esphome: + name: device2 + +esp8266: + board: nodemcuv2 +""") + + setup_core(tmp_path=tmp_path) + mock_run_external_process.return_value = 0 + + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0 + + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Check that Path objects were properly converted to strings + # The output should contain file paths without causing TypeError + assert "device1.yaml" in clean_output + assert "device2.yaml" in clean_output + assert "SUCCESS" in clean_output + assert "SUMMARY" in clean_output + + # Verify run_external_process was called for each file + assert mock_run_external_process.call_count == 2 + + +def test_command_update_all_with_failures( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test command_update_all handles mixed success/failure cases properly.""" + yaml1 = tmp_path / "success_device.yaml" + yaml1.write_text(""" +esphome: + name: success_device + +esp32: + board: nodemcu-32s +""") + + yaml2 = tmp_path / "failed_device.yaml" + yaml2.write_text(""" +esphome: + name: failed_device + +esp8266: + board: nodemcuv2 +""") + + setup_core(tmp_path=tmp_path) + + # Mock mixed results - first succeeds, second fails + mock_run_external_process.side_effect = [0, 1] + + # Should return 1 (failure) since one device failed + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 1 + + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + # Check that both success and failure are properly displayed + assert "SUCCESS" in clean_output + assert "ERROR" in clean_output or "FAILED" in clean_output + assert "SUMMARY" in clean_output + + # Files are processed in alphabetical order, so we need to check which one succeeded/failed + # The mock_run_external_process.side_effect = [0, 1] applies to files in alphabetical order + # So "failed_device.yaml" gets 0 (success) and "success_device.yaml" gets 1 (failure) + assert "failed_device.yaml: SUCCESS" in clean_output + assert "success_device.yaml: FAILED" in clean_output + + +def test_command_update_all_empty_directory( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test command_update_all with an empty directory (no YAML files).""" + setup_core(tmp_path=tmp_path) + + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0 + mock_run_external_process.assert_not_called() + + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + assert "SUMMARY" in clean_output + + +def test_command_update_all_single_file( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test command_update_all with a single YAML file specified.""" + yaml_file = tmp_path / "single_device.yaml" + yaml_file.write_text(""" +esphome: + name: single_device + +esp32: + board: nodemcu-32s +""") + + setup_core(tmp_path=tmp_path) + mock_run_external_process.return_value = 0 + + assert command_update_all(MockArgs(configuration=[str(yaml_file)])) == 0 + + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + assert "single_device.yaml" in clean_output + assert "SUCCESS" in clean_output + mock_run_external_process.assert_called_once() + + +def test_command_update_all_path_formatting_in_color_calls( + tmp_path: Path, + mock_run_external_process: Mock, + capfd: CaptureFixture[str], +) -> None: + """Test that Path objects are properly converted when passed to color() function.""" + yaml_file = tmp_path / "test-device_123.yaml" + yaml_file.write_text(""" +esphome: + name: test-device_123 + +esp32: + board: nodemcu-32s +""") + + setup_core(tmp_path=tmp_path) + mock_run_external_process.return_value = 0 + + assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0 + + captured = capfd.readouterr() + clean_output = strip_ansi_codes(captured.out) + + assert "test-device_123.yaml" in clean_output + assert "Updating" in clean_output + assert "SUCCESS" in clean_output + assert "SUMMARY" in clean_output + + # Should not have any Python error messages + assert "TypeError" not in clean_output + assert "can only concatenate str" not in clean_output From f85f5aae4697e7125401b6f9591bae33953ed4e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 11:23:57 -0600 Subject: [PATCH 1282/1626] base it --- .../components/esphome/ota/ota_esphome.cpp | 118 +++++++----------- esphome/components/esphome/ota/ota_esphome.h | 3 +- esphome/components/md5/md5.h | 22 ++-- esphome/components/sha256/sha256.h | 21 ++-- esphome/core/hash_base.h | 33 +++++ 5 files changed, 111 insertions(+), 86 deletions(-) create mode 100644 esphome/core/hash_base.h diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 8cd4152f6e..ef8bbe78c9 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -108,24 +108,6 @@ static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; // TODO: Remove this flag and all associated code in 2026.1.0 #define ALLOW_OTA_DOWNGRADE_MD5 -template struct HashTraits; - -template<> struct HashTraits { - static constexpr int NONCE_SIZE = 8; - static constexpr int HEX_SIZE = 32; - static constexpr const char *NAME = "MD5"; - static constexpr ota::OTAResponseTypes AUTH_REQUEST = ota::OTA_RESPONSE_REQUEST_AUTH; -}; - -#ifdef USE_OTA_SHA256 -template<> struct HashTraits { - static constexpr int NONCE_SIZE = 16; - static constexpr int HEX_SIZE = 64; - static constexpr const char *NAME = "SHA256"; - static constexpr ota::OTAResponseTypes AUTH_REQUEST = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; -}; -#endif - void ESPHomeOTAComponent::handle_handshake_() { /// Handle the initial OTA handshake. /// @@ -283,10 +265,12 @@ void ESPHomeOTAComponent::handle_data_() { // This prevents users from being locked out if they need to downgrade after updating // TODO: Remove this entire ifdef block in 2026.1.0 if (client_supports_sha256) { - auth_success = this->perform_hash_auth_(this->password_); + sha256::SHA256 sha_hasher; + auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, 16, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH); } else { ESP_LOGW(TAG, "Using MD5 auth for compatibility (deprecated)"); - auth_success = this->perform_hash_auth_(this->password_); + md5::MD5Digest md5_hasher; + auth_success = this->perform_hash_auth_(&md5_hasher, this->password_, 8, ota::OTA_RESPONSE_REQUEST_AUTH); } #else // Strict mode: SHA256 required on capable platforms (future default) @@ -300,7 +284,8 @@ void ESPHomeOTAComponent::handle_data_() { #else // Platform only supports MD5 - use it as the only available option // This is not a security downgrade as the platform cannot support SHA256 - auth_success = this->perform_hash_auth_(this->password_); + md5::MD5Digest md5_hasher; + auth_success = this->perform_hash_auth_(&md5_hasher, this->password_, 8, ota::OTA_RESPONSE_REQUEST_AUTH); #endif // USE_OTA_SHA256 if (!auth_success) { @@ -527,28 +512,28 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { delay(1); } -// Template function definition - placed at end to ensure all types are complete -template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &password) { - using Traits = HashTraits; +// Non-template function definition to reduce binary size +bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, size_t nonce_size, + uint8_t auth_request) { + // Get sizes from the hasher + const size_t hex_size = hasher->get_hex_size(); + const char *name = hasher->get_name(); - // Minimize stack usage by reusing buffers - // We only need 2 buffers at most at the same time - constexpr size_t hex_buffer_size = Traits::HEX_SIZE + 1; - - // These two buffers are reused throughout the function - char hex_buffer1[hex_buffer_size]; // Used for: nonce -> expected result - char hex_buffer2[hex_buffer_size]; // Used for: cnonce -> response + // Use fixed-size buffers for the maximum possible hash size (SHA256 = 64 chars) + // This avoids dynamic allocation overhead + static constexpr size_t MAX_HEX_SIZE = 65; // SHA256 hex + null terminator + char hex_buffer1[MAX_HEX_SIZE]; // Used for: nonce -> expected result + char hex_buffer2[MAX_HEX_SIZE]; // Used for: cnonce -> response // Small stack buffer for auth request and nonce seed bytes uint8_t buf[1]; uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) // Send auth request type - buf[0] = Traits::AUTH_REQUEST; + buf[0] = auth_request; this->writeall_(buf, 1); - HashClass hasher; - hasher.init(); + hasher->init(); // Generate nonce seed bytes uint32_t r1 = random_uint32(); @@ -558,79 +543,70 @@ template bool ESPHomeOTAComponent::perform_hash_auth_(const nonce_bytes[2] = (r1 >> 8) & 0xFF; nonce_bytes[3] = r1 & 0xFF; - if (Traits::NONCE_SIZE == 8) { + if (nonce_size == 8) { // MD5: 8 chars = "%08x" format = 4 bytes from one random uint32 - hasher.add(nonce_bytes, 4); - } -#ifdef USE_OTA_SHA256 - else { + hasher->add(nonce_bytes, 4); + } else { // SHA256: 16 chars = "%08x%08x" format = 8 bytes from two random uint32s uint32_t r2 = random_uint32(); nonce_bytes[4] = (r2 >> 24) & 0xFF; nonce_bytes[5] = (r2 >> 16) & 0xFF; nonce_bytes[6] = (r2 >> 8) & 0xFF; nonce_bytes[7] = r2 & 0xFF; - hasher.add(nonce_bytes, 8); + hasher->add(nonce_bytes, 8); } -#endif - hasher.calculate(); + hasher->calculate(); // Use hex_buffer1 for nonce - hasher.get_hex(hex_buffer1); - hex_buffer1[Traits::HEX_SIZE] = '\0'; - ESP_LOGV(TAG, "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); + hasher->get_hex(hex_buffer1); + hex_buffer1[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Nonce is %s", name, hex_buffer1); // Send nonce - if (!this->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { - ESP_LOGW(TAG, "Auth: Writing %s nonce failed", Traits::NAME); + if (!this->writeall_(reinterpret_cast(hex_buffer1), hex_size)) { + ESP_LOGW(TAG, "Auth: Writing %s nonce failed", name); return false; } // Prepare challenge - hasher.init(); - hasher.add(password.c_str(), password.length()); - hasher.add(hex_buffer1, Traits::HEX_SIZE); // Add nonce + hasher->init(); + hasher->add(password.c_str(), password.length()); + hasher->add(hex_buffer1, hex_size); // Add nonce // Receive cnonce into hex_buffer2 - if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { - ESP_LOGW(TAG, "Auth: Reading %s cnonce failed", Traits::NAME); + if (!this->readall_(reinterpret_cast(hex_buffer2), hex_size)) { + ESP_LOGW(TAG, "Auth: Reading %s cnonce failed", name); return false; } - hex_buffer2[Traits::HEX_SIZE] = '\0'; - ESP_LOGV(TAG, "Auth: %s CNonce is %s", Traits::NAME, hex_buffer2); + hex_buffer2[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s CNonce is %s", name, hex_buffer2); // Add cnonce to hash - hasher.add(hex_buffer2, Traits::HEX_SIZE); + hasher->add(hex_buffer2, hex_size); // Calculate result - reuse hex_buffer1 for expected - hasher.calculate(); - hasher.get_hex(hex_buffer1); - hex_buffer1[Traits::HEX_SIZE] = '\0'; - ESP_LOGV(TAG, "Auth: %s Result is %s", Traits::NAME, hex_buffer1); + hasher->calculate(); + hasher->get_hex(hex_buffer1); + hex_buffer1[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Result is %s", name, hex_buffer1); // Receive response - reuse hex_buffer2 - if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { - ESP_LOGW(TAG, "Auth: Reading %s response failed", Traits::NAME); + if (!this->readall_(reinterpret_cast(hex_buffer2), hex_size)) { + ESP_LOGW(TAG, "Auth: Reading %s response failed", name); return false; } - hex_buffer2[Traits::HEX_SIZE] = '\0'; - ESP_LOGV(TAG, "Auth: %s Response is %s", Traits::NAME, hex_buffer2); + hex_buffer2[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Response is %s", name, hex_buffer2); // Compare - bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::HEX_SIZE) == 0; + bool matches = memcmp(hex_buffer1, hex_buffer2, hex_size) == 0; if (!matches) { - ESP_LOGW(TAG, "Auth failed! %s passwords do not match", Traits::NAME); + ESP_LOGW(TAG, "Auth failed! %s passwords do not match", name); } return matches; } -// Explicit template instantiations -template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &); -#ifdef USE_OTA_SHA256 -template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &); -#endif - } // namespace esphome #endif diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 8bfb1658b2..598f990ebd 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -7,6 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences.h" +#include "esphome/core/hash_base.h" namespace esphome { @@ -30,7 +31,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { protected: void handle_handshake_(); void handle_data_(); - template bool perform_hash_auth_(const std::string &password); + bool perform_hash_auth_(HashBase *hasher, const std::string &password, size_t nonce_size, uint8_t auth_request); bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); void log_socket_error_(const LogString *msg); diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index be1df40423..5c5fbc4cff 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -3,6 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_MD5 +#include "esphome/core/hash_base.h" + #ifdef USE_ESP32 #include "esp_rom_md5.h" #define MD5_CTX_TYPE md5_context_t @@ -26,20 +28,20 @@ namespace esphome { namespace md5 { -class MD5Digest { +class MD5Digest : public HashBase { public: MD5Digest() = default; - ~MD5Digest() = default; + ~MD5Digest() override = default; /// Initialize a new MD5 digest computation. - void init(); + void init() override; /// Add bytes of data for the digest. - void add(const uint8_t *data, size_t len); - void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + void add(const uint8_t *data, size_t len) override; + void add(const char *data, size_t len) override { this->add((const uint8_t *) data, len); } /// Compute the digest, based on the provided data. - void calculate(); + void calculate() override; /// Retrieve the MD5 digest as bytes. /// The output must be able to hold 16 bytes or more. @@ -47,7 +49,13 @@ class MD5Digest { /// Retrieve the MD5 digest as hex characters. /// The output must be able to hold 32 bytes or more. - void get_hex(char *output); + void get_hex(char *output) override; + + /// Get the size of the hex output (32 for MD5) + size_t get_hex_size() const override { return 32; } + + /// Get the algorithm name for logging + const char *get_name() const override { return "MD5"; } /// Compare the digest against a provided byte-encoded digest (16 bytes). bool equals_bytes(const uint8_t *expected); diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 0cea4cdcef..d3e7ee60db 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -8,6 +8,7 @@ #include #include #include +#include "esphome/core/hash_base.h" #if defined(USE_ESP32) || defined(USE_LIBRETINY) #include "mbedtls/sha256.h" @@ -21,22 +22,28 @@ namespace esphome::sha256 { -class SHA256 { +class SHA256 : public esphome::HashBase { public: SHA256() = default; - ~SHA256(); + ~SHA256() override; - void init(); - void add(const uint8_t *data, size_t len); - void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + void init() override; + void add(const uint8_t *data, size_t len) override; + void add(const char *data, size_t len) override { this->add((const uint8_t *) data, len); } void add(const std::string &data) { this->add(data.c_str(), data.length()); } - void calculate(); + void calculate() override; void get_bytes(uint8_t *output); - void get_hex(char *output); + void get_hex(char *output) override; std::string get_hex_string(); + /// Get the size of the hex output (64 for SHA256) + size_t get_hex_size() const override { return 64; } + + /// Get the algorithm name for logging + const char *get_name() const override { return "SHA256"; } + bool equals_bytes(const uint8_t *expected); bool equals_hex(const char *expected); diff --git a/esphome/core/hash_base.h b/esphome/core/hash_base.h new file mode 100644 index 0000000000..f4c5dc630d --- /dev/null +++ b/esphome/core/hash_base.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +namespace esphome { + +/// Base class for hash algorithms +class HashBase { + public: + virtual ~HashBase() = default; + + /// Initialize a new hash computation + virtual void init() = 0; + + /// Add bytes of data for the hash + virtual void add(const uint8_t *data, size_t len) = 0; + virtual void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + + /// Compute the hash based on provided data + virtual void calculate() = 0; + + /// Retrieve the hash as hex characters + virtual void get_hex(char *output) = 0; + + /// Get the size of the hex output (32 for MD5, 64 for SHA256) + virtual size_t get_hex_size() const = 0; + + /// Get the algorithm name for logging + virtual const char *get_name() const = 0; +}; + +} // namespace esphome From 5e9a5798bd635933cc1ed4ecb40656864ceaf415 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 11:30:05 -0600 Subject: [PATCH 1283/1626] cleanup --- esphome/components/md5/md5.h | 1 - esphome/components/sha256/sha256.h | 3 +-- esphome/core/hash_base.h | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index 5c5fbc4cff..3951e635c8 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -38,7 +38,6 @@ class MD5Digest : public HashBase { /// Add bytes of data for the digest. void add(const uint8_t *data, size_t len) override; - void add(const char *data, size_t len) override { this->add((const uint8_t *) data, len); } /// Compute the digest, based on the provided data. void calculate() override; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index d3e7ee60db..35552bc92c 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -29,8 +29,7 @@ class SHA256 : public esphome::HashBase { void init() override; void add(const uint8_t *data, size_t len) override; - void add(const char *data, size_t len) override { this->add((const uint8_t *) data, len); } - void add(const std::string &data) { this->add(data.c_str(), data.length()); } + void add(const std::string &data) { this->add((const uint8_t *) data.c_str(), data.length()); } void calculate() override; diff --git a/esphome/core/hash_base.h b/esphome/core/hash_base.h index f4c5dc630d..66221083e7 100644 --- a/esphome/core/hash_base.h +++ b/esphome/core/hash_base.h @@ -15,7 +15,7 @@ class HashBase { /// Add bytes of data for the hash virtual void add(const uint8_t *data, size_t len) = 0; - virtual void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } /// Compute the hash based on provided data virtual void calculate() = 0; From d5b57384bfdd47bd4d86838f49cfefee73903702 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 11:39:25 -0600 Subject: [PATCH 1284/1626] was overly complex --- esphome/components/sha256/sha256.cpp | 101 +++++++-------------------- esphome/components/sha256/sha256.h | 23 +++--- 2 files changed, 33 insertions(+), 91 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 62edb5aaa2..a1f757cf0d 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -10,99 +10,67 @@ namespace esphome::sha256 { #if defined(USE_ESP32) || defined(USE_LIBRETINY) -SHA256::~SHA256() { - if (this->ctx_) { - mbedtls_sha256_free(&this->ctx_->ctx); - } -} +SHA256::~SHA256() { mbedtls_sha256_free(&this->ctx_); } void SHA256::init() { - if (!this->ctx_) { - this->ctx_ = std::make_unique(); - } - mbedtls_sha256_init(&this->ctx_->ctx); - mbedtls_sha256_starts(&this->ctx_->ctx, 0); // 0 = SHA256, not SHA224 + mbedtls_sha256_init(&this->ctx_); + mbedtls_sha256_starts(&this->ctx_, 0); // 0 = SHA256, not SHA224 } -void SHA256::add(const uint8_t *data, size_t len) { - if (!this->ctx_) { - this->init(); - } - mbedtls_sha256_update(&this->ctx_->ctx, data, len); -} +void SHA256::add(const uint8_t *data, size_t len) { mbedtls_sha256_update(&this->ctx_, data, len); } -void SHA256::calculate() { - if (!this->ctx_) { - this->init(); - } - mbedtls_sha256_finish(&this->ctx_->ctx, this->ctx_->hash); -} +void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_, this->hash_); } #elif defined(USE_ESP8266) || defined(USE_RP2040) SHA256::~SHA256() = default; void SHA256::init() { - if (!this->ctx_) { - this->ctx_ = std::make_unique(); - } - br_sha256_init(&this->ctx_->ctx); - this->ctx_->calculated = false; + br_sha256_init(&this->ctx_); + this->calculated_ = false; } -void SHA256::add(const uint8_t *data, size_t len) { - if (!this->ctx_) { - this->init(); - } - br_sha256_update(&this->ctx_->ctx, data, len); -} +void SHA256::add(const uint8_t *data, size_t len) { br_sha256_update(&this->ctx_, data, len); } void SHA256::calculate() { - if (!this->ctx_) { - this->init(); - } - if (!this->ctx_->calculated) { - br_sha256_out(&this->ctx_->ctx, this->ctx_->hash); - this->ctx_->calculated = true; + if (!this->calculated_) { + br_sha256_out(&this->ctx_, this->hash_); + this->calculated_ = true; } } #elif defined(USE_HOST) SHA256::~SHA256() { - if (this->ctx_ && this->ctx_->ctx) { - EVP_MD_CTX_free(this->ctx_->ctx); - this->ctx_->ctx = nullptr; + if (this->ctx_) { + EVP_MD_CTX_free(this->ctx_); } } void SHA256::init() { - if (!this->ctx_) { - this->ctx_ = std::make_unique(); + if (this->ctx_) { + EVP_MD_CTX_free(this->ctx_); } - if (this->ctx_->ctx) { - EVP_MD_CTX_free(this->ctx_->ctx); - } - this->ctx_->ctx = EVP_MD_CTX_new(); - EVP_DigestInit_ex(this->ctx_->ctx, EVP_sha256(), nullptr); - this->ctx_->calculated = false; + this->ctx_ = EVP_MD_CTX_new(); + EVP_DigestInit_ex(this->ctx_, EVP_sha256(), nullptr); + this->calculated_ = false; } void SHA256::add(const uint8_t *data, size_t len) { if (!this->ctx_) { this->init(); } - EVP_DigestUpdate(this->ctx_->ctx, data, len); + EVP_DigestUpdate(this->ctx_, data, len); } void SHA256::calculate() { if (!this->ctx_) { this->init(); } - if (!this->ctx_->calculated) { + if (!this->calculated_) { unsigned int len = 32; - EVP_DigestFinal_ex(this->ctx_->ctx, this->ctx_->hash, &len); - this->ctx_->calculated = true; + EVP_DigestFinal_ex(this->ctx_, this->hash_, &len); + this->calculated_ = true; } } @@ -110,22 +78,11 @@ void SHA256::calculate() { #error "SHA256 not supported on this platform" #endif -void SHA256::get_bytes(uint8_t *output) { - if (!this->ctx_) { - memset(output, 0, 32); - return; - } - memcpy(output, this->ctx_->hash, 32); -} +void SHA256::get_bytes(uint8_t *output) { memcpy(output, this->hash_, 32); } void SHA256::get_hex(char *output) { - if (!this->ctx_) { - memset(output, '0', 64); - output[64] = '\0'; - return; - } for (size_t i = 0; i < 32; i++) { - uint8_t byte = this->ctx_->hash[i]; + uint8_t byte = this->hash_[i]; output[i * 2] = format_hex_char(byte >> 4); output[i * 2 + 1] = format_hex_char(byte & 0x0F); } @@ -137,17 +94,9 @@ std::string SHA256::get_hex_string() { return std::string(buf); } -bool SHA256::equals_bytes(const uint8_t *expected) { - if (!this->ctx_) { - return false; - } - return memcmp(this->ctx_->hash, expected, 32) == 0; -} +bool SHA256::equals_bytes(const uint8_t *expected) { return memcmp(this->hash_, expected, 32) == 0; } bool SHA256::equals_hex(const char *expected) { - if (!this->ctx_) { - return false; - } uint8_t parsed[32]; if (!parse_hex(expected, parsed, 32)) { return false; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 35552bc92c..30121e20f2 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -48,26 +48,19 @@ class SHA256 : public esphome::HashBase { protected: #if defined(USE_ESP32) || defined(USE_LIBRETINY) - struct SHA256Context { - mbedtls_sha256_context ctx; - uint8_t hash[32]; - }; + mbedtls_sha256_context ctx_{}; + uint8_t hash_[32]; #elif defined(USE_ESP8266) || defined(USE_RP2040) - struct SHA256Context { - br_sha256_context ctx; - uint8_t hash[32]; - bool calculated{false}; - }; + br_sha256_context ctx_{}; + uint8_t hash_[32]; + bool calculated_{false}; #elif defined(USE_HOST) - struct SHA256Context { - EVP_MD_CTX *ctx{nullptr}; - uint8_t hash[32]; - bool calculated{false}; - }; + EVP_MD_CTX *ctx_{nullptr}; + uint8_t hash_[32]; + bool calculated_{false}; #else #error "SHA256 not supported on this platform" #endif - std::unique_ptr ctx_; }; } // namespace esphome::sha256 From 9cbbb167db37174a60125bde251a01c6e51a76c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 11:47:15 -0600 Subject: [PATCH 1285/1626] preen --- .../components/esphome/ota/ota_esphome.cpp | 29 +++++++++++-------- esphome/components/esphome/ota/ota_esphome.h | 4 ++- esphome/components/md5/md5.h | 3 -- esphome/components/sha256/sha256.h | 3 -- esphome/core/hash_base.h | 3 -- 5 files changed, 20 insertions(+), 22 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ef8bbe78c9..206905d0d8 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -266,11 +266,13 @@ void ESPHomeOTAComponent::handle_data_() { // TODO: Remove this entire ifdef block in 2026.1.0 if (client_supports_sha256) { sha256::SHA256 sha_hasher; - auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, 16, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH); + auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, 16, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, + LOG_STR("SHA256")); } else { ESP_LOGW(TAG, "Using MD5 auth for compatibility (deprecated)"); md5::MD5Digest md5_hasher; - auth_success = this->perform_hash_auth_(&md5_hasher, this->password_, 8, ota::OTA_RESPONSE_REQUEST_AUTH); + auth_success = + this->perform_hash_auth_(&md5_hasher, this->password_, 8, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5")); } #else // Strict mode: SHA256 required on capable platforms (future default) @@ -512,12 +514,15 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { delay(1); } +void ESPHomeOTAComponent::log_auth_warning_(const LogString *action, const LogString *hash_name) { + ESP_LOGW(TAG, "Auth: %s %s failed", LOG_STR_ARG(action), LOG_STR_ARG(hash_name)); +} + // Non-template function definition to reduce binary size bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, size_t nonce_size, - uint8_t auth_request) { + uint8_t auth_request, const LogString *name) { // Get sizes from the hasher const size_t hex_size = hasher->get_hex_size(); - const char *name = hasher->get_name(); // Use fixed-size buffers for the maximum possible hash size (SHA256 = 64 chars) // This avoids dynamic allocation overhead @@ -560,11 +565,11 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string // Use hex_buffer1 for nonce hasher->get_hex(hex_buffer1); hex_buffer1[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Nonce is %s", name, hex_buffer1); + ESP_LOGV(TAG, "Auth: %s Nonce is %s", LOG_STR_ARG(name), hex_buffer1); // Send nonce if (!this->writeall_(reinterpret_cast(hex_buffer1), hex_size)) { - ESP_LOGW(TAG, "Auth: Writing %s nonce failed", name); + this->log_auth_warning_(LOG_STR("Writing nonce"), name); return false; } @@ -575,11 +580,11 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string // Receive cnonce into hex_buffer2 if (!this->readall_(reinterpret_cast(hex_buffer2), hex_size)) { - ESP_LOGW(TAG, "Auth: Reading %s cnonce failed", name); + this->log_auth_warning_(LOG_STR("Reading cnonce"), name); return false; } hex_buffer2[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s CNonce is %s", name, hex_buffer2); + ESP_LOGV(TAG, "Auth: %s CNonce is %s", LOG_STR_ARG(name), hex_buffer2); // Add cnonce to hash hasher->add(hex_buffer2, hex_size); @@ -588,21 +593,21 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string hasher->calculate(); hasher->get_hex(hex_buffer1); hex_buffer1[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Result is %s", name, hex_buffer1); + ESP_LOGV(TAG, "Auth: %s Result is %s", LOG_STR_ARG(name), hex_buffer1); // Receive response - reuse hex_buffer2 if (!this->readall_(reinterpret_cast(hex_buffer2), hex_size)) { - ESP_LOGW(TAG, "Auth: Reading %s response failed", name); + this->log_auth_warning_(LOG_STR("Reading response"), name); return false; } hex_buffer2[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Response is %s", name, hex_buffer2); + ESP_LOGV(TAG, "Auth: %s Response is %s", LOG_STR_ARG(name), hex_buffer2); // Compare bool matches = memcmp(hex_buffer1, hex_buffer2, hex_size) == 0; if (!matches) { - ESP_LOGW(TAG, "Auth failed! %s passwords do not match", name); + ESP_LOGW(TAG, "Auth failed! %s passwords do not match", LOG_STR_ARG(name)); } return matches; diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 598f990ebd..5d806028ac 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -31,12 +31,14 @@ class ESPHomeOTAComponent : public ota::OTAComponent { protected: void handle_handshake_(); void handle_data_(); - bool perform_hash_auth_(HashBase *hasher, const std::string &password, size_t nonce_size, uint8_t auth_request); + bool perform_hash_auth_(HashBase *hasher, const std::string &password, size_t nonce_size, uint8_t auth_request, + const LogString *name); bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); void log_socket_error_(const LogString *msg); void log_read_error_(const LogString *what); void log_start_(const LogString *phase); + void log_auth_warning_(const LogString *action, const LogString *hash_name); void cleanup_connection_(); void yield_and_feed_watchdog_(); diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index 3951e635c8..d777d7a143 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -53,9 +53,6 @@ class MD5Digest : public HashBase { /// Get the size of the hex output (32 for MD5) size_t get_hex_size() const override { return 32; } - /// Get the algorithm name for logging - const char *get_name() const override { return "MD5"; } - /// Compare the digest against a provided byte-encoded digest (16 bytes). bool equals_bytes(const uint8_t *expected); diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 30121e20f2..5af4c9a417 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -40,9 +40,6 @@ class SHA256 : public esphome::HashBase { /// Get the size of the hex output (64 for SHA256) size_t get_hex_size() const override { return 64; } - /// Get the algorithm name for logging - const char *get_name() const override { return "SHA256"; } - bool equals_bytes(const uint8_t *expected); bool equals_hex(const char *expected); diff --git a/esphome/core/hash_base.h b/esphome/core/hash_base.h index 66221083e7..ee646698d7 100644 --- a/esphome/core/hash_base.h +++ b/esphome/core/hash_base.h @@ -25,9 +25,6 @@ class HashBase { /// Get the size of the hex output (32 for MD5, 64 for SHA256) virtual size_t get_hex_size() const = 0; - - /// Get the algorithm name for logging - virtual const char *get_name() const = 0; }; } // namespace esphome From 0272228ecedbde2f4611e9c49ed73b99571b625f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 12:02:41 -0600 Subject: [PATCH 1286/1626] preen --- esphome/components/md5/md5.cpp | 16 +--------------- esphome/components/md5/md5.h | 7 ++----- esphome/components/sha256/sha256.cpp | 22 +++++++--------------- esphome/components/sha256/sha256.h | 10 +++++----- esphome/core/hash_base.h | 15 +++++++++++++++ 5 files changed, 30 insertions(+), 40 deletions(-) diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index 21bd2e1cab..202e25cadd 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -49,21 +49,7 @@ void MD5Digest::get_hex(char *output) { } } -bool MD5Digest::equals_bytes(const uint8_t *expected) { - for (size_t i = 0; i < 16; i++) { - if (expected[i] != this->digest_[i]) { - return false; - } - } - return true; -} - -bool MD5Digest::equals_hex(const char *expected) { - uint8_t parsed[16]; - if (!parse_hex(expected, parsed, 16)) - return false; - return equals_bytes(parsed); -} +bool MD5Digest::equals_bytes(const uint8_t *expected) { return memcmp(this->digest_, expected, 16) == 0; } } // namespace md5 } // namespace esphome diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index d777d7a143..4c741ea536 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -53,11 +53,8 @@ class MD5Digest : public HashBase { /// Get the size of the hex output (32 for MD5) size_t get_hex_size() const override { return 32; } - /// Compare the digest against a provided byte-encoded digest (16 bytes). - bool equals_bytes(const uint8_t *expected); - - /// Compare the digest against a provided hex-encoded digest (32 bytes). - bool equals_hex(const char *expected); + /// Compare the digest against a provided byte-encoded digest (16 bytes) + bool equals_bytes(const uint8_t *expected) override; protected: MD5_CTX_TYPE ctx_{}; diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index a1f757cf0d..8042700a10 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -19,7 +19,7 @@ void SHA256::init() { void SHA256::add(const uint8_t *data, size_t len) { mbedtls_sha256_update(&this->ctx_, data, len); } -void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_, this->hash_); } +void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_, this->digest_); } #elif defined(USE_ESP8266) || defined(USE_RP2040) @@ -34,7 +34,7 @@ void SHA256::add(const uint8_t *data, size_t len) { br_sha256_update(&this->ctx_ void SHA256::calculate() { if (!this->calculated_) { - br_sha256_out(&this->ctx_, this->hash_); + br_sha256_out(&this->ctx_, this->digest_); this->calculated_ = true; } } @@ -69,7 +69,7 @@ void SHA256::calculate() { } if (!this->calculated_) { unsigned int len = 32; - EVP_DigestFinal_ex(this->ctx_, this->hash_, &len); + EVP_DigestFinal_ex(this->ctx_, this->digest_, &len); this->calculated_ = true; } } @@ -78,32 +78,24 @@ void SHA256::calculate() { #error "SHA256 not supported on this platform" #endif -void SHA256::get_bytes(uint8_t *output) { memcpy(output, this->hash_, 32); } +void SHA256::get_bytes(uint8_t *output) { memcpy(output, this->digest_, 32); } void SHA256::get_hex(char *output) { for (size_t i = 0; i < 32; i++) { - uint8_t byte = this->hash_[i]; + uint8_t byte = this->digest_[i]; output[i * 2] = format_hex_char(byte >> 4); output[i * 2 + 1] = format_hex_char(byte & 0x0F); } } +bool SHA256::equals_bytes(const uint8_t *expected) { return memcmp(this->digest_, expected, 32) == 0; } + std::string SHA256::get_hex_string() { char buf[65]; this->get_hex(buf); return std::string(buf); } -bool SHA256::equals_bytes(const uint8_t *expected) { return memcmp(this->hash_, expected, 32) == 0; } - -bool SHA256::equals_hex(const char *expected) { - uint8_t parsed[32]; - if (!parse_hex(expected, parsed, 32)) { - return false; - } - return this->equals_bytes(parsed); -} - } // namespace esphome::sha256 #endif // Platform check diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 5af4c9a417..9ba1b19802 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -40,20 +40,20 @@ class SHA256 : public esphome::HashBase { /// Get the size of the hex output (64 for SHA256) size_t get_hex_size() const override { return 64; } - bool equals_bytes(const uint8_t *expected); - bool equals_hex(const char *expected); + /// Compare the digest against a provided byte-encoded digest (32 bytes) + bool equals_bytes(const uint8_t *expected) override; protected: #if defined(USE_ESP32) || defined(USE_LIBRETINY) mbedtls_sha256_context ctx_{}; - uint8_t hash_[32]; + uint8_t digest_[32]; #elif defined(USE_ESP8266) || defined(USE_RP2040) br_sha256_context ctx_{}; - uint8_t hash_[32]; + uint8_t digest_[32]; bool calculated_{false}; #elif defined(USE_HOST) EVP_MD_CTX *ctx_{nullptr}; - uint8_t hash_[32]; + uint8_t digest_[32]; bool calculated_{false}; #else #error "SHA256 not supported on this platform" diff --git a/esphome/core/hash_base.h b/esphome/core/hash_base.h index ee646698d7..48944b9e7a 100644 --- a/esphome/core/hash_base.h +++ b/esphome/core/hash_base.h @@ -2,6 +2,8 @@ #include #include +#include +#include "esphome/core/helpers.h" namespace esphome { @@ -23,6 +25,19 @@ class HashBase { /// Retrieve the hash as hex characters virtual void get_hex(char *output) = 0; + /// Compare the hash against a provided byte-encoded hash + virtual bool equals_bytes(const uint8_t *expected) = 0; + + /// Compare the hash against a provided hex-encoded hash + bool equals_hex(const char *expected) { + const size_t hash_bytes = this->get_hex_size() / 2; + uint8_t parsed[32]; // Max size for SHA256 + if (!parse_hex(expected, parsed, hash_bytes)) { + return false; + } + return this->equals_bytes(parsed); + } + /// Get the size of the hex output (32 for MD5, 64 for SHA256) virtual size_t get_hex_size() const = 0; }; From 8ea13115a0304f1b124bb526d0170982fc55e211 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 12:06:12 -0600 Subject: [PATCH 1287/1626] preen --- esphome/components/md5/md5.cpp | 12 ------------ esphome/components/md5/md5.h | 12 ------------ esphome/components/sha256/sha256.cpp | 12 ------------ esphome/components/sha256/sha256.h | 8 -------- esphome/core/hash_base.h | 23 +++++++++++++++++++++-- 5 files changed, 21 insertions(+), 46 deletions(-) diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index 202e25cadd..866f00eda4 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -39,18 +39,6 @@ void MD5Digest::add(const uint8_t *data, size_t len) { br_md5_update(&this->ctx_ void MD5Digest::calculate() { br_md5_out(&this->ctx_, this->digest_); } #endif // USE_RP2040 -void MD5Digest::get_bytes(uint8_t *output) { memcpy(output, this->digest_, 16); } - -void MD5Digest::get_hex(char *output) { - for (size_t i = 0; i < 16; i++) { - uint8_t byte = this->digest_[i]; - output[i * 2] = format_hex_char(byte >> 4); - output[i * 2 + 1] = format_hex_char(byte & 0x0F); - } -} - -bool MD5Digest::equals_bytes(const uint8_t *expected) { return memcmp(this->digest_, expected, 16) == 0; } - } // namespace md5 } // namespace esphome #endif diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index 4c741ea536..cb0acefd7d 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -42,23 +42,11 @@ class MD5Digest : public HashBase { /// Compute the digest, based on the provided data. void calculate() override; - /// Retrieve the MD5 digest as bytes. - /// The output must be able to hold 16 bytes or more. - void get_bytes(uint8_t *output); - - /// Retrieve the MD5 digest as hex characters. - /// The output must be able to hold 32 bytes or more. - void get_hex(char *output) override; - /// Get the size of the hex output (32 for MD5) size_t get_hex_size() const override { return 32; } - /// Compare the digest against a provided byte-encoded digest (16 bytes) - bool equals_bytes(const uint8_t *expected) override; - protected: MD5_CTX_TYPE ctx_{}; - uint8_t digest_[16]; }; } // namespace md5 diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 8042700a10..24d15be4a7 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -78,18 +78,6 @@ void SHA256::calculate() { #error "SHA256 not supported on this platform" #endif -void SHA256::get_bytes(uint8_t *output) { memcpy(output, this->digest_, 32); } - -void SHA256::get_hex(char *output) { - for (size_t i = 0; i < 32; i++) { - uint8_t byte = this->digest_[i]; - output[i * 2] = format_hex_char(byte >> 4); - output[i * 2 + 1] = format_hex_char(byte & 0x0F); - } -} - -bool SHA256::equals_bytes(const uint8_t *expected) { return memcmp(this->digest_, expected, 32) == 0; } - std::string SHA256::get_hex_string() { char buf[65]; this->get_hex(buf); diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 9ba1b19802..f650008ac7 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -33,27 +33,19 @@ class SHA256 : public esphome::HashBase { void calculate() override; - void get_bytes(uint8_t *output); - void get_hex(char *output) override; std::string get_hex_string(); /// Get the size of the hex output (64 for SHA256) size_t get_hex_size() const override { return 64; } - /// Compare the digest against a provided byte-encoded digest (32 bytes) - bool equals_bytes(const uint8_t *expected) override; - protected: #if defined(USE_ESP32) || defined(USE_LIBRETINY) mbedtls_sha256_context ctx_{}; - uint8_t digest_[32]; #elif defined(USE_ESP8266) || defined(USE_RP2040) br_sha256_context ctx_{}; - uint8_t digest_[32]; bool calculated_{false}; #elif defined(USE_HOST) EVP_MD_CTX *ctx_{nullptr}; - uint8_t digest_[32]; bool calculated_{false}; #else #error "SHA256 not supported on this platform" diff --git a/esphome/core/hash_base.h b/esphome/core/hash_base.h index 48944b9e7a..e35aee0475 100644 --- a/esphome/core/hash_base.h +++ b/esphome/core/hash_base.h @@ -22,11 +22,27 @@ class HashBase { /// Compute the hash based on provided data virtual void calculate() = 0; + /// Retrieve the hash as bytes + void get_bytes(uint8_t *output) { + const size_t hash_bytes = this->get_hex_size() / 2; + memcpy(output, this->digest_, hash_bytes); + } + /// Retrieve the hash as hex characters - virtual void get_hex(char *output) = 0; + void get_hex(char *output) { + const size_t hash_bytes = this->get_hex_size() / 2; + for (size_t i = 0; i < hash_bytes; i++) { + uint8_t byte = this->digest_[i]; + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); + } + } /// Compare the hash against a provided byte-encoded hash - virtual bool equals_bytes(const uint8_t *expected) = 0; + bool equals_bytes(const uint8_t *expected) { + const size_t hash_bytes = this->get_hex_size() / 2; + return memcmp(this->digest_, expected, hash_bytes) == 0; + } /// Compare the hash against a provided hex-encoded hash bool equals_hex(const char *expected) { @@ -40,6 +56,9 @@ class HashBase { /// Get the size of the hex output (32 for MD5, 64 for SHA256) virtual size_t get_hex_size() const = 0; + + protected: + uint8_t digest_[32]; // Common digest storage, sized for largest hash (SHA256) }; } // namespace esphome From f86d9b0aa6dec773d0f8aa915f8399de2e730675 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 12:09:46 -0600 Subject: [PATCH 1288/1626] remove testing --- esphome/components/sha256/sha256.cpp | 6 ------ esphome/components/sha256/sha256.h | 2 -- 2 files changed, 8 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 24d15be4a7..199460acbc 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -78,12 +78,6 @@ void SHA256::calculate() { #error "SHA256 not supported on this platform" #endif -std::string SHA256::get_hex_string() { - char buf[65]; - this->get_hex(buf); - return std::string(buf); -} - } // namespace esphome::sha256 #endif // Platform check diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index f650008ac7..78cccd80f2 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -33,8 +33,6 @@ class SHA256 : public esphome::HashBase { void calculate() override; - std::string get_hex_string(); - /// Get the size of the hex output (64 for SHA256) size_t get_hex_size() const override { return 64; } From 61d6034838464bb291c9c5c37f5e94b4b9267bbf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 12:20:18 -0600 Subject: [PATCH 1289/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 206905d0d8..0116bcc2f8 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -530,13 +530,11 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string char hex_buffer1[MAX_HEX_SIZE]; // Used for: nonce -> expected result char hex_buffer2[MAX_HEX_SIZE]; // Used for: cnonce -> response - // Small stack buffer for auth request and nonce seed bytes - uint8_t buf[1]; + // Small stack buffer for nonce seed bytes uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) // Send auth request type - buf[0] = auth_request; - this->writeall_(buf, 1); + this->writeall_(&auth_request, 1); hasher->init(); From f58ea07ac3739f1e777d571fcacea7c3f933ec9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 12:22:50 -0600 Subject: [PATCH 1290/1626] preen --- .../components/esphome/ota/ota_esphome.cpp | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 0116bcc2f8..cf19a206e9 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -524,11 +524,8 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string // Get sizes from the hasher const size_t hex_size = hasher->get_hex_size(); - // Use fixed-size buffers for the maximum possible hash size (SHA256 = 64 chars) - // This avoids dynamic allocation overhead - static constexpr size_t MAX_HEX_SIZE = 65; // SHA256 hex + null terminator - char hex_buffer1[MAX_HEX_SIZE]; // Used for: nonce -> expected result - char hex_buffer2[MAX_HEX_SIZE]; // Used for: cnonce -> response + // Single hex buffer - reused for nonce, cnonce, expected, response + char hex_buffer[65]; // SHA256 hex + null terminator // Small stack buffer for nonce seed bytes uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) @@ -560,49 +557,47 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string } hasher->calculate(); - // Use hex_buffer1 for nonce - hasher->get_hex(hex_buffer1); - hex_buffer1[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Nonce is %s", LOG_STR_ARG(name), hex_buffer1); + // Generate and send nonce + hasher->get_hex(hex_buffer); + hex_buffer[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Nonce is %s", LOG_STR_ARG(name), hex_buffer); - // Send nonce - if (!this->writeall_(reinterpret_cast(hex_buffer1), hex_size)) { + if (!this->writeall_(reinterpret_cast(hex_buffer), hex_size)) { this->log_auth_warning_(LOG_STR("Writing nonce"), name); return false; } - // Prepare challenge + // Start challenge: password + nonce hasher->init(); hasher->add(password.c_str(), password.length()); - hasher->add(hex_buffer1, hex_size); // Add nonce + hasher->add(hex_buffer, hex_size); - // Receive cnonce into hex_buffer2 - if (!this->readall_(reinterpret_cast(hex_buffer2), hex_size)) { + // Read cnonce and add to hash + if (!this->readall_(reinterpret_cast(hex_buffer), hex_size)) { this->log_auth_warning_(LOG_STR("Reading cnonce"), name); return false; } - hex_buffer2[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s CNonce is %s", LOG_STR_ARG(name), hex_buffer2); + hex_buffer[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s CNonce is %s", LOG_STR_ARG(name), hex_buffer); - // Add cnonce to hash - hasher->add(hex_buffer2, hex_size); - - // Calculate result - reuse hex_buffer1 for expected + hasher->add(hex_buffer, hex_size); hasher->calculate(); - hasher->get_hex(hex_buffer1); - hex_buffer1[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Result is %s", LOG_STR_ARG(name), hex_buffer1); - // Receive response - reuse hex_buffer2 - if (!this->readall_(reinterpret_cast(hex_buffer2), hex_size)) { + // Get expected result + hasher->get_hex(hex_buffer); + hex_buffer[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Result is %s", LOG_STR_ARG(name), hex_buffer); + + // Read response and compare in-place + char response[65]; + if (!this->readall_(reinterpret_cast(response), hex_size)) { this->log_auth_warning_(LOG_STR("Reading response"), name); return false; } - hex_buffer2[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Response is %s", LOG_STR_ARG(name), hex_buffer2); + response[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Response is %s", LOG_STR_ARG(name), response); - // Compare - bool matches = memcmp(hex_buffer1, hex_buffer2, hex_size) == 0; + bool matches = memcmp(hex_buffer, response, hex_size) == 0; if (!matches) { ESP_LOGW(TAG, "Auth failed! %s passwords do not match", LOG_STR_ARG(name)); From 457399f3afa65293dc297655d6f4fd3607e02589 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 12:23:49 -0600 Subject: [PATCH 1291/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 2 ++ esphome/components/esphome/ota/ota_esphome.h | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index cf19a206e9..6089f744b8 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -514,6 +514,7 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { delay(1); } +#ifdef USE_OTA_PASSWORD void ESPHomeOTAComponent::log_auth_warning_(const LogString *action, const LogString *hash_name) { ESP_LOGW(TAG, "Auth: %s %s failed", LOG_STR_ARG(action), LOG_STR_ARG(hash_name)); } @@ -605,6 +606,7 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string return matches; } +#endif // USE_OTA_PASSWORD } // namespace esphome #endif diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 5d806028ac..8eaad9c99f 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -31,14 +31,16 @@ class ESPHomeOTAComponent : public ota::OTAComponent { protected: void handle_handshake_(); void handle_data_(); +#ifdef USE_OTA_PASSWORD bool perform_hash_auth_(HashBase *hasher, const std::string &password, size_t nonce_size, uint8_t auth_request, const LogString *name); + void log_auth_warning_(const LogString *action, const LogString *hash_name); +#endif // USE_OTA_PASSWORD bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); void log_socket_error_(const LogString *msg); void log_read_error_(const LogString *what); void log_start_(const LogString *phase); - void log_auth_warning_(const LogString *action, const LogString *hash_name); void cleanup_connection_(); void yield_and_feed_watchdog_(); From 0432a10543526b8375121a66cff6e87df6845544 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 14:59:19 -0600 Subject: [PATCH 1292/1626] Add coverage for Path to str fix in #10807 (#10808) --- tests/unit_tests/conftest.py | 7 ++ tests/unit_tests/test_main.py | 127 ++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 0151bce00c..3f78acd10d 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -87,3 +87,10 @@ def mock_run_external_command() -> Generator[Mock, None, None]: """Mock run_external_command for platformio_api.""" with patch("esphome.platformio_api.run_external_command") as mock: yield mock + + +@pytest.fixture +def mock_get_idedata() -> Generator[Mock, None, None]: + """Mock get_idedata for platformio_api.""" + with patch("esphome.platformio_api.get_idedata") as mock: + yield mock diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index da280b1fd8..bb047d063c 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -12,6 +12,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest from pytest import CaptureFixture +from esphome import platformio_api from esphome.__main__ import ( Purpose, choose_upload_log_host, @@ -28,7 +29,9 @@ from esphome.__main__ import ( mqtt_get_ip, show_logs, upload_program, + upload_using_esptool, ) +from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( CONF_API, CONF_BROKER, @@ -220,6 +223,14 @@ def mock_run_external_process() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_run_external_command() -> Generator[Mock]: + """Mock run_external_command for testing.""" + with patch("esphome.__main__.run_external_command") as mock: + mock.return_value = 0 # Default to success + yield mock + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() @@ -818,6 +829,122 @@ def test_upload_program_serial_esp8266_with_file( ) +def test_upload_using_esptool_path_conversion( + tmp_path: Path, + mock_run_external_command: Mock, + mock_get_idedata: Mock, +) -> None: + """Test upload_using_esptool properly converts Path objects to strings for esptool. + + This test ensures that img.path (Path object) is converted to string before + passing to esptool, preventing AttributeError. + """ + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test") + + # Set up ESP32-specific data required by get_esp32_variant() + CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32} + + # Create mock IDEData with Path objects + mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" + mock_idedata.extra_flash_images = [ + platformio_api.FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), + platformio_api.FlashImage(path=tmp_path / "partitions.bin", offset="0x8000"), + ] + + mock_get_idedata.return_value = mock_idedata + + # Create the actual firmware files so they exist + (tmp_path / "firmware.bin").touch() + (tmp_path / "bootloader.bin").touch() + (tmp_path / "partitions.bin").touch() + + config = {CONF_ESPHOME: {"platformio_options": {}}} + + # Call upload_using_esptool without custom file argument + result = upload_using_esptool(config, "/dev/ttyUSB0", None, None) + + assert result == 0 + + # Verify that run_external_command was called + assert mock_run_external_command.call_count == 1 + + # Get the actual call arguments + call_args = mock_run_external_command.call_args[0] + + # The first argument should be esptool.main function, + # followed by the command arguments + assert len(call_args) > 1 + + # Find the indices of the flash image arguments + # They should come after "write-flash" and "-z" + cmd_list = list(call_args[1:]) # Skip the esptool.main function + + # Verify all paths are strings, not Path objects + # The firmware and flash images should be at specific positions + write_flash_idx = cmd_list.index("write-flash") + + # After write-flash we have: -z, --flash-size, detect, then offset/path pairs + # Check firmware at offset 0x10000 (ESP32) + firmware_offset_idx = write_flash_idx + 4 + assert cmd_list[firmware_offset_idx] == "0x10000" + firmware_path = cmd_list[firmware_offset_idx + 1] + assert isinstance(firmware_path, str) + assert firmware_path.endswith("firmware.bin") + + # Check bootloader + bootloader_offset_idx = firmware_offset_idx + 2 + assert cmd_list[bootloader_offset_idx] == "0x1000" + bootloader_path = cmd_list[bootloader_offset_idx + 1] + assert isinstance(bootloader_path, str) + assert bootloader_path.endswith("bootloader.bin") + + # Check partitions + partitions_offset_idx = bootloader_offset_idx + 2 + assert cmd_list[partitions_offset_idx] == "0x8000" + partitions_path = cmd_list[partitions_offset_idx + 1] + assert isinstance(partitions_path, str) + assert partitions_path.endswith("partitions.bin") + + +def test_upload_using_esptool_with_file_path( + tmp_path: Path, + mock_run_external_command: Mock, +) -> None: + """Test upload_using_esptool with a custom file that's a Path object.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") + + # Create a test firmware file + firmware_file = tmp_path / "custom_firmware.bin" + firmware_file.touch() + + config = {CONF_ESPHOME: {"platformio_options": {}}} + + # Call with a Path object as the file argument (though usually it's a string) + result = upload_using_esptool(config, "/dev/ttyUSB0", str(firmware_file), None) + + assert result == 0 + + # Verify that run_external_command was called + mock_run_external_command.assert_called_once() + + # Get the actual call arguments + call_args = mock_run_external_command.call_args[0] + cmd_list = list(call_args[1:]) # Skip the esptool.main function + + # Find the firmware path in the command + write_flash_idx = cmd_list.index("write-flash") + + # For custom file, it should be at offset 0x0 + firmware_offset_idx = write_flash_idx + 4 + assert cmd_list[firmware_offset_idx] == "0x0" + firmware_path = cmd_list[firmware_offset_idx + 1] + + # Verify it's a string, not a Path object + assert isinstance(firmware_path, str) + assert firmware_path.endswith("custom_firmware.bin") + + @pytest.mark.parametrize( "platform,device", [ From c64d385fa62db4061006026be2db610863ae96e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:06:59 -0600 Subject: [PATCH 1293/1626] [web_server] Reduce flash usage by eliminating lambda overhead in JSON generation (#10749) --- esphome/components/json/json_util.cpp | 56 +- esphome/components/json/json_util.h | 49 ++ esphome/components/web_server/web_server.cpp | 659 ++++++++++-------- .../web_server_idf/web_server_idf.cpp | 13 +- 4 files changed, 432 insertions(+), 345 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 842b5e283a..51c0fcf9cb 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -8,52 +8,12 @@ namespace json { static const char *const TAG = "json"; -#ifdef USE_PSRAM -// Build an allocator for the JSON Library using the RAMAllocator class -// This is only compiled when PSRAM is enabled -struct SpiRamAllocator : ArduinoJson::Allocator { - void *allocate(size_t size) override { return this->allocator_.allocate(size); } - - void deallocate(void *pointer) override { - // ArduinoJson's Allocator interface doesn't provide the size parameter in deallocate. - // RAMAllocator::deallocate() requires the size, which we don't have access to here. - // RAMAllocator::deallocate implementation just calls free() regardless of whether - // the memory was allocated with heap_caps_malloc or malloc. - // This is safe because ESP-IDF's heap implementation internally tracks the memory region - // and routes free() to the appropriate heap. - free(pointer); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) - } - - void *reallocate(void *ptr, size_t new_size) override { - return this->allocator_.reallocate(static_cast(ptr), new_size); - } - - protected: - RAMAllocator allocator_{RAMAllocator(RAMAllocator::NONE)}; -}; -#endif - std::string build_json(const json_build_t &f) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson -#ifdef USE_PSRAM - auto doc_allocator = SpiRamAllocator(); - JsonDocument json_document(&doc_allocator); -#else - JsonDocument json_document; -#endif - if (json_document.overflowed()) { - ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); - return "{}"; - } - JsonObject root = json_document.to(); + JsonBuilder builder; + JsonObject root = builder.root(); f(root); - if (json_document.overflowed()) { - ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); - return "{}"; - } - std::string output; - serializeJson(json_document, output); - return output; + return builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } @@ -84,5 +44,15 @@ bool parse_json(const std::string &data, const json_parse_t &f) { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } +std::string JsonBuilder::serialize() { + if (doc_.overflowed()) { + ESP_LOGE(TAG, "JSON document overflow"); + return "{}"; + } + std::string output; + serializeJson(doc_, output); + return output; +} + } // namespace json } // namespace esphome diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 72d31c8afe..69b809ec49 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -13,6 +13,31 @@ namespace esphome { namespace json { +#ifdef USE_PSRAM +// Build an allocator for the JSON Library using the RAMAllocator class +// This is only compiled when PSRAM is enabled +struct SpiRamAllocator : ArduinoJson::Allocator { + void *allocate(size_t size) override { return allocator_.allocate(size); } + + void deallocate(void *ptr) override { + // ArduinoJson's Allocator interface doesn't provide the size parameter in deallocate. + // RAMAllocator::deallocate() requires the size, which we don't have access to here. + // RAMAllocator::deallocate implementation just calls free() regardless of whether + // the memory was allocated with heap_caps_malloc or malloc. + // This is safe because ESP-IDF's heap implementation internally tracks the memory region + // and routes free() to the appropriate heap. + free(ptr); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) + } + + void *reallocate(void *ptr, size_t new_size) override { + return allocator_.reallocate(static_cast(ptr), new_size); + } + + protected: + RAMAllocator allocator_{RAMAllocator::NONE}; +}; +#endif + /// Callback function typedef for parsing JsonObjects. using json_parse_t = std::function; @@ -25,5 +50,29 @@ std::string build_json(const json_build_t &f); /// Parse a JSON string and run the provided json parse function if it's valid. bool parse_json(const std::string &data, const json_parse_t &f); +/// Builder class for creating JSON documents without lambdas +class JsonBuilder { + public: + JsonObject root() { + if (!root_created_) { + root_ = doc_.to(); + root_created_ = true; + } + return root_; + } + + std::string serialize(); + + private: +#ifdef USE_PSRAM + SpiRamAllocator allocator_; + JsonDocument doc_{&allocator_}; +#else + JsonDocument doc_; +#endif + JsonObject root_; + bool root_created_{false}; +}; + } // namespace json } // namespace esphome diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 290992b096..0a97e542c3 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -228,10 +228,11 @@ void DeferredUpdateEventSourceList::on_client_connect_(WebServer *ws, DeferredUp #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { - message = json::build_json([group](JsonObject root) { - root["name"] = group.second.name; - root["sorting_weight"] = group.second.weight; - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + root["name"] = group.second.name; + root["sorting_weight"] = group.second.weight; + message = builder.serialize(); // up to 31 groups should be able to be queued initially without defer source->try_send_nodefer(message.c_str(), "sorting_group"); @@ -265,17 +266,20 @@ void WebServer::set_js_include(const char *js_include) { this->js_include_ = js_ #endif std::string WebServer::get_config_json() { - return json::build_json([this](JsonObject root) { - root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); - root["comment"] = App.get_comment(); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); + root["comment"] = App.get_comment(); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) - root["ota"] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal + root["ota"] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else - root["ota"] = true; + root["ota"] = true; #endif - root["log"] = this->expose_log_; - root["lang"] = "en"; - }); + root["log"] = this->expose_log_; + root["lang"] = "en"; + + return builder.serialize(); } void WebServer::setup() { @@ -435,22 +439,26 @@ std::string WebServer::sensor_all_json_generator(WebServer *web_server, void *so return web_server->sensor_json((sensor::Sensor *) (source), ((sensor::Sensor *) (source))->state, DETAIL_ALL); } std::string WebServer::sensor_json(sensor::Sensor *obj, float value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - std::string state; - if (std::isnan(value)) { - state = "NA"; - } else { - state = value_accuracy_to_string(value, obj->get_accuracy_decimals()); - if (!obj->get_unit_of_measurement().empty()) - state += " " + obj->get_unit_of_measurement(); - } - set_json_icon_state_value(root, obj, "sensor-" + obj->get_object_id(), state, value, start_config); - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - if (!obj->get_unit_of_measurement().empty()) - root["uom"] = obj->get_unit_of_measurement(); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + // Build JSON directly inline + std::string state; + if (std::isnan(value)) { + state = "NA"; + } else { + state = value_accuracy_to_string(value, obj->get_accuracy_decimals()); + if (!obj->get_unit_of_measurement().empty()) + state += " " + obj->get_unit_of_measurement(); + } + set_json_icon_state_value(root, obj, "sensor-" + obj->get_object_id(), state, value, start_config); + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + if (!obj->get_unit_of_measurement().empty()) + root["uom"] = obj->get_unit_of_measurement(); + } + + return builder.serialize(); } #endif @@ -483,12 +491,15 @@ std::string WebServer::text_sensor_all_json_generator(WebServer *web_server, voi } std::string WebServer::text_sensor_json(text_sensor::TextSensor *obj, const std::string &value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "text_sensor-" + obj->get_object_id(), value, value, start_config); - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "text_sensor-" + obj->get_object_id(), value, value, start_config); + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -553,13 +564,16 @@ std::string WebServer::switch_all_json_generator(WebServer *web_server, void *so return web_server->switch_json((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_ALL); } std::string WebServer::switch_json(switch_::Switch *obj, bool value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "switch-" + obj->get_object_id(), value ? "ON" : "OFF", value, start_config); - if (start_config == DETAIL_ALL) { - root["assumed_state"] = obj->assumed_state(); - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "switch-" + obj->get_object_id(), value ? "ON" : "OFF", value, start_config); + if (start_config == DETAIL_ALL) { + root["assumed_state"] = obj->assumed_state(); + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -590,12 +604,15 @@ std::string WebServer::button_all_json_generator(WebServer *web_server, void *so return web_server->button_json((button::Button *) (source), DETAIL_ALL); } std::string WebServer::button_json(button::Button *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "button-" + obj->get_object_id(), start_config); - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "button-" + obj->get_object_id(), start_config); + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -627,13 +644,16 @@ std::string WebServer::binary_sensor_all_json_generator(WebServer *web_server, v ((binary_sensor::BinarySensor *) (source))->state, DETAIL_ALL); } std::string WebServer::binary_sensor_json(binary_sensor::BinarySensor *obj, bool value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "binary_sensor-" + obj->get_object_id(), value ? "ON" : "OFF", value, - start_config); - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "binary_sensor-" + obj->get_object_id(), value ? "ON" : "OFF", value, + start_config); + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -694,20 +714,23 @@ std::string WebServer::fan_all_json_generator(WebServer *web_server, void *sourc return web_server->fan_json((fan::Fan *) (source), DETAIL_ALL); } std::string WebServer::fan_json(fan::Fan *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "fan-" + obj->get_object_id(), obj->state ? "ON" : "OFF", obj->state, - start_config); - const auto traits = obj->get_traits(); - if (traits.supports_speed()) { - root["speed_level"] = obj->speed; - root["speed_count"] = traits.supported_speed_count(); - } - if (obj->get_traits().supports_oscillation()) - root["oscillation"] = obj->oscillating; - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "fan-" + obj->get_object_id(), obj->state ? "ON" : "OFF", obj->state, + start_config); + const auto traits = obj->get_traits(); + if (traits.supports_speed()) { + root["speed_level"] = obj->speed; + root["speed_count"] = traits.supported_speed_count(); + } + if (obj->get_traits().supports_oscillation()) + root["oscillation"] = obj->oscillating; + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -767,20 +790,23 @@ std::string WebServer::light_all_json_generator(WebServer *web_server, void *sou return web_server->light_json((light::LightState *) (source), DETAIL_ALL); } std::string WebServer::light_json(light::LightState *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "light-" + obj->get_object_id(), start_config); - root["state"] = obj->remote_values.is_on() ? "ON" : "OFF"; + json::JsonBuilder builder; + JsonObject root = builder.root(); - light::LightJSONSchema::dump_json(*obj, root); - if (start_config == DETAIL_ALL) { - JsonArray opt = root["effects"].to(); - opt.add("None"); - for (auto const &option : obj->get_effects()) { - opt.add(option->get_name()); - } - this->add_sorting_info_(root, obj); + set_json_id(root, obj, "light-" + obj->get_object_id(), start_config); + root["state"] = obj->remote_values.is_on() ? "ON" : "OFF"; + + light::LightJSONSchema::dump_json(*obj, root); + if (start_config == DETAIL_ALL) { + JsonArray opt = root["effects"].to(); + opt.add("None"); + for (auto const &option : obj->get_effects()) { + opt.add(option->get_name()); } - }); + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -839,19 +865,22 @@ std::string WebServer::cover_all_json_generator(WebServer *web_server, void *sou return web_server->cover_json((cover::Cover *) (source), DETAIL_ALL); } std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "cover-" + obj->get_object_id(), obj->is_fully_closed() ? "CLOSED" : "OPEN", - obj->position, start_config); - root["current_operation"] = cover::cover_operation_to_str(obj->current_operation); + json::JsonBuilder builder; + JsonObject root = builder.root(); - if (obj->get_traits().get_supports_position()) - root["position"] = obj->position; - if (obj->get_traits().get_supports_tilt()) - root["tilt"] = obj->tilt; - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + set_json_icon_state_value(root, obj, "cover-" + obj->get_object_id(), obj->is_fully_closed() ? "CLOSED" : "OPEN", + obj->position, start_config); + root["current_operation"] = cover::cover_operation_to_str(obj->current_operation); + + if (obj->get_traits().get_supports_position()) + root["position"] = obj->position; + if (obj->get_traits().get_supports_tilt()) + root["tilt"] = obj->tilt; + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -894,31 +923,33 @@ std::string WebServer::number_all_json_generator(WebServer *web_server, void *so return web_server->number_json((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_ALL); } std::string WebServer::number_json(number::Number *obj, float value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_id(root, obj, "number-" + obj->get_object_id(), start_config); - if (start_config == DETAIL_ALL) { - root["min_value"] = - value_accuracy_to_string(obj->traits.get_min_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root["max_value"] = - value_accuracy_to_string(obj->traits.get_max_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root["step"] = - value_accuracy_to_string(obj->traits.get_step(), step_to_accuracy_decimals(obj->traits.get_step())); - root["mode"] = (int) obj->traits.get_mode(); - if (!obj->traits.get_unit_of_measurement().empty()) - root["uom"] = obj->traits.get_unit_of_measurement(); - this->add_sorting_info_(root, obj); - } - if (std::isnan(value)) { - root["value"] = "\"NaN\""; - root["state"] = "NA"; - } else { - root["value"] = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); - std::string state = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); - if (!obj->traits.get_unit_of_measurement().empty()) - state += " " + obj->traits.get_unit_of_measurement(); - root["state"] = state; - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "number-" + obj->get_object_id(), start_config); + if (start_config == DETAIL_ALL) { + root["min_value"] = + value_accuracy_to_string(obj->traits.get_min_value(), step_to_accuracy_decimals(obj->traits.get_step())); + root["max_value"] = + value_accuracy_to_string(obj->traits.get_max_value(), step_to_accuracy_decimals(obj->traits.get_step())); + root["step"] = value_accuracy_to_string(obj->traits.get_step(), step_to_accuracy_decimals(obj->traits.get_step())); + root["mode"] = (int) obj->traits.get_mode(); + if (!obj->traits.get_unit_of_measurement().empty()) + root["uom"] = obj->traits.get_unit_of_measurement(); + this->add_sorting_info_(root, obj); + } + if (std::isnan(value)) { + root["value"] = "\"NaN\""; + root["state"] = "NA"; + } else { + root["value"] = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); + std::string state = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); + if (!obj->traits.get_unit_of_measurement().empty()) + state += " " + obj->traits.get_unit_of_measurement(); + root["state"] = state; + } + + return builder.serialize(); } #endif @@ -966,15 +997,18 @@ std::string WebServer::date_all_json_generator(WebServer *web_server, void *sour return web_server->date_json((datetime::DateEntity *) (source), DETAIL_ALL); } std::string WebServer::date_json(datetime::DateEntity *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "date-" + obj->get_object_id(), start_config); - std::string value = str_sprintf("%d-%02d-%02d", obj->year, obj->month, obj->day); - root["value"] = value; - root["state"] = value; - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "date-" + obj->get_object_id(), start_config); + std::string value = str_sprintf("%d-%02d-%02d", obj->year, obj->month, obj->day); + root["value"] = value; + root["state"] = value; + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif // USE_DATETIME_DATE @@ -1021,15 +1055,18 @@ std::string WebServer::time_all_json_generator(WebServer *web_server, void *sour return web_server->time_json((datetime::TimeEntity *) (source), DETAIL_ALL); } std::string WebServer::time_json(datetime::TimeEntity *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "time-" + obj->get_object_id(), start_config); - std::string value = str_sprintf("%02d:%02d:%02d", obj->hour, obj->minute, obj->second); - root["value"] = value; - root["state"] = value; - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "time-" + obj->get_object_id(), start_config); + std::string value = str_sprintf("%02d:%02d:%02d", obj->hour, obj->minute, obj->second); + root["value"] = value; + root["state"] = value; + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif // USE_DATETIME_TIME @@ -1076,16 +1113,19 @@ std::string WebServer::datetime_all_json_generator(WebServer *web_server, void * return web_server->datetime_json((datetime::DateTimeEntity *) (source), DETAIL_ALL); } std::string WebServer::datetime_json(datetime::DateTimeEntity *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "datetime-" + obj->get_object_id(), start_config); - std::string value = str_sprintf("%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, - obj->minute, obj->second); - root["value"] = value; - root["state"] = value; - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "datetime-" + obj->get_object_id(), start_config); + std::string value = + str_sprintf("%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second); + root["value"] = value; + root["state"] = value; + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif // USE_DATETIME_DATETIME @@ -1128,22 +1168,25 @@ std::string WebServer::text_all_json_generator(WebServer *web_server, void *sour return web_server->text_json((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_ALL); } std::string WebServer::text_json(text::Text *obj, const std::string &value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_id(root, obj, "text-" + obj->get_object_id(), start_config); - root["min_length"] = obj->traits.get_min_length(); - root["max_length"] = obj->traits.get_max_length(); - root["pattern"] = obj->traits.get_pattern(); - if (obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD) { - root["state"] = "********"; - } else { - root["state"] = value; - } - root["value"] = value; - if (start_config == DETAIL_ALL) { - root["mode"] = (int) obj->traits.get_mode(); - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "text-" + obj->get_object_id(), start_config); + root["min_length"] = obj->traits.get_min_length(); + root["max_length"] = obj->traits.get_max_length(); + root["pattern"] = obj->traits.get_pattern(); + if (obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD) { + root["state"] = "********"; + } else { + root["state"] = value; + } + root["value"] = value; + if (start_config == DETAIL_ALL) { + root["mode"] = (int) obj->traits.get_mode(); + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -1186,16 +1229,19 @@ std::string WebServer::select_all_json_generator(WebServer *web_server, void *so return web_server->select_json((select::Select *) (source), ((select::Select *) (source))->state, DETAIL_ALL); } std::string WebServer::select_json(select::Select *obj, const std::string &value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "select-" + obj->get_object_id(), value, value, start_config); - if (start_config == DETAIL_ALL) { - JsonArray opt = root["option"].to(); - for (auto &option : obj->traits.get_options()) { - opt.add(option); - } - this->add_sorting_info_(root, obj); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "select-" + obj->get_object_id(), value, value, start_config); + if (start_config == DETAIL_ALL) { + JsonArray opt = root["option"].to(); + for (auto &option : obj->traits.get_options()) { + opt.add(option); } - }); + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -1244,98 +1290,102 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url request->send(404); } std::string WebServer::climate_state_json_generator(WebServer *web_server, void *source) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return web_server->climate_json((climate::Climate *) (source), DETAIL_STATE); } std::string WebServer::climate_all_json_generator(WebServer *web_server, void *source) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return web_server->climate_json((climate::Climate *) (source), DETAIL_ALL); } std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "climate-" + obj->get_object_id(), start_config); - const auto traits = obj->get_traits(); - int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); - int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); - char buf[16]; + json::JsonBuilder builder; + JsonObject root = builder.root(); + set_json_id(root, obj, "climate-" + obj->get_object_id(), start_config); + const auto traits = obj->get_traits(); + int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); + int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); + char buf[16]; - if (start_config == DETAIL_ALL) { - JsonArray opt = root["modes"].to(); - for (climate::ClimateMode m : traits.get_supported_modes()) - opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); - if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root["fan_modes"].to(); - for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) - opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); - } - - if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root["custom_fan_modes"].to(); - for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) - opt.add(custom_fan_mode); - } - if (traits.get_supports_swing_modes()) { - JsonArray opt = root["swing_modes"].to(); - for (auto swing_mode : traits.get_supported_swing_modes()) - opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); - } - if (traits.get_supports_presets() && obj->preset.has_value()) { - JsonArray opt = root["presets"].to(); - for (climate::ClimatePreset m : traits.get_supported_presets()) - opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); - } - if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { - JsonArray opt = root["custom_presets"].to(); - for (auto const &custom_preset : traits.get_supported_custom_presets()) - opt.add(custom_preset); - } - this->add_sorting_info_(root, obj); + if (start_config == DETAIL_ALL) { + JsonArray opt = root["modes"].to(); + for (climate::ClimateMode m : traits.get_supported_modes()) + opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); + if (!traits.get_supported_custom_fan_modes().empty()) { + JsonArray opt = root["fan_modes"].to(); + for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) + opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); } - bool has_state = false; - root["mode"] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); - root["max_temp"] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); - root["min_temp"] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); - root["step"] = traits.get_visual_target_temperature_step(); - if (traits.get_supports_action()) { - root["action"] = PSTR_LOCAL(climate_action_to_string(obj->action)); - root["state"] = root["action"]; - has_state = true; - } - if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root["fan_mode"] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); - } - if (!traits.get_supported_custom_fan_modes().empty() && obj->custom_fan_mode.has_value()) { - root["custom_fan_mode"] = obj->custom_fan_mode.value().c_str(); - } - if (traits.get_supports_presets() && obj->preset.has_value()) { - root["preset"] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); - } - if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { - root["custom_preset"] = obj->custom_preset.value().c_str(); + if (!traits.get_supported_custom_fan_modes().empty()) { + JsonArray opt = root["custom_fan_modes"].to(); + for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) + opt.add(custom_fan_mode); } if (traits.get_supports_swing_modes()) { - root["swing_mode"] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + JsonArray opt = root["swing_modes"].to(); + for (auto swing_mode : traits.get_supported_swing_modes()) + opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); } - if (traits.get_supports_current_temperature()) { - if (!std::isnan(obj->current_temperature)) { - root["current_temperature"] = value_accuracy_to_string(obj->current_temperature, current_accuracy); - } else { - root["current_temperature"] = "NA"; - } + if (traits.get_supports_presets() && obj->preset.has_value()) { + JsonArray opt = root["presets"].to(); + for (climate::ClimatePreset m : traits.get_supported_presets()) + opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); } - if (traits.get_supports_two_point_target_temperature()) { - root["target_temperature_low"] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); - root["target_temperature_high"] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy); - if (!has_state) { - root["state"] = value_accuracy_to_string((obj->target_temperature_high + obj->target_temperature_low) / 2.0f, - target_accuracy); - } + if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { + JsonArray opt = root["custom_presets"].to(); + for (auto const &custom_preset : traits.get_supported_custom_presets()) + opt.add(custom_preset); + } + this->add_sorting_info_(root, obj); + } + + bool has_state = false; + root["mode"] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); + root["max_temp"] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); + root["min_temp"] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); + root["step"] = traits.get_visual_target_temperature_step(); + if (traits.get_supports_action()) { + root["action"] = PSTR_LOCAL(climate_action_to_string(obj->action)); + root["state"] = root["action"]; + has_state = true; + } + if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { + root["fan_mode"] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + } + if (!traits.get_supported_custom_fan_modes().empty() && obj->custom_fan_mode.has_value()) { + root["custom_fan_mode"] = obj->custom_fan_mode.value().c_str(); + } + if (traits.get_supports_presets() && obj->preset.has_value()) { + root["preset"] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + } + if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) { + root["custom_preset"] = obj->custom_preset.value().c_str(); + } + if (traits.get_supports_swing_modes()) { + root["swing_mode"] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + } + if (traits.get_supports_current_temperature()) { + if (!std::isnan(obj->current_temperature)) { + root["current_temperature"] = value_accuracy_to_string(obj->current_temperature, current_accuracy); } else { - root["target_temperature"] = value_accuracy_to_string(obj->target_temperature, target_accuracy); - if (!has_state) - root["state"] = root["target_temperature"]; + root["current_temperature"] = "NA"; } - }); + } + if (traits.get_supports_two_point_target_temperature()) { + root["target_temperature_low"] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); + root["target_temperature_high"] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy); + if (!has_state) { + root["state"] = value_accuracy_to_string((obj->target_temperature_high + obj->target_temperature_low) / 2.0f, + target_accuracy); + } + } else { + root["target_temperature"] = value_accuracy_to_string(obj->target_temperature, target_accuracy); + if (!has_state) + root["state"] = root["target_temperature"]; + } + + return builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } #endif @@ -1401,13 +1451,16 @@ std::string WebServer::lock_all_json_generator(WebServer *web_server, void *sour return web_server->lock_json((lock::Lock *) (source), ((lock::Lock *) (source))->state, DETAIL_ALL); } std::string WebServer::lock_json(lock::Lock *obj, lock::LockState value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "lock-" + obj->get_object_id(), lock::lock_state_to_string(value), value, - start_config); - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_icon_state_value(root, obj, "lock-" + obj->get_object_id(), lock::lock_state_to_string(value), value, + start_config); + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -1464,17 +1517,20 @@ std::string WebServer::valve_all_json_generator(WebServer *web_server, void *sou return web_server->valve_json((valve::Valve *) (source), DETAIL_ALL); } std::string WebServer::valve_json(valve::Valve *obj, JsonDetail start_config) { - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_icon_state_value(root, obj, "valve-" + obj->get_object_id(), obj->is_fully_closed() ? "CLOSED" : "OPEN", - obj->position, start_config); - root["current_operation"] = valve::valve_operation_to_str(obj->current_operation); + json::JsonBuilder builder; + JsonObject root = builder.root(); - if (obj->get_traits().get_supports_position()) - root["position"] = obj->position; - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + set_json_icon_state_value(root, obj, "valve-" + obj->get_object_id(), obj->is_fully_closed() ? "CLOSED" : "OPEN", + obj->position, start_config); + root["current_operation"] = valve::valve_operation_to_str(obj->current_operation); + + if (obj->get_traits().get_supports_position()) + root["position"] = obj->position; + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -1533,14 +1589,17 @@ std::string WebServer::alarm_control_panel_all_json_generator(WebServer *web_ser std::string WebServer::alarm_control_panel_json(alarm_control_panel::AlarmControlPanel *obj, alarm_control_panel::AlarmControlPanelState value, JsonDetail start_config) { - return json::build_json([this, obj, value, start_config](JsonObject root) { - char buf[16]; - set_json_icon_state_value(root, obj, "alarm-control-panel-" + obj->get_object_id(), - PSTR_LOCAL(alarm_control_panel_state_to_string(value)), value, start_config); - if (start_config == DETAIL_ALL) { - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + char buf[16]; + set_json_icon_state_value(root, obj, "alarm-control-panel-" + obj->get_object_id(), + PSTR_LOCAL(alarm_control_panel_state_to_string(value)), value, start_config); + if (start_config == DETAIL_ALL) { + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -1577,20 +1636,23 @@ std::string WebServer::event_all_json_generator(WebServer *web_server, void *sou return web_server->event_json(event, get_event_type(event), DETAIL_ALL); } std::string WebServer::event_json(event::Event *obj, const std::string &event_type, JsonDetail start_config) { - return json::build_json([this, obj, event_type, start_config](JsonObject root) { - set_json_id(root, obj, "event-" + obj->get_object_id(), start_config); - if (!event_type.empty()) { - root["event_type"] = event_type; + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "event-" + obj->get_object_id(), start_config); + if (!event_type.empty()) { + root["event_type"] = event_type; + } + if (start_config == DETAIL_ALL) { + JsonArray event_types = root["event_types"].to(); + for (auto const &event_type : obj->get_event_types()) { + event_types.add(event_type); } - if (start_config == DETAIL_ALL) { - JsonArray event_types = root["event_types"].to(); - for (auto const &event_type : obj->get_event_types()) { - event_types.add(event_type); - } - root["device_class"] = obj->get_device_class(); - this->add_sorting_info_(root, obj); - } - }); + root["device_class"] = obj->get_device_class(); + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); } #endif @@ -1637,25 +1699,30 @@ void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlM request->send(404); } std::string WebServer::update_state_json_generator(WebServer *web_server, void *source) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return web_server->update_json((update::UpdateEntity *) (source), DETAIL_STATE); } std::string WebServer::update_all_json_generator(WebServer *web_server, void *source) { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return web_server->update_json((update::UpdateEntity *) (source), DETAIL_STATE); } std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return json::build_json([this, obj, start_config](JsonObject root) { - set_json_id(root, obj, "update-" + obj->get_object_id(), start_config); - root["value"] = obj->update_info.latest_version; - root["state"] = update_state_to_string(obj->state); - if (start_config == DETAIL_ALL) { - root["current_version"] = obj->update_info.current_version; - root["title"] = obj->update_info.title; - root["summary"] = obj->update_info.summary; - root["release_url"] = obj->update_info.release_url; - this->add_sorting_info_(root, obj); - } - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + + set_json_id(root, obj, "update-" + obj->get_object_id(), start_config); + root["value"] = obj->update_info.latest_version; + root["state"] = update_state_to_string(obj->state); + if (start_config == DETAIL_ALL) { + root["current_version"] = obj->update_info.current_version; + root["title"] = obj->update_info.title; + root["summary"] = obj->update_info.summary; + root["release_url"] = obj->update_info.release_url; + this->add_sorting_info_(root, obj); + } + + return builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } #endif diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 55b07c0f5e..51d763c508 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -317,8 +317,8 @@ AsyncEventSource::~AsyncEventSource() { } void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) { - auto *rsp = // NOLINT(cppcoreguidelines-owning-memory) - new AsyncEventSourceResponse(request, this, this->web_server_); + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory,clang-analyzer-cplusplus.NewDeleteLeaks) + auto *rsp = new AsyncEventSourceResponse(request, this, this->web_server_); if (this->on_connect_) { this->on_connect_(rsp); } @@ -392,10 +392,11 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - message = json::build_json([group](JsonObject root) { - root["name"] = group.second.name; - root["sorting_weight"] = group.second.weight; - }); + json::JsonBuilder builder; + JsonObject root = builder.root(); + root["name"] = group.second.name; + root["sorting_weight"] = group.second.weight; + message = builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) // a (very) large number of these should be able to be queued initially without defer From 440b0b5574c3ab9f5dc69d947299e962a5d5e105 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:07:47 -0600 Subject: [PATCH 1294/1626] [tests] Add integration tests for oversized payload handling in API (#10788) --- esphome/writer.py | 5 + tests/integration/conftest.py | 57 ++- tests/integration/fixtures/cache_init.yaml | 10 + .../noise_corrupt_encrypted_frame.yaml | 11 + .../fixtures/oversized_payload_noise.yaml | 11 + .../fixtures/oversized_payload_plaintext.yaml | 9 + .../oversized_protobuf_message_id_noise.yaml | 11 + ...ersized_protobuf_message_id_plaintext.yaml | 9 + tests/integration/test_oversized_payloads.py | 335 ++++++++++++++++++ tests/integration/types.py | 14 + 10 files changed, 461 insertions(+), 11 deletions(-) create mode 100644 tests/integration/fixtures/cache_init.yaml create mode 100644 tests/integration/fixtures/noise_corrupt_encrypted_frame.yaml create mode 100644 tests/integration/fixtures/oversized_payload_noise.yaml create mode 100644 tests/integration/fixtures/oversized_payload_plaintext.yaml create mode 100644 tests/integration/fixtures/oversized_protobuf_message_id_noise.yaml create mode 100644 tests/integration/fixtures/oversized_protobuf_message_id_plaintext.yaml create mode 100644 tests/integration/test_oversized_payloads.py diff --git a/esphome/writer.py b/esphome/writer.py index c0d4379b3a..49b6c3b43e 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -301,6 +301,11 @@ def clean_cmake_cache(): def clean_build(): import shutil + # Allow skipping cache cleaning for integration tests + if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): + _LOGGER.warning("Skipping build cleaning (ESPHOME_SKIP_CLEAN_BUILD set)") + return + pioenvs = CORE.relative_pioenvs_path() if pioenvs.is_dir(): _LOGGER.info("Deleting %s", pioenvs) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 94632f8439..965363972f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -58,6 +58,8 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: env["PLATFORMIO_CORE_DIR"] = str(cache_dir) env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache") env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") + # Prevent cache cleaning during integration tests + env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" return env @@ -68,6 +70,11 @@ def shared_platformio_cache() -> Generator[Path]: test_cache_dir = Path.home() / ".esphome-integration-tests" cache_dir = test_cache_dir / "platformio" + # Create the temp directory that PlatformIO uses to avoid race conditions + # This ensures it exists and won't be deleted by parallel processes + platformio_tmp_dir = cache_dir / ".cache" / "tmp" + platformio_tmp_dir.mkdir(parents=True, exist_ok=True) + # Use a lock file in the home directory to ensure only one process initializes the cache # This is needed when running with pytest-xdist # The lock file must be in a directory that already exists to avoid race conditions @@ -83,17 +90,11 @@ def shared_platformio_cache() -> Generator[Path]: test_cache_dir.mkdir(exist_ok=True) with tempfile.TemporaryDirectory() as tmpdir: - # Create a basic host config + # Use the cache_init fixture for initialization init_dir = Path(tmpdir) + fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml" config_path = init_dir / "cache_init.yaml" - config_path.write_text("""esphome: - name: cache-init -host: -api: - encryption: - key: "IIevImVI42I0FGos5nLqFK91jrJehrgidI0ArwMLr8w=" -logger: -""") + config_path.write_text(fixture_path.read_text()) # Run compilation to populate the cache # We must succeed here to avoid race conditions where multiple @@ -346,7 +347,8 @@ async def wait_and_connect_api_client( noise_psk: str | None = None, client_info: str = "integration-test", timeout: float = API_CONNECTION_TIMEOUT, -) -> AsyncGenerator[APIClient]: + return_disconnect_event: bool = False, +) -> AsyncGenerator[APIClient | tuple[APIClient, asyncio.Event]]: """Wait for API to be available and connect.""" client = APIClient( address=address, @@ -359,14 +361,17 @@ async def wait_and_connect_api_client( # Create a future to signal when connected loop = asyncio.get_running_loop() connected_future: asyncio.Future[None] = loop.create_future() + disconnect_event = asyncio.Event() async def on_connect() -> None: """Called when successfully connected.""" + disconnect_event.clear() # Clear the disconnect event on new connection if not connected_future.done(): connected_future.set_result(None) async def on_disconnect(expected_disconnect: bool) -> None: """Called when disconnected.""" + disconnect_event.set() if not connected_future.done() and not expected_disconnect: connected_future.set_exception( APIConnectionError("Disconnected before fully connected") @@ -397,7 +402,10 @@ async def wait_and_connect_api_client( except TimeoutError: raise TimeoutError(f"Failed to connect to API after {timeout} seconds") - yield client + if return_disconnect_event: + yield client, disconnect_event + else: + yield client finally: # Stop reconnect logic and disconnect await reconnect_logic.stop() @@ -430,6 +438,33 @@ async def api_client_connected( yield _connect_client +@pytest_asyncio.fixture +async def api_client_connected_with_disconnect( + unused_tcp_port: int, +) -> AsyncGenerator: + """Factory for creating connected API client context managers with disconnect event.""" + + def _connect_client_with_disconnect( + address: str = LOCALHOST, + port: int | None = None, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", + timeout: float = API_CONNECTION_TIMEOUT, + ): + return wait_and_connect_api_client( + address=address, + port=port if port is not None else unused_tcp_port, + password=password, + noise_psk=noise_psk, + client_info=client_info, + timeout=timeout, + return_disconnect_event=True, + ) + + yield _connect_client_with_disconnect + + async def _read_stream_lines( stream: asyncio.StreamReader, lines: list[str], diff --git a/tests/integration/fixtures/cache_init.yaml b/tests/integration/fixtures/cache_init.yaml new file mode 100644 index 0000000000..de208196cd --- /dev/null +++ b/tests/integration/fixtures/cache_init.yaml @@ -0,0 +1,10 @@ +esphome: + name: cache-init + +host: + +api: + encryption: + key: "IIevImVI42I0FGos5nLqFK91jrJehrgidI0ArwMLr8w=" + +logger: diff --git a/tests/integration/fixtures/noise_corrupt_encrypted_frame.yaml b/tests/integration/fixtures/noise_corrupt_encrypted_frame.yaml new file mode 100644 index 0000000000..6f0266c6fd --- /dev/null +++ b/tests/integration/fixtures/noise_corrupt_encrypted_frame.yaml @@ -0,0 +1,11 @@ +esphome: + name: oversized-noise + +host: + +api: + encryption: + key: N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU= + +logger: + level: VERY_VERBOSE diff --git a/tests/integration/fixtures/oversized_payload_noise.yaml b/tests/integration/fixtures/oversized_payload_noise.yaml new file mode 100644 index 0000000000..6f0266c6fd --- /dev/null +++ b/tests/integration/fixtures/oversized_payload_noise.yaml @@ -0,0 +1,11 @@ +esphome: + name: oversized-noise + +host: + +api: + encryption: + key: N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU= + +logger: + level: VERY_VERBOSE diff --git a/tests/integration/fixtures/oversized_payload_plaintext.yaml b/tests/integration/fixtures/oversized_payload_plaintext.yaml new file mode 100644 index 0000000000..44ece4f770 --- /dev/null +++ b/tests/integration/fixtures/oversized_payload_plaintext.yaml @@ -0,0 +1,9 @@ +esphome: + name: oversized-plaintext + +host: + +api: + +logger: + level: VERY_VERBOSE diff --git a/tests/integration/fixtures/oversized_protobuf_message_id_noise.yaml b/tests/integration/fixtures/oversized_protobuf_message_id_noise.yaml new file mode 100644 index 0000000000..6f0266c6fd --- /dev/null +++ b/tests/integration/fixtures/oversized_protobuf_message_id_noise.yaml @@ -0,0 +1,11 @@ +esphome: + name: oversized-noise + +host: + +api: + encryption: + key: N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU= + +logger: + level: VERY_VERBOSE diff --git a/tests/integration/fixtures/oversized_protobuf_message_id_plaintext.yaml b/tests/integration/fixtures/oversized_protobuf_message_id_plaintext.yaml new file mode 100644 index 0000000000..1e9eadfdc5 --- /dev/null +++ b/tests/integration/fixtures/oversized_protobuf_message_id_plaintext.yaml @@ -0,0 +1,9 @@ +esphome: + name: oversized-protobuf-plaintext + +host: + +api: + +logger: + level: VERY_VERBOSE diff --git a/tests/integration/test_oversized_payloads.py b/tests/integration/test_oversized_payloads.py new file mode 100644 index 0000000000..f3e422620c --- /dev/null +++ b/tests/integration/test_oversized_payloads.py @@ -0,0 +1,335 @@ +"""Integration tests for oversized payloads and headers that should cause disconnection.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .types import APIClientConnectedWithDisconnectFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_oversized_payload_plaintext( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, +) -> None: + """Test that oversized payloads (>100KiB) from client cause disconnection without crashing.""" + process_exited = False + helper_log_found = False + + def check_logs(line: str) -> None: + nonlocal process_exited, helper_log_found + # Check for signs that the process exited/crashed + if "Segmentation fault" in line or "core dumped" in line: + process_exited = True + # Check for HELPER_LOG message about message size exceeding maximum + if ( + "[VV]" in line + and "Bad packet: message size" in line + and "exceeds maximum" in line + ): + helper_log_found = True + + async with run_compiled(yaml_config, line_callback=check_logs): + async with api_client_connected_with_disconnect() as (client, disconnect_event): + # Verify basic connection works first + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "oversized-plaintext" + + # Create an oversized payload (>100KiB) + oversized_data = b"X" * (100 * 1024 + 1) # 100KiB + 1 byte + + # Access the internal connection to send raw data + frame_helper = client._connection._frame_helper + # Create a message with oversized payload + # Using message type 1 (DeviceInfoRequest) as an example + message_type = 1 + frame_helper.write_packets([(message_type, oversized_data)], True) + + # Wait for the connection to be closed by ESPHome + await asyncio.wait_for(disconnect_event.wait(), timeout=5.0) + + # After disconnection, verify process didn't crash + assert not process_exited, "ESPHome process should not crash" + # Verify we saw the expected HELPER_LOG message + assert helper_log_found, ( + "Expected to see HELPER_LOG about message size exceeding maximum" + ) + + # Try to reconnect to verify the process is still running + async with api_client_connected_with_disconnect() as (client2, _): + device_info = await client2.device_info() + assert device_info is not None + assert device_info.name == "oversized-plaintext" + + +@pytest.mark.asyncio +async def test_oversized_protobuf_message_id_plaintext( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, +) -> None: + """Test that protobuf messages with ID > UINT16_MAX cause disconnection without crashing. + + This tests the message type limit - message IDs must fit in a uint16_t (0-65535). + """ + process_exited = False + helper_log_found = False + + def check_logs(line: str) -> None: + nonlocal process_exited, helper_log_found + # Check for signs that the process exited/crashed + if "Segmentation fault" in line or "core dumped" in line: + process_exited = True + # Check for HELPER_LOG message about message type exceeding maximum + if ( + "[VV]" in line + and "Bad packet: message type" in line + and "exceeds maximum" in line + ): + helper_log_found = True + + async with run_compiled(yaml_config, line_callback=check_logs): + async with api_client_connected_with_disconnect() as (client, disconnect_event): + # Verify basic connection works first + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "oversized-protobuf-plaintext" + + # Access the internal connection to send raw message with large ID + frame_helper = client._connection._frame_helper + # Message ID that exceeds uint16_t limit (> 65535) + large_message_id = 65536 # 2^16, exceeds UINT16_MAX + # Small payload for the test + payload = b"test" + + # This should cause disconnection due to oversized varint + frame_helper.write_packets([(large_message_id, payload)], True) + + # Wait for the connection to be closed by ESPHome + await asyncio.wait_for(disconnect_event.wait(), timeout=5.0) + + # After disconnection, verify process didn't crash + assert not process_exited, "ESPHome process should not crash" + # Verify we saw the expected HELPER_LOG message + assert helper_log_found, ( + "Expected to see HELPER_LOG about message type exceeding maximum" + ) + + # Try to reconnect to verify the process is still running + async with api_client_connected_with_disconnect() as (client2, _): + device_info = await client2.device_info() + assert device_info is not None + assert device_info.name == "oversized-protobuf-plaintext" + + +@pytest.mark.asyncio +async def test_oversized_payload_noise( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, +) -> None: + """Test that oversized payloads (>100KiB) from client cause disconnection without crashing with noise encryption.""" + noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" + process_exited = False + cipherstate_failed = False + + def check_logs(line: str) -> None: + nonlocal process_exited, cipherstate_failed + # Check for signs that the process exited/crashed + if "Segmentation fault" in line or "core dumped" in line: + process_exited = True + # Check for the expected warning about decryption failure + if ( + "[W][api.connection" in line + and "Reading failed CIPHERSTATE_DECRYPT_FAILED" in line + ): + cipherstate_failed = True + + async with run_compiled(yaml_config, line_callback=check_logs): + async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( + client, + disconnect_event, + ): + # Verify basic connection works first + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "oversized-noise" + + # Create an oversized payload (>100KiB) + oversized_data = b"Y" * (100 * 1024 + 1) # 100KiB + 1 byte + + # Access the internal connection to send raw data + frame_helper = client._connection._frame_helper + # For noise connections, we still send through write_packets + # but the frame helper will handle encryption + # Using message type 1 (DeviceInfoRequest) as an example + message_type = 1 + frame_helper.write_packets([(message_type, oversized_data)], True) + + # Wait for the connection to be closed by ESPHome + await asyncio.wait_for(disconnect_event.wait(), timeout=5.0) + + # After disconnection, verify process didn't crash + assert not process_exited, "ESPHome process should not crash" + # Verify we saw the expected warning message + assert cipherstate_failed, ( + "Expected to see warning about CIPHERSTATE_DECRYPT_FAILED" + ) + + # Try to reconnect to verify the process is still running + async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( + client2, + _, + ): + device_info = await client2.device_info() + assert device_info is not None + assert device_info.name == "oversized-noise" + + +@pytest.mark.asyncio +async def test_oversized_protobuf_message_id_noise( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, +) -> None: + """Test that the noise protocol handles unknown message types correctly. + + With noise encryption, message types are stored as uint16_t (2 bytes) after decryption. + Unknown message types should be ignored without disconnecting, as ESPHome needs to + read the full message to maintain encryption stream continuity. + """ + noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" + process_exited = False + + def check_logs(line: str) -> None: + nonlocal process_exited + # Check for signs that the process exited/crashed + if "Segmentation fault" in line or "core dumped" in line: + process_exited = True + + async with run_compiled(yaml_config, line_callback=check_logs): + async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( + client, + disconnect_event, + ): + # Verify basic connection works first + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "oversized-noise" + + # With noise, message types are uint16_t, so we test with an unknown but valid value + frame_helper = client._connection._frame_helper + + # Test with an unknown message type (65535 is not used by ESPHome) + unknown_message_id = 65535 # Valid uint16_t but unknown to ESPHome + payload = b"test" + + # Send the unknown message type - ESPHome should read and ignore it + frame_helper.write_packets([(unknown_message_id, payload)], True) + + # Give ESPHome a moment to process (but expect no disconnection) + # The connection should stay alive as ESPHome ignores unknown message types + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(disconnect_event.wait(), timeout=0.5) + + # Connection should still be alive - unknown types are ignored, not fatal + assert client._connection.is_connected, ( + "Connection should remain open for unknown message types" + ) + + # Verify we can still communicate by sending a valid request + device_info2 = await client.device_info() + assert device_info2 is not None + assert device_info2.name == "oversized-noise" + + # After test, verify process didn't crash + assert not process_exited, "ESPHome process should not crash" + + # Verify we can still reconnect + async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( + client2, + _, + ): + device_info = await client2.device_info() + assert device_info is not None + assert device_info.name == "oversized-noise" + + +@pytest.mark.asyncio +async def test_noise_corrupt_encrypted_frame( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, +) -> None: + """Test that noise protocol properly handles corrupt encrypted frames. + + Send a frame with valid size but corrupt encrypted content (garbage bytes). + This should fail decryption and cause disconnection. + """ + noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" + process_exited = False + cipherstate_failed = False + + def check_logs(line: str) -> None: + nonlocal process_exited, cipherstate_failed + # Check for signs that the process exited/crashed + if "Segmentation fault" in line or "core dumped" in line: + process_exited = True + # Check for the expected warning about decryption failure + if ( + "[W][api.connection" in line + and "Reading failed CIPHERSTATE_DECRYPT_FAILED" in line + ): + cipherstate_failed = True + + async with run_compiled(yaml_config, line_callback=check_logs): + async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( + client, + disconnect_event, + ): + # Verify basic connection works first + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "oversized-noise" + + # Get the socket to send raw corrupt data + socket = client._connection._socket + + # Send a corrupt noise frame directly to the socket + # Format: [indicator=0x01][size_high][size_low][garbage_encrypted_data] + # Size of 32 bytes (reasonable size for a noise frame with MAC) + corrupt_frame = bytes( + [ + 0x01, # Noise indicator + 0x00, # Size high byte + 0x20, # Size low byte (32 bytes) + ] + ) + bytes(32) # 32 bytes of zeros (invalid encrypted data) + + # Send the corrupt frame + socket.sendall(corrupt_frame) + + # Wait for ESPHome to disconnect due to decryption failure + await asyncio.wait_for(disconnect_event.wait(), timeout=5.0) + + # After disconnection, verify process didn't crash + assert not process_exited, ( + "ESPHome process should not crash on corrupt encrypted frames" + ) + # Verify we saw the expected warning message + assert cipherstate_failed, ( + "Expected to see warning about CIPHERSTATE_DECRYPT_FAILED" + ) + + # Verify we can still reconnect after handling the corrupt frame + async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( + client2, + _, + ): + device_info = await client2.device_info() + assert device_info is not None + assert device_info.name == "oversized-noise" diff --git a/tests/integration/types.py b/tests/integration/types.py index 5e4bfaa29d..b6728a2fcb 100644 --- a/tests/integration/types.py +++ b/tests/integration/types.py @@ -54,3 +54,17 @@ class APIClientConnectedFactory(Protocol): client_info: str = "integration-test", timeout: float = 30, ) -> AbstractAsyncContextManager[APIClient]: ... + + +class APIClientConnectedWithDisconnectFactory(Protocol): + """Protocol for connected API client factory that returns disconnect event.""" + + def __call__( # noqa: E704 + self, + address: str = "localhost", + port: int | None = None, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", + timeout: float = 30, + ) -> AbstractAsyncContextManager[tuple[APIClient, asyncio.Event]]: ... From fbb48c504f13e22be073664dcafac3f06953f821 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:08:55 -0600 Subject: [PATCH 1295/1626] [esp32_improv] Disable loop by default until provisioning needed (#10764) --- esphome/components/esp32_improv/esp32_improv_component.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index d41094fda1..d47cc50a00 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -31,6 +31,9 @@ void ESP32ImprovComponent::setup() { #endif global_ble_server->on(BLEServerEvt::EmptyEvt::ON_DISCONNECT, [this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); }); + + // Start with loop disabled - will be enabled by start() when needed + this->disable_loop(); } void ESP32ImprovComponent::setup_characteristics() { From 30bb640c8923c58e894a4dfd231e1c593bbfeb68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:15:49 -0600 Subject: [PATCH 1296/1626] Skip external component updates when running logs command (#10756) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/__main__.py | 7 +- .../external_components/__init__.py | 14 +- esphome/components/packages/__init__.py | 18 +- esphome/config.py | 24 +- esphome/git.py | 8 + tests/component_tests/conftest.py | 28 ++ .../external_components/test_init.py | 134 ++++++++++ tests/component_tests/packages/test_init.py | 114 ++++++++ tests/unit_tests/conftest.py | 7 + tests/unit_tests/test_git.py | 246 ++++++++++++++++++ 10 files changed, 576 insertions(+), 24 deletions(-) create mode 100644 tests/component_tests/external_components/test_init.py create mode 100644 tests/component_tests/packages/test_init.py create mode 100644 tests/unit_tests/test_git.py diff --git a/esphome/__main__.py b/esphome/__main__.py index b63720d672..aa237c83a7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1273,7 +1273,12 @@ def run_esphome(argv): CORE.config_path = conf_path CORE.dashboard = args.dashboard - config = read_config(dict(args.substitution) if args.substitution else {}) + # For logs command, skip updating external components + skip_external = args.command == "logs" + config = read_config( + dict(args.substitution) if args.substitution else {}, + skip_external_update=skip_external, + ) if config is None: return 2 CORE.config = config diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index a09217fd21..ceb402c5b7 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -39,11 +39,13 @@ async def to_code(config): pass -def _process_git_config(config: dict, refresh) -> str: +def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str: + # When skip_update is True, use NEVER_REFRESH to prevent updates + actual_refresh = git.NEVER_REFRESH if skip_update else refresh repo_dir, _ = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), - refresh=refresh, + refresh=actual_refresh, domain=DOMAIN, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), @@ -70,12 +72,12 @@ def _process_git_config(config: dict, refresh) -> str: return components_dir -def _process_single_config(config: dict): +def _process_single_config(config: dict, skip_update: bool = False): conf = config[CONF_SOURCE] if conf[CONF_TYPE] == TYPE_GIT: with cv.prepend_path([CONF_SOURCE]): components_dir = _process_git_config( - config[CONF_SOURCE], config[CONF_REFRESH] + config[CONF_SOURCE], config[CONF_REFRESH], skip_update ) elif conf[CONF_TYPE] == TYPE_LOCAL: components_dir = Path(CORE.relative_config_path(conf[CONF_PATH])) @@ -105,7 +107,7 @@ def _process_single_config(config: dict): loader.install_meta_finder(components_dir, allowed_components=allowed_components) -def do_external_components_pass(config: dict) -> None: +def do_external_components_pass(config: dict, skip_update: bool = False) -> None: conf = config.get(DOMAIN) if conf is None: return @@ -113,4 +115,4 @@ def do_external_components_pass(config: dict) -> None: conf = CONFIG_SCHEMA(conf) for i, c in enumerate(conf): with cv.prepend_path(i): - _process_single_config(c) + _process_single_config(c, skip_update) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 2e7dc0e197..fdc75d995a 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -106,11 +106,13 @@ CONFIG_SCHEMA = cv.Any( ) -def _process_base_package(config: dict) -> dict: +def _process_base_package(config: dict, skip_update: bool = False) -> dict: + # When skip_update is True, use NEVER_REFRESH to prevent updates + actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH] repo_dir, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), - refresh=config[CONF_REFRESH], + refresh=actual_refresh, domain=DOMAIN, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), @@ -180,16 +182,16 @@ def _process_base_package(config: dict) -> dict: return {"packages": packages} -def _process_package(package_config, config): +def _process_package(package_config, config, skip_update: bool = False): recursive_package = package_config if CONF_URL in package_config: - package_config = _process_base_package(package_config) + package_config = _process_base_package(package_config, skip_update) if isinstance(package_config, dict): - recursive_package = do_packages_pass(package_config) + recursive_package = do_packages_pass(package_config, skip_update) return merge_config(recursive_package, config) -def do_packages_pass(config: dict): +def do_packages_pass(config: dict, skip_update: bool = False): if CONF_PACKAGES not in config: return config packages = config[CONF_PACKAGES] @@ -198,10 +200,10 @@ def do_packages_pass(config: dict): if isinstance(packages, dict): for package_name, package_config in reversed(packages.items()): with cv.prepend_path(package_name): - config = _process_package(package_config, config) + config = _process_package(package_config, config, skip_update) elif isinstance(packages, list): for package_config in reversed(packages): - config = _process_package(package_config, config) + config = _process_package(package_config, config, skip_update) else: raise cv.Invalid( f"Packages must be a key to value mapping or list, got {type(packages)} instead" diff --git a/esphome/config.py b/esphome/config.py index 90325cbf6e..36892fcd25 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -846,7 +846,9 @@ class PinUseValidationCheck(ConfigValidationStep): def validate_config( - config: dict[str, Any], command_line_substitutions: dict[str, Any] + config: dict[str, Any], + command_line_substitutions: dict[str, Any], + skip_external_update: bool = False, ) -> Config: result = Config() @@ -859,7 +861,7 @@ def validate_config( result.add_output_path([CONF_PACKAGES], CONF_PACKAGES) try: - config = do_packages_pass(config) + config = do_packages_pass(config, skip_update=skip_external_update) except vol.Invalid as err: result.update(config) result.add_error(err) @@ -896,7 +898,7 @@ def validate_config( result.add_output_path([CONF_EXTERNAL_COMPONENTS], CONF_EXTERNAL_COMPONENTS) try: - do_external_components_pass(config) + do_external_components_pass(config, skip_update=skip_external_update) except vol.Invalid as err: result.update(config) result.add_error(err) @@ -1020,7 +1022,9 @@ class InvalidYAMLError(EsphomeError): self.base_exc = base_exc -def _load_config(command_line_substitutions: dict[str, Any]) -> Config: +def _load_config( + command_line_substitutions: dict[str, Any], skip_external_update: bool = False +) -> Config: """Load the configuration file.""" try: config = yaml_util.load_yaml(CORE.config_path) @@ -1028,7 +1032,7 @@ def _load_config(command_line_substitutions: dict[str, Any]) -> Config: raise InvalidYAMLError(e) from e try: - return validate_config(config, command_line_substitutions) + return validate_config(config, command_line_substitutions, skip_external_update) except EsphomeError: raise except Exception: @@ -1036,9 +1040,11 @@ def _load_config(command_line_substitutions: dict[str, Any]) -> Config: raise -def load_config(command_line_substitutions: dict[str, Any]) -> Config: +def load_config( + command_line_substitutions: dict[str, Any], skip_external_update: bool = False +) -> Config: try: - return _load_config(command_line_substitutions) + return _load_config(command_line_substitutions, skip_external_update) except vol.Invalid as err: raise EsphomeError(f"Error while parsing config: {err}") from err @@ -1178,10 +1184,10 @@ def strip_default_ids(config): return config -def read_config(command_line_substitutions): +def read_config(command_line_substitutions, skip_external_update=False): _LOGGER.info("Reading configuration %s...", CORE.config_path) try: - res = load_config(command_line_substitutions) + res = load_config(command_line_substitutions, skip_external_update) except EsphomeError as err: _LOGGER.error("Error while reading config: %s", err) return None diff --git a/esphome/git.py b/esphome/git.py index 56aedd1519..62fe37a3fe 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -13,6 +13,9 @@ from esphome.core import CORE, TimePeriodSeconds _LOGGER = logging.getLogger(__name__) +# Special value to indicate never refresh +NEVER_REFRESH = TimePeriodSeconds(seconds=-1) + def run_git_command(cmd, cwd=None) -> str: _LOGGER.debug("Running git command: %s", " ".join(cmd)) @@ -85,6 +88,11 @@ def clone_or_update( else: # Check refresh needed + # Skip refresh if NEVER_REFRESH is specified + if refresh == NEVER_REFRESH: + _LOGGER.debug("Skipping update for %s (refresh disabled)", key) + return repo_dir, None + file_timestamp = Path(repo_dir / ".git" / "FETCH_HEAD") # On first clone, FETCH_HEAD does not exists if not file_timestamp.exists(): diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 79b4151c55..0641e698e9 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Generator from pathlib import Path import sys from typing import Any +from unittest import mock import pytest @@ -17,6 +18,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.types import ConfigType +from esphome.util import OrderedDict # Add package root to python path here = Path(__file__).parent @@ -135,3 +137,29 @@ def generate_main() -> Generator[Callable[[str | Path], str]]: return CORE.cpp_main_section yield generator + + +@pytest.fixture +def mock_clone_or_update() -> Generator[Any]: + """Mock git.clone_or_update for testing.""" + with mock.patch("esphome.git.clone_or_update") as mock_func: + # Default return value + mock_func.return_value = (Path("/tmp/test"), None) + yield mock_func + + +@pytest.fixture +def mock_load_yaml() -> Generator[Any]: + """Mock yaml_util.load_yaml for testing.""" + + with mock.patch("esphome.yaml_util.load_yaml") as mock_func: + # Default return value + mock_func.return_value = OrderedDict({"sensor": []}) + yield mock_func + + +@pytest.fixture +def mock_install_meta_finder() -> Generator[Any]: + """Mock loader.install_meta_finder for testing.""" + with mock.patch("esphome.loader.install_meta_finder") as mock_func: + yield mock_func diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py new file mode 100644 index 0000000000..905c0afa8b --- /dev/null +++ b/tests/component_tests/external_components/test_init.py @@ -0,0 +1,134 @@ +"""Tests for the external_components skip_update functionality.""" + +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +from esphome.components.external_components import do_external_components_pass +from esphome.const import ( + CONF_EXTERNAL_COMPONENTS, + CONF_REFRESH, + CONF_SOURCE, + CONF_URL, + TYPE_GIT, +) + + +def test_external_components_skip_update_true( + tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock +) -> None: + """Test that external components don't update when skip_update=True.""" + # Create a components directory structure + components_dir = tmp_path / "components" + components_dir.mkdir() + + # Create a test component + test_component_dir = components_dir / "test_component" + test_component_dir.mkdir() + (test_component_dir / "__init__.py").write_text("# Test component") + + # Set up mock to return our tmp_path + mock_clone_or_update.return_value = (tmp_path, None) + + config: dict[str, Any] = { + CONF_EXTERNAL_COMPONENTS: [ + { + CONF_SOURCE: { + "type": TYPE_GIT, + CONF_URL: "https://github.com/test/components", + }, + CONF_REFRESH: "1d", + "components": "all", + } + ] + } + + # Call with skip_update=True + do_external_components_pass(config, skip_update=True) + + # Verify clone_or_update was called with NEVER_REFRESH + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + from esphome import git + + assert call_args.kwargs["refresh"] == git.NEVER_REFRESH + + +def test_external_components_skip_update_false( + tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock +) -> None: + """Test that external components update when skip_update=False.""" + # Create a components directory structure + components_dir = tmp_path / "components" + components_dir.mkdir() + + # Create a test component + test_component_dir = components_dir / "test_component" + test_component_dir.mkdir() + (test_component_dir / "__init__.py").write_text("# Test component") + + # Set up mock to return our tmp_path + mock_clone_or_update.return_value = (tmp_path, None) + + config: dict[str, Any] = { + CONF_EXTERNAL_COMPONENTS: [ + { + CONF_SOURCE: { + "type": TYPE_GIT, + CONF_URL: "https://github.com/test/components", + }, + CONF_REFRESH: "1d", + "components": "all", + } + ] + } + + # Call with skip_update=False + do_external_components_pass(config, skip_update=False) + + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + from esphome.core import TimePeriodSeconds + + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) + + +def test_external_components_default_no_skip( + tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock +) -> None: + """Test that external components update by default when skip_update not specified.""" + # Create a components directory structure + components_dir = tmp_path / "components" + components_dir.mkdir() + + # Create a test component + test_component_dir = components_dir / "test_component" + test_component_dir.mkdir() + (test_component_dir / "__init__.py").write_text("# Test component") + + # Set up mock to return our tmp_path + mock_clone_or_update.return_value = (tmp_path, None) + + config: dict[str, Any] = { + CONF_EXTERNAL_COMPONENTS: [ + { + CONF_SOURCE: { + "type": TYPE_GIT, + CONF_URL: "https://github.com/test/components", + }, + CONF_REFRESH: "1d", + "components": "all", + } + ] + } + + # Call without skip_update parameter + do_external_components_pass(config) + + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + from esphome.core import TimePeriodSeconds + + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) diff --git a/tests/component_tests/packages/test_init.py b/tests/component_tests/packages/test_init.py new file mode 100644 index 0000000000..779244e2ed --- /dev/null +++ b/tests/component_tests/packages/test_init.py @@ -0,0 +1,114 @@ +"""Tests for the packages component skip_update functionality.""" + +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +from esphome.components.packages import do_packages_pass +from esphome.const import CONF_FILES, CONF_PACKAGES, CONF_REFRESH, CONF_URL +from esphome.util import OrderedDict + + +def test_packages_skip_update_true( + tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock +) -> None: + """Test that packages don't update when skip_update=True.""" + # Set up mock to return our tmp_path + mock_clone_or_update.return_value = (tmp_path, None) + + # Create the test yaml file + test_file = tmp_path / "test.yaml" + test_file.write_text("sensor: []") + + # Set mock_load_yaml to return some valid config + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config: dict[str, Any] = { + CONF_PACKAGES: { + "test_package": { + CONF_URL: "https://github.com/test/repo", + CONF_FILES: ["test.yaml"], + CONF_REFRESH: "1d", + } + } + } + + # Call with skip_update=True + do_packages_pass(config, skip_update=True) + + # Verify clone_or_update was called with NEVER_REFRESH + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + from esphome import git + + assert call_args.kwargs["refresh"] == git.NEVER_REFRESH + + +def test_packages_skip_update_false( + tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock +) -> None: + """Test that packages update when skip_update=False.""" + # Set up mock to return our tmp_path + mock_clone_or_update.return_value = (tmp_path, None) + + # Create the test yaml file + test_file = tmp_path / "test.yaml" + test_file.write_text("sensor: []") + + # Set mock_load_yaml to return some valid config + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config: dict[str, Any] = { + CONF_PACKAGES: { + "test_package": { + CONF_URL: "https://github.com/test/repo", + CONF_FILES: ["test.yaml"], + CONF_REFRESH: "1d", + } + } + } + + # Call with skip_update=False (default) + do_packages_pass(config, skip_update=False) + + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + from esphome.core import TimePeriodSeconds + + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) + + +def test_packages_default_no_skip( + tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock +) -> None: + """Test that packages update by default when skip_update not specified.""" + # Set up mock to return our tmp_path + mock_clone_or_update.return_value = (tmp_path, None) + + # Create the test yaml file + test_file = tmp_path / "test.yaml" + test_file.write_text("sensor: []") + + # Set mock_load_yaml to return some valid config + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config: dict[str, Any] = { + CONF_PACKAGES: { + "test_package": { + CONF_URL: "https://github.com/test/repo", + CONF_FILES: ["test.yaml"], + CONF_REFRESH: "1d", + } + } + } + + # Call without skip_update parameter + do_packages_pass(config) + + # Verify clone_or_update was called with actual refresh value + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + from esphome.core import TimePeriodSeconds + + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 3f78acd10d..e8d9c02524 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -89,6 +89,13 @@ def mock_run_external_command() -> Generator[Mock, None, None]: yield mock +@pytest.fixture +def mock_run_git_command() -> Generator[Mock, None, None]: + """Mock run_git_command for git module.""" + with patch("esphome.git.run_git_command") as mock: + yield mock + + @pytest.fixture def mock_get_idedata() -> Generator[Mock, None, None]: """Mock get_idedata for platformio_api.""" diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py new file mode 100644 index 0000000000..6a51206ec2 --- /dev/null +++ b/tests/unit_tests/test_git.py @@ -0,0 +1,246 @@ +"""Tests for git.py module.""" + +from datetime import datetime, timedelta +import hashlib +import os +from pathlib import Path +from unittest.mock import Mock + +from esphome import git +from esphome.core import CORE, TimePeriodSeconds + + +def test_clone_or_update_with_never_refresh( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Test that NEVER_REFRESH skips updates for existing repos.""" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = tmp_path / "test.yaml" + + # Compute the expected repo directory path + url = "https://github.com/test/repo" + ref = None + key = f"{url}@{ref}" + domain = "test" + + # Compute hash-based directory name (matching _compute_destination_path logic) + h = hashlib.new("sha256") + h.update(key.encode()) + repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + + # Create the git repo directory structure + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() + + # Create FETCH_HEAD file with current timestamp + fetch_head = git_dir / "FETCH_HEAD" + fetch_head.write_text("test") + + # Call with NEVER_REFRESH + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=git.NEVER_REFRESH, + domain=domain, + ) + + # Should NOT call git commands since NEVER_REFRESH and repo exists + mock_run_git_command.assert_not_called() + assert result_dir == repo_dir + assert revert is None + + +def test_clone_or_update_with_refresh_updates_old_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Test that refresh triggers update for old repos.""" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = tmp_path / "test.yaml" + + # Compute the expected repo directory path + url = "https://github.com/test/repo" + ref = None + key = f"{url}@{ref}" + domain = "test" + + # Compute hash-based directory name (matching _compute_destination_path logic) + h = hashlib.new("sha256") + h.update(key.encode()) + repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + + # Create the git repo directory structure + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() + + # Create FETCH_HEAD file with old timestamp (2 days ago) + fetch_head = git_dir / "FETCH_HEAD" + fetch_head.write_text("test") + old_time = datetime.now() - timedelta(days=2) + fetch_head.touch() # Create the file + # Set modification time to 2 days ago + os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp())) + + # Mock git command responses + mock_run_git_command.return_value = "abc123" # SHA for rev-parse + + # Call with refresh=1d (1 day) + refresh = TimePeriodSeconds(days=1) + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + # Should call git fetch and update commands since repo is older than refresh + assert mock_run_git_command.called + # Check for fetch command + fetch_calls = [ + call + for call in mock_run_git_command.call_args_list + if len(call[0]) > 0 and "fetch" in call[0][0] + ] + assert len(fetch_calls) > 0 + + +def test_clone_or_update_with_refresh_skips_fresh_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Test that refresh doesn't update fresh repos.""" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = tmp_path / "test.yaml" + + # Compute the expected repo directory path + url = "https://github.com/test/repo" + ref = None + key = f"{url}@{ref}" + domain = "test" + + # Compute hash-based directory name (matching _compute_destination_path logic) + h = hashlib.new("sha256") + h.update(key.encode()) + repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + + # Create the git repo directory structure + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() + + # Create FETCH_HEAD file with recent timestamp (1 hour ago) + fetch_head = git_dir / "FETCH_HEAD" + fetch_head.write_text("test") + recent_time = datetime.now() - timedelta(hours=1) + fetch_head.touch() # Create the file + # Set modification time to 1 hour ago + os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) + + # Call with refresh=1d (1 day) + refresh = TimePeriodSeconds(days=1) + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + # Should NOT call git fetch since repo is fresh + mock_run_git_command.assert_not_called() + assert result_dir == repo_dir + assert revert is None + + +def test_clone_or_update_clones_missing_repo( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Test that missing repos are cloned regardless of refresh setting.""" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = tmp_path / "test.yaml" + + # Compute the expected repo directory path + url = "https://github.com/test/repo" + ref = None + key = f"{url}@{ref}" + domain = "test" + + # Compute hash-based directory name (matching _compute_destination_path logic) + h = hashlib.new("sha256") + h.update(key.encode()) + repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + + # Create base directory but NOT the repo itself + base_dir = tmp_path / ".esphome" / domain + base_dir.mkdir(parents=True) + # repo_dir should NOT exist + assert not repo_dir.exists() + + # Test with NEVER_REFRESH - should still clone since repo doesn't exist + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=git.NEVER_REFRESH, + domain=domain, + ) + + # Should call git clone + assert mock_run_git_command.called + clone_calls = [ + call + for call in mock_run_git_command.call_args_list + if len(call[0]) > 0 and "clone" in call[0][0] + ] + assert len(clone_calls) > 0 + + +def test_clone_or_update_with_none_refresh_always_updates( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Test that refresh=None always updates existing repos.""" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = tmp_path / "test.yaml" + + # Compute the expected repo directory path + url = "https://github.com/test/repo" + ref = None + key = f"{url}@{ref}" + domain = "test" + + # Compute hash-based directory name (matching _compute_destination_path logic) + h = hashlib.new("sha256") + h.update(key.encode()) + repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + + # Create the git repo directory structure + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() + + # Create FETCH_HEAD file with very recent timestamp (1 second ago) + fetch_head = git_dir / "FETCH_HEAD" + fetch_head.write_text("test") + recent_time = datetime.now() - timedelta(seconds=1) + fetch_head.touch() # Create the file + # Set modification time to 1 second ago + os.utime(fetch_head, (recent_time.timestamp(), recent_time.timestamp())) + + # Mock git command responses + mock_run_git_command.return_value = "abc123" # SHA for rev-parse + + # Call with refresh=None (default behavior) + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=None, + domain=domain, + ) + + # Should call git fetch and update commands since refresh=None means always update + assert mock_run_git_command.called + # Check for fetch command + fetch_calls = [ + call + for call in mock_run_git_command.call_args_list + if len(call[0]) > 0 and "fetch" in call[0][0] + ] + assert len(fetch_calls) > 0 From fe4a0c94cfd48f144f96d2629b195dcaab5058ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:27:38 -0600 Subject: [PATCH 1297/1626] reduce --- .../components/esphome/ota/ota_esphome.cpp | 56 ++++++++++--------- esphome/components/esphome/ota/ota_esphome.h | 2 +- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6089f744b8..e23d996273 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -267,12 +267,12 @@ void ESPHomeOTAComponent::handle_data_() { if (client_supports_sha256) { sha256::SHA256 sha_hasher; auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, 16, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, - LOG_STR("SHA256")); + LOG_STR("SHA256"), sbuf); } else { ESP_LOGW(TAG, "Using MD5 auth for compatibility (deprecated)"); md5::MD5Digest md5_hasher; - auth_success = - this->perform_hash_auth_(&md5_hasher, this->password_, 8, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5")); + auth_success = this->perform_hash_auth_(&md5_hasher, this->password_, 8, ota::OTA_RESPONSE_REQUEST_AUTH, + LOG_STR("MD5"), sbuf); } #else // Strict mode: SHA256 required on capable platforms (future default) @@ -281,13 +281,16 @@ void ESPHomeOTAComponent::handle_data_() { error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - auth_success = this->perform_hash_auth_(this->password_); + sha256::SHA256 sha_hasher; + auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, 16, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, + LOG_STR("SHA256"), sbuf); #endif // ALLOW_OTA_DOWNGRADE_MD5 #else // Platform only supports MD5 - use it as the only available option // This is not a security downgrade as the platform cannot support SHA256 md5::MD5Digest md5_hasher; - auth_success = this->perform_hash_auth_(&md5_hasher, this->password_, 8, ota::OTA_RESPONSE_REQUEST_AUTH); + auth_success = + this->perform_hash_auth_(&md5_hasher, this->password_, 8, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); #endif // USE_OTA_SHA256 if (!auth_success) { @@ -521,12 +524,11 @@ void ESPHomeOTAComponent::log_auth_warning_(const LogString *action, const LogSt // Non-template function definition to reduce binary size bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, size_t nonce_size, - uint8_t auth_request, const LogString *name) { + uint8_t auth_request, const LogString *name, char *buf) { // Get sizes from the hasher const size_t hex_size = hasher->get_hex_size(); - // Single hex buffer - reused for nonce, cnonce, expected, response - char hex_buffer[65]; // SHA256 hex + null terminator + // Use the provided buffer for all hex operations // Small stack buffer for nonce seed bytes uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) @@ -559,11 +561,11 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string hasher->calculate(); // Generate and send nonce - hasher->get_hex(hex_buffer); - hex_buffer[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Nonce is %s", LOG_STR_ARG(name), hex_buffer); + hasher->get_hex(buf); + buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Nonce is %s", LOG_STR_ARG(name), buf); - if (!this->writeall_(reinterpret_cast(hex_buffer), hex_size)) { + if (!this->writeall_(reinterpret_cast(buf), hex_size)) { this->log_auth_warning_(LOG_STR("Writing nonce"), name); return false; } @@ -571,34 +573,34 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string // Start challenge: password + nonce hasher->init(); hasher->add(password.c_str(), password.length()); - hasher->add(hex_buffer, hex_size); + hasher->add(buf, hex_size); // Read cnonce and add to hash - if (!this->readall_(reinterpret_cast(hex_buffer), hex_size)) { + if (!this->readall_(reinterpret_cast(buf), hex_size)) { this->log_auth_warning_(LOG_STR("Reading cnonce"), name); return false; } - hex_buffer[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s CNonce is %s", LOG_STR_ARG(name), hex_buffer); + buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s CNonce is %s", LOG_STR_ARG(name), buf); - hasher->add(hex_buffer, hex_size); + hasher->add(buf, hex_size); hasher->calculate(); - // Get expected result - hasher->get_hex(hex_buffer); - hex_buffer[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Result is %s", LOG_STR_ARG(name), hex_buffer); + // Log expected result (digest is already in hasher) + hasher->get_hex(buf); + buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Result is %s", LOG_STR_ARG(name), buf); - // Read response and compare in-place - char response[65]; - if (!this->readall_(reinterpret_cast(response), hex_size)) { + // Read response into the buffer + if (!this->readall_(reinterpret_cast(buf), hex_size)) { this->log_auth_warning_(LOG_STR("Reading response"), name); return false; } - response[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Response is %s", LOG_STR_ARG(name), response); + buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Response is %s", LOG_STR_ARG(name), buf); - bool matches = memcmp(hex_buffer, response, hex_size) == 0; + // Compare response directly with digest in hasher + bool matches = hasher->equals_hex(buf); if (!matches) { ESP_LOGW(TAG, "Auth failed! %s passwords do not match", LOG_STR_ARG(name)); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 8eaad9c99f..39f2f878de 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -33,7 +33,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { void handle_data_(); #ifdef USE_OTA_PASSWORD bool perform_hash_auth_(HashBase *hasher, const std::string &password, size_t nonce_size, uint8_t auth_request, - const LogString *name); + const LogString *name, char *buf); void log_auth_warning_(const LogString *action, const LogString *hash_name); #endif // USE_OTA_PASSWORD bool readall_(uint8_t *buf, size_t len); From 0e9a1fc80dbff3e694a6816fd867be7eba570515 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:30:36 -0600 Subject: [PATCH 1298/1626] cleanup --- esphome/components/esphome/ota/ota_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e23d996273..a07e94b09b 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -603,7 +603,7 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string bool matches = hasher->equals_hex(buf); if (!matches) { - ESP_LOGW(TAG, "Auth failed! %s passwords do not match", LOG_STR_ARG(name)); + this->log_auth_warning_(LOG_STR("Password mismatch"), name); } return matches; From 0d67d2de601f73cdff67561928f1d311b9303e8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:36:27 -0600 Subject: [PATCH 1299/1626] preen --- esphome/components/esphome/ota/__init__.py | 9 +++++---- esphome/components/esphome/ota/ota_esphome.cpp | 6 ++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 72a690b926..e6f249e021 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -140,13 +140,14 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_port(config[CONF_PORT])) - # Only include SHA256 support on platforms that have it - if supports_sha256(): - cg.add_define("USE_OTA_SHA256") - if CONF_PASSWORD in config: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_PASSWORD") + # Only include hash algorithms when password is configured + cg.add_define("USE_OTA_MD5") + # Only include SHA256 support on platforms that have it + if supports_sha256(): + cg.add_define("USE_OTA_SHA256") cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) await cg.register_component(var, config) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index a07e94b09b..f503ff795e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,6 +1,8 @@ #include "ota_esphome.h" #ifdef USE_OTA +#ifdef USE_OTA_MD5 #include "esphome/components/md5/md5.h" +#endif #ifdef USE_OTA_SHA256 #include "esphome/components/sha256/sha256.h" #endif @@ -269,10 +271,12 @@ void ESPHomeOTAComponent::handle_data_() { auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, 16, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, LOG_STR("SHA256"), sbuf); } else { +#ifdef USE_OTA_MD5 ESP_LOGW(TAG, "Using MD5 auth for compatibility (deprecated)"); md5::MD5Digest md5_hasher; auth_success = this->perform_hash_auth_(&md5_hasher, this->password_, 8, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); +#endif // USE_OTA_MD5 } #else // Strict mode: SHA256 required on capable platforms (future default) @@ -288,9 +292,11 @@ void ESPHomeOTAComponent::handle_data_() { #else // Platform only supports MD5 - use it as the only available option // This is not a security downgrade as the platform cannot support SHA256 +#ifdef USE_OTA_MD5 md5::MD5Digest md5_hasher; auth_success = this->perform_hash_auth_(&md5_hasher, this->password_, 8, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); +#endif // USE_OTA_MD5 #endif // USE_OTA_SHA256 if (!auth_success) { From 57be58baa090ac10e9bf8a7574ccf10f31885e42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:37:45 -0600 Subject: [PATCH 1300/1626] preen --- esphome/writer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/writer.py b/esphome/writer.py index 49b6c3b43e..6d34d8f751 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -1,5 +1,6 @@ import importlib import logging +import os from pathlib import Path import re From ba5e995fc1d8a6f6c1cfd956499991a8757845b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:40:47 -0600 Subject: [PATCH 1301/1626] preen --- esphome/components/sha256/__init__.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index 91d4929a4f..f07157416d 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -13,12 +13,10 @@ CONFIG_SCHEMA = cv.Schema({}) async def to_code(config: ConfigType) -> None: # Add OpenSSL library for host platform - if CORE.is_host: - if IS_MACOS: - # macOS needs special handling for Homebrew OpenSSL - cg.add_build_flag("-I/opt/homebrew/opt/openssl/include") - cg.add_build_flag("-L/opt/homebrew/opt/openssl/lib") - cg.add_build_flag("-lcrypto") - else: - # Linux and other Unix systems usually have OpenSSL in standard paths - cg.add_build_flag("-lcrypto") + if not CORE.is_host: + return + if IS_MACOS: + # macOS needs special handling for Homebrew OpenSSL + cg.add_build_flag("-I/opt/homebrew/opt/openssl/include") + cg.add_build_flag("-L/opt/homebrew/opt/openssl/lib") + cg.add_build_flag("-lcrypto") From b1f90fb78d037a356d2ba523459c64717c3a90fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:53:23 -0600 Subject: [PATCH 1302/1626] preen --- .../components/esphome/ota/ota_esphome.cpp | 2 +- esphome/components/md5/md5.h | 4 ++-- esphome/components/sha256/sha256.h | 4 ++-- esphome/core/hash_base.h | 20 ++++++------------- 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f503ff795e..7fd16ce3d0 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -532,7 +532,7 @@ void ESPHomeOTAComponent::log_auth_warning_(const LogString *action, const LogSt bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, size_t nonce_size, uint8_t auth_request, const LogString *name, char *buf) { // Get sizes from the hasher - const size_t hex_size = hasher->get_hex_size(); + const size_t hex_size = hasher->get_size() * 2; // Hex is twice the byte size // Use the provided buffer for all hex operations diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index cb0acefd7d..73d99205c0 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -42,8 +42,8 @@ class MD5Digest : public HashBase { /// Compute the digest, based on the provided data. void calculate() override; - /// Get the size of the hex output (32 for MD5) - size_t get_hex_size() const override { return 32; } + /// Get the size of the hash in bytes (16 for MD5) + size_t get_size() const override { return 16; } protected: MD5_CTX_TYPE ctx_{}; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 78cccd80f2..004b1e50fc 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -33,8 +33,8 @@ class SHA256 : public esphome::HashBase { void calculate() override; - /// Get the size of the hex output (64 for SHA256) - size_t get_hex_size() const override { return 64; } + /// Get the size of the hash in bytes (32 for SHA256) + size_t get_size() const override { return 32; } protected: #if defined(USE_ESP32) || defined(USE_LIBRETINY) diff --git a/esphome/core/hash_base.h b/esphome/core/hash_base.h index e35aee0475..1af2fd8907 100644 --- a/esphome/core/hash_base.h +++ b/esphome/core/hash_base.h @@ -23,15 +23,11 @@ class HashBase { virtual void calculate() = 0; /// Retrieve the hash as bytes - void get_bytes(uint8_t *output) { - const size_t hash_bytes = this->get_hex_size() / 2; - memcpy(output, this->digest_, hash_bytes); - } + void get_bytes(uint8_t *output) { memcpy(output, this->digest_, this->get_size()); } /// Retrieve the hash as hex characters void get_hex(char *output) { - const size_t hash_bytes = this->get_hex_size() / 2; - for (size_t i = 0; i < hash_bytes; i++) { + for (size_t i = 0; i < this->get_size(); i++) { uint8_t byte = this->digest_[i]; output[i * 2] = format_hex_char(byte >> 4); output[i * 2 + 1] = format_hex_char(byte & 0x0F); @@ -39,23 +35,19 @@ class HashBase { } /// Compare the hash against a provided byte-encoded hash - bool equals_bytes(const uint8_t *expected) { - const size_t hash_bytes = this->get_hex_size() / 2; - return memcmp(this->digest_, expected, hash_bytes) == 0; - } + bool equals_bytes(const uint8_t *expected) { return memcmp(this->digest_, expected, this->get_size()) == 0; } /// Compare the hash against a provided hex-encoded hash bool equals_hex(const char *expected) { - const size_t hash_bytes = this->get_hex_size() / 2; uint8_t parsed[32]; // Max size for SHA256 - if (!parse_hex(expected, parsed, hash_bytes)) { + if (!parse_hex(expected, parsed, this->get_size())) { return false; } return this->equals_bytes(parsed); } - /// Get the size of the hex output (32 for MD5, 64 for SHA256) - virtual size_t get_hex_size() const = 0; + /// Get the size of the hash in bytes (16 for MD5, 32 for SHA256) + virtual size_t get_size() const = 0; protected: uint8_t digest_[32]; // Common digest storage, sized for largest hash (SHA256) From 6c26f75a770d6ec94f736736888700f42e9aba11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:56:05 -0600 Subject: [PATCH 1303/1626] preen --- esphome/components/md5/md5.h | 1 + esphome/components/sha256/sha256.h | 1 + 2 files changed, 2 insertions(+) diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index 73d99205c0..b0da2c0a3b 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -38,6 +38,7 @@ class MD5Digest : public HashBase { /// Add bytes of data for the digest. void add(const uint8_t *data, size_t len) override; + using HashBase::add; // Bring base class overload into scope /// Compute the digest, based on the provided data. void calculate() override; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 004b1e50fc..bb089bc314 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -29,6 +29,7 @@ class SHA256 : public esphome::HashBase { void init() override; void add(const uint8_t *data, size_t len) override; + using HashBase::add; // Bring base class overload into scope void add(const std::string &data) { this->add((const uint8_t *) data.c_str(), data.length()); } void calculate() override; From 5062e7a0e13767a5bb2da3b739b3d33f94a6c3b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:59:44 -0600 Subject: [PATCH 1304/1626] Fix missing os import after merge collisions (#10823) --- esphome/writer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/writer.py b/esphome/writer.py index 49b6c3b43e..6d34d8f751 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -1,5 +1,6 @@ import importlib import logging +import os from pathlib import Path import re From 68eb4091b898aa99a37fbb651c6a0ba666743ce0 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Mon, 22 Sep 2025 06:29:15 +0200 Subject: [PATCH 1305/1626] [substitutions] add missing safe globals tests (#10814) --- .../fixtures/substitutions/02-expressions.approved.yaml | 3 +++ .../fixtures/substitutions/02-expressions.input.yaml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/tests/unit_tests/fixtures/substitutions/02-expressions.approved.yaml b/tests/unit_tests/fixtures/substitutions/02-expressions.approved.yaml index 9e401ec5d6..443cba144e 100644 --- a/tests/unit_tests/fixtures/substitutions/02-expressions.approved.yaml +++ b/tests/unit_tests/fixtures/substitutions/02-expressions.approved.yaml @@ -22,3 +22,6 @@ test_list: - The pin number is 18 - The square root is: 5.0 - The number is 80 + - ord("a") = 97 + - chr(97) = a + - len([1,2,3]) = 3 diff --git a/tests/unit_tests/fixtures/substitutions/02-expressions.input.yaml b/tests/unit_tests/fixtures/substitutions/02-expressions.input.yaml index 1777b46f67..07ad992f1f 100644 --- a/tests/unit_tests/fixtures/substitutions/02-expressions.input.yaml +++ b/tests/unit_tests/fixtures/substitutions/02-expressions.input.yaml @@ -20,3 +20,6 @@ test_list: - The pin number is ${pin.number} - The square root is: ${math.sqrt(area)} - The number is ${var${numberOne} + 1} + - ord("a") = ${ ord("a") } + - chr(97) = ${ chr(97) } + - len([1,2,3]) = ${ len([1,2,3]) } From 7629903afb64c8bfc2121e93e8b8037397f267f8 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Mon, 22 Sep 2025 06:32:59 +0200 Subject: [PATCH 1306/1626] [substitutions] implement !literal (#10785) --- esphome/components/substitutions/__init__.py | 4 +++- esphome/config.py | 6 +++--- esphome/yaml_util.py | 16 +++++++++++----- .../substitutions/00-simple_var.approved.yaml | 7 +++++++ .../substitutions/00-simple_var.input.yaml | 6 ++++++ 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index a96f56a045..1a1736aed1 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -4,7 +4,7 @@ from esphome import core from esphome.config_helpers import Extend, Remove, merge_config import esphome.config_validation as cv from esphome.const import CONF_SUBSTITUTIONS, VALID_SUBSTITUTIONS_CHARACTERS -from esphome.yaml_util import ESPHomeDataBase, make_data_base +from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, make_data_base from .jinja import Jinja, JinjaStr, TemplateError, TemplateRuntimeError, has_jinja @@ -127,6 +127,8 @@ def _expand_substitutions(substitutions, value, path, jinja, ignore_missing): def _substitute_item(substitutions, item, path, jinja, ignore_missing): + if isinstance(item, ESPLiteralValue): + return None # do not substitute inside literal blocks if isinstance(item, list): for i, it in enumerate(item): sub = _substitute_item(substitutions, it, path + [i], jinja, ignore_missing) diff --git a/esphome/config.py b/esphome/config.py index 36892fcd25..a7e47f646b 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -32,7 +32,7 @@ from esphome.log import AnsiFore, color from esphome.types import ConfigFragmentType, ConfigType from esphome.util import OrderedDict, safe_print from esphome.voluptuous_schema import ExtraKeysInvalid -from esphome.yaml_util import ESPForceValue, ESPHomeDataBase, is_secret +from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, is_secret _LOGGER = logging.getLogger(__name__) @@ -306,7 +306,7 @@ def recursive_check_replaceme(value): return cv.Schema([recursive_check_replaceme])(value) if isinstance(value, dict): return cv.Schema({cv.valid: recursive_check_replaceme})(value) - if isinstance(value, ESPForceValue): + if isinstance(value, ESPLiteralValue): pass if isinstance(value, str) and value == "REPLACEME": raise cv.Invalid( @@ -314,7 +314,7 @@ def recursive_check_replaceme(value): "Please make sure you have replaced all fields from the sample " "configuration.\n" "If you want to use the literal REPLACEME string, " - 'please use "!force REPLACEME"' + 'please use "!literal REPLACEME"' ) return value diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index f430fa22df..359b72b48f 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -69,7 +69,7 @@ class ESPHomeDataBase: self._content_offset = database.content_offset -class ESPForceValue: +class ESPLiteralValue: pass @@ -348,9 +348,15 @@ class ESPHomeLoaderMixin: return Lambda(str(node.value)) @_add_data_ref - def construct_force(self, node: yaml.Node) -> ESPForceValue: - obj = self.construct_scalar(node) - return add_class_to_obj(obj, ESPForceValue) + def construct_literal(self, node: yaml.Node) -> ESPLiteralValue: + obj = None + if isinstance(node, yaml.ScalarNode): + obj = self.construct_scalar(node) + elif isinstance(node, yaml.SequenceNode): + obj = self.construct_sequence(node) + elif isinstance(node, yaml.MappingNode): + obj = self.construct_mapping(node) + return add_class_to_obj(obj, ESPLiteralValue) @_add_data_ref def construct_extend(self, node: yaml.Node) -> Extend: @@ -407,7 +413,7 @@ for _loader in (ESPHomeLoader, ESPHomePurePythonLoader): "!include_dir_merge_named", _loader.construct_include_dir_merge_named ) _loader.add_constructor("!lambda", _loader.construct_lambda) - _loader.add_constructor("!force", _loader.construct_force) + _loader.add_constructor("!literal", _loader.construct_literal) _loader.add_constructor("!extend", _loader.construct_extend) _loader.add_constructor("!remove", _loader.construct_remove) diff --git a/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml b/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml index f5d2f8aa20..c59975b2ae 100644 --- a/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml +++ b/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml @@ -1,7 +1,11 @@ substitutions: + substituted: 99 var1: '1' var2: '2' var21: '79' + value: 33 + values: 44 + esphome: name: test test_list: @@ -19,3 +23,6 @@ test_list: - ${ undefined_var } - key1: 1 key2: 2 + - Literal $values ${are not substituted} + - ["list $value", "${is not}", "${substituted}"] + - {"$dictionary": "$value", "${is not}": "${substituted}"} diff --git a/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml b/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml index 5717433c7e..3b7e7a6b4e 100644 --- a/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml +++ b/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml @@ -2,9 +2,12 @@ esphome: name: test substitutions: + substituted: 99 var1: "1" var2: "2" var21: "79" + value: 33 + values: 44 test_list: - "$var1" @@ -21,3 +24,6 @@ test_list: - ${ undefined_var } - key${var1}: 1 key${var2}: 2 + - !literal Literal $values ${are not substituted} + - !literal ["list $value", "${is not}", "${substituted}"] + - !literal {"$dictionary": "$value", "${is not}": "${substituted}"} From 5c943d7c13c01547176602230be70d02c78dcf45 Mon Sep 17 00:00:00 2001 From: Sam <142272307+thekeymeister@users.noreply.github.com> Date: Mon, 22 Sep 2025 06:05:41 +0100 Subject: [PATCH 1307/1626] tuya: handle WIFI_SELECT and WIFI_RESET (#10822) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/tuya/tuya.cpp | 33 ++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 1443d10254..12b14be9ff 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -215,12 +215,37 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY); } break; - case TuyaCommandType::WIFI_RESET: - ESP_LOGE(TAG, "WIFI_RESET is not handled"); - break; case TuyaCommandType::WIFI_SELECT: - ESP_LOGE(TAG, "WIFI_SELECT is not handled"); + case TuyaCommandType::WIFI_RESET: { + const bool is_select = (len >= 1); + // Send WIFI_SELECT ACK + TuyaCommand ack; + ack.cmd = is_select ? TuyaCommandType::WIFI_SELECT : TuyaCommandType::WIFI_RESET; + ack.payload.clear(); + this->send_command_(ack); + // Establish pairing mode for correct first WIFI_STATE byte, EZ (0x00) default + uint8_t first = 0x00; + const char *mode_str = "EZ"; + if (is_select && buffer[0] == 0x01) { + first = 0x01; + mode_str = "AP"; + } + // Send WIFI_STATE response, MCU exits pairing mode + TuyaCommand st; + st.cmd = TuyaCommandType::WIFI_STATE; + st.payload.resize(1); + st.payload[0] = first; + this->send_command_(st); + st.payload[0] = 0x02; + this->send_command_(st); + st.payload[0] = 0x03; + this->send_command_(st); + st.payload[0] = 0x04; + this->send_command_(st); + ESP_LOGI(TAG, "%s received (%s), replied with WIFI_STATE confirming connection established", + is_select ? "WIFI_SELECT" : "WIFI_RESET", mode_str); break; + } case TuyaCommandType::DATAPOINT_DELIVER: break; case TuyaCommandType::DATAPOINT_REPORT_ASYNC: From d302c0c60091f5d5685ad21829c62111b9bef930 Mon Sep 17 00:00:00 2001 From: brambo123 <52667932+brambo123@users.noreply.github.com> Date: Mon, 22 Sep 2025 19:15:19 +0200 Subject: [PATCH 1308/1626] [uart] Multiple ESP32 features and fixes (#8103) Co-authored-by: Keith Burzinski Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/uart/__init__.py | 31 ++++++++ esphome/components/uart/uart.h | 6 ++ esphome/components/uart/uart_component.cpp | 8 ++ esphome/components/uart/uart_component.h | 28 +++++++ .../uart/uart_component_esp_idf.cpp | 74 ++++++++++++++++--- .../components/uart/uart_component_esp_idf.h | 3 + ...st-uart_max_with_usb_cdc.esp32-c3-ard.yaml | 10 ++- ...st-uart_max_with_usb_cdc.esp32-s2-ard.yaml | 10 ++- ...st-uart_max_with_usb_cdc.esp32-s2-idf.yaml | 10 ++- ...st-uart_max_with_usb_cdc.esp32-s3-ard.yaml | 22 +++++- ...max_with_usb_serial_jtag.esp32-c3-idf.yaml | 10 ++- ...max_with_usb_serial_jtag.esp32-s3-idf.yaml | 22 +++++- tests/components/uart/test.esp32-ard.yaml | 3 + tests/components/uart/test.esp32-c3-ard.yaml | 3 + tests/components/uart/test.esp32-c3-idf.yaml | 3 + tests/components/uart/test.esp32-idf.yaml | 3 + 16 files changed, 224 insertions(+), 22 deletions(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 4c8c991eb6..764576744f 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,3 +1,4 @@ +import math import re from esphome import automation, pins @@ -14,6 +15,7 @@ from esphome.const import ( CONF_DIRECTION, CONF_DUMMY_RECEIVER, CONF_DUMMY_RECEIVER_ID, + CONF_FLOW_CONTROL_PIN, CONF_ID, CONF_INVERT, CONF_LAMBDA, @@ -152,6 +154,8 @@ UART_PARITY_OPTIONS = { CONF_STOP_BITS = "stop_bits" CONF_DATA_BITS = "data_bits" CONF_PARITY = "parity" +CONF_RX_FULL_THRESHOLD = "rx_full_threshold" +CONF_RX_TIMEOUT = "rx_timeout" UARTDirection = uart_ns.enum("UARTDirection") UART_DIRECTIONS = { @@ -219,8 +223,17 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_BAUD_RATE): cv.int_range(min=1), cv.Optional(CONF_TX_PIN): pins.internal_gpio_output_pin_schema, cv.Optional(CONF_RX_PIN): validate_rx_pin, + cv.Optional(CONF_FLOW_CONTROL_PIN): cv.All( + cv.only_on_esp32, pins.internal_gpio_output_pin_schema + ), cv.Optional(CONF_PORT): cv.All(validate_port, cv.only_on(PLATFORM_HOST)), cv.Optional(CONF_RX_BUFFER_SIZE, default=256): cv.validate_bytes, + cv.Optional(CONF_RX_FULL_THRESHOLD): cv.All( + cv.only_on_esp32, cv.validate_bytes, cv.int_range(min=1, max=120) + ), + cv.SplitDefault(CONF_RX_TIMEOUT, esp32=2): cv.All( + cv.only_on_esp32, cv.validate_bytes, cv.int_range(min=0, max=92) + ), cv.Optional(CONF_STOP_BITS, default=1): cv.one_of(1, 2, int=True), cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), cv.Optional(CONF_PARITY, default="NONE"): cv.enum( @@ -275,9 +288,27 @@ async def to_code(config): if CONF_RX_PIN in config: rx_pin = await cg.gpio_pin_expression(config[CONF_RX_PIN]) cg.add(var.set_rx_pin(rx_pin)) + if CONF_FLOW_CONTROL_PIN in config: + flow_control_pin = await cg.gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) + cg.add(var.set_flow_control_pin(flow_control_pin)) if CONF_PORT in config: cg.add(var.set_name(config[CONF_PORT])) cg.add(var.set_rx_buffer_size(config[CONF_RX_BUFFER_SIZE])) + if CORE.is_esp32: + if CONF_RX_FULL_THRESHOLD not in config: + # Calculate rx_full_threshold to be 10ms + bytelength = config[CONF_DATA_BITS] + config[CONF_STOP_BITS] + 1 + if config[CONF_PARITY] != "NONE": + bytelength += 1 + config[CONF_RX_FULL_THRESHOLD] = max( + 1, + min( + 120, + math.floor((config[CONF_BAUD_RATE] / (bytelength * 1000 / 10)) - 1), + ), + ) + cg.add(var.set_rx_full_threshold(config[CONF_RX_FULL_THRESHOLD])) + cg.add(var.set_rx_timeout(config[CONF_RX_TIMEOUT])) cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) cg.add(var.set_data_bits(config[CONF_DATA_BITS])) cg.add(var.set_parity(config[CONF_PARITY])) diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index dc6962fbae..e2912db122 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -18,6 +18,12 @@ class UARTDevice { void write_byte(uint8_t data) { this->parent_->write_byte(data); } + void set_rx_full_threshold(size_t rx_full_threshold) { this->parent_->set_rx_full_threshold(rx_full_threshold); } + void set_rx_full_threshold_ms(size_t time) { this->parent_->set_rx_full_threshold_ms(time); } + size_t get_rx_full_threshold() { return this->parent_->get_rx_full_threshold(); } + void set_rx_timeout(size_t rx_timeout) { this->parent_->set_rx_timeout(rx_timeout); } + size_t get_rx_timeout() { return this->parent_->get_rx_timeout(); } + void write_array(const uint8_t *data, size_t len) { this->parent_->write_array(data, len); } void write_array(const std::vector &data) { this->parent_->write_array(data); } template void write_array(const std::array &data) { diff --git a/esphome/components/uart/uart_component.cpp b/esphome/components/uart/uart_component.cpp index 09b8c975ab..8f670275d4 100644 --- a/esphome/components/uart/uart_component.cpp +++ b/esphome/components/uart/uart_component.cpp @@ -20,5 +20,13 @@ bool UARTComponent::check_read_timeout_(size_t len) { return true; } +void UARTComponent::set_rx_full_threshold_ms(uint8_t time) { + uint8_t bytelength = this->data_bits_ + this->stop_bits_ + 1; + if (this->parity_ != UARTParityOptions::UART_CONFIG_PARITY_NONE) + bytelength += 1; + int32_t val = clamp((this->baud_rate_ / (bytelength * 1000 / time)) - 1, 1, 120); + this->set_rx_full_threshold(val); +} + } // namespace uart } // namespace esphome diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index a57910c1a1..452688b3e9 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -6,6 +6,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" #ifdef USE_UART_DEBUGGER #include "esphome/core/automation.h" #endif @@ -82,6 +83,10 @@ class UARTComponent { // @param rx_pin Pointer to the internal GPIO pin used for reception. void set_rx_pin(InternalGPIOPin *rx_pin) { this->rx_pin_ = rx_pin; } + // Sets the flow control pin for the UART bus. + // @param flow_control_pin Pointer to the internal GPIO pin used for flow control. + void set_flow_control_pin(InternalGPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; } + // Sets the size of the RX buffer. // @param rx_buffer_size Size of the RX buffer in bytes. void set_rx_buffer_size(size_t rx_buffer_size) { this->rx_buffer_size_ = rx_buffer_size; } @@ -90,6 +95,26 @@ class UARTComponent { // @return Size of the RX buffer in bytes. size_t get_rx_buffer_size() { return this->rx_buffer_size_; } + // Sets the RX FIFO full interrupt threshold. + // @param rx_full_threshold RX full interrupt threshold in bytes. + virtual void set_rx_full_threshold(size_t rx_full_threshold) {} + + // Sets the RX FIFO full interrupt threshold. + // @param time RX full interrupt threshold in ms. + void set_rx_full_threshold_ms(uint8_t time); + + // Gets the RX FIFO full interrupt threshold. + // @return RX full interrupt threshold in bytes. + size_t get_rx_full_threshold() { return this->rx_full_threshold_; } + + // Sets the RX timeout interrupt threshold. + // @param rx_timeout RX timeout interrupt threshold (unit: time of sending one byte). + virtual void set_rx_timeout(size_t rx_timeout) {} + + // Gets the RX timeout interrupt threshold. + // @return RX timeout interrupt threshold (unit: time of sending one byte). + size_t get_rx_timeout() { return this->rx_timeout_; } + // Sets the number of stop bits used in UART communication. // @param stop_bits Number of stop bits. void set_stop_bits(uint8_t stop_bits) { this->stop_bits_ = stop_bits; } @@ -161,7 +186,10 @@ class UARTComponent { InternalGPIOPin *tx_pin_; InternalGPIOPin *rx_pin_; + InternalGPIOPin *flow_control_pin_; size_t rx_buffer_size_; + size_t rx_full_threshold_{1}; + size_t rx_timeout_{0}; uint32_t baud_rate_; uint8_t stop_bits_; uint8_t data_bits_; diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 6517b4b503..7530856b1e 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -90,6 +90,12 @@ void IDFUARTComponent::setup() { xSemaphoreTake(this->lock_, portMAX_DELAY); + this->load_settings(false); + + xSemaphoreGive(this->lock_); +} + +void IDFUARTComponent::load_settings(bool dump_config) { uart_config_t uart_config = this->get_config_(); esp_err_t err = uart_param_config(this->uart_num_, &uart_config); if (err != ESP_OK) { @@ -100,6 +106,7 @@ void IDFUARTComponent::setup() { int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; + int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; uint32_t invert = 0; if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) @@ -114,13 +121,21 @@ void IDFUARTComponent::setup() { return; } - err = uart_set_pin(this->uart_num_, tx, rx, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); + err = uart_set_pin(this->uart_num_, tx, rx, flow_control, UART_PIN_NO_CHANGE); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_set_pin failed: %s", esp_err_to_name(err)); this->mark_failed(); return; } + if (uart_is_driver_installed(this->uart_num_)) { + uart_driver_delete(this->uart_num_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + } err = uart_driver_install(this->uart_num_, /* UART RX ring buffer size. */ this->rx_buffer_size_, /* UART TX ring buffer size. If set to zero, driver will not use TX buffer, TX function will block task until all data have been sent out.*/ @@ -133,17 +148,29 @@ void IDFUARTComponent::setup() { return; } - xSemaphoreGive(this->lock_); -} - -void IDFUARTComponent::load_settings(bool dump_config) { - uart_config_t uart_config = this->get_config_(); - esp_err_t err = uart_param_config(this->uart_num_, &uart_config); + err = uart_set_rx_full_threshold(this->uart_num_, this->rx_full_threshold_); if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); + ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); this->mark_failed(); return; - } else if (dump_config) { + } + + err = uart_set_rx_timeout(this->uart_num_, this->rx_timeout_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; + err = uart_set_mode(this->uart_num_, mode); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + + if (dump_config) { ESP_LOGCONFIG(TAG, "UART %u was reloaded.", this->uart_num_); this->dump_config(); } @@ -153,8 +180,13 @@ void IDFUARTComponent::dump_config() { ESP_LOGCONFIG(TAG, "UART Bus %u:", this->uart_num_); LOG_PIN(" TX Pin: ", tx_pin_); LOG_PIN(" RX Pin: ", rx_pin_); + LOG_PIN(" Flow Control Pin: ", flow_control_pin_); if (this->rx_pin_ != nullptr) { - ESP_LOGCONFIG(TAG, " RX Buffer Size: %u", this->rx_buffer_size_); + ESP_LOGCONFIG(TAG, + " RX Buffer Size: %u\n" + " RX Full Threshold: %u\n" + " RX Timeout: %u", + this->rx_buffer_size_, this->rx_full_threshold_, this->rx_timeout_); } ESP_LOGCONFIG(TAG, " Baud Rate: %" PRIu32 " baud\n" @@ -165,6 +197,28 @@ void IDFUARTComponent::dump_config() { this->check_logger_conflict(); } +void IDFUARTComponent::set_rx_full_threshold(size_t rx_full_threshold) { + if (this->is_ready()) { + esp_err_t err = uart_set_rx_full_threshold(this->uart_num_, rx_full_threshold); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); + return; + } + } + this->rx_full_threshold_ = rx_full_threshold; +} + +void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) { + if (this->is_ready()) { + esp_err_t err = uart_set_rx_timeout(this->uart_num_, rx_timeout); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err)); + return; + } + } + this->rx_timeout_ = rx_timeout; +} + void IDFUARTComponent::write_array(const uint8_t *data, size_t len) { xSemaphoreTake(this->lock_, portMAX_DELAY); uart_write_bytes(this->uart_num_, data, len); diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index 606e17e5b6..a2ba2aa968 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -15,6 +15,9 @@ class IDFUARTComponent : public UARTComponent, public Component { void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } + void set_rx_full_threshold(size_t rx_full_threshold) override; + void set_rx_timeout(size_t rx_timeout) override; + void write_array(const uint8_t *data, size_t len) override; bool peek_byte(uint8_t *data) override; diff --git a/tests/components/uart/test-uart_max_with_usb_cdc.esp32-c3-ard.yaml b/tests/components/uart/test-uart_max_with_usb_cdc.esp32-c3-ard.yaml index 2a73826c51..602766869c 100644 --- a/tests/components/uart/test-uart_max_with_usb_cdc.esp32-c3-ard.yaml +++ b/tests/components/uart/test-uart_max_with_usb_cdc.esp32-c3-ard.yaml @@ -14,17 +14,23 @@ uart: - id: uart_1 tx_pin: 4 rx_pin: 5 + flow_control_pin: 6 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 - id: uart_2 - tx_pin: 6 - rx_pin: 7 + tx_pin: 7 + rx_pin: 8 + flow_control_pin: 9 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 diff --git a/tests/components/uart/test-uart_max_with_usb_cdc.esp32-s2-ard.yaml b/tests/components/uart/test-uart_max_with_usb_cdc.esp32-s2-ard.yaml index 2a73826c51..602766869c 100644 --- a/tests/components/uart/test-uart_max_with_usb_cdc.esp32-s2-ard.yaml +++ b/tests/components/uart/test-uart_max_with_usb_cdc.esp32-s2-ard.yaml @@ -14,17 +14,23 @@ uart: - id: uart_1 tx_pin: 4 rx_pin: 5 + flow_control_pin: 6 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 - id: uart_2 - tx_pin: 6 - rx_pin: 7 + tx_pin: 7 + rx_pin: 8 + flow_control_pin: 9 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 diff --git a/tests/components/uart/test-uart_max_with_usb_cdc.esp32-s2-idf.yaml b/tests/components/uart/test-uart_max_with_usb_cdc.esp32-s2-idf.yaml index 2a73826c51..602766869c 100644 --- a/tests/components/uart/test-uart_max_with_usb_cdc.esp32-s2-idf.yaml +++ b/tests/components/uart/test-uart_max_with_usb_cdc.esp32-s2-idf.yaml @@ -14,17 +14,23 @@ uart: - id: uart_1 tx_pin: 4 rx_pin: 5 + flow_control_pin: 6 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 - id: uart_2 - tx_pin: 6 - rx_pin: 7 + tx_pin: 7 + rx_pin: 8 + flow_control_pin: 9 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 diff --git a/tests/components/uart/test-uart_max_with_usb_cdc.esp32-s3-ard.yaml b/tests/components/uart/test-uart_max_with_usb_cdc.esp32-s3-ard.yaml index 2a73826c51..4af255e1e4 100644 --- a/tests/components/uart/test-uart_max_with_usb_cdc.esp32-s3-ard.yaml +++ b/tests/components/uart/test-uart_max_with_usb_cdc.esp32-s3-ard.yaml @@ -14,17 +14,35 @@ uart: - id: uart_1 tx_pin: 4 rx_pin: 5 + flow_control_pin: 6 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 - id: uart_2 - tx_pin: 6 - rx_pin: 7 + tx_pin: 7 + rx_pin: 8 + flow_control_pin: 9 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 + parity: EVEN + stop_bits: 2 + + - id: uart_3 + tx_pin: 10 + rx_pin: 11 + flow_control_pin: 12 + baud_rate: 9600 + data_bits: 8 + rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 diff --git a/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-c3-idf.yaml b/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-c3-idf.yaml index e0a07dde91..3151403896 100644 --- a/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-c3-idf.yaml +++ b/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-c3-idf.yaml @@ -14,17 +14,23 @@ uart: - id: uart_1 tx_pin: 4 rx_pin: 5 + flow_control_pin: 6 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 - id: uart_2 - tx_pin: 6 - rx_pin: 7 + tx_pin: 7 + rx_pin: 8 + flow_control_pin: 9 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 diff --git a/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-s3-idf.yaml b/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-s3-idf.yaml index e0a07dde91..88a806eb92 100644 --- a/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-s3-idf.yaml +++ b/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-s3-idf.yaml @@ -14,17 +14,35 @@ uart: - id: uart_1 tx_pin: 4 rx_pin: 5 + flow_control_pin: 6 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 - id: uart_2 - tx_pin: 6 - rx_pin: 7 + tx_pin: 7 + rx_pin: 8 + flow_control_pin: 9 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 + parity: EVEN + stop_bits: 2 + + - id: uart_3 + tx_pin: 10 + rx_pin: 11 + flow_control_pin: 12 + baud_rate: 9600 + data_bits: 8 + rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 diff --git a/tests/components/uart/test.esp32-ard.yaml b/tests/components/uart/test.esp32-ard.yaml index bef5b460ab..a201185309 100644 --- a/tests/components/uart/test.esp32-ard.yaml +++ b/tests/components/uart/test.esp32-ard.yaml @@ -8,8 +8,11 @@ uart: - id: uart_uart tx_pin: 17 rx_pin: 16 + flow_control_pin: 4 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 diff --git a/tests/components/uart/test.esp32-c3-ard.yaml b/tests/components/uart/test.esp32-c3-ard.yaml index 09178f1663..b053290a8b 100644 --- a/tests/components/uart/test.esp32-c3-ard.yaml +++ b/tests/components/uart/test.esp32-c3-ard.yaml @@ -8,8 +8,11 @@ uart: - id: uart_uart tx_pin: 4 rx_pin: 5 + flow_control_pin: 6 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 diff --git a/tests/components/uart/test.esp32-c3-idf.yaml b/tests/components/uart/test.esp32-c3-idf.yaml index 09178f1663..b053290a8b 100644 --- a/tests/components/uart/test.esp32-c3-idf.yaml +++ b/tests/components/uart/test.esp32-c3-idf.yaml @@ -8,8 +8,11 @@ uart: - id: uart_uart tx_pin: 4 rx_pin: 5 + flow_control_pin: 6 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index 5a0ed7eba7..5634c5c6f6 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -8,9 +8,12 @@ uart: - id: uart_uart tx_pin: 17 rx_pin: 16 + flow_control_pin: 4 baud_rate: 9600 data_bits: 8 rx_buffer_size: 512 + rx_full_threshold: 10 + rx_timeout: 1 parity: EVEN stop_bits: 2 From 5e94460608b824d788011729bb78874174dcd930 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 23 Sep 2025 07:48:39 +1200 Subject: [PATCH 1309/1626] [CI] Format files after sync (#10828) --- .github/workflows/sync-device-classes.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index b129e8f4bf..c14071ea62 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -30,11 +30,16 @@ jobs: run: | python -m pip install --upgrade pip pip install -e lib/home-assistant + pip install -r requirements_test.txt pre-commit - name: Sync run: | python ./script/sync-device_class.py + - name: Run pre-commit hooks + run: | + python script/run-in-env.py pre-commit run --all-files + - name: Commit changes uses: peter-evans/create-pull-request@v7.0.8 with: From 8714a45a5c013d485e0b662263ddd190d798eddd Mon Sep 17 00:00:00 2001 From: "Nerdiy.de" Date: Tue, 23 Sep 2025 03:48:34 +0200 Subject: [PATCH 1310/1626] Fix incorrect factor for value calculation in MMC5603 component (#9925) --- esphome/components/mmc5603/mmc5603.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index d712e2401d..f0d1044f3f 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -128,21 +128,21 @@ void MMC5603Component::update() { raw_x |= buffer[1] << 4; raw_x |= buffer[2] << 0; - const float x = 0.0625 * (raw_x - 524288); + const float x = 0.00625 * (raw_x - 524288); int32_t raw_y = 0; raw_y |= buffer[3] << 12; raw_y |= buffer[4] << 4; raw_y |= buffer[5] << 0; - const float y = 0.0625 * (raw_y - 524288); + const float y = 0.00625 * (raw_y - 524288); int32_t raw_z = 0; raw_z |= buffer[6] << 12; raw_z |= buffer[7] << 4; raw_z |= buffer[8] << 0; - const float z = 0.0625 * (raw_z - 524288); + const float z = 0.00625 * (raw_z - 524288); const float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f°", x, y, z, heading); From d7bff38ad956c202e62131ee2320590ea7f9b627 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Sep 2025 22:30:30 -0500 Subject: [PATCH 1311/1626] Implement zero-copy API for zwave_proxy --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_options.proto | 7 ++ esphome/components/api/api_pb2.cpp | 9 +- esphome/components/api/api_pb2.h | 4 +- esphome/components/api/api_pb2_dump.cpp | 7 +- esphome/components/api/proto.h | 4 + .../components/zwave_proxy/zwave_proxy.cpp | 8 +- esphome/components/zwave_proxy/zwave_proxy.h | 10 +- script/api_protobuf/api_protobuf.py | 99 +++++++++++++++++-- 9 files changed, 121 insertions(+), 29 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index ad99de4b4a..eceee9d27d 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2292,7 +2292,7 @@ message ZWaveProxyFrame { option (ifdef) = "USE_ZWAVE_PROXY"; option (no_delay) = true; - bytes data = 1 [(fixed_array_size) = 257]; + bytes data = 1 [(pointer_to_buffer) = true]; } enum ZWaveProxyRequestType { diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 50c43b96fd..633f39b552 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -32,6 +32,13 @@ extend google.protobuf.FieldOptions { optional string fixed_array_size_define = 50010; optional string fixed_array_with_length_define = 50011; + // pointer_to_buffer: Use pointer instead of array for fixed-size byte fields + // When set, the field will be declared as a pointer (const uint8_t *data) + // instead of an array (uint8_t data[N]). This allows zero-copy on decode + // by pointing directly to the protobuf buffer. The buffer must remain valid + // until the message is processed (which is guaranteed for stack-allocated messages). + optional bool pointer_to_buffer = 50012 [default=false]; + // container_pointer: Zero-copy optimization for repeated fields. // // When container_pointer is set on a repeated field, the generated message will diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 245933724b..e9c2cb2cff 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3029,12 +3029,9 @@ bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - const std::string &data_str = value.as_string(); - this->data_len = data_str.size(); - if (this->data_len > 257) { - this->data_len = 257; - } - memcpy(this->data, data_str.data(), this->data_len); + // Use raw data directly to avoid allocation + this->data = value.data(); + this->data_len = value.size(); break; } default: diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 248a4b1f82..5715f840c2 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -2929,11 +2929,11 @@ class UpdateCommandRequest final : public CommandProtoMessage { class ZWaveProxyFrame final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 128; - static constexpr uint8_t ESTIMATED_SIZE = 33; + static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "z_wave_proxy_frame"; } #endif - uint8_t data[257]{}; + const uint8_t *data{nullptr}; uint16_t data_len{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ac43af6d54..b67f909bd0 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2126,12 +2126,7 @@ void UpdateCommandRequest::dump_to(std::string &out) const { } #endif #ifdef USE_ZWAVE_PROXY -void ZWaveProxyFrame::dump_to(std::string &out) const { - MessageDumpHelper helper(out, "ZWaveProxyFrame"); - out.append(" data: "); - out.append(format_hex_pretty(this->data, this->data_len)); - out.append("\n"); -} +void ZWaveProxyFrame::dump_to(std::string &out) const { dump_field(out, "data", this->data); } void ZWaveProxyRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "ZWaveProxyRequest"); dump_field(out, "type", static_cast(this->type)); diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 0e5ec61050..6be5f00e75 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -182,6 +182,10 @@ class ProtoLengthDelimited { explicit ProtoLengthDelimited(const uint8_t *value, size_t length) : value_(value), length_(length) {} std::string as_string() const { return std::string(reinterpret_cast(this->value_), this->length_); } + // Direct access to raw data without string allocation + const uint8_t *data() const { return this->value_; } + size_t size() const { return this->length_; } + /** * Decode the length-delimited data into an existing ProtoDecodableMessage instance. * diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 12c4ee0c0d..19e4182c1b 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -61,14 +61,14 @@ void ZWaveProxy::loop() { } ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); if (this->api_connection_ != nullptr) { - // minimize copying to reduce CPU overhead + // Zero-copy: point directly to our buffer + this->outgoing_proto_msg_.data = this->buffer_.data(); if (this->in_bootloader_) { this->outgoing_proto_msg_.data_len = this->buffer_index_; } else { // If this is a data frame, use frame length indicator + 2 (for SoF + checksum), else assume 1 for ACK/NAK/CAN this->outgoing_proto_msg_.data_len = this->buffer_[0] == ZWAVE_FRAME_TYPE_START ? this->buffer_[1] + 2 : 1; } - std::memcpy(this->outgoing_proto_msg_.data, this->buffer_.data(), this->outgoing_proto_msg_.data_len); this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE); } } @@ -228,7 +228,9 @@ void ZWaveProxy::parse_start_(uint8_t byte) { } // Forward response (ACK/NAK/CAN) back to client for processing if (this->api_connection_ != nullptr) { - this->outgoing_proto_msg_.data[0] = byte; + // Store single byte in buffer and point to it + this->buffer_[0] = byte; + this->outgoing_proto_msg_.data = this->buffer_.data(); this->outgoing_proto_msg_.data_len = 1; this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE); } diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 5d908b328c..e5080e4f86 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -63,11 +63,11 @@ class ZWaveProxy : public uart::UARTDevice, public Component { api::APIConnection *api_connection_{nullptr}; // Current subscribed client - std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID - std::array buffer_; // Fixed buffer for incoming data - uint8_t buffer_index_{0}; // Index for populating the data buffer - uint8_t end_frame_after_{0}; // Payload reception ends after this index - uint8_t last_response_{0}; // Last response type sent + std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID + std::array buffer_; // Fixed buffer for incoming data + uint8_t buffer_index_{0}; // Index for populating the data buffer + uint8_t end_frame_after_{0}; // Payload reception ends after this index + uint8_t last_response_{0}; // Last response type sent ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index fa04222c5d..d212401bd2 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -353,12 +353,25 @@ def create_field_type_info( return FixedArrayRepeatedType(field, size_define) return RepeatedTypeInfo(field) - # Check for fixed_array_size option on bytes fields - if ( - field.type == 12 - and (fixed_size := get_field_opt(field, pb.fixed_array_size)) is not None - ): - return FixedArrayBytesType(field, fixed_size) + # Check for mutually exclusive options on bytes fields + if field.type == 12: + has_pointer_to_buffer = get_field_opt(field, pb.pointer_to_buffer, False) + fixed_size = get_field_opt(field, pb.fixed_array_size, None) + + if has_pointer_to_buffer and fixed_size is not None: + raise ValueError( + f"Field '{field.name}' has both pointer_to_buffer and fixed_array_size. " + "These options are mutually exclusive. Use pointer_to_buffer for zero-copy " + "or fixed_array_size for traditional array storage." + ) + + if has_pointer_to_buffer: + # Zero-copy pointer approach - no size needed, will use size_t for length + return PointerToBytesBufferType(field, None) + + if fixed_size is not None: + # Traditional fixed array approach with copy + return FixedArrayBytesType(field, fixed_size) # Special handling for bytes fields if field.type == 12: @@ -818,6 +831,80 @@ class BytesType(TypeInfo): return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes +class PointerToBytesBufferType(TypeInfo): + """Type for bytes fields that use pointer_to_buffer option for zero-copy.""" + + @classmethod + def can_use_dump_field(cls) -> bool: + return False + + def __init__( + self, field: descriptor.FieldDescriptorProto, size: int | None = None + ) -> None: + super().__init__(field) + # Size is not used for pointer_to_buffer - we always use size_t for length + self.array_size = 0 + + @property + def cpp_type(self) -> str: + return "const uint8_t*" + + @property + def default_value(self) -> str: + return "nullptr" + + @property + def reference_type(self) -> str: + return "const uint8_t*" + + @property + def const_reference_type(self) -> str: + return "const uint8_t*" + + @property + def public_content(self) -> list[str]: + # Use uint16_t for length - max packet size is well below 65535 + # Add pointer and length fields + return [ + f"const uint8_t* {self.field_name}{{nullptr}};", + f"uint16_t {self.field_name}_len{{0}};", + ] + + @property + def encode_content(self) -> str: + return f"buffer.encode_bytes({self.number}, this->{self.field_name}, this->{self.field_name}_len);" + + @property + def decode_length_content(self) -> str | None: + # Decode directly stores the pointer to avoid allocation + return f"""case {self.number}: {{ + // Use raw data directly to avoid allocation + this->{self.field_name} = value.data(); + this->{self.field_name}_len = value.size(); + break; + }}""" + + @property + def decode_length(self) -> str | None: + # This is handled in decode_length_content + return None + + @property + def wire_type(self) -> WireType: + """Get the wire type for this bytes field.""" + return WireType.LENGTH_DELIMITED # Uses wire type 2 + + def dump(self, name: str) -> str: + return f"format_hex_pretty(this->{name}, this->{name}_len)" + + def get_size_calculation(self, name: str, force: bool = False) -> str: + return f"size.add_length({self.number}, this->{self.field_name}_len);" + + def get_estimated_size(self) -> int: + # field ID + length varint + typical data (assume small for pointer fields) + return self.calculate_field_id_size() + 2 + 16 + + class FixedArrayBytesType(TypeInfo): """Special type for fixed-size byte arrays.""" From cf7fad9c14c6dac5f3269f7d893592e83d4e2f61 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Sep 2025 22:37:57 -0500 Subject: [PATCH 1312/1626] Implement zero-copy API for zwave_proxy --- esphome/components/zwave_proxy/zwave_proxy.h | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index e5080e4f86..fe51bf9c72 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -11,6 +11,8 @@ namespace esphome { namespace zwave_proxy { +static constexpr size_t MAX_ZWAVE_FRAME_SIZE = 257; // Maximum Z-Wave frame size + enum ZWaveResponseTypes : uint8_t { ZWAVE_FRAME_TYPE_ACK = 0x06, ZWAVE_FRAME_TYPE_CAN = 0x18, @@ -63,11 +65,11 @@ class ZWaveProxy : public uart::UARTDevice, public Component { api::APIConnection *api_connection_{nullptr}; // Current subscribed client - std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID - std::array buffer_; // Fixed buffer for incoming data - uint8_t buffer_index_{0}; // Index for populating the data buffer - uint8_t end_frame_after_{0}; // Payload reception ends after this index - uint8_t last_response_{0}; // Last response type sent + std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID + std::array buffer_; // Fixed buffer for incoming data + uint8_t buffer_index_{0}; // Index for populating the data buffer + uint8_t end_frame_after_{0}; // Payload reception ends after this index + uint8_t last_response_{0}; // Last response type sent ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode From 8a54b6d76e324d370273d2c508f9534f7b662db5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Sep 2025 22:43:56 -0500 Subject: [PATCH 1313/1626] fix dump --- esphome/components/api/api_pb2_dump.cpp | 7 ++++++- script/api_protobuf/api_protobuf.py | 13 ++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index b67f909bd0..ac43af6d54 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2126,7 +2126,12 @@ void UpdateCommandRequest::dump_to(std::string &out) const { } #endif #ifdef USE_ZWAVE_PROXY -void ZWaveProxyFrame::dump_to(std::string &out) const { dump_field(out, "data", this->data); } +void ZWaveProxyFrame::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "ZWaveProxyFrame"); + out.append(" data: "); + out.append(format_hex_pretty(this->data, this->data_len)); + out.append("\n"); +} void ZWaveProxyRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "ZWaveProxyRequest"); dump_field(out, "type", static_cast(this->type)); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index d212401bd2..22bebcbd29 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -895,7 +895,18 @@ class PointerToBytesBufferType(TypeInfo): return WireType.LENGTH_DELIMITED # Uses wire type 2 def dump(self, name: str) -> str: - return f"format_hex_pretty(this->{name}, this->{name}_len)" + return ( + f"format_hex_pretty(this->{self.field_name}, this->{self.field_name}_len)" + ) + + @property + def dump_content(self) -> str: + # Custom dump that doesn't use dump_field template + return ( + f'out.append(" {self.name}: ");\n' + + f"out.append({self.dump(self.field_name)});\n" + + 'out.append("\\n");' + ) def get_size_calculation(self, name: str, force: bool = False) -> str: return f"size.add_length({self.number}, this->{self.field_name}_len);" From 1771c852af19adb453d65bef4753b04c87c126c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Sep 2025 23:01:37 -0500 Subject: [PATCH 1314/1626] Pin ruamel.yaml.clib to 0.2.12 (#10837) --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 2424db1639..42a83820e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ aioesphomeapi==41.4.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import +ruamel.yaml.clib==0.2.12 # dashboard_import esphome-glyphsets==0.2.0 pillow==10.4.0 cairosvg==2.8.2 From 25e9ec178262e6a5fd3c6f6d992e583370c39afb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 23:06:11 -0500 Subject: [PATCH 1315/1626] Bump aioesphomeapi from 41.4.0 to 41.6.0 (#10833) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 42a83820e2..1663a5e5f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==41.4.0 +aioesphomeapi==41.6.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 85b5b859b5d505db2b06d2221381ad90a6941946 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Sep 2025 23:44:09 -0500 Subject: [PATCH 1316/1626] Implement zero-copy API for bluetooth_proxy writes This is the same as https://github.com/esphome/esphome/pull/10836 for Bluetooth proxy writes. This avoids the copy since all the messages live on the stack anyways and there are no lifetime concerns Doing bluetooth first since there is a wider test case vs zwave --- esphome/components/api/api.proto | 8 +++---- esphome/components/api/api_pb2.cpp | 23 +++++++++++++------ esphome/components/api/api_pb2.h | 14 ++++++----- esphome/components/api/api_pb2_dump.cpp | 4 ++-- .../components/zwave_proxy/zwave_proxy.cpp | 8 +++---- esphome/components/zwave_proxy/zwave_proxy.h | 12 ++++------ 6 files changed, 38 insertions(+), 31 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index eceee9d27d..c59ccc6e29 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1465,7 +1465,7 @@ message BluetoothDeviceRequest { uint64 address = 1; BluetoothDeviceRequestType request_type = 2; - bool has_address_type = 3; + bool has_address_type = 3; // Deprecated, should be removed in 2027.8 - https://github.com/esphome/esphome/pull/10318 uint32 address_type = 4; } @@ -1571,7 +1571,7 @@ message BluetoothGATTWriteRequest { uint32 handle = 2; bool response = 3; - bytes data = 4; + bytes data = 4 [(pointer_to_buffer) = true]; } message BluetoothGATTReadDescriptorRequest { @@ -1591,7 +1591,7 @@ message BluetoothGATTWriteDescriptorRequest { uint64 address = 1; uint32 handle = 2; - bytes data = 3; + bytes data = 3 [(pointer_to_buffer) = true]; } message BluetoothGATTNotifyRequest { @@ -2292,7 +2292,7 @@ message ZWaveProxyFrame { option (ifdef) = "USE_ZWAVE_PROXY"; option (no_delay) = true; - bytes data = 1 [(pointer_to_buffer) = true]; + bytes data = 1 [(fixed_array_size) = 257]; } enum ZWaveProxyRequestType { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index e9c2cb2cff..08384c6869 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2028,9 +2028,12 @@ bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt val } bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 4: - this->data = value.as_string(); + case 4: { + // Use raw data directly to avoid allocation + this->data = value.data(); + this->data_len = value.size(); break; + } default: return false; } @@ -2064,9 +2067,12 @@ bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, Proto } bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 3: - this->data = value.as_string(); + case 3: { + // Use raw data directly to avoid allocation + this->data = value.data(); + this->data_len = value.size(); break; + } default: return false; } @@ -3029,9 +3035,12 @@ bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - // Use raw data directly to avoid allocation - this->data = value.data(); - this->data_len = value.size(); + const std::string &data_str = value.as_string(); + this->data_len = data_str.size(); + if (this->data_len > 257) { + this->data_len = 257; + } + memcpy(this->data, data_str.data(), this->data_len); break; } default: diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5715f840c2..adc06ad5b7 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1985,14 +1985,15 @@ class BluetoothGATTReadResponse final : public ProtoMessage { class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 75; - static constexpr uint8_t ESTIMATED_SIZE = 19; + static constexpr uint8_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_request"; } #endif uint64_t address{0}; uint32_t handle{0}; bool response{false}; - std::string data{}; + const uint8_t *data{nullptr}; + uint16_t data_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2020,13 +2021,14 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 77; - static constexpr uint8_t ESTIMATED_SIZE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_descriptor_request"; } #endif uint64_t address{0}; uint32_t handle{0}; - std::string data{}; + const uint8_t *data{nullptr}; + uint16_t data_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2929,11 +2931,11 @@ class UpdateCommandRequest final : public CommandProtoMessage { class ZWaveProxyFrame final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 128; - static constexpr uint8_t ESTIMATED_SIZE = 19; + static constexpr uint8_t ESTIMATED_SIZE = 33; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "z_wave_proxy_frame"; } #endif - const uint8_t *data{nullptr}; + uint8_t data[257]{}; uint16_t data_len{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ac43af6d54..e2a4772692 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1649,7 +1649,7 @@ void BluetoothGATTWriteRequest::dump_to(std::string &out) const { dump_field(out, "handle", this->handle); dump_field(out, "response", this->response); out.append(" data: "); - out.append(format_hex_pretty(reinterpret_cast(this->data.data()), this->data.size())); + out.append(format_hex_pretty(this->data, this->data_len)); out.append("\n"); } void BluetoothGATTReadDescriptorRequest::dump_to(std::string &out) const { @@ -1662,7 +1662,7 @@ void BluetoothGATTWriteDescriptorRequest::dump_to(std::string &out) const { dump_field(out, "address", this->address); dump_field(out, "handle", this->handle); out.append(" data: "); - out.append(format_hex_pretty(reinterpret_cast(this->data.data()), this->data.size())); + out.append(format_hex_pretty(this->data, this->data_len)); out.append("\n"); } void BluetoothGATTNotifyRequest::dump_to(std::string &out) const { diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 19e4182c1b..12c4ee0c0d 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -61,14 +61,14 @@ void ZWaveProxy::loop() { } ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); if (this->api_connection_ != nullptr) { - // Zero-copy: point directly to our buffer - this->outgoing_proto_msg_.data = this->buffer_.data(); + // minimize copying to reduce CPU overhead if (this->in_bootloader_) { this->outgoing_proto_msg_.data_len = this->buffer_index_; } else { // If this is a data frame, use frame length indicator + 2 (for SoF + checksum), else assume 1 for ACK/NAK/CAN this->outgoing_proto_msg_.data_len = this->buffer_[0] == ZWAVE_FRAME_TYPE_START ? this->buffer_[1] + 2 : 1; } + std::memcpy(this->outgoing_proto_msg_.data, this->buffer_.data(), this->outgoing_proto_msg_.data_len); this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE); } } @@ -228,9 +228,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { } // Forward response (ACK/NAK/CAN) back to client for processing if (this->api_connection_ != nullptr) { - // Store single byte in buffer and point to it - this->buffer_[0] = byte; - this->outgoing_proto_msg_.data = this->buffer_.data(); + this->outgoing_proto_msg_.data[0] = byte; this->outgoing_proto_msg_.data_len = 1; this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE); } diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index fe51bf9c72..5d908b328c 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -11,8 +11,6 @@ namespace esphome { namespace zwave_proxy { -static constexpr size_t MAX_ZWAVE_FRAME_SIZE = 257; // Maximum Z-Wave frame size - enum ZWaveResponseTypes : uint8_t { ZWAVE_FRAME_TYPE_ACK = 0x06, ZWAVE_FRAME_TYPE_CAN = 0x18, @@ -65,11 +63,11 @@ class ZWaveProxy : public uart::UARTDevice, public Component { api::APIConnection *api_connection_{nullptr}; // Current subscribed client - std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID - std::array buffer_; // Fixed buffer for incoming data - uint8_t buffer_index_{0}; // Index for populating the data buffer - uint8_t end_frame_after_{0}; // Payload reception ends after this index - uint8_t last_response_{0}; // Last response type sent + std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID + std::array buffer_; // Fixed buffer for incoming data + uint8_t buffer_index_{0}; // Index for populating the data buffer + uint8_t end_frame_after_{0}; // Payload reception ends after this index + uint8_t last_response_{0}; // Last response type sent ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode From af7fea368001587c8148a3958366f24bc6c957b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Sep 2025 23:49:32 -0500 Subject: [PATCH 1317/1626] not string anymore --- .../components/bluetooth_proxy/bluetooth_connection.cpp | 9 +++++---- .../components/bluetooth_proxy/bluetooth_connection.h | 4 ++-- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 540492f8c5..94d18ac543 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -514,7 +514,8 @@ esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { return this->check_and_log_error_("esp_ble_gattc_read_char", err); } -esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std::string &data, bool response) { +esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, + bool response) { if (!this->connected()) { this->log_gatt_not_connected_("write", "characteristic"); return ESP_GATT_NOT_CONNECTED; @@ -523,7 +524,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std:: handle); esp_err_t err = - esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), + esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, length, const_cast(data), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); return this->check_and_log_error_("esp_ble_gattc_write_char", err); } @@ -540,7 +541,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); } -esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::string &data, bool response) { +esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) { if (!this->connected()) { this->log_gatt_not_connected_("write", "descriptor"); return ESP_GATT_NOT_CONNECTED; @@ -549,7 +550,7 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::stri handle); esp_err_t err = esp_ble_gattc_write_char_descr( - this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), + this->gattc_if_, this->conn_id_, handle, length, const_cast(data), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index e5d5ff2dd6..60bbc93e8b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -18,9 +18,9 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; esp_err_t read_characteristic(uint16_t handle); - esp_err_t write_characteristic(uint16_t handle, const std::string &data, bool response); + esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); esp_err_t read_descriptor(uint16_t handle); - esp_err_t write_descriptor(uint16_t handle, const std::string &data, bool response); + esp_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); esp_err_t notify_characteristic(uint16_t handle, bool enable); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 532aff550e..cd7261d5e5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -305,7 +305,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & return; } - auto err = connection->write_characteristic(msg.handle, msg.data, msg.response); + auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response); if (err != ESP_OK) { this->send_gatt_error(msg.address, msg.handle, err); } @@ -331,7 +331,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri return; } - auto err = connection->write_descriptor(msg.handle, msg.data, true); + auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true); if (err != ESP_OK) { this->send_gatt_error(msg.address, msg.handle, err); } From 56e8af79c327d84a23185ab42d6d23a962b55bce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Sep 2025 00:18:13 -0500 Subject: [PATCH 1318/1626] Bump aioesphomeapi from 41.6.0 to 41.7.0 (#10841) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1663a5e5f4..67f6e89f93 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==41.6.0 +aioesphomeapi==41.7.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 94819fb946b12015edd9f5853a8d1366e8c9bb52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 00:41:49 -0500 Subject: [PATCH 1319/1626] add comments --- esphome/components/bluetooth_proxy/bluetooth_connection.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 94d18ac543..cde82fbfb0 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -523,6 +523,9 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8 ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_.c_str(), handle); + // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data + // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) + // const_cast is safe here and was previously hidden by a C-style cast esp_err_t err = esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, length, const_cast(data), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); @@ -549,6 +552,9 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t * ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_.c_str(), handle); + // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data + // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) + // const_cast is safe here and was previously hidden by a C-style cast esp_err_t err = esp_ble_gattc_write_char_descr( this->gattc_if_, this->conn_id_, handle, length, const_cast(data), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); From 373c2d31dd9c5bee742897dd0bb2e2de06935dad Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Sep 2025 07:33:34 -0400 Subject: [PATCH 1320/1626] Fix lib_ignore handling and ingore some libraries on libretiny --- esphome/components/heatpumpir/climate.py | 2 +- esphome/components/web_server_base/__init__.py | 2 ++ esphome/core/config.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 4f83bf2435..ec6eac670f 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -128,4 +128,4 @@ async def to_code(config): cg.add_library("tonia/HeatpumpIR", "1.0.37") if CORE.is_libretiny or CORE.is_esp32: - CORE.add_platformio_option("lib_ignore", "IRremoteESP8266") + CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 2aff405036..a82ec462d9 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -40,5 +40,7 @@ async def to_code(config): cg.add_library("Update", None) if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) + if CORE.is_libretiny: + CORE.add_platformio_option("lib_ignore", ["ESPAsyncTCP", "RPAsyncTCP"]) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json cg.add_library("ESP32Async/ESPAsyncWebServer", "3.7.10") diff --git a/esphome/core/config.py b/esphome/core/config.py index 6d4f5af692..7bf7f82a8b 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -396,7 +396,7 @@ async def add_includes(includes: list[str]) -> None: async def _add_platformio_options(pio_options): # Add includes at the very end, so that they override everything for key, val in pio_options.items(): - if key == "build_flags" and not isinstance(val, list): + if key in ["build_flags", "lib_ignore"] and not isinstance(val, list): val = [val] cg.add_platformio_option(key, val) From 2e220fcca285b4e577053cfd182b413f5cd832fa Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Sep 2025 01:44:43 +1200 Subject: [PATCH 1321/1626] [camera-encoder] Use defines instead of build flags (#10824) Co-authored-by: J. Nick Koston --- esphome/components/camera_encoder/__init__.py | 6 ++---- .../components/camera_encoder/esp32_camera_jpeg_encoder.cpp | 6 ++++-- .../components/camera_encoder/esp32_camera_jpeg_encoder.h | 4 +++- esphome/core/defines.h | 1 + 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index c0f0ca2fe0..89181d27b4 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components.esp32 import add_idf_component import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE -from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@DT-art1"] @@ -51,9 +50,8 @@ async def to_code(config: ConfigType) -> None: buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID]) cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: - if CORE.using_esp_idf: - add_idf_component(name="espressif/esp32-camera", ref="2.1.0") - cg.add_build_flag("-DUSE_ESP32_CAMERA_JPEG_ENCODER") + add_idf_component(name="espressif/esp32-camera", ref="2.1.1") + cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], config[CONF_QUALITY], diff --git a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp index 7e21122087..55a3f0b96c 100644 --- a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp +++ b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp @@ -1,3 +1,5 @@ +#include "esphome/core/defines.h" + #ifdef USE_ESP32_CAMERA_JPEG_ENCODER #include "esp32_camera_jpeg_encoder.h" @@ -15,7 +17,7 @@ camera::EncoderError ESP32CameraJPEGEncoder::encode_pixels(camera::CameraImageSp this->bytes_written_ = 0; this->out_of_output_memory_ = false; bool success = fmt2jpg_cb(pixels->get_data_buffer(), pixels->get_data_length(), spec->width, spec->height, - to_internal_(spec->format), this->quality_, callback_, this); + to_internal_(spec->format), this->quality_, callback, this); if (!success) return camera::ENCODER_ERROR_CONFIGURATION; @@ -49,7 +51,7 @@ void ESP32CameraJPEGEncoder::dump_config() { this->output_->get_max_size(), this->quality_, this->buffer_expand_size_); } -size_t ESP32CameraJPEGEncoder::callback_(void *arg, size_t index, const void *data, size_t len) { +size_t ESP32CameraJPEGEncoder::callback(void *arg, size_t index, const void *data, size_t len) { ESP32CameraJPEGEncoder *that = reinterpret_cast(arg); uint8_t *buffer = that->output_->get_data(); size_t buffer_length = that->output_->get_max_size(); diff --git a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h index b585252584..0ede366e73 100644 --- a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h +++ b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h @@ -1,5 +1,7 @@ #pragma once +#include "esphome/core/defines.h" + #ifdef USE_ESP32_CAMERA_JPEG_ENCODER #include @@ -24,7 +26,7 @@ class ESP32CameraJPEGEncoder : public camera::Encoder { void dump_config() override; // ------------------------- protected: - static size_t callback_(void *arg, size_t index, const void *data, size_t len); + static size_t callback(void *arg, size_t index, const void *data, size_t len); pixformat_t to_internal_(camera::PixelFormat format); camera::EncoderBuffer *output_{}; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bdc59598f6..784e8cd2b3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -157,6 +157,7 @@ #define USE_ESP32_BLE_SERVER #define USE_ESP32_BLE_UUID #define USE_ESP32_BLE_ADVERTISING +#define USE_ESP32_CAMERA_JPEG_ENCODER #define USE_I2C #define USE_IMPROV #define USE_MICROPHONE From 13c0aa1ba8ccf1c90497da8e5bbe28f98538568e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 08:53:11 -0500 Subject: [PATCH 1322/1626] [wifi] Unify ESP32 WiFi implementation to use ESP-IDF driver --- esphome/components/wifi/__init__.py | 22 +- esphome/components/wifi/wifi_component.cpp | 6 +- esphome/components/wifi/wifi_component.h | 8 +- .../wifi/wifi_component_esp32_arduino.cpp | 860 ------------------ .../wifi/wifi_component_esp_idf.cpp | 4 +- 5 files changed, 21 insertions(+), 879 deletions(-) delete mode 100644 esphome/components/wifi/wifi_component_esp32_arduino.cpp diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index ef74c14924..a784123006 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -125,8 +125,8 @@ EAP_AUTH_SCHEMA = cv.All( cv.Optional(CONF_USERNAME): cv.string_strict, cv.Optional(CONF_PASSWORD): cv.string_strict, cv.Optional(CONF_CERTIFICATE_AUTHORITY): wpa2_eap.validate_certificate, - cv.SplitDefault(CONF_TTLS_PHASE_2, esp32_idf="mschapv2"): cv.All( - cv.enum(TTLS_PHASE_2), cv.only_with_esp_idf + cv.SplitDefault(CONF_TTLS_PHASE_2, esp32="mschapv2"): cv.All( + cv.enum(TTLS_PHASE_2), cv.only_on_esp32 ), cv.Inclusive( CONF_CERTIFICATE, "certificate_and_key" @@ -280,11 +280,11 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault(CONF_OUTPUT_POWER, esp8266=20.0): cv.All( cv.decibel, cv.float_range(min=8.5, max=20.5) ), - cv.SplitDefault(CONF_ENABLE_BTM, esp32_idf=False): cv.All( - cv.boolean, cv.only_with_esp_idf + cv.SplitDefault(CONF_ENABLE_BTM, esp32=False): cv.All( + cv.boolean, cv.only_on_esp32 ), - cv.SplitDefault(CONF_ENABLE_RRM, esp32_idf=False): cv.All( - cv.boolean, cv.only_with_esp_idf + cv.SplitDefault(CONF_ENABLE_RRM, esp32=False): cv.All( + cv.boolean, cv.only_on_esp32 ), cv.Optional(CONF_PASSIVE_SCAN, default=False): cv.boolean, cv.Optional("enable_mdns"): cv.invalid( @@ -416,10 +416,10 @@ async def to_code(config): if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) - elif (CORE.is_esp32 and CORE.using_arduino) or CORE.is_rp2040: + elif CORE.is_rp2040: cg.add_library("WiFi", None) - if CORE.is_esp32 and CORE.using_esp_idf: + if CORE.is_esp32: if config[CONF_ENABLE_BTM] or config[CONF_ENABLE_RRM]: add_idf_sdkconfig_option("CONFIG_WPA_11KV_SUPPORT", True) cg.add_define("USE_WIFI_11KV_SUPPORT") @@ -506,8 +506,10 @@ async def wifi_set_sta_to_code(config, action_id, template_arg, args): FILTER_SOURCE_FILES = filter_source_files_from_platform( { - "wifi_component_esp32_arduino.cpp": {PlatformFramework.ESP32_ARDUINO}, - "wifi_component_esp_idf.cpp": {PlatformFramework.ESP32_IDF}, + "wifi_component_esp_idf.cpp": { + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + }, "wifi_component_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "wifi_component_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 43ece636e5..8c7b55c274 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -3,7 +3,7 @@ #include #include -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1) #include #else @@ -11,7 +11,7 @@ #endif #endif -#if defined(USE_ESP32) || defined(USE_ESP_IDF) +#if defined(USE_ESP32) #include #endif #ifdef USE_ESP8266 @@ -344,7 +344,7 @@ void WiFiComponent::start_connecting(const WiFiAP &ap, bool two) { ESP_LOGV(TAG, " Identity: " LOG_SECRET("'%s'"), eap_config.identity.c_str()); ESP_LOGV(TAG, " Username: " LOG_SECRET("'%s'"), eap_config.username.c_str()); ESP_LOGV(TAG, " Password: " LOG_SECRET("'%s'"), eap_config.password.c_str()); -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE std::map phase2types = {{ESP_EAP_TTLS_PHASE2_PAP, "pap"}, {ESP_EAP_TTLS_PHASE2_CHAP, "chap"}, diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index bbe1bbb874..ee62ec1a69 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -20,7 +20,7 @@ #include #endif -#if defined(USE_ESP_IDF) && defined(USE_WIFI_WPA2_EAP) +#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) #if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) #include #else @@ -113,7 +113,7 @@ struct EAPAuth { const char *client_cert; const char *client_key; // used for EAP-TTLS -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 esp_eap_ttls_phase2_types ttls_phase_2; #endif }; @@ -199,7 +199,7 @@ enum WiFiPowerSaveMode : uint8_t { WIFI_POWER_SAVE_HIGH, }; -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 struct IDFWiFiEvent; #endif @@ -368,7 +368,7 @@ class WiFiComponent : public Component { void wifi_event_callback_(arduino_event_id_t event, arduino_event_info_t info); void wifi_scan_done_callback_(); #endif -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 void wifi_process_event_(IDFWiFiEvent *data); #endif diff --git a/esphome/components/wifi/wifi_component_esp32_arduino.cpp b/esphome/components/wifi/wifi_component_esp32_arduino.cpp deleted file mode 100644 index 89298e07c7..0000000000 --- a/esphome/components/wifi/wifi_component_esp32_arduino.cpp +++ /dev/null @@ -1,860 +0,0 @@ -#include "wifi_component.h" - -#ifdef USE_WIFI -#ifdef USE_ESP32_FRAMEWORK_ARDUINO - -#include -#include - -#include -#include -#ifdef USE_WIFI_WPA2_EAP -#include -#endif - -#ifdef USE_WIFI_AP -#include "dhcpserver/dhcpserver.h" -#endif // USE_WIFI_AP - -#include "lwip/apps/sntp.h" -#include "lwip/dns.h" -#include "lwip/err.h" - -#include "esphome/core/application.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" -#include "esphome/core/util.h" - -namespace esphome { -namespace wifi { - -static const char *const TAG = "wifi_esp32"; - -static esp_netif_t *s_sta_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#ifdef USE_WIFI_AP -static esp_netif_t *s_ap_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#endif // USE_WIFI_AP - -static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) - -void WiFiComponent::wifi_pre_setup_() { - uint8_t mac[6]; - if (has_custom_mac_address()) { - get_mac_address_raw(mac); - set_mac_address(mac); - } - auto f = std::bind(&WiFiComponent::wifi_event_callback_, this, std::placeholders::_1, std::placeholders::_2); - WiFi.onEvent(f); - WiFi.persistent(false); - // Make sure WiFi is in clean state before anything starts - this->wifi_mode_(false, false); -} - -bool WiFiComponent::wifi_mode_(optional sta, optional ap) { - wifi_mode_t current_mode = WiFiClass::getMode(); - bool current_sta = current_mode == WIFI_MODE_STA || current_mode == WIFI_MODE_APSTA; - bool current_ap = current_mode == WIFI_MODE_AP || current_mode == WIFI_MODE_APSTA; - - bool set_sta = sta.value_or(current_sta); - bool set_ap = ap.value_or(current_ap); - - wifi_mode_t set_mode; - if (set_sta && set_ap) { - set_mode = WIFI_MODE_APSTA; - } else if (set_sta && !set_ap) { - set_mode = WIFI_MODE_STA; - } else if (!set_sta && set_ap) { - set_mode = WIFI_MODE_AP; - } else { - set_mode = WIFI_MODE_NULL; - } - - if (current_mode == set_mode) - return true; - - if (set_sta && !current_sta) { - ESP_LOGV(TAG, "Enabling STA"); - } else if (!set_sta && current_sta) { - ESP_LOGV(TAG, "Disabling STA"); - } - if (set_ap && !current_ap) { - ESP_LOGV(TAG, "Enabling AP"); - } else if (!set_ap && current_ap) { - ESP_LOGV(TAG, "Disabling AP"); - } - - bool ret = WiFiClass::mode(set_mode); - - if (!ret) { - ESP_LOGW(TAG, "Setting mode failed"); - return false; - } - - // WiFiClass::mode above calls esp_netif_create_default_wifi_sta() and - // esp_netif_create_default_wifi_ap(), which creates the interfaces. - // s_sta_netif handle is set during ESPHOME_EVENT_ID_WIFI_STA_START event - -#ifdef USE_WIFI_AP - if (set_ap) - s_ap_netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); -#endif - - return ret; -} - -bool WiFiComponent::wifi_sta_pre_setup_() { - if (!this->wifi_mode_(true, {})) - return false; - - WiFi.setAutoReconnect(false); - delay(10); - return true; -} - -bool WiFiComponent::wifi_apply_output_power_(float output_power) { - int8_t val = static_cast(output_power * 4); - return esp_wifi_set_max_tx_power(val) == ESP_OK; -} - -bool WiFiComponent::wifi_apply_power_save_() { - wifi_ps_type_t power_save; - switch (this->power_save_) { - case WIFI_POWER_SAVE_LIGHT: - power_save = WIFI_PS_MIN_MODEM; - break; - case WIFI_POWER_SAVE_HIGH: - power_save = WIFI_PS_MAX_MODEM; - break; - case WIFI_POWER_SAVE_NONE: - default: - power_save = WIFI_PS_NONE; - break; - } - return esp_wifi_set_ps(power_save) == ESP_OK; -} - -bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { - // enable STA - if (!this->wifi_mode_(true, {})) - return false; - - // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/network/esp_wifi.html#_CPPv417wifi_sta_config_t - wifi_config_t conf; - memset(&conf, 0, sizeof(conf)); - if (ap.get_ssid().size() > sizeof(conf.sta.ssid)) { - ESP_LOGE(TAG, "SSID too long"); - return false; - } - if (ap.get_password().size() > sizeof(conf.sta.password)) { - ESP_LOGE(TAG, "Password too long"); - return false; - } - memcpy(reinterpret_cast(conf.sta.ssid), ap.get_ssid().c_str(), ap.get_ssid().size()); - memcpy(reinterpret_cast(conf.sta.password), ap.get_password().c_str(), ap.get_password().size()); - - // The weakest authmode to accept in the fast scan mode - if (ap.get_password().empty()) { - conf.sta.threshold.authmode = WIFI_AUTH_OPEN; - } else { - conf.sta.threshold.authmode = WIFI_AUTH_WPA_WPA2_PSK; - } - -#ifdef USE_WIFI_WPA2_EAP - if (ap.get_eap().has_value()) { - conf.sta.threshold.authmode = WIFI_AUTH_WPA2_ENTERPRISE; - } -#endif - - if (ap.get_bssid().has_value()) { - conf.sta.bssid_set = true; - memcpy(conf.sta.bssid, ap.get_bssid()->data(), 6); - } else { - conf.sta.bssid_set = false; - } - if (ap.get_channel().has_value()) { - conf.sta.channel = *ap.get_channel(); - conf.sta.scan_method = WIFI_FAST_SCAN; - } else { - conf.sta.scan_method = WIFI_ALL_CHANNEL_SCAN; - } - // Listen interval for ESP32 station to receive beacon when WIFI_PS_MAX_MODEM is set. - // Units: AP beacon intervals. Defaults to 3 if set to 0. - conf.sta.listen_interval = 0; - - // Protected Management Frame - // Device will prefer to connect in PMF mode if other device also advertises PMF capability. - conf.sta.pmf_cfg.capable = true; - conf.sta.pmf_cfg.required = false; - - // note, we do our own filtering - // The minimum rssi to accept in the fast scan mode - conf.sta.threshold.rssi = -127; - - conf.sta.threshold.authmode = WIFI_AUTH_OPEN; - - wifi_config_t current_conf; - esp_err_t err; - err = esp_wifi_get_config(WIFI_IF_STA, ¤t_conf); - if (err != ERR_OK) { - ESP_LOGW(TAG, "esp_wifi_get_config failed: %s", esp_err_to_name(err)); - // can continue - } - - if (memcmp(¤t_conf, &conf, sizeof(wifi_config_t)) != 0) { // NOLINT - err = esp_wifi_disconnect(); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_wifi_disconnect failed: %s", esp_err_to_name(err)); - return false; - } - } - - err = esp_wifi_set_config(WIFI_IF_STA, &conf); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_wifi_set_config failed: %s", esp_err_to_name(err)); - return false; - } - - if (!this->wifi_sta_ip_config_(ap.get_manual_ip())) { - return false; - } - - // setup enterprise authentication if required -#ifdef USE_WIFI_WPA2_EAP - if (ap.get_eap().has_value()) { - // note: all certificates and keys have to be null terminated. Lengths are appended by +1 to include \0. - EAPAuth eap = ap.get_eap().value(); - err = esp_eap_client_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length()); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_eap_client_set_identity failed: %d", err); - } - int ca_cert_len = strlen(eap.ca_cert); - int client_cert_len = strlen(eap.client_cert); - int client_key_len = strlen(eap.client_key); - if (ca_cert_len) { - err = esp_eap_client_set_ca_cert((uint8_t *) eap.ca_cert, ca_cert_len + 1); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_eap_client_set_ca_cert failed: %d", err); - } - } - // workout what type of EAP this is - // validation is not required as the config tool has already validated it - if (client_cert_len && client_key_len) { - // if we have certs, this must be EAP-TLS - err = esp_eap_client_set_certificate_and_key((uint8_t *) eap.client_cert, client_cert_len + 1, - (uint8_t *) eap.client_key, client_key_len + 1, - (uint8_t *) eap.password.c_str(), strlen(eap.password.c_str())); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_eap_client_set_certificate_and_key failed: %d", err); - } - } else { - // in the absence of certs, assume this is username/password based - err = esp_eap_client_set_username((uint8_t *) eap.username.c_str(), eap.username.length()); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_eap_client_set_username failed: %d", err); - } - err = esp_eap_client_set_password((uint8_t *) eap.password.c_str(), eap.password.length()); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_eap_client_set_password failed: %d", err); - } - } - err = esp_wifi_sta_enterprise_enable(); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_wifi_sta_enterprise_enable failed: %d", err); - } - } -#endif // USE_WIFI_WPA2_EAP - - this->wifi_apply_hostname_(); - - s_sta_connecting = true; - - err = esp_wifi_connect(); - if (err != ESP_OK) { - ESP_LOGW(TAG, "esp_wifi_connect failed: %s", esp_err_to_name(err)); - return false; - } - - return true; -} - -bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { - // enable STA - if (!this->wifi_mode_(true, {})) - return false; - - // Check if the STA interface is initialized before using it - if (s_sta_netif == nullptr) { - ESP_LOGW(TAG, "STA interface not initialized"); - return false; - } - - esp_netif_dhcp_status_t dhcp_status; - esp_err_t err = esp_netif_dhcpc_get_status(s_sta_netif, &dhcp_status); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_netif_dhcpc_get_status failed: %s", esp_err_to_name(err)); - return false; - } - - if (!manual_ip.has_value()) { - // sntp_servermode_dhcp lwip/sntp.c (Required to lock TCPIP core functionality!) - // https://github.com/esphome/issues/issues/6591 - // https://github.com/espressif/arduino-esp32/issues/10526 - { - LwIPLock lock; - // lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly, - // the built-in SNTP client has a memory leak in certain situations. Disable this feature. - // https://github.com/esphome/issues/issues/2299 - sntp_servermode_dhcp(false); - } - - // No manual IP is set; use DHCP client - if (dhcp_status != ESP_NETIF_DHCP_STARTED) { - err = esp_netif_dhcpc_start(s_sta_netif); - if (err != ESP_OK) { - ESP_LOGV(TAG, "Starting DHCP client failed: %d", err); - } - return err == ESP_OK; - } - return true; - } - - esp_netif_ip_info_t info; // struct of ip4_addr_t with ip, netmask, gw - info.ip = manual_ip->static_ip; - info.gw = manual_ip->gateway; - info.netmask = manual_ip->subnet; - err = esp_netif_dhcpc_stop(s_sta_netif); - if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { - ESP_LOGV(TAG, "Stopping DHCP client failed: %s", esp_err_to_name(err)); - } - - err = esp_netif_set_ip_info(s_sta_netif, &info); - if (err != ESP_OK) { - ESP_LOGV(TAG, "Setting manual IP info failed: %s", esp_err_to_name(err)); - } - - esp_netif_dns_info_t dns; - if (manual_ip->dns1.is_set()) { - dns.ip = manual_ip->dns1; - esp_netif_set_dns_info(s_sta_netif, ESP_NETIF_DNS_MAIN, &dns); - } - if (manual_ip->dns2.is_set()) { - dns.ip = manual_ip->dns2; - esp_netif_set_dns_info(s_sta_netif, ESP_NETIF_DNS_BACKUP, &dns); - } - - return true; -} - -network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { - if (!this->has_sta()) - return {}; - network::IPAddresses addresses; - esp_netif_ip_info_t ip; - esp_err_t err = esp_netif_get_ip_info(s_sta_netif, &ip); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_netif_get_ip_info failed: %s", esp_err_to_name(err)); - // TODO: do something smarter - // return false; - } else { - addresses[0] = network::IPAddress(&ip.ip); - } -#if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(s_sta_netif, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); - } -#endif /* USE_NETWORK_IPV6 */ - return addresses; -} - -bool WiFiComponent::wifi_apply_hostname_() { - // setting is done in SYSTEM_EVENT_STA_START callback - return true; -} -const char *get_auth_mode_str(uint8_t mode) { - switch (mode) { - case WIFI_AUTH_OPEN: - return "OPEN"; - case WIFI_AUTH_WEP: - return "WEP"; - case WIFI_AUTH_WPA_PSK: - return "WPA PSK"; - case WIFI_AUTH_WPA2_PSK: - return "WPA2 PSK"; - case WIFI_AUTH_WPA_WPA2_PSK: - return "WPA/WPA2 PSK"; - case WIFI_AUTH_WPA2_ENTERPRISE: - return "WPA2 Enterprise"; - case WIFI_AUTH_WPA3_PSK: - return "WPA3 PSK"; - case WIFI_AUTH_WPA2_WPA3_PSK: - return "WPA2/WPA3 PSK"; - case WIFI_AUTH_WAPI_PSK: - return "WAPI PSK"; - default: - return "UNKNOWN"; - } -} - -using esphome_ip4_addr_t = esp_ip4_addr_t; - -std::string format_ip4_addr(const esphome_ip4_addr_t &ip) { - char buf[20]; - sprintf(buf, "%u.%u.%u.%u", uint8_t(ip.addr >> 0), uint8_t(ip.addr >> 8), uint8_t(ip.addr >> 16), - uint8_t(ip.addr >> 24)); - return buf; -} -const char *get_op_mode_str(uint8_t mode) { - switch (mode) { - case WIFI_OFF: - return "OFF"; - case WIFI_STA: - return "STA"; - case WIFI_AP: - return "AP"; - case WIFI_AP_STA: - return "AP+STA"; - default: - return "UNKNOWN"; - } -} -const char *get_disconnect_reason_str(uint8_t reason) { - switch (reason) { - case WIFI_REASON_AUTH_EXPIRE: - return "Auth Expired"; - case WIFI_REASON_AUTH_LEAVE: - return "Auth Leave"; - case WIFI_REASON_ASSOC_EXPIRE: - return "Association Expired"; - case WIFI_REASON_ASSOC_TOOMANY: - return "Too Many Associations"; - case WIFI_REASON_NOT_AUTHED: - return "Not Authenticated"; - case WIFI_REASON_NOT_ASSOCED: - return "Not Associated"; - case WIFI_REASON_ASSOC_LEAVE: - return "Association Leave"; - case WIFI_REASON_ASSOC_NOT_AUTHED: - return "Association not Authenticated"; - case WIFI_REASON_DISASSOC_PWRCAP_BAD: - return "Disassociate Power Cap Bad"; - case WIFI_REASON_DISASSOC_SUPCHAN_BAD: - return "Disassociate Supported Channel Bad"; - case WIFI_REASON_IE_INVALID: - return "IE Invalid"; - case WIFI_REASON_MIC_FAILURE: - return "Mic Failure"; - case WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT: - return "4-Way Handshake Timeout"; - case WIFI_REASON_GROUP_KEY_UPDATE_TIMEOUT: - return "Group Key Update Timeout"; - case WIFI_REASON_IE_IN_4WAY_DIFFERS: - return "IE In 4-Way Handshake Differs"; - case WIFI_REASON_GROUP_CIPHER_INVALID: - return "Group Cipher Invalid"; - case WIFI_REASON_PAIRWISE_CIPHER_INVALID: - return "Pairwise Cipher Invalid"; - case WIFI_REASON_AKMP_INVALID: - return "AKMP Invalid"; - case WIFI_REASON_UNSUPP_RSN_IE_VERSION: - return "Unsupported RSN IE version"; - case WIFI_REASON_INVALID_RSN_IE_CAP: - return "Invalid RSN IE Cap"; - case WIFI_REASON_802_1X_AUTH_FAILED: - return "802.1x Authentication Failed"; - case WIFI_REASON_CIPHER_SUITE_REJECTED: - return "Cipher Suite Rejected"; - case WIFI_REASON_BEACON_TIMEOUT: - return "Beacon Timeout"; - case WIFI_REASON_NO_AP_FOUND: - return "AP Not Found"; - case WIFI_REASON_AUTH_FAIL: - return "Authentication Failed"; - case WIFI_REASON_ASSOC_FAIL: - return "Association Failed"; - case WIFI_REASON_HANDSHAKE_TIMEOUT: - return "Handshake Failed"; - case WIFI_REASON_CONNECTION_FAIL: - return "Connection Failed"; - case WIFI_REASON_AP_TSF_RESET: - return "AP TSF reset"; - case WIFI_REASON_ROAMING: - return "Station Roaming"; - case WIFI_REASON_ASSOC_COMEBACK_TIME_TOO_LONG: - return "Association comeback time too long"; - case WIFI_REASON_SA_QUERY_TIMEOUT: - return "SA query timeout"; - case WIFI_REASON_NO_AP_FOUND_W_COMPATIBLE_SECURITY: - return "No AP found with compatible security"; - case WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD: - return "No AP found in auth mode threshold"; - case WIFI_REASON_NO_AP_FOUND_IN_RSSI_THRESHOLD: - return "No AP found in RSSI threshold"; - case WIFI_REASON_UNSPECIFIED: - default: - return "Unspecified"; - } -} - -void WiFiComponent::wifi_loop_() {} - -#define ESPHOME_EVENT_ID_WIFI_READY ARDUINO_EVENT_WIFI_READY -#define ESPHOME_EVENT_ID_WIFI_SCAN_DONE ARDUINO_EVENT_WIFI_SCAN_DONE -#define ESPHOME_EVENT_ID_WIFI_STA_START ARDUINO_EVENT_WIFI_STA_START -#define ESPHOME_EVENT_ID_WIFI_STA_STOP ARDUINO_EVENT_WIFI_STA_STOP -#define ESPHOME_EVENT_ID_WIFI_STA_CONNECTED ARDUINO_EVENT_WIFI_STA_CONNECTED -#define ESPHOME_EVENT_ID_WIFI_STA_DISCONNECTED ARDUINO_EVENT_WIFI_STA_DISCONNECTED -#define ESPHOME_EVENT_ID_WIFI_STA_AUTHMODE_CHANGE ARDUINO_EVENT_WIFI_STA_AUTHMODE_CHANGE -#define ESPHOME_EVENT_ID_WIFI_STA_GOT_IP ARDUINO_EVENT_WIFI_STA_GOT_IP -#define ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6 ARDUINO_EVENT_WIFI_STA_GOT_IP6 -#define ESPHOME_EVENT_ID_WIFI_STA_LOST_IP ARDUINO_EVENT_WIFI_STA_LOST_IP -#define ESPHOME_EVENT_ID_WIFI_AP_START ARDUINO_EVENT_WIFI_AP_START -#define ESPHOME_EVENT_ID_WIFI_AP_STOP ARDUINO_EVENT_WIFI_AP_STOP -#define ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED ARDUINO_EVENT_WIFI_AP_STACONNECTED -#define ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED ARDUINO_EVENT_WIFI_AP_STADISCONNECTED -#define ESPHOME_EVENT_ID_WIFI_AP_STAIPASSIGNED ARDUINO_EVENT_WIFI_AP_STAIPASSIGNED -#define ESPHOME_EVENT_ID_WIFI_AP_PROBEREQRECVED ARDUINO_EVENT_WIFI_AP_PROBEREQRECVED -#define ESPHOME_EVENT_ID_WIFI_AP_GOT_IP6 ARDUINO_EVENT_WIFI_AP_GOT_IP6 -using esphome_wifi_event_id_t = arduino_event_id_t; -using esphome_wifi_event_info_t = arduino_event_info_t; - -void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_wifi_event_info_t info) { - switch (event) { - case ESPHOME_EVENT_ID_WIFI_READY: { - ESP_LOGV(TAG, "Ready"); - break; - } - case ESPHOME_EVENT_ID_WIFI_SCAN_DONE: { - auto it = info.wifi_scan_done; - ESP_LOGV(TAG, "Scan done: status=%u number=%u scan_id=%u", it.status, it.number, it.scan_id); - - this->wifi_scan_done_callback_(); - break; - } - case ESPHOME_EVENT_ID_WIFI_STA_START: { - ESP_LOGV(TAG, "STA start"); - // apply hostname - s_sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); - esp_err_t err = esp_netif_set_hostname(s_sta_netif, App.get_name().c_str()); - if (err != ERR_OK) { - ESP_LOGW(TAG, "esp_netif_set_hostname failed: %s", esp_err_to_name(err)); - } - break; - } - case ESPHOME_EVENT_ID_WIFI_STA_STOP: { - ESP_LOGV(TAG, "STA stop"); - break; - } - case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { - auto it = info.wifi_sta_connected; - char buf[33]; - memcpy(buf, it.ssid, it.ssid_len); - buf[it.ssid_len] = '\0'; - ESP_LOGV(TAG, "Connected ssid='%s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", buf, - format_mac_address_pretty(it.bssid).c_str(), it.channel, get_auth_mode_str(it.authmode)); -#if USE_NETWORK_IPV6 - this->set_timeout(100, [] { WiFi.enableIPv6(); }); -#endif /* USE_NETWORK_IPV6 */ - - break; - } - case ESPHOME_EVENT_ID_WIFI_STA_DISCONNECTED: { - auto it = info.wifi_sta_disconnected; - char buf[33]; - memcpy(buf, it.ssid, it.ssid_len); - buf[it.ssid_len] = '\0'; - if (it.reason == WIFI_REASON_NO_AP_FOUND) { - ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf); - } else { - ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, - format_mac_address_pretty(it.bssid).c_str(), get_disconnect_reason_str(it.reason)); - } - - uint8_t reason = it.reason; - if (reason == WIFI_REASON_AUTH_EXPIRE || reason == WIFI_REASON_BEACON_TIMEOUT || - reason == WIFI_REASON_NO_AP_FOUND || reason == WIFI_REASON_ASSOC_FAIL || - reason == WIFI_REASON_HANDSHAKE_TIMEOUT) { - err_t err = esp_wifi_disconnect(); - if (err != ESP_OK) { - ESP_LOGV(TAG, "Disconnect failed: %s", esp_err_to_name(err)); - } - this->error_from_callback_ = true; - } - - s_sta_connecting = false; - break; - } - case ESPHOME_EVENT_ID_WIFI_STA_AUTHMODE_CHANGE: { - auto it = info.wifi_sta_authmode_change; - ESP_LOGV(TAG, "Authmode Change old=%s new=%s", get_auth_mode_str(it.old_mode), get_auth_mode_str(it.new_mode)); - // Mitigate CVE-2020-12638 - // https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors - if (it.old_mode != WIFI_AUTH_OPEN && it.new_mode == WIFI_AUTH_OPEN) { - ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); - // we can't call retry_connect() from this context, so disconnect immediately - // and notify main thread with error_from_callback_ - err_t err = esp_wifi_disconnect(); - if (err != ESP_OK) { - ESP_LOGW(TAG, "Disconnect failed: %s", esp_err_to_name(err)); - } - this->error_from_callback_ = true; - } - break; - } - case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP: { - auto it = info.got_ip.ip_info; - ESP_LOGV(TAG, "static_ip=%s gateway=%s", format_ip4_addr(it.ip).c_str(), format_ip4_addr(it.gw).c_str()); - this->got_ipv4_address_ = true; -#if USE_NETWORK_IPV6 - s_sta_connecting = this->num_ipv6_addresses_ < USE_NETWORK_MIN_IPV6_ADDR_COUNT; -#else - s_sta_connecting = false; -#endif /* USE_NETWORK_IPV6 */ - break; - } -#if USE_NETWORK_IPV6 - case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6: { - auto it = info.got_ip6.ip6_info; - ESP_LOGV(TAG, "IPv6 address=" IPV6STR, IPV62STR(it.ip)); - this->num_ipv6_addresses_++; - s_sta_connecting = !(this->got_ipv4_address_ & (this->num_ipv6_addresses_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT)); - break; - } -#endif /* USE_NETWORK_IPV6 */ - case ESPHOME_EVENT_ID_WIFI_STA_LOST_IP: { - ESP_LOGV(TAG, "Lost IP"); - this->got_ipv4_address_ = false; - break; - } - case ESPHOME_EVENT_ID_WIFI_AP_START: { - ESP_LOGV(TAG, "AP start"); - break; - } - case ESPHOME_EVENT_ID_WIFI_AP_STOP: { - ESP_LOGV(TAG, "AP stop"); - break; - } - case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { - auto it = info.wifi_sta_connected; - auto &mac = it.bssid; - ESP_LOGV(TAG, "AP client connected MAC=%s", format_mac_address_pretty(mac).c_str()); - break; - } - case ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED: { - auto it = info.wifi_sta_disconnected; - auto &mac = it.bssid; - ESP_LOGV(TAG, "AP client disconnected MAC=%s", format_mac_address_pretty(mac).c_str()); - break; - } - case ESPHOME_EVENT_ID_WIFI_AP_STAIPASSIGNED: { - ESP_LOGV(TAG, "AP client assigned IP"); - break; - } - case ESPHOME_EVENT_ID_WIFI_AP_PROBEREQRECVED: { - auto it = info.wifi_ap_probereqrecved; - ESP_LOGVV(TAG, "AP receive Probe Request MAC=%s RSSI=%d", format_mac_address_pretty(it.mac).c_str(), it.rssi); - break; - } - default: - break; - } -} - -WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() { - const auto status = WiFi.status(); - if (status == WL_CONNECT_FAILED || status == WL_CONNECTION_LOST) { - return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED; - } - if (status == WL_NO_SSID_AVAIL) { - return WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND; - } - if (s_sta_connecting) { - return WiFiSTAConnectStatus::CONNECTING; - } - if (status == WL_CONNECTED) { - return WiFiSTAConnectStatus::CONNECTED; - } - return WiFiSTAConnectStatus::IDLE; -} -bool WiFiComponent::wifi_scan_start_(bool passive) { - // enable STA - if (!this->wifi_mode_(true, {})) - return false; - - // need to use WiFi because of WiFiScanClass allocations :( - int16_t err = WiFi.scanNetworks(true, true, passive, 200); - if (err != WIFI_SCAN_RUNNING) { - ESP_LOGV(TAG, "WiFi.scanNetworks failed: %d", err); - return false; - } - - return true; -} -void WiFiComponent::wifi_scan_done_callback_() { - this->scan_result_.clear(); - - int16_t num = WiFi.scanComplete(); - if (num < 0) - return; - - this->scan_result_.reserve(static_cast(num)); - for (int i = 0; i < num; i++) { - String ssid = WiFi.SSID(i); - wifi_auth_mode_t authmode = WiFi.encryptionType(i); - int32_t rssi = WiFi.RSSI(i); - uint8_t *bssid = WiFi.BSSID(i); - int32_t channel = WiFi.channel(i); - - WiFiScanResult scan({bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]}, std::string(ssid.c_str()), - channel, rssi, authmode != WIFI_AUTH_OPEN, ssid.length() == 0); - this->scan_result_.push_back(scan); - } - WiFi.scanDelete(); - this->scan_done_ = true; -} - -#ifdef USE_WIFI_AP -bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { - esp_err_t err; - - // enable AP - if (!this->wifi_mode_({}, true)) - return false; - - // Check if the AP interface is initialized before using it - if (s_ap_netif == nullptr) { - ESP_LOGW(TAG, "AP interface not initialized"); - return false; - } - - esp_netif_ip_info_t info; - if (manual_ip.has_value()) { - info.ip = manual_ip->static_ip; - info.gw = manual_ip->gateway; - info.netmask = manual_ip->subnet; - } else { - info.ip = network::IPAddress(192, 168, 4, 1); - info.gw = network::IPAddress(192, 168, 4, 1); - info.netmask = network::IPAddress(255, 255, 255, 0); - } - - err = esp_netif_dhcps_stop(s_ap_netif); - if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { - ESP_LOGE(TAG, "esp_netif_dhcps_stop failed: %s", esp_err_to_name(err)); - return false; - } - - err = esp_netif_set_ip_info(s_ap_netif, &info); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_netif_set_ip_info failed: %d", err); - return false; - } - - dhcps_lease_t lease; - lease.enable = true; - network::IPAddress start_address = network::IPAddress(&info.ip); - start_address += 99; - lease.start_ip = start_address; - ESP_LOGV(TAG, "DHCP server IP lease start: %s", start_address.str().c_str()); - start_address += 10; - lease.end_ip = start_address; - ESP_LOGV(TAG, "DHCP server IP lease end: %s", start_address.str().c_str()); - err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_REQUESTED_IP_ADDRESS, &lease, sizeof(lease)); - - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_netif_dhcps_option failed: %d", err); - return false; - } - - err = esp_netif_dhcps_start(s_ap_netif); - - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_netif_dhcps_start failed: %d", err); - return false; - } - - return true; -} - -bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { - // enable AP - if (!this->wifi_mode_({}, true)) - return false; - - wifi_config_t conf; - memset(&conf, 0, sizeof(conf)); - if (ap.get_ssid().size() > sizeof(conf.ap.ssid)) { - ESP_LOGE(TAG, "AP SSID too long"); - return false; - } - memcpy(reinterpret_cast(conf.ap.ssid), ap.get_ssid().c_str(), ap.get_ssid().size()); - conf.ap.channel = ap.get_channel().value_or(1); - conf.ap.ssid_hidden = ap.get_ssid().size(); - conf.ap.max_connection = 5; - conf.ap.beacon_interval = 100; - - if (ap.get_password().empty()) { - conf.ap.authmode = WIFI_AUTH_OPEN; - *conf.ap.password = 0; - } else { - conf.ap.authmode = WIFI_AUTH_WPA2_PSK; - if (ap.get_password().size() > sizeof(conf.ap.password)) { - ESP_LOGE(TAG, "AP password too long"); - return false; - } - memcpy(reinterpret_cast(conf.ap.password), ap.get_password().c_str(), ap.get_password().size()); - } - - // pairwise cipher of SoftAP, group cipher will be derived using this. - conf.ap.pairwise_cipher = WIFI_CIPHER_TYPE_CCMP; - - esp_err_t err = esp_wifi_set_config(WIFI_IF_AP, &conf); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_wifi_set_config failed: %d", err); - return false; - } - - yield(); - - if (!this->wifi_ap_ip_config_(ap.get_manual_ip())) { - ESP_LOGV(TAG, "wifi_ap_ip_config_ failed"); - return false; - } - - return true; -} - -network::IPAddress WiFiComponent::wifi_soft_ap_ip() { - esp_netif_ip_info_t ip; - esp_netif_get_ip_info(s_ap_netif, &ip); - return network::IPAddress(&ip.ip); -} -#endif // USE_WIFI_AP - -bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); } - -bssid_t WiFiComponent::wifi_bssid() { - bssid_t bssid{}; - uint8_t *raw_bssid = WiFi.BSSID(); - if (raw_bssid != nullptr) { - for (size_t i = 0; i < bssid.size(); i++) - bssid[i] = raw_bssid[i]; - } - return bssid; -} -std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } -int8_t WiFiComponent::wifi_rssi() { return WiFi.RSSI(); } -int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } -network::IPAddress WiFiComponent::wifi_subnet_mask_() { return network::IPAddress(WiFi.subnetMask()); } -network::IPAddress WiFiComponent::wifi_gateway_ip_() { return network::IPAddress(WiFi.gatewayIP()); } -network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(WiFi.dnsIP(num)); } - -} // namespace wifi -} // namespace esphome - -#endif // USE_ESP32_FRAMEWORK_ARDUINO -#endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 31ee712a48..aa0a993e79 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1,7 +1,7 @@ #include "wifi_component.h" #ifdef USE_WIFI -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include #include @@ -1050,5 +1050,5 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { } // namespace wifi } // namespace esphome -#endif // USE_ESP_IDF +#endif // USE_ESP32 #endif From 3b40172073f5ae8a5b8eb9d96aeae8442639b2cd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:21:19 -0400 Subject: [PATCH 1323/1626] [libretiny] Fix lib_ignore handling and ignore incompatible libraries (#10846) --- esphome/components/heatpumpir/climate.py | 2 +- esphome/components/web_server_base/__init__.py | 2 ++ esphome/core/config.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 4f83bf2435..ec6eac670f 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -128,4 +128,4 @@ async def to_code(config): cg.add_library("tonia/HeatpumpIR", "1.0.37") if CORE.is_libretiny or CORE.is_esp32: - CORE.add_platformio_option("lib_ignore", "IRremoteESP8266") + CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 2aff405036..a82ec462d9 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -40,5 +40,7 @@ async def to_code(config): cg.add_library("Update", None) if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) + if CORE.is_libretiny: + CORE.add_platformio_option("lib_ignore", ["ESPAsyncTCP", "RPAsyncTCP"]) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json cg.add_library("ESP32Async/ESPAsyncWebServer", "3.7.10") diff --git a/esphome/core/config.py b/esphome/core/config.py index 6d4f5af692..7bf7f82a8b 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -396,7 +396,7 @@ async def add_includes(includes: list[str]) -> None: async def _add_platformio_options(pio_options): # Add includes at the very end, so that they override everything for key, val in pio_options.items(): - if key == "build_flags" and not isinstance(val, list): + if key in ["build_flags", "lib_ignore"] and not isinstance(val, list): val = [val] cg.add_platformio_option(key, val) From 3b209691713db1bf7e3c51183757ed367f0321ed Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Sep 2025 02:32:13 +1200 Subject: [PATCH 1324/1626] [core] Add typing to some core files (#10843) --- esphome/automation.py | 162 +++++++++++++++++++++++++++++++-------- esphome/cpp_generator.py | 33 +------- esphome/types.py | 32 +++++++- esphome/util.py | 23 ++++-- 4 files changed, 181 insertions(+), 69 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 99d4362845..99def9f273 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -15,7 +15,10 @@ from esphome.const import ( CONF_TYPE_ID, CONF_UPDATE_INTERVAL, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType from esphome.util import Registry @@ -49,11 +52,11 @@ def maybe_conf(conf, *validators): return validate -def register_action(name, action_type, schema): +def register_action(name: str, action_type: MockObjClass, schema: cv.Schema): return ACTION_REGISTRY.register(name, action_type, schema) -def register_condition(name, condition_type, schema): +def register_condition(name: str, condition_type: MockObjClass, schema: cv.Schema): return CONDITION_REGISTRY.register(name, condition_type, schema) @@ -164,43 +167,78 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition) @register_condition("and", AndCondition, validate_condition_list) -async def and_condition_to_code(config, condition_id, template_arg, args): +async def and_condition_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: conditions = await build_condition_list(config, template_arg, args) return cg.new_Pvariable(condition_id, template_arg, conditions) @register_condition("or", OrCondition, validate_condition_list) -async def or_condition_to_code(config, condition_id, template_arg, args): +async def or_condition_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: conditions = await build_condition_list(config, template_arg, args) return cg.new_Pvariable(condition_id, template_arg, conditions) @register_condition("all", AndCondition, validate_condition_list) -async def all_condition_to_code(config, condition_id, template_arg, args): +async def all_condition_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: conditions = await build_condition_list(config, template_arg, args) return cg.new_Pvariable(condition_id, template_arg, conditions) @register_condition("any", OrCondition, validate_condition_list) -async def any_condition_to_code(config, condition_id, template_arg, args): +async def any_condition_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: conditions = await build_condition_list(config, template_arg, args) return cg.new_Pvariable(condition_id, template_arg, conditions) @register_condition("not", NotCondition, validate_potentially_and_condition) -async def not_condition_to_code(config, condition_id, template_arg, args): +async def not_condition_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: condition = await build_condition(config, template_arg, args) return cg.new_Pvariable(condition_id, template_arg, condition) @register_condition("xor", XorCondition, validate_condition_list) -async def xor_condition_to_code(config, condition_id, template_arg, args): +async def xor_condition_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: conditions = await build_condition_list(config, template_arg, args) return cg.new_Pvariable(condition_id, template_arg, conditions) @register_condition("lambda", LambdaCondition, cv.returning_lambda) -async def lambda_condition_to_code(config, condition_id, template_arg, args): +async def lambda_condition_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: lambda_ = await cg.process_lambda(config, args, return_type=bool) return cg.new_Pvariable(condition_id, template_arg, lambda_) @@ -217,7 +255,12 @@ async def lambda_condition_to_code(config, condition_id, template_arg, args): } ).extend(cv.COMPONENT_SCHEMA), ) -async def for_condition_to_code(config, condition_id, template_arg, args): +async def for_condition_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: condition = await build_condition( config[CONF_CONDITION], cg.TemplateArguments(), [] ) @@ -231,7 +274,12 @@ async def for_condition_to_code(config, condition_id, template_arg, args): @register_action( "delay", DelayAction, cv.templatable(cv.positive_time_period_milliseconds) ) -async def delay_action_to_code(config, action_id, template_arg, args): +async def delay_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_component(var, {}) template_ = await cg.templatable(config, args, cg.uint32) @@ -256,10 +304,15 @@ async def delay_action_to_code(config, action_id, template_arg, args): cv.has_at_least_one_key(CONF_CONDITION, CONF_ANY, CONF_ALL), ), ) -async def if_action_to_code(config, action_id, template_arg, args): +async def if_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: cond_conf = next(el for el in config if el in (CONF_ANY, CONF_ALL, CONF_CONDITION)) - conditions = await build_condition(config[cond_conf], template_arg, args) - var = cg.new_Pvariable(action_id, template_arg, conditions) + condition = await build_condition(config[cond_conf], template_arg, args) + var = cg.new_Pvariable(action_id, template_arg, condition) if CONF_THEN in config: actions = await build_action_list(config[CONF_THEN], template_arg, args) cg.add(var.add_then(actions)) @@ -279,9 +332,14 @@ async def if_action_to_code(config, action_id, template_arg, args): } ), ) -async def while_action_to_code(config, action_id, template_arg, args): - conditions = await build_condition(config[CONF_CONDITION], template_arg, args) - var = cg.new_Pvariable(action_id, template_arg, conditions) +async def while_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + condition = await build_condition(config[CONF_CONDITION], template_arg, args) + var = cg.new_Pvariable(action_id, template_arg, condition) actions = await build_action_list(config[CONF_THEN], template_arg, args) cg.add(var.add_then(actions)) return var @@ -297,7 +355,12 @@ async def while_action_to_code(config, action_id, template_arg, args): } ), ) -async def repeat_action_to_code(config, action_id, template_arg, args): +async def repeat_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) count_template = await cg.templatable(config[CONF_COUNT], args, cg.uint32) cg.add(var.set_count(count_template)) @@ -320,9 +383,14 @@ _validate_wait_until = cv.maybe_simple_value( @register_action("wait_until", WaitUntilAction, _validate_wait_until) -async def wait_until_action_to_code(config, action_id, template_arg, args): - conditions = await build_condition(config[CONF_CONDITION], template_arg, args) - var = cg.new_Pvariable(action_id, template_arg, conditions) +async def wait_until_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + condition = await build_condition(config[CONF_CONDITION], template_arg, args) + var = cg.new_Pvariable(action_id, template_arg, condition) if CONF_TIMEOUT in config: template_ = await cg.templatable(config[CONF_TIMEOUT], args, cg.uint32) cg.add(var.set_timeout_value(template_)) @@ -331,7 +399,12 @@ async def wait_until_action_to_code(config, action_id, template_arg, args): @register_action("lambda", LambdaAction, cv.lambda_) -async def lambda_action_to_code(config, action_id, template_arg, args): +async def lambda_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: lambda_ = await cg.process_lambda(config, args, return_type=cg.void) return cg.new_Pvariable(action_id, template_arg, lambda_) @@ -345,7 +418,12 @@ async def lambda_action_to_code(config, action_id, template_arg, args): } ), ) -async def component_update_action_to_code(config, action_id, template_arg, args): +async def component_update_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: comp = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, comp) @@ -359,7 +437,12 @@ async def component_update_action_to_code(config, action_id, template_arg, args) } ), ) -async def component_suspend_action_to_code(config, action_id, template_arg, args): +async def component_suspend_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: comp = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, comp) @@ -376,7 +459,12 @@ async def component_suspend_action_to_code(config, action_id, template_arg, args } ), ) -async def component_resume_action_to_code(config, action_id, template_arg, args): +async def component_resume_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: comp = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, comp) if CONF_UPDATE_INTERVAL in config: @@ -385,7 +473,9 @@ async def component_resume_action_to_code(config, action_id, template_arg, args) return var -async def build_action(full_config, template_arg, args): +async def build_action( + full_config: ConfigType, template_arg: cg.TemplateArguments, args: TemplateArgsType +) -> MockObj: registry_entry, config = cg.extract_registry_entry_config( ACTION_REGISTRY, full_config ) @@ -394,15 +484,19 @@ async def build_action(full_config, template_arg, args): return await builder(config, action_id, template_arg, args) -async def build_action_list(config, templ, arg_type): - actions = [] +async def build_action_list( + config: list[ConfigType], templ: cg.TemplateArguments, arg_type: TemplateArgsType +) -> list[MockObj]: + actions: list[MockObj] = [] for conf in config: action = await build_action(conf, templ, arg_type) actions.append(action) return actions -async def build_condition(full_config, template_arg, args): +async def build_condition( + full_config: ConfigType, template_arg: cg.TemplateArguments, args: TemplateArgsType +) -> MockObj: registry_entry, config = cg.extract_registry_entry_config( CONDITION_REGISTRY, full_config ) @@ -411,15 +505,19 @@ async def build_condition(full_config, template_arg, args): return await builder(config, action_id, template_arg, args) -async def build_condition_list(config, templ, args): - conditions = [] +async def build_condition_list( + config: ConfigType, templ: cg.TemplateArguments, args: TemplateArgsType +) -> list[MockObj]: + conditions: list[MockObj] = [] for conf in config: condition = await build_condition(conf, templ, args) conditions.append(condition) return conditions -async def build_automation(trigger, args, config): +async def build_automation( + trigger: MockObj, args: TemplateArgsType, config: ConfigType +) -> MockObj: arg_types = [arg[0] for arg in args] templ = cg.TemplateArguments(*arg_types) obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, trigger) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 291592dd2b..b2022c7ae6 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1,5 +1,5 @@ import abc -from collections.abc import Callable, Sequence +from collections.abc import Callable import inspect import math import re @@ -13,7 +13,6 @@ from esphome.core import ( HexInt, Lambda, Library, - TimePeriod, TimePeriodMicroseconds, TimePeriodMilliseconds, TimePeriodMinutes, @@ -21,35 +20,11 @@ from esphome.core import ( TimePeriodSeconds, ) from esphome.helpers import cpp_string_escape, indent_all_but_first_and_last +from esphome.types import Expression, SafeExpType, TemplateArgsType from esphome.util import OrderedDict from esphome.yaml_util import ESPHomeDataBase -class Expression(abc.ABC): - __slots__ = () - - @abc.abstractmethod - def __str__(self): - """ - Convert expression into C++ code - """ - - -SafeExpType = ( - Expression - | bool - | str - | str - | int - | float - | TimePeriod - | type[bool] - | type[int] - | type[float] - | Sequence[Any] -) - - class RawExpression(Expression): __slots__ = ("text",) @@ -575,7 +550,7 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": return obj -def new_Pvariable(id_: ID, *args: SafeExpType) -> Pvariable: +def new_Pvariable(id_: ID, *args: SafeExpType) -> "MockObj": """Declare a new pointer variable in the code generation by calling it's constructor with the given arguments. @@ -681,7 +656,7 @@ async def get_variable_with_full_id(id_: ID) -> tuple[ID, "MockObj"]: async def process_lambda( value: Lambda, - parameters: list[tuple[SafeExpType, str]], + parameters: TemplateArgsType, capture: str = "=", return_type: SafeExpType = None, ) -> LambdaExpression | None: diff --git a/esphome/types.py b/esphome/types.py index 62499a953c..c474d0d076 100644 --- a/esphome/types.py +++ b/esphome/types.py @@ -1,8 +1,10 @@ """This helper module tracks commonly used types in the esphome python codebase.""" -from typing import TypedDict +import abc +from collections.abc import Sequence +from typing import Any, TypedDict -from esphome.core import ID, EsphomeCore, Lambda +from esphome.core import ID, EsphomeCore, Lambda, TimePeriod ConfigFragmentType = ( str @@ -20,6 +22,32 @@ CoreType = EsphomeCore ConfigPathType = str | int +class Expression(abc.ABC): + __slots__ = () + + @abc.abstractmethod + def __str__(self): + """ + Convert expression into C++ code + """ + + +SafeExpType = ( + Expression + | bool + | str + | int + | float + | TimePeriod + | type[bool] + | type[int] + | type[float] + | Sequence[Any] +) + +TemplateArgsType = list[tuple[SafeExpType, str]] + + class EntityMetadata(TypedDict): """Metadata stored for each entity to help with duplicate detection.""" diff --git a/esphome/util.py b/esphome/util.py index 3bf3248cb3..d41800dc20 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -1,19 +1,30 @@ import collections +from collections.abc import Callable import io import logging from pathlib import Path import re import subprocess import sys -from typing import Any +from typing import TYPE_CHECKING, Any from esphome import const _LOGGER = logging.getLogger(__name__) +if TYPE_CHECKING: + from esphome.config_validation import Schema + from esphome.cpp_generator import MockObjClass + class RegistryEntry: - def __init__(self, name, fun, type_id, schema): + def __init__( + self, + name: str, + fun: Callable[..., Any], + type_id: "MockObjClass", + schema: "Schema", + ): self.name = name self.fun = fun self.type_id = type_id @@ -38,8 +49,8 @@ class Registry(dict[str, RegistryEntry]): self.base_schema = base_schema or {} self.type_id_key = type_id_key - def register(self, name, type_id, schema): - def decorator(fun): + def register(self, name: str, type_id: "MockObjClass", schema: "Schema"): + def decorator(fun: Callable[..., Any]): self[name] = RegistryEntry(name, fun, type_id, schema) return fun @@ -47,8 +58,8 @@ class Registry(dict[str, RegistryEntry]): class SimpleRegistry(dict): - def register(self, name, data): - def decorator(fun): + def register(self, name: str, data: Any): + def decorator(fun: Callable[..., Any]): self[name] = (fun, data) return fun From 7e273879b578694fe8b3d78e8b18554e96da38ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 09:45:02 -0500 Subject: [PATCH 1325/1626] reduce magic numbers --- .../components/esphome/ota/ota_esphome.cpp | 19 ++++++++++--------- esphome/components/esphome/ota/ota_esphome.h | 4 ++-- esphome/espota2.py | 2 +- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 7fd16ce3d0..6df7144064 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -268,14 +268,14 @@ void ESPHomeOTAComponent::handle_data_() { // TODO: Remove this entire ifdef block in 2026.1.0 if (client_supports_sha256) { sha256::SHA256 sha_hasher; - auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, 16, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, + auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, LOG_STR("SHA256"), sbuf); } else { #ifdef USE_OTA_MD5 ESP_LOGW(TAG, "Using MD5 auth for compatibility (deprecated)"); md5::MD5Digest md5_hasher; - auth_success = this->perform_hash_auth_(&md5_hasher, this->password_, 8, ota::OTA_RESPONSE_REQUEST_AUTH, - LOG_STR("MD5"), sbuf); + auth_success = + this->perform_hash_auth_(&md5_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); #endif // USE_OTA_MD5 } #else @@ -286,7 +286,7 @@ void ESPHomeOTAComponent::handle_data_() { goto error; // NOLINT(cppcoreguidelines-avoid-goto) } sha256::SHA256 sha_hasher; - auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, 16, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, + auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, LOG_STR("SHA256"), sbuf); #endif // ALLOW_OTA_DOWNGRADE_MD5 #else @@ -295,7 +295,7 @@ void ESPHomeOTAComponent::handle_data_() { #ifdef USE_OTA_MD5 md5::MD5Digest md5_hasher; auth_success = - this->perform_hash_auth_(&md5_hasher, this->password_, 8, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); + this->perform_hash_auth_(&md5_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); #endif // USE_OTA_MD5 #endif // USE_OTA_SHA256 @@ -529,10 +529,11 @@ void ESPHomeOTAComponent::log_auth_warning_(const LogString *action, const LogSt } // Non-template function definition to reduce binary size -bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, size_t nonce_size, - uint8_t auth_request, const LogString *name, char *buf) { +bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, + const LogString *name, char *buf) { // Get sizes from the hasher - const size_t hex_size = hasher->get_size() * 2; // Hex is twice the byte size + const size_t hex_size = hasher->get_size() * 2; // Hex is twice the byte size + const size_t nonce_hex_len = hasher->get_size() / 2; // Nonce hex length is 1/4 of full hex size // Use the provided buffer for all hex operations @@ -552,7 +553,7 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string nonce_bytes[2] = (r1 >> 8) & 0xFF; nonce_bytes[3] = r1 & 0xFF; - if (nonce_size == 8) { + if (nonce_hex_len == 8) { // MD5: 8 chars = "%08x" format = 4 bytes from one random uint32 hasher->add(nonce_bytes, 4); } else { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 39f2f878de..5bacb60706 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -32,8 +32,8 @@ class ESPHomeOTAComponent : public ota::OTAComponent { void handle_handshake_(); void handle_data_(); #ifdef USE_OTA_PASSWORD - bool perform_hash_auth_(HashBase *hasher, const std::string &password, size_t nonce_size, uint8_t auth_request, - const LogString *name, char *buf); + bool perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, const LogString *name, + char *buf); void log_auth_warning_(const LogString *action, const LogString *hash_name); #endif // USE_OTA_PASSWORD bool readall_(uint8_t *buf, size_t len); diff --git a/esphome/espota2.py b/esphome/espota2.py index 2a4d21dc3e..2712d00127 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -166,7 +166,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None raise OTAError("Error: Authentication invalid. Is the password correct?") if dat == RESPONSE_ERROR_WRITING_FLASH: raise OTAError( - "Error: Wring OTA data to flash memory failed. See USB logs for more " + "Error: Writing OTA data to flash memory failed. See USB logs for more " "information." ) if dat == RESPONSE_ERROR_UPDATE_END: From 307ad1c18bba241f5f1f9fc1a5c65d63bf5c21fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 09:46:58 -0500 Subject: [PATCH 1326/1626] reduce magic numbers --- esphome/components/esphome/ota/ota_esphome.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6df7144064..e56f2a7ab6 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -532,8 +532,8 @@ void ESPHomeOTAComponent::log_auth_warning_(const LogString *action, const LogSt bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, const LogString *name, char *buf) { // Get sizes from the hasher - const size_t hex_size = hasher->get_size() * 2; // Hex is twice the byte size - const size_t nonce_hex_len = hasher->get_size() / 2; // Nonce hex length is 1/4 of full hex size + const size_t hex_size = hasher->get_size() * 2; // Hex is twice the byte size + const size_t nonce_len = hasher->get_size() / 4; // Nonce is 1/4 of hash size in bytes // Use the provided buffer for all hex operations @@ -553,11 +553,11 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string nonce_bytes[2] = (r1 >> 8) & 0xFF; nonce_bytes[3] = r1 & 0xFF; - if (nonce_hex_len == 8) { - // MD5: 8 chars = "%08x" format = 4 bytes from one random uint32 + if (nonce_len == 4) { + // MD5: 4 bytes from one random uint32 hasher->add(nonce_bytes, 4); } else { - // SHA256: 16 chars = "%08x%08x" format = 8 bytes from two random uint32s + // SHA256: 8 bytes from two random uint32s uint32_t r2 = random_uint32(); nonce_bytes[4] = (r2 >> 24) & 0xFF; nonce_bytes[5] = (r2 >> 16) & 0xFF; From 106f8e6804064be1782738fcd7c1e2b827c43cf7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 09:48:36 -0500 Subject: [PATCH 1327/1626] dry --- .../components/esphome/ota/ota_esphome.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e56f2a7ab6..039950bee1 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -528,6 +528,14 @@ void ESPHomeOTAComponent::log_auth_warning_(const LogString *action, const LogSt ESP_LOGW(TAG, "Auth: %s %s failed", LOG_STR_ARG(action), LOG_STR_ARG(hash_name)); } +// Helper to convert uint32 to big-endian bytes +static inline void uint32_to_bytes(uint32_t value, uint8_t *bytes) { + bytes[0] = (value >> 24) & 0xFF; + bytes[1] = (value >> 16) & 0xFF; + bytes[2] = (value >> 8) & 0xFF; + bytes[3] = value & 0xFF; +} + // Non-template function definition to reduce binary size bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, const LogString *name, char *buf) { @@ -547,11 +555,7 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string // Generate nonce seed bytes uint32_t r1 = random_uint32(); - // Convert first uint32 to bytes (always needed for MD5) - nonce_bytes[0] = (r1 >> 24) & 0xFF; - nonce_bytes[1] = (r1 >> 16) & 0xFF; - nonce_bytes[2] = (r1 >> 8) & 0xFF; - nonce_bytes[3] = r1 & 0xFF; + uint32_to_bytes(r1, nonce_bytes); if (nonce_len == 4) { // MD5: 4 bytes from one random uint32 @@ -559,10 +563,7 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string } else { // SHA256: 8 bytes from two random uint32s uint32_t r2 = random_uint32(); - nonce_bytes[4] = (r2 >> 24) & 0xFF; - nonce_bytes[5] = (r2 >> 16) & 0xFF; - nonce_bytes[6] = (r2 >> 8) & 0xFF; - nonce_bytes[7] = r2 & 0xFF; + uint32_to_bytes(r2, nonce_bytes + 4); hasher->add(nonce_bytes, 8); } hasher->calculate(); From 7ac0f1c9a2aea7982257b338c1fdf1cc417731f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 09:49:03 -0500 Subject: [PATCH 1328/1626] dry --- esphome/components/esphome/ota/ota_esphome.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 039950bee1..eaffbb0705 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -554,16 +554,14 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string hasher->init(); // Generate nonce seed bytes - uint32_t r1 = random_uint32(); - uint32_to_bytes(r1, nonce_bytes); + uint32_to_bytes(random_uint32(), nonce_bytes); if (nonce_len == 4) { // MD5: 4 bytes from one random uint32 hasher->add(nonce_bytes, 4); } else { // SHA256: 8 bytes from two random uint32s - uint32_t r2 = random_uint32(); - uint32_to_bytes(r2, nonce_bytes + 4); + uint32_to_bytes(random_uint32(), nonce_bytes + 4); hasher->add(nonce_bytes, 8); } hasher->calculate(); From 174cdac5e11158d0ba547954dcc55b7a25ae2420 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 09:49:42 -0500 Subject: [PATCH 1329/1626] dry --- esphome/components/esphome/ota/ota_esphome.cpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index eaffbb0705..405633b990 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -555,15 +555,10 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string // Generate nonce seed bytes uint32_to_bytes(random_uint32(), nonce_bytes); - - if (nonce_len == 4) { - // MD5: 4 bytes from one random uint32 - hasher->add(nonce_bytes, 4); - } else { - // SHA256: 8 bytes from two random uint32s + if (nonce_len > 4) { uint32_to_bytes(random_uint32(), nonce_bytes + 4); - hasher->add(nonce_bytes, 8); } + hasher->add(nonce_bytes, nonce_len); hasher->calculate(); // Generate and send nonce From f42b523fd918fa679ba18917a7c1163e5b2ccd5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 09:54:39 -0500 Subject: [PATCH 1330/1626] dry --- tests/unit_tests/test_espota2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index c036a5de8e..bd1a6bde81 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -149,7 +149,7 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None: (espota2.RESPONSE_ERROR_AUTH_INVALID, "Error: Authentication invalid"), ( espota2.RESPONSE_ERROR_WRITING_FLASH, - "Error: Wring OTA data to flash memory failed", + "Error: Writing OTA data to flash memory failed", ), (espota2.RESPONSE_ERROR_UPDATE_END, "Error: Finishing update failed"), ( From ed62cc22ade327c60a789ca3fbfe0a5efe681f77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 11:08:41 -0500 Subject: [PATCH 1331/1626] preen --- esphome/components/api/api.proto | 10 +++++----- esphome/components/api/api_pb2.cpp | 14 ++++---------- esphome/components/api/api_pb2.h | 10 ++++------ esphome/components/api/api_pb2_dump.cpp | 4 ++-- .../bluetooth_proxy/bluetooth_connection.cpp | 15 ++++----------- .../bluetooth_proxy/bluetooth_connection.h | 4 ++-- .../bluetooth_proxy/bluetooth_proxy.cpp | 4 ++-- 7 files changed, 23 insertions(+), 38 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c59ccc6e29..632aa38ce2 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -124,7 +124,7 @@ message HelloResponse { // A string identifying the server (ESP); like client info this may be empty // and only exists for debugging/logging purposes. // For example "ESPHome v1.10.0 on ESP8266" - string server_info = 3; + string server_info = 3 [(pointer_to_buffer) = true]; // The name of the server (App.get_name()) string name = 4; @@ -139,7 +139,7 @@ message AuthenticationRequest { option (ifdef) = "USE_API_PASSWORD"; // The password to log in with - string password = 1; + string password = 1 [(pointer_to_buffer) = true]; } // Confirmation of successful connection. After this the connection is available for all traffic. @@ -824,7 +824,7 @@ message GetTimeResponse { option (no_delay) = true; fixed32 epoch_seconds = 1; - string timezone = 2; + string timezone = 2 [(pointer_to_buffer) = true]; } // ==================== USER-DEFINES SERVICES ==================== @@ -1571,7 +1571,7 @@ message BluetoothGATTWriteRequest { uint32 handle = 2; bool response = 3; - bytes data = 4 [(pointer_to_buffer) = true]; + bytes data = 4; } message BluetoothGATTReadDescriptorRequest { @@ -1591,7 +1591,7 @@ message BluetoothGATTWriteDescriptorRequest { uint64 address = 1; uint32 handle = 2; - bytes data = 3 [(pointer_to_buffer) = true]; + bytes data = 3; } message BluetoothGATTNotifyRequest { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 08384c6869..245933724b 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2028,12 +2028,9 @@ bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt val } bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 4: { - // Use raw data directly to avoid allocation - this->data = value.data(); - this->data_len = value.size(); + case 4: + this->data = value.as_string(); break; - } default: return false; } @@ -2067,12 +2064,9 @@ bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, Proto } bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 3: { - // Use raw data directly to avoid allocation - this->data = value.data(); - this->data_len = value.size(); + case 3: + this->data = value.as_string(); break; - } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index adc06ad5b7..248a4b1f82 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1985,15 +1985,14 @@ class BluetoothGATTReadResponse final : public ProtoMessage { class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 75; - static constexpr uint8_t ESTIMATED_SIZE = 29; + static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_request"; } #endif uint64_t address{0}; uint32_t handle{0}; bool response{false}; - const uint8_t *data{nullptr}; - uint16_t data_len{0}; + std::string data{}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2021,14 +2020,13 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 77; - static constexpr uint8_t ESTIMATED_SIZE = 27; + static constexpr uint8_t ESTIMATED_SIZE = 17; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_descriptor_request"; } #endif uint64_t address{0}; uint32_t handle{0}; - const uint8_t *data{nullptr}; - uint16_t data_len{0}; + std::string data{}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index e2a4772692..ac43af6d54 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1649,7 +1649,7 @@ void BluetoothGATTWriteRequest::dump_to(std::string &out) const { dump_field(out, "handle", this->handle); dump_field(out, "response", this->response); out.append(" data: "); - out.append(format_hex_pretty(this->data, this->data_len)); + out.append(format_hex_pretty(reinterpret_cast(this->data.data()), this->data.size())); out.append("\n"); } void BluetoothGATTReadDescriptorRequest::dump_to(std::string &out) const { @@ -1662,7 +1662,7 @@ void BluetoothGATTWriteDescriptorRequest::dump_to(std::string &out) const { dump_field(out, "address", this->address); dump_field(out, "handle", this->handle); out.append(" data: "); - out.append(format_hex_pretty(this->data, this->data_len)); + out.append(format_hex_pretty(reinterpret_cast(this->data.data()), this->data.size())); out.append("\n"); } void BluetoothGATTNotifyRequest::dump_to(std::string &out) const { diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index cde82fbfb0..540492f8c5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -514,8 +514,7 @@ esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { return this->check_and_log_error_("esp_ble_gattc_read_char", err); } -esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, - bool response) { +esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std::string &data, bool response) { if (!this->connected()) { this->log_gatt_not_connected_("write", "characteristic"); return ESP_GATT_NOT_CONNECTED; @@ -523,11 +522,8 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8 ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_.c_str(), handle); - // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data - // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) - // const_cast is safe here and was previously hidden by a C-style cast esp_err_t err = - esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, length, const_cast(data), + esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); return this->check_and_log_error_("esp_ble_gattc_write_char", err); } @@ -544,7 +540,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); } -esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) { +esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::string &data, bool response) { if (!this->connected()) { this->log_gatt_not_connected_("write", "descriptor"); return ESP_GATT_NOT_CONNECTED; @@ -552,11 +548,8 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t * ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_.c_str(), handle); - // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data - // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) - // const_cast is safe here and was previously hidden by a C-style cast esp_err_t err = esp_ble_gattc_write_char_descr( - this->gattc_if_, this->conn_id_, handle, length, const_cast(data), + this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index 60bbc93e8b..e5d5ff2dd6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -18,9 +18,9 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; esp_err_t read_characteristic(uint16_t handle); - esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); + esp_err_t write_characteristic(uint16_t handle, const std::string &data, bool response); esp_err_t read_descriptor(uint16_t handle); - esp_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); + esp_err_t write_descriptor(uint16_t handle, const std::string &data, bool response); esp_err_t notify_characteristic(uint16_t handle, bool enable); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index cd7261d5e5..532aff550e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -305,7 +305,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & return; } - auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response); + auto err = connection->write_characteristic(msg.handle, msg.data, msg.response); if (err != ESP_OK) { this->send_gatt_error(msg.address, msg.handle, err); } @@ -331,7 +331,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri return; } - auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true); + auto err = connection->write_descriptor(msg.handle, msg.data, true); if (err != ESP_OK) { this->send_gatt_error(msg.address, msg.handle, err); } From e368f4782de88bb135dcf91ece8fa43e7b5e2c15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 11:18:03 -0500 Subject: [PATCH 1332/1626] cleanup --- esphome/components/api/api.proto | 4 ++-- esphome/components/api/api_connection.cpp | 11 +++++++---- esphome/components/api/api_pb2.cpp | 21 +++++++++++++++------ esphome/components/api/api_pb2.h | 15 +++++++++------ esphome/components/api/api_pb2_dump.cpp | 15 ++++++++++++--- esphome/components/api/api_server.cpp | 9 +++++---- esphome/components/api/api_server.h | 2 +- script/api_protobuf/api_protobuf.py | 8 ++++++++ 8 files changed, 59 insertions(+), 26 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 632aa38ce2..796fd4a4d9 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -102,7 +102,7 @@ message HelloRequest { // For example "Home Assistant" // Not strictly necessary to send but nice for debugging // purposes. - string client_info = 1; + string client_info = 1 [(pointer_to_buffer) = true]; uint32 api_version_major = 2; uint32 api_version_minor = 3; } @@ -124,7 +124,7 @@ message HelloResponse { // A string identifying the server (ESP); like client info this may be empty // and only exists for debugging/logging purposes. // For example "ESPHome v1.10.0 on ESP8266" - string server_info = 3 [(pointer_to_buffer) = true]; + string server_info = 3; // The name of the server (App.get_name()) string name = 4; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index a27adfe241..45a9c120ee 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1078,8 +1078,11 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { if (homeassistant::global_homeassistant_time != nullptr) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); #ifdef USE_TIME_TIMEZONE - if (!value.timezone.empty() && value.timezone != homeassistant::global_homeassistant_time->get_timezone()) { - homeassistant::global_homeassistant_time->set_timezone(value.timezone); + if (value.timezone_len > 0) { + std::string timezone_str(reinterpret_cast(value.timezone), value.timezone_len); + if (timezone_str != homeassistant::global_homeassistant_time->get_timezone()) { + homeassistant::global_homeassistant_time->set_timezone(timezone_str); + } } #endif } @@ -1374,7 +1377,7 @@ void APIConnection::complete_authentication_() { } bool APIConnection::send_hello_response(const HelloRequest &msg) { - this->client_info_.name = msg.client_info; + this->client_info_.name = std::string(reinterpret_cast(msg.client_info), msg.client_info_len); this->client_info_.peername = this->helper_->getpeername(); this->client_api_version_major_ = msg.api_version_major; this->client_api_version_minor_ = msg.api_version_minor; @@ -1402,7 +1405,7 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { bool APIConnection::send_authenticate_response(const AuthenticationRequest &msg) { AuthenticationResponse resp; // bool invalid_password = 1; - resp.invalid_password = !this->parent_->check_password(msg.password); + resp.invalid_password = !this->parent_->check_password(msg.password, msg.password_len); if (!resp.invalid_password) { this->complete_authentication_(); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 245933724b..d2c62bff05 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -22,9 +22,12 @@ bool HelloRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: - this->client_info = value.as_string(); + case 1: { + // Use raw data directly to avoid allocation + this->client_info = value.data(); + this->client_info_len = value.size(); break; + } default: return false; } @@ -45,9 +48,12 @@ void HelloResponse::calculate_size(ProtoSize &size) const { #ifdef USE_API_PASSWORD bool AuthenticationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: - this->password = value.as_string(); + case 1: { + // Use raw data directly to avoid allocation + this->password = value.data(); + this->password_len = value.size(); break; + } default: return false; } @@ -917,9 +923,12 @@ bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDel #endif bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: - this->timezone = value.as_string(); + case 2: { + // Use raw data directly to avoid allocation + this->timezone = value.data(); + this->timezone_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 248a4b1f82..75894f3ffd 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -330,11 +330,12 @@ class CommandProtoMessage : public ProtoDecodableMessage { class HelloRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 1; - static constexpr uint8_t ESTIMATED_SIZE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "hello_request"; } #endif - std::string client_info{}; + const uint8_t *client_info{nullptr}; + uint16_t client_info_len{0}; uint32_t api_version_major{0}; uint32_t api_version_minor{0}; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -370,11 +371,12 @@ class HelloResponse final : public ProtoMessage { class AuthenticationRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 3; - static constexpr uint8_t ESTIMATED_SIZE = 9; + static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "authentication_request"; } #endif - std::string password{}; + const uint8_t *password{nullptr}; + uint16_t password_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1188,12 +1190,13 @@ class GetTimeRequest final : public ProtoMessage { class GetTimeResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 37; - static constexpr uint8_t ESTIMATED_SIZE = 14; + static constexpr uint8_t ESTIMATED_SIZE = 24; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "get_time_response"; } #endif uint32_t epoch_seconds{0}; - std::string timezone{}; + const uint8_t *timezone{nullptr}; + uint16_t timezone_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ac43af6d54..020da7b3eb 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -670,7 +670,9 @@ template<> const char *proto_enum_to_string(enums: void HelloRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "HelloRequest"); - dump_field(out, "client_info", this->client_info); + out.append(" client_info: "); + out.append(format_hex_pretty(this->client_info, this->client_info_len)); + out.append("\n"); dump_field(out, "api_version_major", this->api_version_major); dump_field(out, "api_version_minor", this->api_version_minor); } @@ -682,7 +684,12 @@ void HelloResponse::dump_to(std::string &out) const { dump_field(out, "name", this->name_ref_); } #ifdef USE_API_PASSWORD -void AuthenticationRequest::dump_to(std::string &out) const { dump_field(out, "password", this->password); } +void AuthenticationRequest::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "AuthenticationRequest"); + out.append(" password: "); + out.append(format_hex_pretty(this->password, this->password_len)); + out.append("\n"); +} void AuthenticationResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "AuthenticationResponse"); dump_field(out, "invalid_password", this->invalid_password); @@ -1136,7 +1143,9 @@ void GetTimeRequest::dump_to(std::string &out) const { out.append("GetTimeReques void GetTimeResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "GetTimeResponse"); dump_field(out, "epoch_seconds", this->epoch_seconds); - dump_field(out, "timezone", this->timezone); + out.append(" timezone: "); + out.append(format_hex_pretty(this->timezone, this->timezone_len)); + out.append("\n"); } #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::dump_to(std::string &out) const { diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1f38f4a31a..3d727724b8 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -217,12 +217,12 @@ void APIServer::dump_config() { } #ifdef USE_API_PASSWORD -bool APIServer::check_password(const std::string &password) const { +bool APIServer::check_password(const uint8_t *password_data, size_t password_len) const { // depend only on input password length const char *a = this->password_.c_str(); uint32_t len_a = this->password_.length(); - const char *b = password.c_str(); - uint32_t len_b = password.length(); + const char *b = reinterpret_cast(password_data); + uint32_t len_b = password_len; // disable optimization with volatile volatile uint32_t length = len_b; @@ -240,11 +240,12 @@ bool APIServer::check_password(const std::string &password) const { } for (size_t i = 0; i < length; i++) { - result |= *left++ ^ *right++; // NOLINT + result |= *left++ ^ *right++; } return result == 0; } + #endif void APIServer::handle_disconnect(APIConnection *conn) {} diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 8b5e624df2..e5470e852d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -37,7 +37,7 @@ class APIServer : public Component, public Controller { void on_shutdown() override; bool teardown() override; #ifdef USE_API_PASSWORD - bool check_password(const std::string &password) const; + bool check_password(const uint8_t *password_data, size_t password_len) const; void set_password(const std::string &password); #endif void set_port(uint16_t port); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 22bebcbd29..7f3f8014f7 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -373,6 +373,14 @@ def create_field_type_info( # Traditional fixed array approach with copy return FixedArrayBytesType(field, fixed_size) + # Check for pointer_to_buffer option on string fields + if field.type == 9: + has_pointer_to_buffer = get_field_opt(field, pb.pointer_to_buffer, False) + + if has_pointer_to_buffer: + # Zero-copy pointer approach for strings + return PointerToBytesBufferType(field, None) + # Special handling for bytes fields if field.type == 12: return BytesType(field, needs_decode, needs_encode) From f3b685acf922438cd55921e5e8be0d85193d6dbe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 11:19:32 -0500 Subject: [PATCH 1333/1626] wip --- esphome/components/api/api_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 3d727724b8..775bfe902b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -240,7 +240,7 @@ bool APIServer::check_password(const uint8_t *password_data, size_t password_len } for (size_t i = 0; i < length; i++) { - result |= *left++ ^ *right++; + result |= *left++ ^ *right++; // NOLINT } return result == 0; From 40271f5a3049724dd91432f9dd3a15604695c7b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 11:23:09 -0500 Subject: [PATCH 1334/1626] wip --- esphome/components/api/api_connection.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 45a9c120ee..357b6e1a32 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1079,9 +1079,12 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); #ifdef USE_TIME_TIMEZONE if (value.timezone_len > 0) { - std::string timezone_str(reinterpret_cast(value.timezone), value.timezone_len); - if (timezone_str != homeassistant::global_homeassistant_time->get_timezone()) { - homeassistant::global_homeassistant_time->set_timezone(timezone_str); + const std::string ¤t_tz = homeassistant::global_homeassistant_time->get_timezone(); + // Compare without allocating a string + if (current_tz.length() != value.timezone_len || + memcmp(current_tz.c_str(), value.timezone, value.timezone_len) != 0) { + homeassistant::global_homeassistant_time->set_timezone( + std::string(reinterpret_cast(value.timezone), value.timezone_len)); } } #endif @@ -1377,7 +1380,7 @@ void APIConnection::complete_authentication_() { } bool APIConnection::send_hello_response(const HelloRequest &msg) { - this->client_info_.name = std::string(reinterpret_cast(msg.client_info), msg.client_info_len); + this->client_info_.name.assign(reinterpret_cast(msg.client_info), msg.client_info_len); this->client_info_.peername = this->helper_->getpeername(); this->client_api_version_major_ = msg.api_version_major; this->client_api_version_minor_ = msg.api_version_minor; From 3cb2a4569c82efda8255777d381386f7aa344fb0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:41:25 -0400 Subject: [PATCH 1335/1626] [core] Add a clean-platform option (#10831) --- esphome/__main__.py | 23 ++++- esphome/dashboard/web_server.py | 7 ++ esphome/writer.py | 36 ++++++-- tests/unit_tests/test_main.py | 100 +++++++++++++++++++++ tests/unit_tests/test_writer.py | 153 ++++++++++++++++++++++++++++++-- 5 files changed, 301 insertions(+), 18 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index aa237c83a7..27aced5f33 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -731,6 +731,16 @@ def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None: return clean_mqtt(config, args) +def command_clean_platform(args: ArgsProtocol, config: ConfigType) -> int | None: + try: + writer.clean_platform() + except OSError as err: + _LOGGER.error("Error deleting platform files: %s", err) + return 1 + _LOGGER.info("Done!") + return 0 + + def command_mqtt_fingerprint(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import mqtt @@ -929,9 +939,10 @@ POST_CONFIG_ACTIONS = { "upload": command_upload, "logs": command_logs, "run": command_run, - "clean-mqtt": command_clean_mqtt, - "mqtt-fingerprint": command_mqtt_fingerprint, "clean": command_clean, + "clean-mqtt": command_clean_mqtt, + "clean-platform": command_clean_platform, + "mqtt-fingerprint": command_mqtt_fingerprint, "idedata": command_idedata, "rename": command_rename, "discover": command_discover, @@ -940,6 +951,7 @@ POST_CONFIG_ACTIONS = { SIMPLE_CONFIG_ACTIONS = [ "clean", "clean-mqtt", + "clean-platform", "config", ] @@ -1144,6 +1156,13 @@ def parse_args(argv): "configuration", help="Your YAML configuration file(s).", nargs="+" ) + parser_clean = subparsers.add_parser( + "clean-platform", help="Delete all platform files." + ) + parser_clean.add_argument( + "configuration", help="Your YAML configuration file(s).", nargs="+" + ) + parser_dashboard = subparsers.add_parser( "dashboard", help="Create a simple web server for a dashboard." ) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 7b6e6b4507..a4c24369a3 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -479,6 +479,12 @@ class EsphomeCleanMqttHandler(EsphomeCommandWebSocket): return [*DASHBOARD_COMMAND, "clean-mqtt", config_file] +class EsphomeCleanPlatformHandler(EsphomeCommandWebSocket): + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + config_file = settings.rel_path(json_message["configuration"]) + return [*DASHBOARD_COMMAND, "clean-platform", config_file] + + class EsphomeCleanHandler(EsphomeCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: config_file = settings.rel_path(json_message["configuration"]) @@ -1313,6 +1319,7 @@ def make_app(debug=get_bool_env(ENV_DEV)) -> tornado.web.Application: (f"{rel}compile", EsphomeCompileHandler), (f"{rel}validate", EsphomeValidateHandler), (f"{rel}clean-mqtt", EsphomeCleanMqttHandler), + (f"{rel}clean-platform", EsphomeCleanPlatformHandler), (f"{rel}clean", EsphomeCleanHandler), (f"{rel}vscode", EsphomeVscodeHandler), (f"{rel}ace", EsphomeAceEditorHandler), diff --git a/esphome/writer.py b/esphome/writer.py index 6d34d8f751..718041876a 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -323,17 +323,39 @@ def clean_build(): # Clean PlatformIO cache to resolve CMake compiler detection issues # This helps when toolchain paths change or get corrupted try: - from platformio.project.helpers import get_project_cache_dir + from platformio.project.config import ProjectConfig except ImportError: # PlatformIO is not available, skip cache cleaning pass else: - cache_dir = get_project_cache_dir() - if cache_dir and cache_dir.strip(): - cache_path = Path(cache_dir) - if cache_path.is_dir(): - _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) - shutil.rmtree(cache_dir) + config = ProjectConfig.get_instance() + cache_dir = Path(config.get("platformio", "cache_dir")) + if cache_dir.is_dir(): + _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) + shutil.rmtree(cache_dir) + + +def clean_platform(): + import shutil + + # Clean entire build dir + if CORE.build_path.is_dir(): + _LOGGER.info("Deleting %s", CORE.build_path) + shutil.rmtree(CORE.build_path) + + # Clean PlatformIO project files + try: + from platformio.project.config import ProjectConfig + except ImportError: + # PlatformIO is not available, skip cleaning + pass + else: + config = ProjectConfig.get_instance() + for pio_dir in ["cache_dir", "packages_dir", "platforms_dir", "core_dir"]: + path = Path(config.get("platformio", pio_dir)) + if path.is_dir(): + _LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path) + shutil.rmtree(path) GITIGNORE_CONTENT = """# Gitignore settings for ESPHome diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bb047d063c..8799ac56ff 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass +import logging from pathlib import Path import re from typing import Any @@ -16,6 +17,7 @@ from esphome import platformio_api from esphome.__main__ import ( Purpose, choose_upload_log_host, + command_clean_platform, command_rename, command_update_all, command_wizard, @@ -1853,3 +1855,101 @@ esp32: # Should not have any Python error messages assert "TypeError" not in clean_output assert "can only concatenate str" not in clean_output + + +def test_command_clean_platform_success( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test command_clean_platform when writer.clean_platform() succeeds.""" + args = MockArgs() + config = {} + + # Set logger level to capture INFO messages + with ( + caplog.at_level(logging.INFO), + patch("esphome.writer.clean_platform") as mock_clean_platform, + ): + result = command_clean_platform(args, config) + + assert result == 0 + mock_clean_platform.assert_called_once() + + # Check that success message was logged + assert "Done!" in caplog.text + + +def test_command_clean_platform_oserror( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test command_clean_platform when writer.clean_platform() raises OSError.""" + args = MockArgs() + config = {} + + # Create a mock OSError with a specific message + mock_error = OSError("Permission denied: cannot delete directory") + + # Set logger level to capture ERROR and INFO messages + with ( + caplog.at_level(logging.INFO), + patch( + "esphome.writer.clean_platform", side_effect=mock_error + ) as mock_clean_platform, + ): + result = command_clean_platform(args, config) + + assert result == 1 + mock_clean_platform.assert_called_once() + + # Check that error message was logged + assert ( + "Error deleting platform files: Permission denied: cannot delete directory" + in caplog.text + ) + # Should not have success message + assert "Done!" not in caplog.text + + +def test_command_clean_platform_oserror_no_message( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test command_clean_platform when writer.clean_platform() raises OSError without message.""" + args = MockArgs() + config = {} + + # Create a mock OSError without a message + mock_error = OSError() + + # Set logger level to capture ERROR and INFO messages + with ( + caplog.at_level(logging.INFO), + patch( + "esphome.writer.clean_platform", side_effect=mock_error + ) as mock_clean_platform, + ): + result = command_clean_platform(args, config) + + assert result == 1 + mock_clean_platform.assert_called_once() + + # Check that error message was logged (should show empty string for OSError without message) + assert "Error deleting platform files:" in caplog.text + # Should not have success message + assert "Done!" not in caplog.text + + +def test_command_clean_platform_args_and_config_ignored() -> None: + """Test that command_clean_platform ignores args and config parameters.""" + # Test with various args and config to ensure they don't affect the function + args1 = MockArgs(name="test1", file="test.bin") + config1 = {"wifi": {"ssid": "test"}} + + args2 = MockArgs(name="test2", dashboard=True) + config2 = {"api": {}, "ota": {}} + + with patch("esphome.writer.clean_platform") as mock_clean_platform: + result1 = command_clean_platform(args1, config1) + result2 = command_clean_platform(args2, config2) + + assert result1 == 0 + assert result2 == 0 + assert mock_clean_platform.call_count == 2 diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index ba309f2406..dc5fbf8db5 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -362,11 +362,17 @@ def test_clean_build( assert dependencies_lock.exists() assert platformio_cache_dir.exists() - # Mock PlatformIO's get_project_cache_dir + # Mock PlatformIO's ProjectConfig cache_dir with patch( - "platformio.project.helpers.get_project_cache_dir" - ) as mock_get_cache_dir: - mock_get_cache_dir.return_value = str(platformio_cache_dir) + "platformio.project.config.ProjectConfig.get_instance" + ) as mock_get_instance: + mock_config = MagicMock() + mock_get_instance.return_value = mock_config + mock_config.get.side_effect = ( + lambda section, option: str(platformio_cache_dir) + if (section, option) == ("platformio", "cache_dir") + else "" + ) # Call the function with caplog.at_level("INFO"): @@ -486,7 +492,7 @@ def test_clean_build_platformio_not_available( # Mock import error for platformio with ( - patch.dict("sys.modules", {"platformio.project.helpers": None}), + patch.dict("sys.modules", {"platformio.project.config": None}), caplog.at_level("INFO"), ): # Call the function @@ -520,11 +526,17 @@ def test_clean_build_empty_cache_dir( # Verify pioenvs exists before assert pioenvs_dir.exists() - # Mock PlatformIO's get_project_cache_dir to return whitespace + # Mock PlatformIO's ProjectConfig cache_dir to return whitespace with patch( - "platformio.project.helpers.get_project_cache_dir" - ) as mock_get_cache_dir: - mock_get_cache_dir.return_value = " " # Whitespace only + "platformio.project.config.ProjectConfig.get_instance" + ) as mock_get_instance: + mock_config = MagicMock() + mock_get_instance.return_value = mock_config + mock_config.get.side_effect = ( + lambda section, option: " " # Whitespace only + if (section, option) == ("platformio", "cache_dir") + else "" + ) # Call the function with caplog.at_level("INFO"): @@ -723,3 +735,126 @@ def test_write_cpp_with_duplicate_markers( # Call should raise an error with pytest.raises(EsphomeError, match="Found multiple auto generate code begins"): write_cpp("// New code") + + +@patch("esphome.writer.CORE") +def test_clean_platform( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_platform removes build and PlatformIO dirs.""" + # Create build directory + build_dir = tmp_path / "build" + build_dir.mkdir() + (build_dir / "dummy.txt").write_text("x") + + # Create PlatformIO directories + pio_cache = tmp_path / "pio_cache" + pio_packages = tmp_path / "pio_packages" + pio_platforms = tmp_path / "pio_platforms" + pio_core = tmp_path / "pio_core" + for d in (pio_cache, pio_packages, pio_platforms, pio_core): + d.mkdir() + (d / "keep").write_text("x") + + # Setup CORE + mock_core.build_path = build_dir + + # Mock ProjectConfig + with patch( + "platformio.project.config.ProjectConfig.get_instance" + ) as mock_get_instance: + mock_config = MagicMock() + mock_get_instance.return_value = mock_config + + def cfg_get(section: str, option: str) -> str: + mapping = { + ("platformio", "cache_dir"): str(pio_cache), + ("platformio", "packages_dir"): str(pio_packages), + ("platformio", "platforms_dir"): str(pio_platforms), + ("platformio", "core_dir"): str(pio_core), + } + return mapping.get((section, option), "") + + mock_config.get.side_effect = cfg_get + + # Call + from esphome.writer import clean_platform + + with caplog.at_level("INFO"): + clean_platform() + + # Verify deletions + assert not build_dir.exists() + assert not pio_cache.exists() + assert not pio_packages.exists() + assert not pio_platforms.exists() + assert not pio_core.exists() + + # Verify logging mentions each + assert "Deleting" in caplog.text + assert str(build_dir) in caplog.text + assert "PlatformIO cache" in caplog.text + assert "PlatformIO packages" in caplog.text + assert "PlatformIO platforms" in caplog.text + assert "PlatformIO core" in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_platform_platformio_not_available( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_platform when PlatformIO is not available.""" + # Build dir + build_dir = tmp_path / "build" + build_dir.mkdir() + mock_core.build_path = build_dir + + # PlatformIO dirs that should remain untouched + pio_cache = tmp_path / "pio_cache" + pio_cache.mkdir() + + from esphome.writer import clean_platform + + with ( + patch.dict("sys.modules", {"platformio.project.config": None}), + caplog.at_level("INFO"), + ): + clean_platform() + + # Build dir removed, PlatformIO dirs remain + assert not build_dir.exists() + assert pio_cache.exists() + + # No PlatformIO-specific logs + assert "PlatformIO" not in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_platform_partial_exists( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_platform when only build dir exists.""" + build_dir = tmp_path / "build" + build_dir.mkdir() + mock_core.build_path = build_dir + + with patch( + "platformio.project.config.ProjectConfig.get_instance" + ) as mock_get_instance: + mock_config = MagicMock() + mock_get_instance.return_value = mock_config + # Return non-existent dirs + mock_config.get.side_effect = lambda *_args, **_kw: str( + tmp_path / "does_not_exist" + ) + + from esphome.writer import clean_platform + + clean_platform() + + assert not build_dir.exists() From 43cfdb79193c512e4bcf348c77862fa595b0db2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 11:47:38 -0500 Subject: [PATCH 1336/1626] Reduce duplicate code in API to check auth and connection --- esphome/components/api/api_pb2_service.cpp | 233 +++++++-------------- esphome/components/api/api_pb2_service.h | 1 + esphome/components/api/proto.h | 4 +- script/api_protobuf/api_protobuf.py | 100 ++++++--- 4 files changed, 147 insertions(+), 191 deletions(-) diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 4afc66dc44..24a7740ec0 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -639,241 +639,139 @@ void APIServerConnection::on_ping_request(const PingRequest &msg) { } } void APIServerConnection::on_device_info_request(const DeviceInfoRequest &msg) { - if (this->check_connection_setup_() && !this->send_device_info_response(msg)) { + if (!this->send_device_info_response(msg)) { this->on_fatal_error(); } } -void APIServerConnection::on_list_entities_request(const ListEntitiesRequest &msg) { - if (this->check_authenticated_()) { - this->list_entities(msg); - } -} +void APIServerConnection::on_list_entities_request(const ListEntitiesRequest &msg) { this->list_entities(msg); } void APIServerConnection::on_subscribe_states_request(const SubscribeStatesRequest &msg) { - if (this->check_authenticated_()) { - this->subscribe_states(msg); - } -} -void APIServerConnection::on_subscribe_logs_request(const SubscribeLogsRequest &msg) { - if (this->check_authenticated_()) { - this->subscribe_logs(msg); - } + this->subscribe_states(msg); } +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) { - if (this->check_authenticated_()) { - this->subscribe_homeassistant_services(msg); - } + this->subscribe_homeassistant_services(msg); } #endif #ifdef USE_API_HOMEASSISTANT_STATES void APIServerConnection::on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) { - if (this->check_authenticated_()) { - this->subscribe_home_assistant_states(msg); - } + this->subscribe_home_assistant_states(msg); } #endif #ifdef USE_API_SERVICES -void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { - if (this->check_authenticated_()) { - this->execute_service(msg); - } -} +void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { this->execute_service(msg); } #endif #ifdef USE_API_NOISE void APIServerConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) { - if (this->check_authenticated_() && !this->send_noise_encryption_set_key_response(msg)) { + if (!this->send_noise_encryption_set_key_response(msg)) { this->on_fatal_error(); } } #endif #ifdef USE_BUTTON -void APIServerConnection::on_button_command_request(const ButtonCommandRequest &msg) { - if (this->check_authenticated_()) { - this->button_command(msg); - } -} +void APIServerConnection::on_button_command_request(const ButtonCommandRequest &msg) { this->button_command(msg); } #endif #ifdef USE_CAMERA -void APIServerConnection::on_camera_image_request(const CameraImageRequest &msg) { - if (this->check_authenticated_()) { - this->camera_image(msg); - } -} +void APIServerConnection::on_camera_image_request(const CameraImageRequest &msg) { this->camera_image(msg); } #endif #ifdef USE_CLIMATE -void APIServerConnection::on_climate_command_request(const ClimateCommandRequest &msg) { - if (this->check_authenticated_()) { - this->climate_command(msg); - } -} +void APIServerConnection::on_climate_command_request(const ClimateCommandRequest &msg) { this->climate_command(msg); } #endif #ifdef USE_COVER -void APIServerConnection::on_cover_command_request(const CoverCommandRequest &msg) { - if (this->check_authenticated_()) { - this->cover_command(msg); - } -} +void APIServerConnection::on_cover_command_request(const CoverCommandRequest &msg) { this->cover_command(msg); } #endif #ifdef USE_DATETIME_DATE -void APIServerConnection::on_date_command_request(const DateCommandRequest &msg) { - if (this->check_authenticated_()) { - this->date_command(msg); - } -} +void APIServerConnection::on_date_command_request(const DateCommandRequest &msg) { this->date_command(msg); } #endif #ifdef USE_DATETIME_DATETIME void APIServerConnection::on_date_time_command_request(const DateTimeCommandRequest &msg) { - if (this->check_authenticated_()) { - this->datetime_command(msg); - } + this->datetime_command(msg); } #endif #ifdef USE_FAN -void APIServerConnection::on_fan_command_request(const FanCommandRequest &msg) { - if (this->check_authenticated_()) { - this->fan_command(msg); - } -} +void APIServerConnection::on_fan_command_request(const FanCommandRequest &msg) { this->fan_command(msg); } #endif #ifdef USE_LIGHT -void APIServerConnection::on_light_command_request(const LightCommandRequest &msg) { - if (this->check_authenticated_()) { - this->light_command(msg); - } -} +void APIServerConnection::on_light_command_request(const LightCommandRequest &msg) { this->light_command(msg); } #endif #ifdef USE_LOCK -void APIServerConnection::on_lock_command_request(const LockCommandRequest &msg) { - if (this->check_authenticated_()) { - this->lock_command(msg); - } -} +void APIServerConnection::on_lock_command_request(const LockCommandRequest &msg) { this->lock_command(msg); } #endif #ifdef USE_MEDIA_PLAYER void APIServerConnection::on_media_player_command_request(const MediaPlayerCommandRequest &msg) { - if (this->check_authenticated_()) { - this->media_player_command(msg); - } + this->media_player_command(msg); } #endif #ifdef USE_NUMBER -void APIServerConnection::on_number_command_request(const NumberCommandRequest &msg) { - if (this->check_authenticated_()) { - this->number_command(msg); - } -} +void APIServerConnection::on_number_command_request(const NumberCommandRequest &msg) { this->number_command(msg); } #endif #ifdef USE_SELECT -void APIServerConnection::on_select_command_request(const SelectCommandRequest &msg) { - if (this->check_authenticated_()) { - this->select_command(msg); - } -} +void APIServerConnection::on_select_command_request(const SelectCommandRequest &msg) { this->select_command(msg); } #endif #ifdef USE_SIREN -void APIServerConnection::on_siren_command_request(const SirenCommandRequest &msg) { - if (this->check_authenticated_()) { - this->siren_command(msg); - } -} +void APIServerConnection::on_siren_command_request(const SirenCommandRequest &msg) { this->siren_command(msg); } #endif #ifdef USE_SWITCH -void APIServerConnection::on_switch_command_request(const SwitchCommandRequest &msg) { - if (this->check_authenticated_()) { - this->switch_command(msg); - } -} +void APIServerConnection::on_switch_command_request(const SwitchCommandRequest &msg) { this->switch_command(msg); } #endif #ifdef USE_TEXT -void APIServerConnection::on_text_command_request(const TextCommandRequest &msg) { - if (this->check_authenticated_()) { - this->text_command(msg); - } -} +void APIServerConnection::on_text_command_request(const TextCommandRequest &msg) { this->text_command(msg); } #endif #ifdef USE_DATETIME_TIME -void APIServerConnection::on_time_command_request(const TimeCommandRequest &msg) { - if (this->check_authenticated_()) { - this->time_command(msg); - } -} +void APIServerConnection::on_time_command_request(const TimeCommandRequest &msg) { this->time_command(msg); } #endif #ifdef USE_UPDATE -void APIServerConnection::on_update_command_request(const UpdateCommandRequest &msg) { - if (this->check_authenticated_()) { - this->update_command(msg); - } -} +void APIServerConnection::on_update_command_request(const UpdateCommandRequest &msg) { this->update_command(msg); } #endif #ifdef USE_VALVE -void APIServerConnection::on_valve_command_request(const ValveCommandRequest &msg) { - if (this->check_authenticated_()) { - this->valve_command(msg); - } -} +void APIServerConnection::on_valve_command_request(const ValveCommandRequest &msg) { this->valve_command(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_subscribe_bluetooth_le_advertisements_request( const SubscribeBluetoothLEAdvertisementsRequest &msg) { - if (this->check_authenticated_()) { - this->subscribe_bluetooth_le_advertisements(msg); - } + this->subscribe_bluetooth_le_advertisements(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_device_request(msg); - } + this->bluetooth_device_request(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_gatt_get_services(msg); - } + this->bluetooth_gatt_get_services(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_gatt_read(msg); - } + this->bluetooth_gatt_read(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_gatt_write(msg); - } + this->bluetooth_gatt_write(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_gatt_read_descriptor(msg); - } + this->bluetooth_gatt_read_descriptor(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_gatt_write_descriptor(msg); - } + this->bluetooth_gatt_write_descriptor(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_gatt_notify(msg); - } + this->bluetooth_gatt_notify(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_subscribe_bluetooth_connections_free_request( const SubscribeBluetoothConnectionsFreeRequest &msg) { - if (this->check_authenticated_() && !this->send_subscribe_bluetooth_connections_free_response(msg)) { + if (!this->send_subscribe_bluetooth_connections_free_response(msg)) { this->on_fatal_error(); } } @@ -881,59 +779,68 @@ void APIServerConnection::on_subscribe_bluetooth_connections_free_request( #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_unsubscribe_bluetooth_le_advertisements_request( const UnsubscribeBluetoothLEAdvertisementsRequest &msg) { - if (this->check_authenticated_()) { - this->unsubscribe_bluetooth_le_advertisements(msg); - } + this->unsubscribe_bluetooth_le_advertisements(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_scanner_set_mode(msg); - } + this->bluetooth_scanner_set_mode(msg); } #endif #ifdef USE_VOICE_ASSISTANT void APIServerConnection::on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg) { - if (this->check_authenticated_()) { - this->subscribe_voice_assistant(msg); - } + this->subscribe_voice_assistant(msg); } #endif #ifdef USE_VOICE_ASSISTANT void APIServerConnection::on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) { - if (this->check_authenticated_() && !this->send_voice_assistant_get_configuration_response(msg)) { + if (!this->send_voice_assistant_get_configuration_response(msg)) { this->on_fatal_error(); } } #endif #ifdef USE_VOICE_ASSISTANT void APIServerConnection::on_voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) { - if (this->check_authenticated_()) { - this->voice_assistant_set_configuration(msg); - } + this->voice_assistant_set_configuration(msg); } #endif #ifdef USE_ALARM_CONTROL_PANEL void APIServerConnection::on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg) { - if (this->check_authenticated_()) { - this->alarm_control_panel_command(msg); - } + this->alarm_control_panel_command(msg); } #endif #ifdef USE_ZWAVE_PROXY -void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { - if (this->check_authenticated_()) { - this->zwave_proxy_frame(msg); - } -} +void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { this->zwave_proxy_frame(msg); } #endif #ifdef USE_ZWAVE_PROXY -void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { - if (this->check_authenticated_()) { - this->zwave_proxy_request(msg); - } -} +void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { this->zwave_proxy_request(msg); } #endif +void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) { + // Check authentication/connection requirements for messages + switch (msg_type) { + case HelloRequest::MESSAGE_TYPE: // No setup required +#ifdef USE_API_PASSWORD + case AuthenticationRequest::MESSAGE_TYPE: // No setup required +#endif + case DisconnectRequest::MESSAGE_TYPE: // No setup required + case PingRequest::MESSAGE_TYPE: // No setup required + break; // Skip all checks for these messages + case DeviceInfoRequest::MESSAGE_TYPE: // Connection setup only + if (!this->check_connection_setup_()) { + return; // Connection not setup + } + break; + default: + // All other messages require authentication (which includes connection check) + if (!this->check_authenticated_()) { + return; // Authentication failed + } + break; + } + + // Call base implementation to process the message + APIServerConnectionBase::read_message(msg_size, msg_type, msg_data); +} + } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 9379dfee7d..1afcba6664 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -477,6 +477,7 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_ZWAVE_PROXY void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override; #endif + void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; }; } // namespace esphome::api diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 0e5ec61050..3232ec358a 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -827,7 +827,7 @@ class ProtoService { } // Authentication helper methods - bool check_connection_setup_() { + inline bool check_connection_setup_() { if (!this->is_connection_setup()) { this->on_no_setup_connection(); return false; @@ -835,7 +835,7 @@ class ProtoService { return true; } - bool check_authenticated_() { + inline bool check_authenticated_() { #ifdef USE_API_PASSWORD if (!this->check_connection_setup_()) { return false; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index fa04222c5d..f66034310b 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2615,6 +2615,10 @@ static const char *const TAG = "api.service"; hpp_protected = "" cpp += "\n" + # Build a mapping of message input types to their authentication requirements + message_auth_map: dict[str, bool] = {} + message_conn_map: dict[str, bool] = {} + m = serv.method[0] for m in serv.method: func = m.name @@ -2626,6 +2630,10 @@ static const char *const TAG = "api.service"; needs_conn = get_opt(m, pb.needs_setup_connection, True) needs_auth = get_opt(m, pb.needs_authentication, True) + # Store authentication requirements for message types + message_auth_map[inp] = needs_auth + message_conn_map[inp] = needs_conn + ifdef = message_ifdef_map.get(inp, ifdefs.get(inp)) if ifdef is not None: @@ -2643,33 +2651,14 @@ static const char *const TAG = "api.service"; cpp += f"void {class_name}::{on_func}(const {inp} &msg) {{\n" - # Start with authentication/connection check if needed - if needs_auth or needs_conn: - # Determine which check to use - if needs_auth: - check_func = "this->check_authenticated_()" - else: - check_func = "this->check_connection_setup_()" - - if is_void: - # For void methods, just wrap with auth check - body = f"if ({check_func}) {{\n" - body += f" this->{func}(msg);\n" - body += "}\n" - else: - # For non-void methods, combine auth check and send response check - body = f"if ({check_func} && !this->send_{func}_response(msg)) {{\n" - body += " this->on_fatal_error();\n" - body += "}\n" + # No authentication check here - it's done in read_message + body = "" + if is_void: + body += f"this->{func}(msg);\n" else: - # No auth check needed, just call the handler - body = "" - if is_void: - body += f"this->{func}(msg);\n" - else: - body += f"if (!this->send_{func}_response(msg)) {{\n" - body += " this->on_fatal_error();\n" - body += "}\n" + body += f"if (!this->send_{func}_response(msg)) {{\n" + body += " this->on_fatal_error();\n" + body += "}\n" cpp += indent(body) + "\n" + "}\n" @@ -2678,6 +2667,65 @@ static const char *const TAG = "api.service"; hpp_protected += "#endif\n" cpp += "#endif\n" + # Generate optimized read_message with authentication checking + # Categorize messages by their authentication requirements + no_conn_ids: set[int] = set() + conn_only_ids: set[int] = set() + + for id_, (_, _, case_msg_name) in cases: + if case_msg_name in message_auth_map: + needs_auth = message_auth_map[case_msg_name] + needs_conn = message_conn_map[case_msg_name] + + if not needs_conn: + no_conn_ids.add(id_) + elif not needs_auth: + conn_only_ids.add(id_) + + # Generate override if we have messages that skip checks + if no_conn_ids or conn_only_ids: + # Helper to generate case statements with ifdefs + def generate_cases(ids: set[int], comment: str) -> str: + result = "" + for id_ in sorted(ids): + _, ifdef, msg_name = RECEIVE_CASES[id_] + if ifdef: + result += f"#ifdef {ifdef}\n" + result += f" case {msg_name}::MESSAGE_TYPE: {comment}\n" + if ifdef: + result += "#endif\n" + return result + + hpp_protected += " void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;\n" + + cpp += f"\nvoid {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) {{\n" + cpp += " // Check authentication/connection requirements for messages\n" + cpp += " switch (msg_type) {\n" + + # Messages that don't need any checks + if no_conn_ids: + cpp += generate_cases(no_conn_ids, "// No setup required") + cpp += " break; // Skip all checks for these messages\n" + + # Messages that only need connection setup + if conn_only_ids: + cpp += generate_cases(conn_only_ids, "// Connection setup only") + cpp += " if (!this->check_connection_setup_()) {\n" + cpp += " return; // Connection not setup\n" + cpp += " }\n" + cpp += " break;\n" + + cpp += " default:\n" + cpp += " // All other messages require authentication (which includes connection check)\n" + cpp += " if (!this->check_authenticated_()) {\n" + cpp += " return; // Authentication failed\n" + cpp += " }\n" + cpp += " break;\n" + cpp += " }\n\n" + cpp += " // Call base implementation to process the message\n" + cpp += f" {class_name}Base::read_message(msg_size, msg_type, msg_data);\n" + cpp += "}\n" + hpp += " protected:\n" hpp += hpp_protected hpp += "};\n" From a7ee7b962e72b201f43c3f8c21926aed7bd4bce0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 12:15:12 -0500 Subject: [PATCH 1337/1626] [wifi] Unify ESP32 WiFi implementation to use ESP-IDF driver (#10849) --- esphome/components/wifi/__init__.py | 22 +- esphome/components/wifi/wifi_component.cpp | 6 +- esphome/components/wifi/wifi_component.h | 8 +- .../wifi/wifi_component_esp32_arduino.cpp | 860 ------------------ .../wifi/wifi_component_esp_idf.cpp | 4 +- 5 files changed, 21 insertions(+), 879 deletions(-) delete mode 100644 esphome/components/wifi/wifi_component_esp32_arduino.cpp diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index ef74c14924..a784123006 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -125,8 +125,8 @@ EAP_AUTH_SCHEMA = cv.All( cv.Optional(CONF_USERNAME): cv.string_strict, cv.Optional(CONF_PASSWORD): cv.string_strict, cv.Optional(CONF_CERTIFICATE_AUTHORITY): wpa2_eap.validate_certificate, - cv.SplitDefault(CONF_TTLS_PHASE_2, esp32_idf="mschapv2"): cv.All( - cv.enum(TTLS_PHASE_2), cv.only_with_esp_idf + cv.SplitDefault(CONF_TTLS_PHASE_2, esp32="mschapv2"): cv.All( + cv.enum(TTLS_PHASE_2), cv.only_on_esp32 ), cv.Inclusive( CONF_CERTIFICATE, "certificate_and_key" @@ -280,11 +280,11 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault(CONF_OUTPUT_POWER, esp8266=20.0): cv.All( cv.decibel, cv.float_range(min=8.5, max=20.5) ), - cv.SplitDefault(CONF_ENABLE_BTM, esp32_idf=False): cv.All( - cv.boolean, cv.only_with_esp_idf + cv.SplitDefault(CONF_ENABLE_BTM, esp32=False): cv.All( + cv.boolean, cv.only_on_esp32 ), - cv.SplitDefault(CONF_ENABLE_RRM, esp32_idf=False): cv.All( - cv.boolean, cv.only_with_esp_idf + cv.SplitDefault(CONF_ENABLE_RRM, esp32=False): cv.All( + cv.boolean, cv.only_on_esp32 ), cv.Optional(CONF_PASSIVE_SCAN, default=False): cv.boolean, cv.Optional("enable_mdns"): cv.invalid( @@ -416,10 +416,10 @@ async def to_code(config): if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) - elif (CORE.is_esp32 and CORE.using_arduino) or CORE.is_rp2040: + elif CORE.is_rp2040: cg.add_library("WiFi", None) - if CORE.is_esp32 and CORE.using_esp_idf: + if CORE.is_esp32: if config[CONF_ENABLE_BTM] or config[CONF_ENABLE_RRM]: add_idf_sdkconfig_option("CONFIG_WPA_11KV_SUPPORT", True) cg.add_define("USE_WIFI_11KV_SUPPORT") @@ -506,8 +506,10 @@ async def wifi_set_sta_to_code(config, action_id, template_arg, args): FILTER_SOURCE_FILES = filter_source_files_from_platform( { - "wifi_component_esp32_arduino.cpp": {PlatformFramework.ESP32_ARDUINO}, - "wifi_component_esp_idf.cpp": {PlatformFramework.ESP32_IDF}, + "wifi_component_esp_idf.cpp": { + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + }, "wifi_component_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "wifi_component_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 43ece636e5..8c7b55c274 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -3,7 +3,7 @@ #include #include -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1) #include #else @@ -11,7 +11,7 @@ #endif #endif -#if defined(USE_ESP32) || defined(USE_ESP_IDF) +#if defined(USE_ESP32) #include #endif #ifdef USE_ESP8266 @@ -344,7 +344,7 @@ void WiFiComponent::start_connecting(const WiFiAP &ap, bool two) { ESP_LOGV(TAG, " Identity: " LOG_SECRET("'%s'"), eap_config.identity.c_str()); ESP_LOGV(TAG, " Username: " LOG_SECRET("'%s'"), eap_config.username.c_str()); ESP_LOGV(TAG, " Password: " LOG_SECRET("'%s'"), eap_config.password.c_str()); -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE std::map phase2types = {{ESP_EAP_TTLS_PHASE2_PAP, "pap"}, {ESP_EAP_TTLS_PHASE2_CHAP, "chap"}, diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index bbe1bbb874..ee62ec1a69 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -20,7 +20,7 @@ #include #endif -#if defined(USE_ESP_IDF) && defined(USE_WIFI_WPA2_EAP) +#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) #if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) #include #else @@ -113,7 +113,7 @@ struct EAPAuth { const char *client_cert; const char *client_key; // used for EAP-TTLS -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 esp_eap_ttls_phase2_types ttls_phase_2; #endif }; @@ -199,7 +199,7 @@ enum WiFiPowerSaveMode : uint8_t { WIFI_POWER_SAVE_HIGH, }; -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 struct IDFWiFiEvent; #endif @@ -368,7 +368,7 @@ class WiFiComponent : public Component { void wifi_event_callback_(arduino_event_id_t event, arduino_event_info_t info); void wifi_scan_done_callback_(); #endif -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 void wifi_process_event_(IDFWiFiEvent *data); #endif diff --git a/esphome/components/wifi/wifi_component_esp32_arduino.cpp b/esphome/components/wifi/wifi_component_esp32_arduino.cpp deleted file mode 100644 index 89298e07c7..0000000000 --- a/esphome/components/wifi/wifi_component_esp32_arduino.cpp +++ /dev/null @@ -1,860 +0,0 @@ -#include "wifi_component.h" - -#ifdef USE_WIFI -#ifdef USE_ESP32_FRAMEWORK_ARDUINO - -#include -#include - -#include -#include -#ifdef USE_WIFI_WPA2_EAP -#include -#endif - -#ifdef USE_WIFI_AP -#include "dhcpserver/dhcpserver.h" -#endif // USE_WIFI_AP - -#include "lwip/apps/sntp.h" -#include "lwip/dns.h" -#include "lwip/err.h" - -#include "esphome/core/application.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" -#include "esphome/core/util.h" - -namespace esphome { -namespace wifi { - -static const char *const TAG = "wifi_esp32"; - -static esp_netif_t *s_sta_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#ifdef USE_WIFI_AP -static esp_netif_t *s_ap_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#endif // USE_WIFI_AP - -static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) - -void WiFiComponent::wifi_pre_setup_() { - uint8_t mac[6]; - if (has_custom_mac_address()) { - get_mac_address_raw(mac); - set_mac_address(mac); - } - auto f = std::bind(&WiFiComponent::wifi_event_callback_, this, std::placeholders::_1, std::placeholders::_2); - WiFi.onEvent(f); - WiFi.persistent(false); - // Make sure WiFi is in clean state before anything starts - this->wifi_mode_(false, false); -} - -bool WiFiComponent::wifi_mode_(optional sta, optional ap) { - wifi_mode_t current_mode = WiFiClass::getMode(); - bool current_sta = current_mode == WIFI_MODE_STA || current_mode == WIFI_MODE_APSTA; - bool current_ap = current_mode == WIFI_MODE_AP || current_mode == WIFI_MODE_APSTA; - - bool set_sta = sta.value_or(current_sta); - bool set_ap = ap.value_or(current_ap); - - wifi_mode_t set_mode; - if (set_sta && set_ap) { - set_mode = WIFI_MODE_APSTA; - } else if (set_sta && !set_ap) { - set_mode = WIFI_MODE_STA; - } else if (!set_sta && set_ap) { - set_mode = WIFI_MODE_AP; - } else { - set_mode = WIFI_MODE_NULL; - } - - if (current_mode == set_mode) - return true; - - if (set_sta && !current_sta) { - ESP_LOGV(TAG, "Enabling STA"); - } else if (!set_sta && current_sta) { - ESP_LOGV(TAG, "Disabling STA"); - } - if (set_ap && !current_ap) { - ESP_LOGV(TAG, "Enabling AP"); - } else if (!set_ap && current_ap) { - ESP_LOGV(TAG, "Disabling AP"); - } - - bool ret = WiFiClass::mode(set_mode); - - if (!ret) { - ESP_LOGW(TAG, "Setting mode failed"); - return false; - } - - // WiFiClass::mode above calls esp_netif_create_default_wifi_sta() and - // esp_netif_create_default_wifi_ap(), which creates the interfaces. - // s_sta_netif handle is set during ESPHOME_EVENT_ID_WIFI_STA_START event - -#ifdef USE_WIFI_AP - if (set_ap) - s_ap_netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); -#endif - - return ret; -} - -bool WiFiComponent::wifi_sta_pre_setup_() { - if (!this->wifi_mode_(true, {})) - return false; - - WiFi.setAutoReconnect(false); - delay(10); - return true; -} - -bool WiFiComponent::wifi_apply_output_power_(float output_power) { - int8_t val = static_cast(output_power * 4); - return esp_wifi_set_max_tx_power(val) == ESP_OK; -} - -bool WiFiComponent::wifi_apply_power_save_() { - wifi_ps_type_t power_save; - switch (this->power_save_) { - case WIFI_POWER_SAVE_LIGHT: - power_save = WIFI_PS_MIN_MODEM; - break; - case WIFI_POWER_SAVE_HIGH: - power_save = WIFI_PS_MAX_MODEM; - break; - case WIFI_POWER_SAVE_NONE: - default: - power_save = WIFI_PS_NONE; - break; - } - return esp_wifi_set_ps(power_save) == ESP_OK; -} - -bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { - // enable STA - if (!this->wifi_mode_(true, {})) - return false; - - // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/network/esp_wifi.html#_CPPv417wifi_sta_config_t - wifi_config_t conf; - memset(&conf, 0, sizeof(conf)); - if (ap.get_ssid().size() > sizeof(conf.sta.ssid)) { - ESP_LOGE(TAG, "SSID too long"); - return false; - } - if (ap.get_password().size() > sizeof(conf.sta.password)) { - ESP_LOGE(TAG, "Password too long"); - return false; - } - memcpy(reinterpret_cast(conf.sta.ssid), ap.get_ssid().c_str(), ap.get_ssid().size()); - memcpy(reinterpret_cast(conf.sta.password), ap.get_password().c_str(), ap.get_password().size()); - - // The weakest authmode to accept in the fast scan mode - if (ap.get_password().empty()) { - conf.sta.threshold.authmode = WIFI_AUTH_OPEN; - } else { - conf.sta.threshold.authmode = WIFI_AUTH_WPA_WPA2_PSK; - } - -#ifdef USE_WIFI_WPA2_EAP - if (ap.get_eap().has_value()) { - conf.sta.threshold.authmode = WIFI_AUTH_WPA2_ENTERPRISE; - } -#endif - - if (ap.get_bssid().has_value()) { - conf.sta.bssid_set = true; - memcpy(conf.sta.bssid, ap.get_bssid()->data(), 6); - } else { - conf.sta.bssid_set = false; - } - if (ap.get_channel().has_value()) { - conf.sta.channel = *ap.get_channel(); - conf.sta.scan_method = WIFI_FAST_SCAN; - } else { - conf.sta.scan_method = WIFI_ALL_CHANNEL_SCAN; - } - // Listen interval for ESP32 station to receive beacon when WIFI_PS_MAX_MODEM is set. - // Units: AP beacon intervals. Defaults to 3 if set to 0. - conf.sta.listen_interval = 0; - - // Protected Management Frame - // Device will prefer to connect in PMF mode if other device also advertises PMF capability. - conf.sta.pmf_cfg.capable = true; - conf.sta.pmf_cfg.required = false; - - // note, we do our own filtering - // The minimum rssi to accept in the fast scan mode - conf.sta.threshold.rssi = -127; - - conf.sta.threshold.authmode = WIFI_AUTH_OPEN; - - wifi_config_t current_conf; - esp_err_t err; - err = esp_wifi_get_config(WIFI_IF_STA, ¤t_conf); - if (err != ERR_OK) { - ESP_LOGW(TAG, "esp_wifi_get_config failed: %s", esp_err_to_name(err)); - // can continue - } - - if (memcmp(¤t_conf, &conf, sizeof(wifi_config_t)) != 0) { // NOLINT - err = esp_wifi_disconnect(); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_wifi_disconnect failed: %s", esp_err_to_name(err)); - return false; - } - } - - err = esp_wifi_set_config(WIFI_IF_STA, &conf); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_wifi_set_config failed: %s", esp_err_to_name(err)); - return false; - } - - if (!this->wifi_sta_ip_config_(ap.get_manual_ip())) { - return false; - } - - // setup enterprise authentication if required -#ifdef USE_WIFI_WPA2_EAP - if (ap.get_eap().has_value()) { - // note: all certificates and keys have to be null terminated. Lengths are appended by +1 to include \0. - EAPAuth eap = ap.get_eap().value(); - err = esp_eap_client_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length()); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_eap_client_set_identity failed: %d", err); - } - int ca_cert_len = strlen(eap.ca_cert); - int client_cert_len = strlen(eap.client_cert); - int client_key_len = strlen(eap.client_key); - if (ca_cert_len) { - err = esp_eap_client_set_ca_cert((uint8_t *) eap.ca_cert, ca_cert_len + 1); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_eap_client_set_ca_cert failed: %d", err); - } - } - // workout what type of EAP this is - // validation is not required as the config tool has already validated it - if (client_cert_len && client_key_len) { - // if we have certs, this must be EAP-TLS - err = esp_eap_client_set_certificate_and_key((uint8_t *) eap.client_cert, client_cert_len + 1, - (uint8_t *) eap.client_key, client_key_len + 1, - (uint8_t *) eap.password.c_str(), strlen(eap.password.c_str())); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_eap_client_set_certificate_and_key failed: %d", err); - } - } else { - // in the absence of certs, assume this is username/password based - err = esp_eap_client_set_username((uint8_t *) eap.username.c_str(), eap.username.length()); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_eap_client_set_username failed: %d", err); - } - err = esp_eap_client_set_password((uint8_t *) eap.password.c_str(), eap.password.length()); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_eap_client_set_password failed: %d", err); - } - } - err = esp_wifi_sta_enterprise_enable(); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_wifi_sta_enterprise_enable failed: %d", err); - } - } -#endif // USE_WIFI_WPA2_EAP - - this->wifi_apply_hostname_(); - - s_sta_connecting = true; - - err = esp_wifi_connect(); - if (err != ESP_OK) { - ESP_LOGW(TAG, "esp_wifi_connect failed: %s", esp_err_to_name(err)); - return false; - } - - return true; -} - -bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { - // enable STA - if (!this->wifi_mode_(true, {})) - return false; - - // Check if the STA interface is initialized before using it - if (s_sta_netif == nullptr) { - ESP_LOGW(TAG, "STA interface not initialized"); - return false; - } - - esp_netif_dhcp_status_t dhcp_status; - esp_err_t err = esp_netif_dhcpc_get_status(s_sta_netif, &dhcp_status); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_netif_dhcpc_get_status failed: %s", esp_err_to_name(err)); - return false; - } - - if (!manual_ip.has_value()) { - // sntp_servermode_dhcp lwip/sntp.c (Required to lock TCPIP core functionality!) - // https://github.com/esphome/issues/issues/6591 - // https://github.com/espressif/arduino-esp32/issues/10526 - { - LwIPLock lock; - // lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly, - // the built-in SNTP client has a memory leak in certain situations. Disable this feature. - // https://github.com/esphome/issues/issues/2299 - sntp_servermode_dhcp(false); - } - - // No manual IP is set; use DHCP client - if (dhcp_status != ESP_NETIF_DHCP_STARTED) { - err = esp_netif_dhcpc_start(s_sta_netif); - if (err != ESP_OK) { - ESP_LOGV(TAG, "Starting DHCP client failed: %d", err); - } - return err == ESP_OK; - } - return true; - } - - esp_netif_ip_info_t info; // struct of ip4_addr_t with ip, netmask, gw - info.ip = manual_ip->static_ip; - info.gw = manual_ip->gateway; - info.netmask = manual_ip->subnet; - err = esp_netif_dhcpc_stop(s_sta_netif); - if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { - ESP_LOGV(TAG, "Stopping DHCP client failed: %s", esp_err_to_name(err)); - } - - err = esp_netif_set_ip_info(s_sta_netif, &info); - if (err != ESP_OK) { - ESP_LOGV(TAG, "Setting manual IP info failed: %s", esp_err_to_name(err)); - } - - esp_netif_dns_info_t dns; - if (manual_ip->dns1.is_set()) { - dns.ip = manual_ip->dns1; - esp_netif_set_dns_info(s_sta_netif, ESP_NETIF_DNS_MAIN, &dns); - } - if (manual_ip->dns2.is_set()) { - dns.ip = manual_ip->dns2; - esp_netif_set_dns_info(s_sta_netif, ESP_NETIF_DNS_BACKUP, &dns); - } - - return true; -} - -network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { - if (!this->has_sta()) - return {}; - network::IPAddresses addresses; - esp_netif_ip_info_t ip; - esp_err_t err = esp_netif_get_ip_info(s_sta_netif, &ip); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_netif_get_ip_info failed: %s", esp_err_to_name(err)); - // TODO: do something smarter - // return false; - } else { - addresses[0] = network::IPAddress(&ip.ip); - } -#if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(s_sta_netif, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); - } -#endif /* USE_NETWORK_IPV6 */ - return addresses; -} - -bool WiFiComponent::wifi_apply_hostname_() { - // setting is done in SYSTEM_EVENT_STA_START callback - return true; -} -const char *get_auth_mode_str(uint8_t mode) { - switch (mode) { - case WIFI_AUTH_OPEN: - return "OPEN"; - case WIFI_AUTH_WEP: - return "WEP"; - case WIFI_AUTH_WPA_PSK: - return "WPA PSK"; - case WIFI_AUTH_WPA2_PSK: - return "WPA2 PSK"; - case WIFI_AUTH_WPA_WPA2_PSK: - return "WPA/WPA2 PSK"; - case WIFI_AUTH_WPA2_ENTERPRISE: - return "WPA2 Enterprise"; - case WIFI_AUTH_WPA3_PSK: - return "WPA3 PSK"; - case WIFI_AUTH_WPA2_WPA3_PSK: - return "WPA2/WPA3 PSK"; - case WIFI_AUTH_WAPI_PSK: - return "WAPI PSK"; - default: - return "UNKNOWN"; - } -} - -using esphome_ip4_addr_t = esp_ip4_addr_t; - -std::string format_ip4_addr(const esphome_ip4_addr_t &ip) { - char buf[20]; - sprintf(buf, "%u.%u.%u.%u", uint8_t(ip.addr >> 0), uint8_t(ip.addr >> 8), uint8_t(ip.addr >> 16), - uint8_t(ip.addr >> 24)); - return buf; -} -const char *get_op_mode_str(uint8_t mode) { - switch (mode) { - case WIFI_OFF: - return "OFF"; - case WIFI_STA: - return "STA"; - case WIFI_AP: - return "AP"; - case WIFI_AP_STA: - return "AP+STA"; - default: - return "UNKNOWN"; - } -} -const char *get_disconnect_reason_str(uint8_t reason) { - switch (reason) { - case WIFI_REASON_AUTH_EXPIRE: - return "Auth Expired"; - case WIFI_REASON_AUTH_LEAVE: - return "Auth Leave"; - case WIFI_REASON_ASSOC_EXPIRE: - return "Association Expired"; - case WIFI_REASON_ASSOC_TOOMANY: - return "Too Many Associations"; - case WIFI_REASON_NOT_AUTHED: - return "Not Authenticated"; - case WIFI_REASON_NOT_ASSOCED: - return "Not Associated"; - case WIFI_REASON_ASSOC_LEAVE: - return "Association Leave"; - case WIFI_REASON_ASSOC_NOT_AUTHED: - return "Association not Authenticated"; - case WIFI_REASON_DISASSOC_PWRCAP_BAD: - return "Disassociate Power Cap Bad"; - case WIFI_REASON_DISASSOC_SUPCHAN_BAD: - return "Disassociate Supported Channel Bad"; - case WIFI_REASON_IE_INVALID: - return "IE Invalid"; - case WIFI_REASON_MIC_FAILURE: - return "Mic Failure"; - case WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT: - return "4-Way Handshake Timeout"; - case WIFI_REASON_GROUP_KEY_UPDATE_TIMEOUT: - return "Group Key Update Timeout"; - case WIFI_REASON_IE_IN_4WAY_DIFFERS: - return "IE In 4-Way Handshake Differs"; - case WIFI_REASON_GROUP_CIPHER_INVALID: - return "Group Cipher Invalid"; - case WIFI_REASON_PAIRWISE_CIPHER_INVALID: - return "Pairwise Cipher Invalid"; - case WIFI_REASON_AKMP_INVALID: - return "AKMP Invalid"; - case WIFI_REASON_UNSUPP_RSN_IE_VERSION: - return "Unsupported RSN IE version"; - case WIFI_REASON_INVALID_RSN_IE_CAP: - return "Invalid RSN IE Cap"; - case WIFI_REASON_802_1X_AUTH_FAILED: - return "802.1x Authentication Failed"; - case WIFI_REASON_CIPHER_SUITE_REJECTED: - return "Cipher Suite Rejected"; - case WIFI_REASON_BEACON_TIMEOUT: - return "Beacon Timeout"; - case WIFI_REASON_NO_AP_FOUND: - return "AP Not Found"; - case WIFI_REASON_AUTH_FAIL: - return "Authentication Failed"; - case WIFI_REASON_ASSOC_FAIL: - return "Association Failed"; - case WIFI_REASON_HANDSHAKE_TIMEOUT: - return "Handshake Failed"; - case WIFI_REASON_CONNECTION_FAIL: - return "Connection Failed"; - case WIFI_REASON_AP_TSF_RESET: - return "AP TSF reset"; - case WIFI_REASON_ROAMING: - return "Station Roaming"; - case WIFI_REASON_ASSOC_COMEBACK_TIME_TOO_LONG: - return "Association comeback time too long"; - case WIFI_REASON_SA_QUERY_TIMEOUT: - return "SA query timeout"; - case WIFI_REASON_NO_AP_FOUND_W_COMPATIBLE_SECURITY: - return "No AP found with compatible security"; - case WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD: - return "No AP found in auth mode threshold"; - case WIFI_REASON_NO_AP_FOUND_IN_RSSI_THRESHOLD: - return "No AP found in RSSI threshold"; - case WIFI_REASON_UNSPECIFIED: - default: - return "Unspecified"; - } -} - -void WiFiComponent::wifi_loop_() {} - -#define ESPHOME_EVENT_ID_WIFI_READY ARDUINO_EVENT_WIFI_READY -#define ESPHOME_EVENT_ID_WIFI_SCAN_DONE ARDUINO_EVENT_WIFI_SCAN_DONE -#define ESPHOME_EVENT_ID_WIFI_STA_START ARDUINO_EVENT_WIFI_STA_START -#define ESPHOME_EVENT_ID_WIFI_STA_STOP ARDUINO_EVENT_WIFI_STA_STOP -#define ESPHOME_EVENT_ID_WIFI_STA_CONNECTED ARDUINO_EVENT_WIFI_STA_CONNECTED -#define ESPHOME_EVENT_ID_WIFI_STA_DISCONNECTED ARDUINO_EVENT_WIFI_STA_DISCONNECTED -#define ESPHOME_EVENT_ID_WIFI_STA_AUTHMODE_CHANGE ARDUINO_EVENT_WIFI_STA_AUTHMODE_CHANGE -#define ESPHOME_EVENT_ID_WIFI_STA_GOT_IP ARDUINO_EVENT_WIFI_STA_GOT_IP -#define ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6 ARDUINO_EVENT_WIFI_STA_GOT_IP6 -#define ESPHOME_EVENT_ID_WIFI_STA_LOST_IP ARDUINO_EVENT_WIFI_STA_LOST_IP -#define ESPHOME_EVENT_ID_WIFI_AP_START ARDUINO_EVENT_WIFI_AP_START -#define ESPHOME_EVENT_ID_WIFI_AP_STOP ARDUINO_EVENT_WIFI_AP_STOP -#define ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED ARDUINO_EVENT_WIFI_AP_STACONNECTED -#define ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED ARDUINO_EVENT_WIFI_AP_STADISCONNECTED -#define ESPHOME_EVENT_ID_WIFI_AP_STAIPASSIGNED ARDUINO_EVENT_WIFI_AP_STAIPASSIGNED -#define ESPHOME_EVENT_ID_WIFI_AP_PROBEREQRECVED ARDUINO_EVENT_WIFI_AP_PROBEREQRECVED -#define ESPHOME_EVENT_ID_WIFI_AP_GOT_IP6 ARDUINO_EVENT_WIFI_AP_GOT_IP6 -using esphome_wifi_event_id_t = arduino_event_id_t; -using esphome_wifi_event_info_t = arduino_event_info_t; - -void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_wifi_event_info_t info) { - switch (event) { - case ESPHOME_EVENT_ID_WIFI_READY: { - ESP_LOGV(TAG, "Ready"); - break; - } - case ESPHOME_EVENT_ID_WIFI_SCAN_DONE: { - auto it = info.wifi_scan_done; - ESP_LOGV(TAG, "Scan done: status=%u number=%u scan_id=%u", it.status, it.number, it.scan_id); - - this->wifi_scan_done_callback_(); - break; - } - case ESPHOME_EVENT_ID_WIFI_STA_START: { - ESP_LOGV(TAG, "STA start"); - // apply hostname - s_sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); - esp_err_t err = esp_netif_set_hostname(s_sta_netif, App.get_name().c_str()); - if (err != ERR_OK) { - ESP_LOGW(TAG, "esp_netif_set_hostname failed: %s", esp_err_to_name(err)); - } - break; - } - case ESPHOME_EVENT_ID_WIFI_STA_STOP: { - ESP_LOGV(TAG, "STA stop"); - break; - } - case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { - auto it = info.wifi_sta_connected; - char buf[33]; - memcpy(buf, it.ssid, it.ssid_len); - buf[it.ssid_len] = '\0'; - ESP_LOGV(TAG, "Connected ssid='%s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", buf, - format_mac_address_pretty(it.bssid).c_str(), it.channel, get_auth_mode_str(it.authmode)); -#if USE_NETWORK_IPV6 - this->set_timeout(100, [] { WiFi.enableIPv6(); }); -#endif /* USE_NETWORK_IPV6 */ - - break; - } - case ESPHOME_EVENT_ID_WIFI_STA_DISCONNECTED: { - auto it = info.wifi_sta_disconnected; - char buf[33]; - memcpy(buf, it.ssid, it.ssid_len); - buf[it.ssid_len] = '\0'; - if (it.reason == WIFI_REASON_NO_AP_FOUND) { - ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf); - } else { - ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, - format_mac_address_pretty(it.bssid).c_str(), get_disconnect_reason_str(it.reason)); - } - - uint8_t reason = it.reason; - if (reason == WIFI_REASON_AUTH_EXPIRE || reason == WIFI_REASON_BEACON_TIMEOUT || - reason == WIFI_REASON_NO_AP_FOUND || reason == WIFI_REASON_ASSOC_FAIL || - reason == WIFI_REASON_HANDSHAKE_TIMEOUT) { - err_t err = esp_wifi_disconnect(); - if (err != ESP_OK) { - ESP_LOGV(TAG, "Disconnect failed: %s", esp_err_to_name(err)); - } - this->error_from_callback_ = true; - } - - s_sta_connecting = false; - break; - } - case ESPHOME_EVENT_ID_WIFI_STA_AUTHMODE_CHANGE: { - auto it = info.wifi_sta_authmode_change; - ESP_LOGV(TAG, "Authmode Change old=%s new=%s", get_auth_mode_str(it.old_mode), get_auth_mode_str(it.new_mode)); - // Mitigate CVE-2020-12638 - // https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors - if (it.old_mode != WIFI_AUTH_OPEN && it.new_mode == WIFI_AUTH_OPEN) { - ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); - // we can't call retry_connect() from this context, so disconnect immediately - // and notify main thread with error_from_callback_ - err_t err = esp_wifi_disconnect(); - if (err != ESP_OK) { - ESP_LOGW(TAG, "Disconnect failed: %s", esp_err_to_name(err)); - } - this->error_from_callback_ = true; - } - break; - } - case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP: { - auto it = info.got_ip.ip_info; - ESP_LOGV(TAG, "static_ip=%s gateway=%s", format_ip4_addr(it.ip).c_str(), format_ip4_addr(it.gw).c_str()); - this->got_ipv4_address_ = true; -#if USE_NETWORK_IPV6 - s_sta_connecting = this->num_ipv6_addresses_ < USE_NETWORK_MIN_IPV6_ADDR_COUNT; -#else - s_sta_connecting = false; -#endif /* USE_NETWORK_IPV6 */ - break; - } -#if USE_NETWORK_IPV6 - case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6: { - auto it = info.got_ip6.ip6_info; - ESP_LOGV(TAG, "IPv6 address=" IPV6STR, IPV62STR(it.ip)); - this->num_ipv6_addresses_++; - s_sta_connecting = !(this->got_ipv4_address_ & (this->num_ipv6_addresses_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT)); - break; - } -#endif /* USE_NETWORK_IPV6 */ - case ESPHOME_EVENT_ID_WIFI_STA_LOST_IP: { - ESP_LOGV(TAG, "Lost IP"); - this->got_ipv4_address_ = false; - break; - } - case ESPHOME_EVENT_ID_WIFI_AP_START: { - ESP_LOGV(TAG, "AP start"); - break; - } - case ESPHOME_EVENT_ID_WIFI_AP_STOP: { - ESP_LOGV(TAG, "AP stop"); - break; - } - case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { - auto it = info.wifi_sta_connected; - auto &mac = it.bssid; - ESP_LOGV(TAG, "AP client connected MAC=%s", format_mac_address_pretty(mac).c_str()); - break; - } - case ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED: { - auto it = info.wifi_sta_disconnected; - auto &mac = it.bssid; - ESP_LOGV(TAG, "AP client disconnected MAC=%s", format_mac_address_pretty(mac).c_str()); - break; - } - case ESPHOME_EVENT_ID_WIFI_AP_STAIPASSIGNED: { - ESP_LOGV(TAG, "AP client assigned IP"); - break; - } - case ESPHOME_EVENT_ID_WIFI_AP_PROBEREQRECVED: { - auto it = info.wifi_ap_probereqrecved; - ESP_LOGVV(TAG, "AP receive Probe Request MAC=%s RSSI=%d", format_mac_address_pretty(it.mac).c_str(), it.rssi); - break; - } - default: - break; - } -} - -WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() { - const auto status = WiFi.status(); - if (status == WL_CONNECT_FAILED || status == WL_CONNECTION_LOST) { - return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED; - } - if (status == WL_NO_SSID_AVAIL) { - return WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND; - } - if (s_sta_connecting) { - return WiFiSTAConnectStatus::CONNECTING; - } - if (status == WL_CONNECTED) { - return WiFiSTAConnectStatus::CONNECTED; - } - return WiFiSTAConnectStatus::IDLE; -} -bool WiFiComponent::wifi_scan_start_(bool passive) { - // enable STA - if (!this->wifi_mode_(true, {})) - return false; - - // need to use WiFi because of WiFiScanClass allocations :( - int16_t err = WiFi.scanNetworks(true, true, passive, 200); - if (err != WIFI_SCAN_RUNNING) { - ESP_LOGV(TAG, "WiFi.scanNetworks failed: %d", err); - return false; - } - - return true; -} -void WiFiComponent::wifi_scan_done_callback_() { - this->scan_result_.clear(); - - int16_t num = WiFi.scanComplete(); - if (num < 0) - return; - - this->scan_result_.reserve(static_cast(num)); - for (int i = 0; i < num; i++) { - String ssid = WiFi.SSID(i); - wifi_auth_mode_t authmode = WiFi.encryptionType(i); - int32_t rssi = WiFi.RSSI(i); - uint8_t *bssid = WiFi.BSSID(i); - int32_t channel = WiFi.channel(i); - - WiFiScanResult scan({bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]}, std::string(ssid.c_str()), - channel, rssi, authmode != WIFI_AUTH_OPEN, ssid.length() == 0); - this->scan_result_.push_back(scan); - } - WiFi.scanDelete(); - this->scan_done_ = true; -} - -#ifdef USE_WIFI_AP -bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { - esp_err_t err; - - // enable AP - if (!this->wifi_mode_({}, true)) - return false; - - // Check if the AP interface is initialized before using it - if (s_ap_netif == nullptr) { - ESP_LOGW(TAG, "AP interface not initialized"); - return false; - } - - esp_netif_ip_info_t info; - if (manual_ip.has_value()) { - info.ip = manual_ip->static_ip; - info.gw = manual_ip->gateway; - info.netmask = manual_ip->subnet; - } else { - info.ip = network::IPAddress(192, 168, 4, 1); - info.gw = network::IPAddress(192, 168, 4, 1); - info.netmask = network::IPAddress(255, 255, 255, 0); - } - - err = esp_netif_dhcps_stop(s_ap_netif); - if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { - ESP_LOGE(TAG, "esp_netif_dhcps_stop failed: %s", esp_err_to_name(err)); - return false; - } - - err = esp_netif_set_ip_info(s_ap_netif, &info); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_netif_set_ip_info failed: %d", err); - return false; - } - - dhcps_lease_t lease; - lease.enable = true; - network::IPAddress start_address = network::IPAddress(&info.ip); - start_address += 99; - lease.start_ip = start_address; - ESP_LOGV(TAG, "DHCP server IP lease start: %s", start_address.str().c_str()); - start_address += 10; - lease.end_ip = start_address; - ESP_LOGV(TAG, "DHCP server IP lease end: %s", start_address.str().c_str()); - err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_REQUESTED_IP_ADDRESS, &lease, sizeof(lease)); - - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_netif_dhcps_option failed: %d", err); - return false; - } - - err = esp_netif_dhcps_start(s_ap_netif); - - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_netif_dhcps_start failed: %d", err); - return false; - } - - return true; -} - -bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { - // enable AP - if (!this->wifi_mode_({}, true)) - return false; - - wifi_config_t conf; - memset(&conf, 0, sizeof(conf)); - if (ap.get_ssid().size() > sizeof(conf.ap.ssid)) { - ESP_LOGE(TAG, "AP SSID too long"); - return false; - } - memcpy(reinterpret_cast(conf.ap.ssid), ap.get_ssid().c_str(), ap.get_ssid().size()); - conf.ap.channel = ap.get_channel().value_or(1); - conf.ap.ssid_hidden = ap.get_ssid().size(); - conf.ap.max_connection = 5; - conf.ap.beacon_interval = 100; - - if (ap.get_password().empty()) { - conf.ap.authmode = WIFI_AUTH_OPEN; - *conf.ap.password = 0; - } else { - conf.ap.authmode = WIFI_AUTH_WPA2_PSK; - if (ap.get_password().size() > sizeof(conf.ap.password)) { - ESP_LOGE(TAG, "AP password too long"); - return false; - } - memcpy(reinterpret_cast(conf.ap.password), ap.get_password().c_str(), ap.get_password().size()); - } - - // pairwise cipher of SoftAP, group cipher will be derived using this. - conf.ap.pairwise_cipher = WIFI_CIPHER_TYPE_CCMP; - - esp_err_t err = esp_wifi_set_config(WIFI_IF_AP, &conf); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_wifi_set_config failed: %d", err); - return false; - } - - yield(); - - if (!this->wifi_ap_ip_config_(ap.get_manual_ip())) { - ESP_LOGV(TAG, "wifi_ap_ip_config_ failed"); - return false; - } - - return true; -} - -network::IPAddress WiFiComponent::wifi_soft_ap_ip() { - esp_netif_ip_info_t ip; - esp_netif_get_ip_info(s_ap_netif, &ip); - return network::IPAddress(&ip.ip); -} -#endif // USE_WIFI_AP - -bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); } - -bssid_t WiFiComponent::wifi_bssid() { - bssid_t bssid{}; - uint8_t *raw_bssid = WiFi.BSSID(); - if (raw_bssid != nullptr) { - for (size_t i = 0; i < bssid.size(); i++) - bssid[i] = raw_bssid[i]; - } - return bssid; -} -std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } -int8_t WiFiComponent::wifi_rssi() { return WiFi.RSSI(); } -int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } -network::IPAddress WiFiComponent::wifi_subnet_mask_() { return network::IPAddress(WiFi.subnetMask()); } -network::IPAddress WiFiComponent::wifi_gateway_ip_() { return network::IPAddress(WiFi.gatewayIP()); } -network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(WiFi.dnsIP(num)); } - -} // namespace wifi -} // namespace esphome - -#endif // USE_ESP32_FRAMEWORK_ARDUINO -#endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 31ee712a48..aa0a993e79 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1,7 +1,7 @@ #include "wifi_component.h" #ifdef USE_WIFI -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include #include @@ -1050,5 +1050,5 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { } // namespace wifi } // namespace esphome -#endif // USE_ESP_IDF +#endif // USE_ESP32 #endif From 2f8a4d0caaa2429764326dbe37f4c72afe45d301 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 13:48:57 -0500 Subject: [PATCH 1338/1626] [api] Implement zero-copy API for bluetooth_proxy writes (#10840) --- esphome/components/api/api.proto | 6 +- esphome/components/api/api_options.proto | 7 ++ esphome/components/api/api_pb2.cpp | 14 ++- esphome/components/api/api_pb2.h | 10 +- esphome/components/api/api_pb2_dump.cpp | 4 +- esphome/components/api/proto.h | 4 + .../bluetooth_proxy/bluetooth_connection.cpp | 15 ++- .../bluetooth_proxy/bluetooth_connection.h | 4 +- .../bluetooth_proxy/bluetooth_proxy.cpp | 4 +- script/api_protobuf/api_protobuf.py | 110 +++++++++++++++++- 10 files changed, 151 insertions(+), 27 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index ad99de4b4a..c59ccc6e29 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1465,7 +1465,7 @@ message BluetoothDeviceRequest { uint64 address = 1; BluetoothDeviceRequestType request_type = 2; - bool has_address_type = 3; + bool has_address_type = 3; // Deprecated, should be removed in 2027.8 - https://github.com/esphome/esphome/pull/10318 uint32 address_type = 4; } @@ -1571,7 +1571,7 @@ message BluetoothGATTWriteRequest { uint32 handle = 2; bool response = 3; - bytes data = 4; + bytes data = 4 [(pointer_to_buffer) = true]; } message BluetoothGATTReadDescriptorRequest { @@ -1591,7 +1591,7 @@ message BluetoothGATTWriteDescriptorRequest { uint64 address = 1; uint32 handle = 2; - bytes data = 3; + bytes data = 3 [(pointer_to_buffer) = true]; } message BluetoothGATTNotifyRequest { diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 50c43b96fd..633f39b552 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -32,6 +32,13 @@ extend google.protobuf.FieldOptions { optional string fixed_array_size_define = 50010; optional string fixed_array_with_length_define = 50011; + // pointer_to_buffer: Use pointer instead of array for fixed-size byte fields + // When set, the field will be declared as a pointer (const uint8_t *data) + // instead of an array (uint8_t data[N]). This allows zero-copy on decode + // by pointing directly to the protobuf buffer. The buffer must remain valid + // until the message is processed (which is guaranteed for stack-allocated messages). + optional bool pointer_to_buffer = 50012 [default=false]; + // container_pointer: Zero-copy optimization for repeated fields. // // When container_pointer is set on a repeated field, the generated message will diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 245933724b..08384c6869 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2028,9 +2028,12 @@ bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt val } bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 4: - this->data = value.as_string(); + case 4: { + // Use raw data directly to avoid allocation + this->data = value.data(); + this->data_len = value.size(); break; + } default: return false; } @@ -2064,9 +2067,12 @@ bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, Proto } bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 3: - this->data = value.as_string(); + case 3: { + // Use raw data directly to avoid allocation + this->data = value.data(); + this->data_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 248a4b1f82..adc06ad5b7 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1985,14 +1985,15 @@ class BluetoothGATTReadResponse final : public ProtoMessage { class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 75; - static constexpr uint8_t ESTIMATED_SIZE = 19; + static constexpr uint8_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_request"; } #endif uint64_t address{0}; uint32_t handle{0}; bool response{false}; - std::string data{}; + const uint8_t *data{nullptr}; + uint16_t data_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -2020,13 +2021,14 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 77; - static constexpr uint8_t ESTIMATED_SIZE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_descriptor_request"; } #endif uint64_t address{0}; uint32_t handle{0}; - std::string data{}; + const uint8_t *data{nullptr}; + uint16_t data_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ac43af6d54..e2a4772692 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1649,7 +1649,7 @@ void BluetoothGATTWriteRequest::dump_to(std::string &out) const { dump_field(out, "handle", this->handle); dump_field(out, "response", this->response); out.append(" data: "); - out.append(format_hex_pretty(reinterpret_cast(this->data.data()), this->data.size())); + out.append(format_hex_pretty(this->data, this->data_len)); out.append("\n"); } void BluetoothGATTReadDescriptorRequest::dump_to(std::string &out) const { @@ -1662,7 +1662,7 @@ void BluetoothGATTWriteDescriptorRequest::dump_to(std::string &out) const { dump_field(out, "address", this->address); dump_field(out, "handle", this->handle); out.append(" data: "); - out.append(format_hex_pretty(reinterpret_cast(this->data.data()), this->data.size())); + out.append(format_hex_pretty(this->data, this->data_len)); out.append("\n"); } void BluetoothGATTNotifyRequest::dump_to(std::string &out) const { diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 0e5ec61050..6be5f00e75 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -182,6 +182,10 @@ class ProtoLengthDelimited { explicit ProtoLengthDelimited(const uint8_t *value, size_t length) : value_(value), length_(length) {} std::string as_string() const { return std::string(reinterpret_cast(this->value_), this->length_); } + // Direct access to raw data without string allocation + const uint8_t *data() const { return this->value_; } + size_t size() const { return this->length_; } + /** * Decode the length-delimited data into an existing ProtoDecodableMessage instance. * diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 540492f8c5..cde82fbfb0 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -514,7 +514,8 @@ esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { return this->check_and_log_error_("esp_ble_gattc_read_char", err); } -esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std::string &data, bool response) { +esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, + bool response) { if (!this->connected()) { this->log_gatt_not_connected_("write", "characteristic"); return ESP_GATT_NOT_CONNECTED; @@ -522,8 +523,11 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const std:: ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_.c_str(), handle); + // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data + // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) + // const_cast is safe here and was previously hidden by a C-style cast esp_err_t err = - esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), + esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, length, const_cast(data), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); return this->check_and_log_error_("esp_ble_gattc_write_char", err); } @@ -540,7 +544,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); } -esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::string &data, bool response) { +esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) { if (!this->connected()) { this->log_gatt_not_connected_("write", "descriptor"); return ESP_GATT_NOT_CONNECTED; @@ -548,8 +552,11 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const std::stri ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_.c_str(), handle); + // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data + // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) + // const_cast is safe here and was previously hidden by a C-style cast esp_err_t err = esp_ble_gattc_write_char_descr( - this->gattc_if_, this->conn_id_, handle, data.size(), (uint8_t *) data.data(), + this->gattc_if_, this->conn_id_, handle, length, const_cast(data), response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h index e5d5ff2dd6..60bbc93e8b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h @@ -18,9 +18,9 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; esp_err_t read_characteristic(uint16_t handle); - esp_err_t write_characteristic(uint16_t handle, const std::string &data, bool response); + esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); esp_err_t read_descriptor(uint16_t handle); - esp_err_t write_descriptor(uint16_t handle, const std::string &data, bool response); + esp_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); esp_err_t notify_characteristic(uint16_t handle, bool enable); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 532aff550e..cd7261d5e5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -305,7 +305,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & return; } - auto err = connection->write_characteristic(msg.handle, msg.data, msg.response); + auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response); if (err != ESP_OK) { this->send_gatt_error(msg.address, msg.handle, err); } @@ -331,7 +331,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri return; } - auto err = connection->write_descriptor(msg.handle, msg.data, true); + auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true); if (err != ESP_OK) { this->send_gatt_error(msg.address, msg.handle, err); } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index fa04222c5d..22bebcbd29 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -353,12 +353,25 @@ def create_field_type_info( return FixedArrayRepeatedType(field, size_define) return RepeatedTypeInfo(field) - # Check for fixed_array_size option on bytes fields - if ( - field.type == 12 - and (fixed_size := get_field_opt(field, pb.fixed_array_size)) is not None - ): - return FixedArrayBytesType(field, fixed_size) + # Check for mutually exclusive options on bytes fields + if field.type == 12: + has_pointer_to_buffer = get_field_opt(field, pb.pointer_to_buffer, False) + fixed_size = get_field_opt(field, pb.fixed_array_size, None) + + if has_pointer_to_buffer and fixed_size is not None: + raise ValueError( + f"Field '{field.name}' has both pointer_to_buffer and fixed_array_size. " + "These options are mutually exclusive. Use pointer_to_buffer for zero-copy " + "or fixed_array_size for traditional array storage." + ) + + if has_pointer_to_buffer: + # Zero-copy pointer approach - no size needed, will use size_t for length + return PointerToBytesBufferType(field, None) + + if fixed_size is not None: + # Traditional fixed array approach with copy + return FixedArrayBytesType(field, fixed_size) # Special handling for bytes fields if field.type == 12: @@ -818,6 +831,91 @@ class BytesType(TypeInfo): return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes +class PointerToBytesBufferType(TypeInfo): + """Type for bytes fields that use pointer_to_buffer option for zero-copy.""" + + @classmethod + def can_use_dump_field(cls) -> bool: + return False + + def __init__( + self, field: descriptor.FieldDescriptorProto, size: int | None = None + ) -> None: + super().__init__(field) + # Size is not used for pointer_to_buffer - we always use size_t for length + self.array_size = 0 + + @property + def cpp_type(self) -> str: + return "const uint8_t*" + + @property + def default_value(self) -> str: + return "nullptr" + + @property + def reference_type(self) -> str: + return "const uint8_t*" + + @property + def const_reference_type(self) -> str: + return "const uint8_t*" + + @property + def public_content(self) -> list[str]: + # Use uint16_t for length - max packet size is well below 65535 + # Add pointer and length fields + return [ + f"const uint8_t* {self.field_name}{{nullptr}};", + f"uint16_t {self.field_name}_len{{0}};", + ] + + @property + def encode_content(self) -> str: + return f"buffer.encode_bytes({self.number}, this->{self.field_name}, this->{self.field_name}_len);" + + @property + def decode_length_content(self) -> str | None: + # Decode directly stores the pointer to avoid allocation + return f"""case {self.number}: {{ + // Use raw data directly to avoid allocation + this->{self.field_name} = value.data(); + this->{self.field_name}_len = value.size(); + break; + }}""" + + @property + def decode_length(self) -> str | None: + # This is handled in decode_length_content + return None + + @property + def wire_type(self) -> WireType: + """Get the wire type for this bytes field.""" + return WireType.LENGTH_DELIMITED # Uses wire type 2 + + def dump(self, name: str) -> str: + return ( + f"format_hex_pretty(this->{self.field_name}, this->{self.field_name}_len)" + ) + + @property + def dump_content(self) -> str: + # Custom dump that doesn't use dump_field template + return ( + f'out.append(" {self.name}: ");\n' + + f"out.append({self.dump(self.field_name)});\n" + + 'out.append("\\n");' + ) + + def get_size_calculation(self, name: str, force: bool = False) -> str: + return f"size.add_length({self.number}, this->{self.field_name}_len);" + + def get_estimated_size(self) -> int: + # field ID + length varint + typical data (assume small for pointer fields) + return self.calculate_field_id_size() + 2 + 16 + + class FixedArrayBytesType(TypeInfo): """Special type for fixed-size byte arrays.""" From bc7fc8df18a62df5d734d29f7be5ea6a09f3ab7b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Sep 2025 14:19:43 -0500 Subject: [PATCH 1339/1626] Bump aioesphomeapi from 41.7.0 to 41.8.0 (#10853) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 67f6e89f93..ca3db9821e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==41.7.0 +aioesphomeapi==41.8.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From afda9500bf37126055554d8eeb587dd002c78954 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 14:21:49 -0500 Subject: [PATCH 1340/1626] [zwave_proxy] Fix race condition sending zero home ID on reboot (#10848) Co-authored-by: Keith Burzinski --- .../components/zwave_proxy/zwave_proxy.cpp | 48 ++++++++++++++++++- esphome/components/zwave_proxy/zwave_proxy.h | 27 +++++++---- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 12c4ee0c0d..a894899dc4 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -1,4 +1,5 @@ #include "zwave_proxy.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" @@ -12,6 +13,7 @@ static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20; // GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 9; // TYPE + CMD + HOME_ID(4) + NODE_ID + checksum +static constexpr uint32_t HOME_ID_TIMEOUT_MS = 100; // Timeout for waiting for home ID during setup static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { // Calculate Z-Wave frame checksum @@ -26,7 +28,44 @@ static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { ZWaveProxy::ZWaveProxy() { global_zwave_proxy = this; } -void ZWaveProxy::setup() { this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS); } +void ZWaveProxy::setup() { + this->setup_time_ = App.get_loop_component_start_time(); + this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS); +} + +float ZWaveProxy::get_setup_priority() const { + // Set up before API so home ID is ready when API starts + return setup_priority::BEFORE_CONNECTION; +} + +bool ZWaveProxy::can_proceed() { + // If we already have the home ID, we can proceed + if (this->home_id_ready_) { + return true; + } + + // Handle any pending responses + if (this->response_handler_()) { + ESP_LOGV(TAG, "Handled response during setup"); + } + + // Process UART data to check for home ID + this->process_uart_(); + + // Check if we got the home ID after processing + if (this->home_id_ready_) { + return true; + } + + // Wait up to HOME_ID_TIMEOUT_MS for home ID response + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->setup_time_ > HOME_ID_TIMEOUT_MS) { + ESP_LOGW(TAG, "Timeout reading Home ID during setup"); + return true; // Proceed anyway after timeout + } + + return false; // Keep waiting +} void ZWaveProxy::loop() { if (this->response_handler_()) { @@ -37,6 +76,11 @@ void ZWaveProxy::loop() { this->api_connection_ = nullptr; // Unsubscribe if disconnected } + this->process_uart_(); + this->status_clear_warning(); +} + +void ZWaveProxy::process_uart_() { while (this->available()) { uint8_t byte; if (!this->read_byte(&byte)) { @@ -56,6 +100,7 @@ void ZWaveProxy::loop() { // Extract the 4-byte Home ID starting at offset 4 // The frame parser has already validated the checksum and ensured all bytes are present std::memcpy(this->home_id_.data(), this->buffer_.data() + 4, this->home_id_.size()); + this->home_id_ready_ = true; ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty(this->home_id_.data(), this->home_id_.size(), ':', false).c_str()); } @@ -73,7 +118,6 @@ void ZWaveProxy::loop() { } } } - this->status_clear_warning(); } void ZWaveProxy::dump_config() { ESP_LOGCONFIG(TAG, "Z-Wave Proxy"); } diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 5d908b328c..68bec4e7ce 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -44,6 +44,8 @@ class ZWaveProxy : public uart::UARTDevice, public Component { void setup() override; void loop() override; void dump_config() override; + float get_setup_priority() const override; + bool can_proceed() override; void zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type); api::APIConnection *get_api_connection() { return this->api_connection_; } @@ -60,19 +62,24 @@ class ZWaveProxy : public uart::UARTDevice, public Component { bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) void parse_start_(uint8_t byte); bool response_handler_(); - - api::APIConnection *api_connection_{nullptr}; // Current subscribed client - - std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID - std::array buffer_; // Fixed buffer for incoming data - uint8_t buffer_index_{0}; // Index for populating the data buffer - uint8_t end_frame_after_{0}; // Payload reception ends after this index - uint8_t last_response_{0}; // Last response type sent - ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; - bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode + void process_uart_(); // Process all available UART data // Pre-allocated message - always ready to send api::ZWaveProxyFrame outgoing_proto_msg_; + std::array buffer_; // Fixed buffer for incoming data + std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID + + // Pointers and 32-bit values (aligned together) + api::APIConnection *api_connection_{nullptr}; // Current subscribed client + uint32_t setup_time_{0}; // Time when setup() was called + + // 8-bit values (grouped together to minimize padding) + uint8_t buffer_index_{0}; // Index for populating the data buffer + uint8_t end_frame_after_{0}; // Payload reception ends after this index + uint8_t last_response_{0}; // Last response type sent + ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; + bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode + bool home_id_ready_{false}; // True when home ID has been received from Z-Wave module }; extern ZWaveProxy *global_zwave_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) From e9c2e211ef4a421d8cc0723daa2ac2e92f49e9a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:09:04 +0000 Subject: [PATCH 1341/1626] Bump aioesphomeapi from 41.8.0 to 41.9.0 (#10855) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ca3db9821e..4256804722 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==41.8.0 +aioesphomeapi==41.9.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 365e3afa9b1c0f6832b760472c49a45fc64d594e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 15:12:54 -0500 Subject: [PATCH 1342/1626] Implement zero-copy API for zwave_proxy (#10836) Co-authored-by: Keith Burzinski --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_pb2.cpp | 9 +++------ esphome/components/api/api_pb2.h | 4 ++-- esphome/components/zwave_proxy/zwave_proxy.cpp | 8 +++++--- esphome/components/zwave_proxy/zwave_proxy.h | 6 ++++-- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c59ccc6e29..26f679e2d7 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2292,7 +2292,7 @@ message ZWaveProxyFrame { option (ifdef) = "USE_ZWAVE_PROXY"; option (no_delay) = true; - bytes data = 1 [(fixed_array_size) = 257]; + bytes data = 1 [(pointer_to_buffer) = true]; } enum ZWaveProxyRequestType { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 08384c6869..4388a13ca1 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3035,12 +3035,9 @@ bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 1: { - const std::string &data_str = value.as_string(); - this->data_len = data_str.size(); - if (this->data_len > 257) { - this->data_len = 257; - } - memcpy(this->data, data_str.data(), this->data_len); + // Use raw data directly to avoid allocation + this->data = value.data(); + this->data_len = value.size(); break; } default: diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index adc06ad5b7..b6531b37a6 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -2931,11 +2931,11 @@ class UpdateCommandRequest final : public CommandProtoMessage { class ZWaveProxyFrame final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 128; - static constexpr uint8_t ESTIMATED_SIZE = 33; + static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "z_wave_proxy_frame"; } #endif - uint8_t data[257]{}; + const uint8_t *data{nullptr}; uint16_t data_len{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index a894899dc4..feaf6e2d42 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -106,14 +106,14 @@ void ZWaveProxy::process_uart_() { } ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); if (this->api_connection_ != nullptr) { - // minimize copying to reduce CPU overhead + // Zero-copy: point directly to our buffer + this->outgoing_proto_msg_.data = this->buffer_.data(); if (this->in_bootloader_) { this->outgoing_proto_msg_.data_len = this->buffer_index_; } else { // If this is a data frame, use frame length indicator + 2 (for SoF + checksum), else assume 1 for ACK/NAK/CAN this->outgoing_proto_msg_.data_len = this->buffer_[0] == ZWAVE_FRAME_TYPE_START ? this->buffer_[1] + 2 : 1; } - std::memcpy(this->outgoing_proto_msg_.data, this->buffer_.data(), this->outgoing_proto_msg_.data_len); this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE); } } @@ -272,7 +272,9 @@ void ZWaveProxy::parse_start_(uint8_t byte) { } // Forward response (ACK/NAK/CAN) back to client for processing if (this->api_connection_ != nullptr) { - this->outgoing_proto_msg_.data[0] = byte; + // Store single byte in buffer and point to it + this->buffer_[0] = byte; + this->outgoing_proto_msg_.data = this->buffer_.data(); this->outgoing_proto_msg_.data_len = 1; this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE); } diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 68bec4e7ce..ea6837888b 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -11,6 +11,8 @@ namespace esphome { namespace zwave_proxy { +static constexpr size_t MAX_ZWAVE_FRAME_SIZE = 257; // Maximum Z-Wave frame size + enum ZWaveResponseTypes : uint8_t { ZWAVE_FRAME_TYPE_ACK = 0x06, ZWAVE_FRAME_TYPE_CAN = 0x18, @@ -66,8 +68,8 @@ class ZWaveProxy : public uart::UARTDevice, public Component { // Pre-allocated message - always ready to send api::ZWaveProxyFrame outgoing_proto_msg_; - std::array buffer_; // Fixed buffer for incoming data - std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID + std::array buffer_; // Fixed buffer for incoming data + std::array home_id_{0, 0, 0, 0}; // Fixed buffer for home ID // Pointers and 32-bit values (aligned together) api::APIConnection *api_connection_{nullptr}; // Current subscribed client From cd7922faaf62ef1aef8f522203b940185ef738df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:58:49 +0000 Subject: [PATCH 1343/1626] Bump aioesphomeapi from 41.9.0 to 41.9.1 (#10857) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4256804722..24c93fc4bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==41.9.0 +aioesphomeapi==41.9.1 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From d0d7abb54254569a9d5520f8b0cc5de6a41d4e31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 16:15:28 -0500 Subject: [PATCH 1344/1626] Implement zero-copy for strings in base API calls (#10851) --- esphome/components/api/api.proto | 6 +++--- esphome/components/api/api_connection.cpp | 14 ++++++++++---- esphome/components/api/api_pb2.cpp | 21 +++++++++++++++------ esphome/components/api/api_pb2.h | 15 +++++++++------ esphome/components/api/api_pb2_dump.cpp | 15 ++++++++++++--- esphome/components/api/api_server.cpp | 7 ++++--- esphome/components/api/api_server.h | 2 +- script/api_protobuf/api_protobuf.py | 8 ++++++++ 8 files changed, 62 insertions(+), 26 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 26f679e2d7..c58aabd3c6 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -102,7 +102,7 @@ message HelloRequest { // For example "Home Assistant" // Not strictly necessary to send but nice for debugging // purposes. - string client_info = 1; + string client_info = 1 [(pointer_to_buffer) = true]; uint32 api_version_major = 2; uint32 api_version_minor = 3; } @@ -139,7 +139,7 @@ message AuthenticationRequest { option (ifdef) = "USE_API_PASSWORD"; // The password to log in with - string password = 1; + string password = 1 [(pointer_to_buffer) = true]; } // Confirmation of successful connection. After this the connection is available for all traffic. @@ -824,7 +824,7 @@ message GetTimeResponse { option (no_delay) = true; fixed32 epoch_seconds = 1; - string timezone = 2; + string timezone = 2 [(pointer_to_buffer) = true]; } // ==================== USER-DEFINES SERVICES ==================== diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index a27adfe241..357b6e1a32 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1078,8 +1078,14 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { if (homeassistant::global_homeassistant_time != nullptr) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); #ifdef USE_TIME_TIMEZONE - if (!value.timezone.empty() && value.timezone != homeassistant::global_homeassistant_time->get_timezone()) { - homeassistant::global_homeassistant_time->set_timezone(value.timezone); + if (value.timezone_len > 0) { + const std::string ¤t_tz = homeassistant::global_homeassistant_time->get_timezone(); + // Compare without allocating a string + if (current_tz.length() != value.timezone_len || + memcmp(current_tz.c_str(), value.timezone, value.timezone_len) != 0) { + homeassistant::global_homeassistant_time->set_timezone( + std::string(reinterpret_cast(value.timezone), value.timezone_len)); + } } #endif } @@ -1374,7 +1380,7 @@ void APIConnection::complete_authentication_() { } bool APIConnection::send_hello_response(const HelloRequest &msg) { - this->client_info_.name = msg.client_info; + this->client_info_.name.assign(reinterpret_cast(msg.client_info), msg.client_info_len); this->client_info_.peername = this->helper_->getpeername(); this->client_api_version_major_ = msg.api_version_major; this->client_api_version_minor_ = msg.api_version_minor; @@ -1402,7 +1408,7 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { bool APIConnection::send_authenticate_response(const AuthenticationRequest &msg) { AuthenticationResponse resp; // bool invalid_password = 1; - resp.invalid_password = !this->parent_->check_password(msg.password); + resp.invalid_password = !this->parent_->check_password(msg.password, msg.password_len); if (!resp.invalid_password) { this->complete_authentication_(); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 4388a13ca1..0a6b271fac 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -22,9 +22,12 @@ bool HelloRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: - this->client_info = value.as_string(); + case 1: { + // Use raw data directly to avoid allocation + this->client_info = value.data(); + this->client_info_len = value.size(); break; + } default: return false; } @@ -45,9 +48,12 @@ void HelloResponse::calculate_size(ProtoSize &size) const { #ifdef USE_API_PASSWORD bool AuthenticationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 1: - this->password = value.as_string(); + case 1: { + // Use raw data directly to avoid allocation + this->password = value.data(); + this->password_len = value.size(); break; + } default: return false; } @@ -917,9 +923,12 @@ bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDel #endif bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: - this->timezone = value.as_string(); + case 2: { + // Use raw data directly to avoid allocation + this->timezone = value.data(); + this->timezone_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index b6531b37a6..b0503b4b7a 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -330,11 +330,12 @@ class CommandProtoMessage : public ProtoDecodableMessage { class HelloRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 1; - static constexpr uint8_t ESTIMATED_SIZE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "hello_request"; } #endif - std::string client_info{}; + const uint8_t *client_info{nullptr}; + uint16_t client_info_len{0}; uint32_t api_version_major{0}; uint32_t api_version_minor{0}; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -370,11 +371,12 @@ class HelloResponse final : public ProtoMessage { class AuthenticationRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 3; - static constexpr uint8_t ESTIMATED_SIZE = 9; + static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "authentication_request"; } #endif - std::string password{}; + const uint8_t *password{nullptr}; + uint16_t password_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1188,12 +1190,13 @@ class GetTimeRequest final : public ProtoMessage { class GetTimeResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 37; - static constexpr uint8_t ESTIMATED_SIZE = 14; + static constexpr uint8_t ESTIMATED_SIZE = 24; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "get_time_response"; } #endif uint32_t epoch_seconds{0}; - std::string timezone{}; + const uint8_t *timezone{nullptr}; + uint16_t timezone_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index e2a4772692..131f6e361a 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -670,7 +670,9 @@ template<> const char *proto_enum_to_string(enums: void HelloRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "HelloRequest"); - dump_field(out, "client_info", this->client_info); + out.append(" client_info: "); + out.append(format_hex_pretty(this->client_info, this->client_info_len)); + out.append("\n"); dump_field(out, "api_version_major", this->api_version_major); dump_field(out, "api_version_minor", this->api_version_minor); } @@ -682,7 +684,12 @@ void HelloResponse::dump_to(std::string &out) const { dump_field(out, "name", this->name_ref_); } #ifdef USE_API_PASSWORD -void AuthenticationRequest::dump_to(std::string &out) const { dump_field(out, "password", this->password); } +void AuthenticationRequest::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "AuthenticationRequest"); + out.append(" password: "); + out.append(format_hex_pretty(this->password, this->password_len)); + out.append("\n"); +} void AuthenticationResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "AuthenticationResponse"); dump_field(out, "invalid_password", this->invalid_password); @@ -1136,7 +1143,9 @@ void GetTimeRequest::dump_to(std::string &out) const { out.append("GetTimeReques void GetTimeResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "GetTimeResponse"); dump_field(out, "epoch_seconds", this->epoch_seconds); - dump_field(out, "timezone", this->timezone); + out.append(" timezone: "); + out.append(format_hex_pretty(this->timezone, this->timezone_len)); + out.append("\n"); } #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::dump_to(std::string &out) const { diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1f38f4a31a..775bfe902b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -217,12 +217,12 @@ void APIServer::dump_config() { } #ifdef USE_API_PASSWORD -bool APIServer::check_password(const std::string &password) const { +bool APIServer::check_password(const uint8_t *password_data, size_t password_len) const { // depend only on input password length const char *a = this->password_.c_str(); uint32_t len_a = this->password_.length(); - const char *b = password.c_str(); - uint32_t len_b = password.length(); + const char *b = reinterpret_cast(password_data); + uint32_t len_b = password_len; // disable optimization with volatile volatile uint32_t length = len_b; @@ -245,6 +245,7 @@ bool APIServer::check_password(const std::string &password) const { return result == 0; } + #endif void APIServer::handle_disconnect(APIConnection *conn) {} diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 8b5e624df2..e5470e852d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -37,7 +37,7 @@ class APIServer : public Component, public Controller { void on_shutdown() override; bool teardown() override; #ifdef USE_API_PASSWORD - bool check_password(const std::string &password) const; + bool check_password(const uint8_t *password_data, size_t password_len) const; void set_password(const std::string &password); #endif void set_port(uint16_t port); diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 22bebcbd29..7f3f8014f7 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -373,6 +373,14 @@ def create_field_type_info( # Traditional fixed array approach with copy return FixedArrayBytesType(field, fixed_size) + # Check for pointer_to_buffer option on string fields + if field.type == 9: + has_pointer_to_buffer = get_field_opt(field, pb.pointer_to_buffer, False) + + if has_pointer_to_buffer: + # Zero-copy pointer approach for strings + return PointerToBytesBufferType(field, None) + # Special handling for bytes fields if field.type == 12: return BytesType(field, needs_decode, needs_encode) From 63326cbd6d375e83f49d1b8da7f7dfd51e170ce5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 Sep 2025 10:58:24 +1200 Subject: [PATCH 1345/1626] [api] Rename `HomeassistantServiceResponse` to `HomeassistantActionRequest` (#10839) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Keith Burzinski --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_connection.h | 6 +++--- esphome/components/api/api_pb2.cpp | 4 ++-- esphome/components/api/api_pb2.h | 4 ++-- esphome/components/api/api_pb2_dump.cpp | 4 ++-- esphome/components/api/api_server.cpp | 4 ++-- esphome/components/api/api_server.h | 3 ++- esphome/components/api/custom_api_device.h | 16 ++++++++-------- esphome/components/api/homeassistant_service.h | 6 +++--- .../number/homeassistant_number.cpp | 4 ++-- .../switch/homeassistant_switch.cpp | 4 ++-- 11 files changed, 29 insertions(+), 28 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c58aabd3c6..21727df307 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -769,7 +769,7 @@ message HomeassistantServiceMap { string value = 2 [(no_zero_copy) = true]; } -message HomeassistantServiceResponse { +message HomeassistantActionRequest { option (id) = 35; option (source) = SOURCE_SERVER; option (no_delay) = true; diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 380894a4ff..cc7e4d6895 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -10,8 +10,8 @@ #include "esphome/core/component.h" #include "esphome/core/entity_base.h" -#include #include +#include namespace esphome::api { @@ -132,10 +132,10 @@ class APIConnection final : public APIServerConnection { #endif bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len); #ifdef USE_API_HOMEASSISTANT_SERVICES - void send_homeassistant_service_call(const HomeassistantServiceResponse &call) { + void send_homeassistant_action(const HomeassistantActionRequest &call) { if (!this->flags_.service_call_subscription) return; - this->send_message(call, HomeassistantServiceResponse::MESSAGE_TYPE); + this->send_message(call, HomeassistantActionRequest::MESSAGE_TYPE); } #endif #ifdef USE_BLUETOOTH_PROXY diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 0a6b271fac..21641e7963 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -872,7 +872,7 @@ void HomeassistantServiceMap::calculate_size(ProtoSize &size) const { size.add_length(1, this->key_ref_.size()); size.add_length(1, this->value.size()); } -void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { +void HomeassistantActionRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->service_ref_); for (auto &it : this->data) { buffer.encode_message(2, it, true); @@ -885,7 +885,7 @@ void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const { } buffer.encode_bool(5, this->is_event); } -void HomeassistantServiceResponse::calculate_size(ProtoSize &size) const { +void HomeassistantActionRequest::calculate_size(ProtoSize &size) const { size.add_length(1, this->service_ref_.size()); size.add_repeated_message(1, this->data); size.add_repeated_message(1, this->data_template); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index b0503b4b7a..2d06ddbb08 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1100,12 +1100,12 @@ class HomeassistantServiceMap final : public ProtoMessage { protected: }; -class HomeassistantServiceResponse final : public ProtoMessage { +class HomeassistantActionRequest final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 35; static constexpr uint8_t ESTIMATED_SIZE = 113; #ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "homeassistant_service_response"; } + const char *message_name() const override { return "homeassistant_action_request"; } #endif StringRef service_ref_{}; void set_service(const StringRef &ref) { this->service_ref_ = ref; } diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 131f6e361a..ca3b041456 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1101,8 +1101,8 @@ void HomeassistantServiceMap::dump_to(std::string &out) const { dump_field(out, "key", this->key_ref_); dump_field(out, "value", this->value); } -void HomeassistantServiceResponse::dump_to(std::string &out) const { - MessageDumpHelper helper(out, "HomeassistantServiceResponse"); +void HomeassistantActionRequest::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "HomeassistantActionRequest"); dump_field(out, "service", this->service_ref_); for (const auto &it : this->data) { out.append(" data: "); diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 775bfe902b..ff94b92b5d 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -371,9 +371,9 @@ void APIServer::set_password(const std::string &password) { this->password_ = pa void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; } #ifdef USE_API_HOMEASSISTANT_SERVICES -void APIServer::send_homeassistant_service_call(const HomeassistantServiceResponse &call) { +void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { for (auto &client : this->clients_) { - client->send_homeassistant_service_call(call); + client->send_homeassistant_action(call); } } #endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index e5470e852d..354c764825 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -107,7 +107,8 @@ class APIServer : public Component, public Controller { void on_media_player_update(media_player::MediaPlayer *obj) override; #endif #ifdef USE_API_HOMEASSISTANT_SERVICES - void send_homeassistant_service_call(const HomeassistantServiceResponse &call); + void send_homeassistant_action(const HomeassistantActionRequest &call); + #endif #ifdef USE_API_SERVICES void register_user_service(UserServiceDescriptor *descriptor) { this->user_services_.push_back(descriptor); } diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index 44f9eee571..0c6e49d6ca 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -179,9 +179,9 @@ class CustomAPIDevice { * @param service_name The service to call. */ void call_homeassistant_service(const std::string &service_name) { - HomeassistantServiceResponse resp; + HomeassistantActionRequest resp; resp.set_service(StringRef(service_name)); - global_api_server->send_homeassistant_service_call(resp); + global_api_server->send_homeassistant_action(resp); } /** Call a Home Assistant service from ESPHome. @@ -199,7 +199,7 @@ class CustomAPIDevice { * @param data The data for the service call, mapping from string to string. */ void call_homeassistant_service(const std::string &service_name, const std::map &data) { - HomeassistantServiceResponse resp; + HomeassistantActionRequest resp; resp.set_service(StringRef(service_name)); for (auto &it : data) { resp.data.emplace_back(); @@ -207,7 +207,7 @@ class CustomAPIDevice { kv.set_key(StringRef(it.first)); kv.value = it.second; } - global_api_server->send_homeassistant_service_call(resp); + global_api_server->send_homeassistant_action(resp); } /** Fire an ESPHome event in Home Assistant. @@ -221,10 +221,10 @@ class CustomAPIDevice { * @param event_name The event to fire. */ void fire_homeassistant_event(const std::string &event_name) { - HomeassistantServiceResponse resp; + HomeassistantActionRequest resp; resp.set_service(StringRef(event_name)); resp.is_event = true; - global_api_server->send_homeassistant_service_call(resp); + global_api_server->send_homeassistant_action(resp); } /** Fire an ESPHome event in Home Assistant. @@ -241,7 +241,7 @@ class CustomAPIDevice { * @param data The data for the event, mapping from string to string. */ void fire_homeassistant_event(const std::string &service_name, const std::map &data) { - HomeassistantServiceResponse resp; + HomeassistantActionRequest resp; resp.set_service(StringRef(service_name)); resp.is_event = true; for (auto &it : data) { @@ -250,7 +250,7 @@ class CustomAPIDevice { kv.set_key(StringRef(it.first)); kv.value = it.second; } - global_api_server->send_homeassistant_service_call(resp); + global_api_server->send_homeassistant_action(resp); } #else template void call_homeassistant_service(const std::string &service_name) { diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 5df9c7c792..4026741ee4 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -3,10 +3,10 @@ #include "api_server.h" #ifdef USE_API #ifdef USE_API_HOMEASSISTANT_SERVICES +#include #include "api_pb2.h" #include "esphome/core/automation.h" #include "esphome/core/helpers.h" -#include namespace esphome::api { @@ -62,7 +62,7 @@ template class HomeAssistantServiceCallAction : public Actionservice_.value(x...); resp.set_service(StringRef(service_value)); resp.is_event = this->is_event_; @@ -84,7 +84,7 @@ template class HomeAssistantServiceCallAction : public Actionparent_->send_homeassistant_service_call(resp); + this->parent_->send_homeassistant_action(resp); } protected: diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index 87bf6727f2..c9fb006568 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -87,7 +87,7 @@ void HomeassistantNumber::control(float value) { static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); static constexpr auto VALUE_KEY = StringRef::from_lit("value"); - api::HomeassistantServiceResponse resp; + api::HomeassistantActionRequest resp; resp.set_service(SERVICE_NAME); resp.data.emplace_back(); @@ -100,7 +100,7 @@ void HomeassistantNumber::control(float value) { entity_value.set_key(VALUE_KEY); entity_value.value = to_string(value); - api::global_api_server->send_homeassistant_service_call(resp); + api::global_api_server->send_homeassistant_action(resp); } } // namespace homeassistant diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index b3300335b9..8feec26fe6 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -44,7 +44,7 @@ void HomeassistantSwitch::write_state(bool state) { static constexpr auto SERVICE_OFF = StringRef::from_lit("homeassistant.turn_off"); static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); - api::HomeassistantServiceResponse resp; + api::HomeassistantActionRequest resp; if (state) { resp.set_service(SERVICE_ON); } else { @@ -56,7 +56,7 @@ void HomeassistantSwitch::write_state(bool state) { entity_id_kv.set_key(ENTITY_ID_KEY); entity_id_kv.value = this->entity_id_; - api::global_api_server->send_homeassistant_service_call(resp); + api::global_api_server->send_homeassistant_action(resp); } } // namespace homeassistant From f8226cd481dfcd5d6e296edb43827a47b128d065 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 19:42:46 -0500 Subject: [PATCH 1346/1626] [esp32_ble] Remove Arduino-specific BLE limitations and SplitDefaults (#10780) --- .../components/bluetooth_proxy/__init__.py | 30 ++----------------- esphome/components/esp32_ble/__init__.py | 10 ++----- .../components/esp32_ble_tracker/__init__.py | 13 +++----- 3 files changed, 10 insertions(+), 43 deletions(-) diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index f21b5028c7..42a88f1421 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -6,8 +6,6 @@ from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.esp32_ble import BTLoggers import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_ID -from esphome.core import CORE -from esphome.log import AnsiFore, color AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"] DEPENDENCIES = ["api", "esp32"] @@ -48,26 +46,6 @@ def validate_connections(config): config ) - # Warn about connection slot waste when using Arduino framework - if CORE.using_arduino and connection_slots: - _LOGGER.warning( - "Bluetooth Proxy with active connections on Arduino framework has suboptimal performance.\n" - "If BLE connections fail, they can waste connection slots for 10 seconds because\n" - "Arduino doesn't allow configuring the BLE connection timeout (fixed at 30s).\n" - "ESP-IDF framework allows setting it to 20s to match client timeouts.\n" - "\n" - "To switch to ESP-IDF, add this to your YAML:\n" - " esp32:\n" - " framework:\n" - " type: esp-idf\n" - "\n" - "For detailed migration instructions, see:\n" - "%s", - color( - AnsiFore.BLUE, "https://esphome.io/guides/esp32_arduino_to_idf.html" - ), - ) - return { **config, CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)], @@ -81,19 +59,17 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(BluetoothProxy), cv.Optional(CONF_ACTIVE, default=True): cv.boolean, - cv.SplitDefault(CONF_CACHE_SERVICES, esp32_idf=True): cv.All( - cv.only_with_esp_idf, cv.boolean - ), + cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean, cv.Optional( CONF_CONNECTION_SLOTS, default=DEFAULT_CONNECTION_SLOTS, ): cv.All( cv.positive_int, - cv.Range(min=1, max=esp32_ble_tracker.max_connections()), + cv.Range(min=1, max=esp32_ble_tracker.IDF_MAX_CONNECTIONS), ), cv.Optional(CONF_CONNECTIONS): cv.All( cv.ensure_list(CONNECTION_SCHEMA), - cv.Length(min=1, max=esp32_ble_tracker.max_connections()), + cv.Length(min=1, max=esp32_ble_tracker.IDF_MAX_CONNECTIONS), ), } ) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 8886dc415b..dae9799028 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -174,16 +174,12 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional( CONF_ADVERTISING_CYCLE_TIME, default="10s" ): cv.positive_time_period_milliseconds, - cv.SplitDefault(CONF_DISABLE_BT_LOGS, esp32_idf=True): cv.All( - cv.only_with_esp_idf, cv.boolean - ), - cv.SplitDefault(CONF_CONNECTION_TIMEOUT, esp32_idf="20s"): cv.All( - cv.only_with_esp_idf, + cv.Optional(CONF_DISABLE_BT_LOGS, default=True): cv.boolean, + cv.Optional(CONF_CONNECTION_TIMEOUT, default="20s"): cv.All( cv.positive_time_period_seconds, cv.Range(min=TimePeriod(seconds=10), max=TimePeriod(seconds=180)), ), - cv.SplitDefault(CONF_MAX_NOTIFICATIONS, esp32_idf=12): cv.All( - cv.only_with_esp_idf, + cv.Optional(CONF_MAX_NOTIFICATIONS, default=12): cv.All( cv.positive_int, cv.Range(min=1, max=64), ), diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 8655d5a02a..787fb9fb65 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -150,10 +150,6 @@ def as_reversed_hex_array(value): ) -def max_connections() -> int: - return IDF_MAX_CONNECTIONS if CORE.using_esp_idf else DEFAULT_MAX_CONNECTIONS - - def consume_connection_slots( value: int, consumer: str ) -> Callable[[MutableMapping], MutableMapping]: @@ -172,7 +168,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(ESP32BLETracker), cv.GenerateID(esp32_ble.CONF_BLE_ID): cv.use_id(esp32_ble.ESP32BLE), cv.Optional(CONF_MAX_CONNECTIONS, default=DEFAULT_MAX_CONNECTIONS): cv.All( - cv.positive_int, cv.Range(min=0, max=max_connections()) + cv.positive_int, cv.Range(min=0, max=IDF_MAX_CONNECTIONS) ), cv.Optional(CONF_SCAN_PARAMETERS, default={}): cv.All( cv.Schema( @@ -238,9 +234,8 @@ def validate_remaining_connections(config): if used_slots <= config[CONF_MAX_CONNECTIONS]: return config slot_users = ", ".join(slots) - hard_limit = max_connections() - if used_slots < hard_limit: + if used_slots < IDF_MAX_CONNECTIONS: _LOGGER.warning( "esp32_ble_tracker exceeded `%s`: components attempted to consume %d " "connection slot(s) out of available configured maximum %d connection " @@ -262,9 +257,9 @@ def validate_remaining_connections(config): f"out of available configured maximum {config[CONF_MAX_CONNECTIONS]} " f"connection slot(s); Decrease the number of BLE clients ({slot_users})" ) - if config[CONF_MAX_CONNECTIONS] < hard_limit: + if config[CONF_MAX_CONNECTIONS] < IDF_MAX_CONNECTIONS: msg += f" or increase {CONF_MAX_CONNECTIONS}` to {used_slots}" - msg += f" to stay under the {hard_limit} connection slot(s) limit." + msg += f" to stay under the {IDF_MAX_CONNECTIONS} connection slot(s) limit." raise cv.Invalid(msg) From adfacdf1b75da7523158a34ad22af78764429d4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 19:43:55 -0500 Subject: [PATCH 1347/1626] [api] Consolidate authentication checks to reduce function call overhead (#10852) --- esphome/components/api/api_pb2_service.cpp | 233 +++++++-------------- esphome/components/api/api_pb2_service.h | 1 + esphome/components/api/proto.h | 4 +- script/api_protobuf/api_protobuf.py | 100 ++++++--- 4 files changed, 147 insertions(+), 191 deletions(-) diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 4afc66dc44..24a7740ec0 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -639,241 +639,139 @@ void APIServerConnection::on_ping_request(const PingRequest &msg) { } } void APIServerConnection::on_device_info_request(const DeviceInfoRequest &msg) { - if (this->check_connection_setup_() && !this->send_device_info_response(msg)) { + if (!this->send_device_info_response(msg)) { this->on_fatal_error(); } } -void APIServerConnection::on_list_entities_request(const ListEntitiesRequest &msg) { - if (this->check_authenticated_()) { - this->list_entities(msg); - } -} +void APIServerConnection::on_list_entities_request(const ListEntitiesRequest &msg) { this->list_entities(msg); } void APIServerConnection::on_subscribe_states_request(const SubscribeStatesRequest &msg) { - if (this->check_authenticated_()) { - this->subscribe_states(msg); - } -} -void APIServerConnection::on_subscribe_logs_request(const SubscribeLogsRequest &msg) { - if (this->check_authenticated_()) { - this->subscribe_logs(msg); - } + this->subscribe_states(msg); } +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) { - if (this->check_authenticated_()) { - this->subscribe_homeassistant_services(msg); - } + this->subscribe_homeassistant_services(msg); } #endif #ifdef USE_API_HOMEASSISTANT_STATES void APIServerConnection::on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) { - if (this->check_authenticated_()) { - this->subscribe_home_assistant_states(msg); - } + this->subscribe_home_assistant_states(msg); } #endif #ifdef USE_API_SERVICES -void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { - if (this->check_authenticated_()) { - this->execute_service(msg); - } -} +void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { this->execute_service(msg); } #endif #ifdef USE_API_NOISE void APIServerConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) { - if (this->check_authenticated_() && !this->send_noise_encryption_set_key_response(msg)) { + if (!this->send_noise_encryption_set_key_response(msg)) { this->on_fatal_error(); } } #endif #ifdef USE_BUTTON -void APIServerConnection::on_button_command_request(const ButtonCommandRequest &msg) { - if (this->check_authenticated_()) { - this->button_command(msg); - } -} +void APIServerConnection::on_button_command_request(const ButtonCommandRequest &msg) { this->button_command(msg); } #endif #ifdef USE_CAMERA -void APIServerConnection::on_camera_image_request(const CameraImageRequest &msg) { - if (this->check_authenticated_()) { - this->camera_image(msg); - } -} +void APIServerConnection::on_camera_image_request(const CameraImageRequest &msg) { this->camera_image(msg); } #endif #ifdef USE_CLIMATE -void APIServerConnection::on_climate_command_request(const ClimateCommandRequest &msg) { - if (this->check_authenticated_()) { - this->climate_command(msg); - } -} +void APIServerConnection::on_climate_command_request(const ClimateCommandRequest &msg) { this->climate_command(msg); } #endif #ifdef USE_COVER -void APIServerConnection::on_cover_command_request(const CoverCommandRequest &msg) { - if (this->check_authenticated_()) { - this->cover_command(msg); - } -} +void APIServerConnection::on_cover_command_request(const CoverCommandRequest &msg) { this->cover_command(msg); } #endif #ifdef USE_DATETIME_DATE -void APIServerConnection::on_date_command_request(const DateCommandRequest &msg) { - if (this->check_authenticated_()) { - this->date_command(msg); - } -} +void APIServerConnection::on_date_command_request(const DateCommandRequest &msg) { this->date_command(msg); } #endif #ifdef USE_DATETIME_DATETIME void APIServerConnection::on_date_time_command_request(const DateTimeCommandRequest &msg) { - if (this->check_authenticated_()) { - this->datetime_command(msg); - } + this->datetime_command(msg); } #endif #ifdef USE_FAN -void APIServerConnection::on_fan_command_request(const FanCommandRequest &msg) { - if (this->check_authenticated_()) { - this->fan_command(msg); - } -} +void APIServerConnection::on_fan_command_request(const FanCommandRequest &msg) { this->fan_command(msg); } #endif #ifdef USE_LIGHT -void APIServerConnection::on_light_command_request(const LightCommandRequest &msg) { - if (this->check_authenticated_()) { - this->light_command(msg); - } -} +void APIServerConnection::on_light_command_request(const LightCommandRequest &msg) { this->light_command(msg); } #endif #ifdef USE_LOCK -void APIServerConnection::on_lock_command_request(const LockCommandRequest &msg) { - if (this->check_authenticated_()) { - this->lock_command(msg); - } -} +void APIServerConnection::on_lock_command_request(const LockCommandRequest &msg) { this->lock_command(msg); } #endif #ifdef USE_MEDIA_PLAYER void APIServerConnection::on_media_player_command_request(const MediaPlayerCommandRequest &msg) { - if (this->check_authenticated_()) { - this->media_player_command(msg); - } + this->media_player_command(msg); } #endif #ifdef USE_NUMBER -void APIServerConnection::on_number_command_request(const NumberCommandRequest &msg) { - if (this->check_authenticated_()) { - this->number_command(msg); - } -} +void APIServerConnection::on_number_command_request(const NumberCommandRequest &msg) { this->number_command(msg); } #endif #ifdef USE_SELECT -void APIServerConnection::on_select_command_request(const SelectCommandRequest &msg) { - if (this->check_authenticated_()) { - this->select_command(msg); - } -} +void APIServerConnection::on_select_command_request(const SelectCommandRequest &msg) { this->select_command(msg); } #endif #ifdef USE_SIREN -void APIServerConnection::on_siren_command_request(const SirenCommandRequest &msg) { - if (this->check_authenticated_()) { - this->siren_command(msg); - } -} +void APIServerConnection::on_siren_command_request(const SirenCommandRequest &msg) { this->siren_command(msg); } #endif #ifdef USE_SWITCH -void APIServerConnection::on_switch_command_request(const SwitchCommandRequest &msg) { - if (this->check_authenticated_()) { - this->switch_command(msg); - } -} +void APIServerConnection::on_switch_command_request(const SwitchCommandRequest &msg) { this->switch_command(msg); } #endif #ifdef USE_TEXT -void APIServerConnection::on_text_command_request(const TextCommandRequest &msg) { - if (this->check_authenticated_()) { - this->text_command(msg); - } -} +void APIServerConnection::on_text_command_request(const TextCommandRequest &msg) { this->text_command(msg); } #endif #ifdef USE_DATETIME_TIME -void APIServerConnection::on_time_command_request(const TimeCommandRequest &msg) { - if (this->check_authenticated_()) { - this->time_command(msg); - } -} +void APIServerConnection::on_time_command_request(const TimeCommandRequest &msg) { this->time_command(msg); } #endif #ifdef USE_UPDATE -void APIServerConnection::on_update_command_request(const UpdateCommandRequest &msg) { - if (this->check_authenticated_()) { - this->update_command(msg); - } -} +void APIServerConnection::on_update_command_request(const UpdateCommandRequest &msg) { this->update_command(msg); } #endif #ifdef USE_VALVE -void APIServerConnection::on_valve_command_request(const ValveCommandRequest &msg) { - if (this->check_authenticated_()) { - this->valve_command(msg); - } -} +void APIServerConnection::on_valve_command_request(const ValveCommandRequest &msg) { this->valve_command(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_subscribe_bluetooth_le_advertisements_request( const SubscribeBluetoothLEAdvertisementsRequest &msg) { - if (this->check_authenticated_()) { - this->subscribe_bluetooth_le_advertisements(msg); - } + this->subscribe_bluetooth_le_advertisements(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_device_request(msg); - } + this->bluetooth_device_request(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_gatt_get_services(msg); - } + this->bluetooth_gatt_get_services(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_gatt_read(msg); - } + this->bluetooth_gatt_read(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_gatt_write(msg); - } + this->bluetooth_gatt_write(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_gatt_read_descriptor(msg); - } + this->bluetooth_gatt_read_descriptor(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_gatt_write_descriptor(msg); - } + this->bluetooth_gatt_write_descriptor(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_gatt_notify(msg); - } + this->bluetooth_gatt_notify(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_subscribe_bluetooth_connections_free_request( const SubscribeBluetoothConnectionsFreeRequest &msg) { - if (this->check_authenticated_() && !this->send_subscribe_bluetooth_connections_free_response(msg)) { + if (!this->send_subscribe_bluetooth_connections_free_response(msg)) { this->on_fatal_error(); } } @@ -881,59 +779,68 @@ void APIServerConnection::on_subscribe_bluetooth_connections_free_request( #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_unsubscribe_bluetooth_le_advertisements_request( const UnsubscribeBluetoothLEAdvertisementsRequest &msg) { - if (this->check_authenticated_()) { - this->unsubscribe_bluetooth_le_advertisements(msg); - } + this->unsubscribe_bluetooth_le_advertisements(msg); } #endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) { - if (this->check_authenticated_()) { - this->bluetooth_scanner_set_mode(msg); - } + this->bluetooth_scanner_set_mode(msg); } #endif #ifdef USE_VOICE_ASSISTANT void APIServerConnection::on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg) { - if (this->check_authenticated_()) { - this->subscribe_voice_assistant(msg); - } + this->subscribe_voice_assistant(msg); } #endif #ifdef USE_VOICE_ASSISTANT void APIServerConnection::on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) { - if (this->check_authenticated_() && !this->send_voice_assistant_get_configuration_response(msg)) { + if (!this->send_voice_assistant_get_configuration_response(msg)) { this->on_fatal_error(); } } #endif #ifdef USE_VOICE_ASSISTANT void APIServerConnection::on_voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) { - if (this->check_authenticated_()) { - this->voice_assistant_set_configuration(msg); - } + this->voice_assistant_set_configuration(msg); } #endif #ifdef USE_ALARM_CONTROL_PANEL void APIServerConnection::on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg) { - if (this->check_authenticated_()) { - this->alarm_control_panel_command(msg); - } + this->alarm_control_panel_command(msg); } #endif #ifdef USE_ZWAVE_PROXY -void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { - if (this->check_authenticated_()) { - this->zwave_proxy_frame(msg); - } -} +void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { this->zwave_proxy_frame(msg); } #endif #ifdef USE_ZWAVE_PROXY -void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { - if (this->check_authenticated_()) { - this->zwave_proxy_request(msg); - } -} +void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { this->zwave_proxy_request(msg); } #endif +void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) { + // Check authentication/connection requirements for messages + switch (msg_type) { + case HelloRequest::MESSAGE_TYPE: // No setup required +#ifdef USE_API_PASSWORD + case AuthenticationRequest::MESSAGE_TYPE: // No setup required +#endif + case DisconnectRequest::MESSAGE_TYPE: // No setup required + case PingRequest::MESSAGE_TYPE: // No setup required + break; // Skip all checks for these messages + case DeviceInfoRequest::MESSAGE_TYPE: // Connection setup only + if (!this->check_connection_setup_()) { + return; // Connection not setup + } + break; + default: + // All other messages require authentication (which includes connection check) + if (!this->check_authenticated_()) { + return; // Authentication failed + } + break; + } + + // Call base implementation to process the message + APIServerConnectionBase::read_message(msg_size, msg_type, msg_data); +} + } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 9379dfee7d..1afcba6664 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -477,6 +477,7 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_ZWAVE_PROXY void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override; #endif + void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; }; } // namespace esphome::api diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 6be5f00e75..9d780692ec 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -831,7 +831,7 @@ class ProtoService { } // Authentication helper methods - bool check_connection_setup_() { + inline bool check_connection_setup_() { if (!this->is_connection_setup()) { this->on_no_setup_connection(); return false; @@ -839,7 +839,7 @@ class ProtoService { return true; } - bool check_authenticated_() { + inline bool check_authenticated_() { #ifdef USE_API_PASSWORD if (!this->check_connection_setup_()) { return false; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 7f3f8014f7..487c187372 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2721,6 +2721,10 @@ static const char *const TAG = "api.service"; hpp_protected = "" cpp += "\n" + # Build a mapping of message input types to their authentication requirements + message_auth_map: dict[str, bool] = {} + message_conn_map: dict[str, bool] = {} + m = serv.method[0] for m in serv.method: func = m.name @@ -2732,6 +2736,10 @@ static const char *const TAG = "api.service"; needs_conn = get_opt(m, pb.needs_setup_connection, True) needs_auth = get_opt(m, pb.needs_authentication, True) + # Store authentication requirements for message types + message_auth_map[inp] = needs_auth + message_conn_map[inp] = needs_conn + ifdef = message_ifdef_map.get(inp, ifdefs.get(inp)) if ifdef is not None: @@ -2749,33 +2757,14 @@ static const char *const TAG = "api.service"; cpp += f"void {class_name}::{on_func}(const {inp} &msg) {{\n" - # Start with authentication/connection check if needed - if needs_auth or needs_conn: - # Determine which check to use - if needs_auth: - check_func = "this->check_authenticated_()" - else: - check_func = "this->check_connection_setup_()" - - if is_void: - # For void methods, just wrap with auth check - body = f"if ({check_func}) {{\n" - body += f" this->{func}(msg);\n" - body += "}\n" - else: - # For non-void methods, combine auth check and send response check - body = f"if ({check_func} && !this->send_{func}_response(msg)) {{\n" - body += " this->on_fatal_error();\n" - body += "}\n" + # No authentication check here - it's done in read_message + body = "" + if is_void: + body += f"this->{func}(msg);\n" else: - # No auth check needed, just call the handler - body = "" - if is_void: - body += f"this->{func}(msg);\n" - else: - body += f"if (!this->send_{func}_response(msg)) {{\n" - body += " this->on_fatal_error();\n" - body += "}\n" + body += f"if (!this->send_{func}_response(msg)) {{\n" + body += " this->on_fatal_error();\n" + body += "}\n" cpp += indent(body) + "\n" + "}\n" @@ -2784,6 +2773,65 @@ static const char *const TAG = "api.service"; hpp_protected += "#endif\n" cpp += "#endif\n" + # Generate optimized read_message with authentication checking + # Categorize messages by their authentication requirements + no_conn_ids: set[int] = set() + conn_only_ids: set[int] = set() + + for id_, (_, _, case_msg_name) in cases: + if case_msg_name in message_auth_map: + needs_auth = message_auth_map[case_msg_name] + needs_conn = message_conn_map[case_msg_name] + + if not needs_conn: + no_conn_ids.add(id_) + elif not needs_auth: + conn_only_ids.add(id_) + + # Generate override if we have messages that skip checks + if no_conn_ids or conn_only_ids: + # Helper to generate case statements with ifdefs + def generate_cases(ids: set[int], comment: str) -> str: + result = "" + for id_ in sorted(ids): + _, ifdef, msg_name = RECEIVE_CASES[id_] + if ifdef: + result += f"#ifdef {ifdef}\n" + result += f" case {msg_name}::MESSAGE_TYPE: {comment}\n" + if ifdef: + result += "#endif\n" + return result + + hpp_protected += " void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;\n" + + cpp += f"\nvoid {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) {{\n" + cpp += " // Check authentication/connection requirements for messages\n" + cpp += " switch (msg_type) {\n" + + # Messages that don't need any checks + if no_conn_ids: + cpp += generate_cases(no_conn_ids, "// No setup required") + cpp += " break; // Skip all checks for these messages\n" + + # Messages that only need connection setup + if conn_only_ids: + cpp += generate_cases(conn_only_ids, "// Connection setup only") + cpp += " if (!this->check_connection_setup_()) {\n" + cpp += " return; // Connection not setup\n" + cpp += " }\n" + cpp += " break;\n" + + cpp += " default:\n" + cpp += " // All other messages require authentication (which includes connection check)\n" + cpp += " if (!this->check_authenticated_()) {\n" + cpp += " return; // Authentication failed\n" + cpp += " }\n" + cpp += " break;\n" + cpp += " }\n\n" + cpp += " // Call base implementation to process the message\n" + cpp += f" {class_name}Base::read_message(msg_size, msg_type, msg_data);\n" + cpp += "}\n" + hpp += " protected:\n" hpp += hpp_protected hpp += "};\n" From ce784299d8d3d451dcae69638741ed9a04c3a260 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 21:23:58 -0500 Subject: [PATCH 1348/1626] wip --- esphome/components/usb_host/usb_host.h | 41 ++++++- .../components/usb_host/usb_host_client.cpp | 104 ++++++++++++++++-- esphome/components/usb_uart/usb_uart.cpp | 80 +++++++++++--- esphome/components/usb_uart/usb_uart.h | 31 +++++- 4 files changed, 229 insertions(+), 27 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index c5466eb1f0..3625100e4a 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -5,7 +5,10 @@ #include "esphome/core/component.h" #include #include "usb/usb_host.h" - +#include +#include +#include +#include #include namespace esphome { @@ -13,6 +16,10 @@ namespace usb_host { static const char *const TAG = "usb_host"; +// Forward declarations +struct TransferRequest; +class USBClient; + // constants for setup packet type static const uint8_t USB_RECIP_DEVICE = 0; static const uint8_t USB_RECIP_INTERFACE = 1; @@ -49,6 +56,30 @@ struct TransferRequest { USBClient *client; }; +// Lightweight event types for queue +enum EventType { + EVENT_DEVICE_NEW, + EVENT_DEVICE_GONE, + EVENT_TRANSFER_COMPLETE, + EVENT_CONTROL_COMPLETE, +}; + +struct UsbEvent { + EventType type; + union { + struct { + uint8_t address; + } device_new; + struct { + usb_device_handle_t handle; + } device_gone; + struct { + TransferRequest *trq; + bool callback_executed; // Flag to indicate callback was already executed in USB task + } transfer; + } data; +}; + // callback function type. enum ClientState { @@ -83,6 +114,7 @@ class USBClient : public Component { void release_trq(TransferRequest *trq); bool control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback, const std::vector &data = {}); + QueueHandle_t get_event_queue() { return event_queue_; } protected: bool register_(); @@ -91,6 +123,13 @@ class USBClient : public Component { virtual void on_connected() {} virtual void on_disconnected() { this->init_pool(); } + // USB task management + static void usb_task_fn(void *arg); + void usb_task_loop(); + + TaskHandle_t usb_task_handle_{nullptr}; + QueueHandle_t event_queue_{nullptr}; // Queue of UsbEvent structs + usb_host_client_handle_t handle_{}; usb_device_handle_t device_handle_{}; int device_addr_{-1}; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 4c0c12fa18..98a1f6178b 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -139,18 +139,25 @@ static std::string get_descriptor_string(const usb_str_desc_t *desc) { return {buffer}; } +// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void *ptr) { auto *client = static_cast(ptr); + UsbEvent event; + + // Queue events to be processed in main loop switch (event_msg->event) { case USB_HOST_CLIENT_EVENT_NEW_DEV: { - auto addr = event_msg->new_dev.address; ESP_LOGD(TAG, "New device %d", event_msg->new_dev.address); - client->on_opened(addr); + event.type = EVENT_DEVICE_NEW; + event.data.device_new.address = event_msg->new_dev.address; + xQueueSend(client->get_event_queue(), &event, portMAX_DELAY); break; } case USB_HOST_CLIENT_EVENT_DEV_GONE: { - client->on_removed(event_msg->dev_gone.dev_hdl); - ESP_LOGD(TAG, "Device gone %d", event_msg->new_dev.address); + ESP_LOGD(TAG, "Device gone"); + event.type = EVENT_DEVICE_GONE; + event.data.device_gone.handle = event_msg->dev_gone.dev_hdl; + xQueueSend(client->get_event_queue(), &event, portMAX_DELAY); break; } default: @@ -173,9 +180,66 @@ void USBClient::setup() { usb_host_transfer_alloc(64, 0, &trq->transfer); trq->client = this; } + + // Create event queue for communication between USB task and main loop + this->event_queue_ = xQueueCreate(32, sizeof(UsbEvent)); + if (this->event_queue_ == nullptr) { + ESP_LOGE(TAG, "Failed to create event queue"); + this->mark_failed(); + return; + } + + // Create and start USB task + xTaskCreatePinnedToCore(usb_task_fn, "usb_task", + 2048, // Stack size (minimal - just handles USB events) + this, // Task parameter + 5, // Priority (higher than main loop) + &this->usb_task_handle_, + 1 // Core 1 + ); + + if (this->usb_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create USB task"); + this->mark_failed(); + } +} + +void USBClient::usb_task_fn(void *arg) { + auto *client = static_cast(arg); + client->usb_task_loop(); +} + +void USBClient::usb_task_loop() { + ESP_LOGI(TAG, "USB task started on core %d", xPortGetCoreID()); + + // Run forever - ESPHome reboots rather than shutting down cleanly + while (true) { + // Handle USB events with a timeout to prevent blocking forever + usb_host_client_handle_events(this->handle_, pdMS_TO_TICKS(10)); + } } void USBClient::loop() { + // Process any events from the USB task + UsbEvent event; + while (xQueueReceive(this->event_queue_, &event, 0) == pdTRUE) { + switch (event.type) { + case EVENT_DEVICE_NEW: + this->on_opened(event.data.device_new.address); + break; + case EVENT_DEVICE_GONE: + this->on_removed(event.data.device_gone.handle); + break; + case EVENT_TRANSFER_COMPLETE: + case EVENT_CONTROL_COMPLETE: { + auto *trq = event.data.transfer.trq; + // Callback was already executed in USB task, just cleanup + this->release_trq(trq); + break; + } + } + } + switch (this->state_) { case USB_CLIENT_OPEN: { int err; @@ -228,7 +292,7 @@ void USBClient::loop() { } default: - usb_host_client_handle_events(this->handle_, 0); + // USB events are now handled in the dedicated task break; } } @@ -245,6 +309,7 @@ void USBClient::on_removed(usb_device_handle_t handle) { } } +// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) static void control_callback(const usb_transfer_t *xfer) { auto *trq = static_cast(xfer->context); trq->status.error_code = xfer->status; @@ -252,9 +317,18 @@ static void control_callback(const usb_transfer_t *xfer) { trq->status.endpoint = xfer->bEndpointAddress; trq->status.data = xfer->data_buffer; trq->status.data_len = xfer->actual_num_bytes; - if (trq->callback != nullptr) + + // Execute callback in USB task context + if (trq->callback != nullptr) { trq->callback(trq->status); - trq->client->release_trq(trq); + } + + // Queue cleanup to main loop + UsbEvent event; + event.type = EVENT_CONTROL_COMPLETE; + event.data.transfer.trq = trq; + event.data.transfer.callback_executed = true; + xQueueSend(trq->client->get_event_queue(), &event, portMAX_DELAY); } TransferRequest *USBClient::get_trq_() { @@ -315,6 +389,7 @@ bool USBClient::control_transfer(uint8_t type, uint8_t request, uint16_t value, return true; } +// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) static void transfer_callback(usb_transfer_t *xfer) { auto *trq = static_cast(xfer->context); trq->status.error_code = xfer->status; @@ -322,9 +397,19 @@ static void transfer_callback(usb_transfer_t *xfer) { trq->status.endpoint = xfer->bEndpointAddress; trq->status.data = xfer->data_buffer; trq->status.data_len = xfer->actual_num_bytes; - if (trq->callback != nullptr) + + // Always execute callback in USB task context + // Callbacks should be fast and non-blocking (e.g., copy data to queue) + if (trq->callback != nullptr) { trq->callback(trq->status); - trq->client->release_trq(trq); + } + + // Queue cleanup to main loop + UsbEvent event; + event.type = EVENT_TRANSFER_COMPLETE; + event.data.transfer.trq = trq; + event.data.transfer.callback_executed = true; + xQueueSend(trq->client->get_event_queue(), &event, portMAX_DELAY); } /** * Performs a transfer input operation. @@ -345,6 +430,7 @@ void USBClient::transfer_in(uint8_t ep_address, const transfer_cb_t &callback, u trq->transfer->callback = transfer_callback; trq->transfer->bEndpointAddress = ep_address | USB_DIR_IN; trq->transfer->num_bytes = length; + auto err = usb_host_transfer_submit(trq->transfer); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to submit transfer, address=%x, length=%d, err=%x", ep_address, length, err); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index bf1c9086f1..4b2464fd59 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -170,7 +170,37 @@ bool USBUartChannel::read_array(uint8_t *data, size_t len) { return status; } void USBUartComponent::setup() { USBClient::setup(); } -void USBUartComponent::loop() { USBClient::loop(); } +void USBUartComponent::loop() { + USBClient::loop(); + + // Process USB data from the lock-free queue + UsbDataChunk *chunk; + int chunks_processed = 0; + while ((chunk = this->usb_data_queue_.pop()) != nullptr) { + chunks_processed++; + auto *channel = chunk->channel; + +#ifdef USE_UART_DEBUGGER + if (channel->debug_) { + uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX, std::vector(chunk->data, chunk->data + chunk->length), + ','); // NOLINT() + } +#endif + + // Push data to ring buffer (now safe in main loop) + for (size_t i = 0; i < chunk->length; i++) { + channel->input_buffer_.push(chunk->data[i]); + } + + // Return chunk to pool for reuse + this->free_chunks_.push(chunk); + } + + static constexpr int LOG_CHUNK_THRESHOLD = 5; + if (chunks_processed > LOG_CHUNK_THRESHOLD) { + ESP_LOGV(TAG, "Processed %d chunks from USB queue", chunks_processed); + } +} void USBUartComponent::dump_config() { USBClient::dump_config(); for (auto &channel : this->channels_) { @@ -187,31 +217,46 @@ void USBUartComponent::dump_config() { } } void USBUartComponent::start_input(USBUartChannel *channel) { - if (!channel->initialised_ || channel->input_started_ || - channel->input_buffer_.get_free_space() < channel->cdc_dev_.in_ep->wMaxPacketSize) + if (!channel->initialised_ || channel->input_started_) return; + // Note: We no longer check ring buffer space here since this may be called from USB task + // The lock-free queue provides backpressure instead const auto *ep = channel->cdc_dev_.in_ep; + // CALLBACK CONTEXT: This lambda is executed in USB task via transfer_callback auto callback = [this, channel](const usb_host::TransferStatus &status) { ESP_LOGV(TAG, "Transfer result: length: %u; status %X", status.data_len, status.error_code); if (!status.success) { ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); return; } -#ifdef USE_UART_DEBUGGER - if (channel->debug_) { - uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX, - std::vector(status.data, status.data + status.data_len), ','); // NOLINT() - } -#endif - channel->input_started_ = false; - if (!channel->dummy_receiver_) { - for (size_t i = 0; i != status.data_len; i++) { - channel->input_buffer_.push(status.data[i]); + + if (!channel->dummy_receiver_ && status.data_len > 0) { + // Get a free chunk from the pool + UsbDataChunk *chunk = this->free_chunks_.pop(); + if (chunk == nullptr) { + ESP_LOGW(TAG, "No free chunks available, dropping %u bytes", status.data_len); + // Mark input as not started so we can retry + channel->input_started_ = false; + return; + } + + // Copy data to chunk (this is fast, happens in USB task) + memcpy(chunk->data, status.data, status.data_len); + chunk->length = status.data_len; + chunk->channel = channel; + + // Push to lock-free queue for main loop processing + if (!this->usb_data_queue_.push(chunk)) { + ESP_LOGW(TAG, "USB data queue full, dropping %u bytes", status.data_len); + // Return chunk to pool + this->free_chunks_.push(chunk); } } - if (channel->input_buffer_.get_free_space() >= channel->cdc_dev_.in_ep->wMaxPacketSize) { - this->defer([this, channel] { this->start_input(channel); }); - } + + // Always restart input immediately from USB task + // The lock-free queue will handle backpressure + channel->input_started_ = false; + this->start_input(channel); }; channel->input_started_ = true; this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize); @@ -224,9 +269,12 @@ void USBUartComponent::start_output(USBUartChannel *channel) { return; } const auto *ep = channel->cdc_dev_.out_ep; + // CALLBACK CONTEXT: This lambda is stored in TransferRequest and will be executed + // in MAIN LOOP after being queued by transfer_callback in USB task auto callback = [this, channel](const usb_host::TransferStatus &status) { ESP_LOGV(TAG, "Output Transfer result: length: %u; status %X", status.data_len, status.error_code); channel->output_started_ = false; + // DEFERRED CONTEXT: Main loop (restart output in main loop) this->defer([this, channel] { this->start_output(channel); }); }; channel->output_started_ = true; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index a103c51add..c1affe2bc9 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -5,11 +5,13 @@ #include "esphome/core/helpers.h" #include "esphome/components/uart/uart_component.h" #include "esphome/components/usb_host/usb_host.h" +#include "esphome/core/lock_free_queue.h" namespace esphome { namespace usb_uart { class USBUartTypeCdcAcm; class USBUartComponent; +class USBUartChannel; static const char *const TAG = "usb_uart"; @@ -68,6 +70,14 @@ class RingBuffer { uint8_t *buffer_; }; +// Structure for queuing received USB data chunks +struct UsbDataChunk { + static constexpr size_t MAX_CHUNK_SIZE = 64; // USB packet size + uint8_t data[MAX_CHUNK_SIZE]; + size_t length; + USBUartChannel *channel; +}; + class USBUartChannel : public uart::UARTComponent, public Parented { friend class USBUartComponent; friend class USBUartTypeCdcAcm; @@ -104,7 +114,18 @@ class USBUartChannel : public uart::UARTComponent, public Parenteddata_chunk_pool_[i] = new UsbDataChunk(); + this->free_chunks_.push(this->data_chunk_pool_[i]); + } + } + ~USBUartComponent() { + for (int i = 0; i < MAX_DATA_CHUNKS; i++) { + delete this->data_chunk_pool_[i]; + } + } void setup() override; void loop() override; void dump_config() override; @@ -115,8 +136,16 @@ class USBUartComponent : public usb_host::USBClient { void start_input(USBUartChannel *channel); void start_output(USBUartChannel *channel); + // Lock-free data transfer from USB task to main loop + LockFreeQueue usb_data_queue_; + protected: std::vector channels_{}; + + // Pool of pre-allocated data chunks to avoid dynamic allocation + static constexpr int MAX_DATA_CHUNKS = 32; + UsbDataChunk *data_chunk_pool_[MAX_DATA_CHUNKS]; + LockFreeQueue free_chunks_; }; class USBUartTypeCdcAcm : public USBUartComponent { From 4699e5683250e550b3f6e16228b9261e35dc0738 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 21:28:14 -0500 Subject: [PATCH 1349/1626] wip --- esphome/components/usb_uart/usb_uart.h | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index c1affe2bc9..91a4329f22 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -115,17 +115,12 @@ class USBUartChannel : public uart::UARTComponent, public Parenteddata_chunk_pool_[i] = new UsbDataChunk(); this->free_chunks_.push(this->data_chunk_pool_[i]); } } - ~USBUartComponent() { - for (int i = 0; i < MAX_DATA_CHUNKS; i++) { - delete this->data_chunk_pool_[i]; - } - } void setup() override; void loop() override; void dump_config() override; @@ -137,14 +132,17 @@ class USBUartComponent : public usb_host::USBClient { void start_output(USBUartChannel *channel); // Lock-free data transfer from USB task to main loop - LockFreeQueue usb_data_queue_; + static constexpr int USB_DATA_QUEUE_SIZE = 32; + LockFreeQueue usb_data_queue_; protected: std::vector channels_{}; // Pool of pre-allocated data chunks to avoid dynamic allocation - static constexpr int MAX_DATA_CHUNKS = 32; + static constexpr int MAX_DATA_CHUNKS = 40; UsbDataChunk *data_chunk_pool_[MAX_DATA_CHUNKS]; + // IMPORTANT: This is used bidirectionally (USB task pops, main loop pushes) + // which technically violates SPSC, but works in practice because operations are atomic LockFreeQueue free_chunks_; }; From 0ed6ba9afaf226cd321e2e93337f5014979301bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 21:37:07 -0500 Subject: [PATCH 1350/1626] wip --- esphome/components/usb_uart/usb_uart.cpp | 8 ++++---- esphome/components/usb_uart/usb_uart.h | 26 +++++++++++------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 4b2464fd59..a872b37fa2 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -193,7 +193,7 @@ void USBUartComponent::loop() { } // Return chunk to pool for reuse - this->free_chunks_.push(chunk); + this->chunk_pool_.release(chunk); } static constexpr int LOG_CHUNK_THRESHOLD = 5; @@ -231,8 +231,8 @@ void USBUartComponent::start_input(USBUartChannel *channel) { } if (!channel->dummy_receiver_ && status.data_len > 0) { - // Get a free chunk from the pool - UsbDataChunk *chunk = this->free_chunks_.pop(); + // Allocate a chunk from the pool + UsbDataChunk *chunk = this->chunk_pool_.allocate(); if (chunk == nullptr) { ESP_LOGW(TAG, "No free chunks available, dropping %u bytes", status.data_len); // Mark input as not started so we can retry @@ -249,7 +249,7 @@ void USBUartComponent::start_input(USBUartChannel *channel) { if (!this->usb_data_queue_.push(chunk)) { ESP_LOGW(TAG, "USB data queue full, dropping %u bytes", status.data_len); // Return chunk to pool - this->free_chunks_.push(chunk); + this->chunk_pool_.release(chunk); } } diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 91a4329f22..1389ceeaee 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -6,6 +6,7 @@ #include "esphome/components/uart/uart_component.h" #include "esphome/components/usb_host/usb_host.h" #include "esphome/core/lock_free_queue.h" +#include "esphome/core/event_pool.h" namespace esphome { namespace usb_uart { @@ -76,6 +77,12 @@ struct UsbDataChunk { uint8_t data[MAX_CHUNK_SIZE]; size_t length; USBUartChannel *channel; + + // Required for EventPool - reset to clean state + void release() { + this->length = 0; + this->channel = nullptr; + } }; class USBUartChannel : public uart::UARTComponent, public Parented { @@ -114,13 +121,7 @@ class USBUartChannel : public uart::UARTComponent, public Parenteddata_chunk_pool_[i] = new UsbDataChunk(); - this->free_chunks_.push(this->data_chunk_pool_[i]); - } - } + USBUartComponent(uint16_t vid, uint16_t pid) : usb_host::USBClient(vid, pid) {} void setup() override; void loop() override; void dump_config() override; @@ -135,15 +136,12 @@ class USBUartComponent : public usb_host::USBClient { static constexpr int USB_DATA_QUEUE_SIZE = 32; LockFreeQueue usb_data_queue_; + // Pool for allocating data chunks (uses EventPool pattern like BLE) + static constexpr int MAX_DATA_CHUNKS = 40; + EventPool chunk_pool_; + protected: std::vector channels_{}; - - // Pool of pre-allocated data chunks to avoid dynamic allocation - static constexpr int MAX_DATA_CHUNKS = 40; - UsbDataChunk *data_chunk_pool_[MAX_DATA_CHUNKS]; - // IMPORTANT: This is used bidirectionally (USB task pops, main loop pushes) - // which technically violates SPSC, but works in practice because operations are atomic - LockFreeQueue free_chunks_; }; class USBUartTypeCdcAcm : public USBUartComponent { From 0370a3061df5da7fe24dfe1f1513c28ee98f0e5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 21:41:48 -0500 Subject: [PATCH 1351/1626] fix --- esphome/components/usb_uart/usb_uart.cpp | 7 ++----- esphome/components/usb_uart/usb_uart.h | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index a872b37fa2..b485397214 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -246,11 +246,8 @@ void USBUartComponent::start_input(USBUartChannel *channel) { chunk->channel = channel; // Push to lock-free queue for main loop processing - if (!this->usb_data_queue_.push(chunk)) { - ESP_LOGW(TAG, "USB data queue full, dropping %u bytes", status.data_len); - // Return chunk to pool - this->chunk_pool_.release(chunk); - } + // Push always succeeds because pool size == queue size + this->usb_data_queue_.push(chunk); } // Always restart input immediately from USB task diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 1389ceeaee..aab36c52b5 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -137,8 +137,8 @@ class USBUartComponent : public usb_host::USBClient { LockFreeQueue usb_data_queue_; // Pool for allocating data chunks (uses EventPool pattern like BLE) - static constexpr int MAX_DATA_CHUNKS = 40; - EventPool chunk_pool_; + // MUST be same size as queue to guarantee push always succeeds after allocate + EventPool chunk_pool_; protected: std::vector channels_{}; From 70e89f79dbb0c647dc3b1d264c5a7c35eb05bb05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 21:54:33 -0500 Subject: [PATCH 1352/1626] fix --- esphome/components/usb_host/usb_host_client.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 98a1f6178b..f9ecc89efa 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -191,7 +191,7 @@ void USBClient::setup() { // Create and start USB task xTaskCreatePinnedToCore(usb_task_fn, "usb_task", - 2048, // Stack size (minimal - just handles USB events) + 4096, // Stack size (same as ESP-IDF USB examples) this, // Task parameter 5, // Priority (higher than main loop) &this->usb_task_handle_, From c08c0c111a3cc7ba5ddf456a25708a1dee205378 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 21:56:22 -0500 Subject: [PATCH 1353/1626] fix --- esphome/components/usb_host/usb_host.h | 1 - esphome/components/usb_host/usb_host_client.cpp | 3 --- 2 files changed, 4 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 3625100e4a..99b8437421 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -75,7 +75,6 @@ struct UsbEvent { } device_gone; struct { TransferRequest *trq; - bool callback_executed; // Flag to indicate callback was already executed in USB task } transfer; } data; }; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index f9ecc89efa..d294520568 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -212,9 +212,7 @@ void USBClient::usb_task_fn(void *arg) { void USBClient::usb_task_loop() { ESP_LOGI(TAG, "USB task started on core %d", xPortGetCoreID()); - // Run forever - ESPHome reboots rather than shutting down cleanly while (true) { - // Handle USB events with a timeout to prevent blocking forever usb_host_client_handle_events(this->handle_, pdMS_TO_TICKS(10)); } } @@ -327,7 +325,6 @@ static void control_callback(const usb_transfer_t *xfer) { UsbEvent event; event.type = EVENT_CONTROL_COMPLETE; event.data.transfer.trq = trq; - event.data.transfer.callback_executed = true; xQueueSend(trq->client->get_event_queue(), &event, portMAX_DELAY); } From d5ad9dc0fb41beed00580ae6c02d0510d2bb8f9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 21:58:01 -0500 Subject: [PATCH 1354/1626] fix --- .../components/usb_host/usb_host_client.cpp | 19 ++++++++++--------- esphome/components/usb_uart/usb_uart.cpp | 5 ++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index d294520568..1abc260bbf 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -307,6 +307,14 @@ void USBClient::on_removed(usb_device_handle_t handle) { } } +// Helper to queue transfer cleanup to main loop +static void queue_transfer_cleanup(TransferRequest *trq, EventType type) { + UsbEvent event; + event.type = type; + event.data.transfer.trq = trq; + xQueueSend(trq->client->get_event_queue(), &event, portMAX_DELAY); +} + // CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) static void control_callback(const usb_transfer_t *xfer) { auto *trq = static_cast(xfer->context); @@ -322,10 +330,7 @@ static void control_callback(const usb_transfer_t *xfer) { } // Queue cleanup to main loop - UsbEvent event; - event.type = EVENT_CONTROL_COMPLETE; - event.data.transfer.trq = trq; - xQueueSend(trq->client->get_event_queue(), &event, portMAX_DELAY); + queue_transfer_cleanup(trq, EVENT_CONTROL_COMPLETE); } TransferRequest *USBClient::get_trq_() { @@ -402,11 +407,7 @@ static void transfer_callback(usb_transfer_t *xfer) { } // Queue cleanup to main loop - UsbEvent event; - event.type = EVENT_TRANSFER_COMPLETE; - event.data.transfer.trq = trq; - event.data.transfer.callback_executed = true; - xQueueSend(trq->client->get_event_queue(), &event, portMAX_DELAY); + queue_transfer_cleanup(trq, EVENT_TRANSFER_COMPLETE); } /** * Performs a transfer input operation. diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index b485397214..5aa9f65f12 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -266,12 +266,11 @@ void USBUartComponent::start_output(USBUartChannel *channel) { return; } const auto *ep = channel->cdc_dev_.out_ep; - // CALLBACK CONTEXT: This lambda is stored in TransferRequest and will be executed - // in MAIN LOOP after being queued by transfer_callback in USB task + // CALLBACK CONTEXT: This lambda is executed in USB task via transfer_callback auto callback = [this, channel](const usb_host::TransferStatus &status) { ESP_LOGV(TAG, "Output Transfer result: length: %u; status %X", status.data_len, status.error_code); channel->output_started_ = false; - // DEFERRED CONTEXT: Main loop (restart output in main loop) + // Defer restart to main loop (defer is thread-safe) this->defer([this, channel] { this->start_output(channel); }); }; channel->output_started_ = true; From fb9334e5bab4907e7f0b3e44d3d2db9ebd27e82b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 21:59:38 -0500 Subject: [PATCH 1355/1626] fix --- esphome/components/usb_uart/usb_uart.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 5aa9f65f12..35ebdf7b96 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -188,9 +188,7 @@ void USBUartComponent::loop() { #endif // Push data to ring buffer (now safe in main loop) - for (size_t i = 0; i < chunk->length; i++) { - channel->input_buffer_.push(chunk->data[i]); - } + channel->input_buffer_.push(chunk->data, chunk->length); // Return chunk to pool for reuse this->chunk_pool_.release(chunk); From 02b144c2e50090f81eac61bc33d6c08ae03fc3d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:02:03 -0500 Subject: [PATCH 1356/1626] fix --- esphome/components/usb_host/usb_host.h | 2 +- esphome/components/usb_uart/usb_uart.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 99b8437421..f1600aa0cf 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -57,7 +57,7 @@ struct TransferRequest { }; // Lightweight event types for queue -enum EventType { +enum EventType : uint8_t { EVENT_DEVICE_NEW, EVENT_DEVICE_GONE, EVENT_TRANSFER_COMPLETE, diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index aab36c52b5..0ac710e4c5 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -75,7 +75,7 @@ class RingBuffer { struct UsbDataChunk { static constexpr size_t MAX_CHUNK_SIZE = 64; // USB packet size uint8_t data[MAX_CHUNK_SIZE]; - size_t length; + uint8_t length; // Max 64 bytes, so uint8_t is sufficient USBUartChannel *channel; // Required for EventPool - reset to clean state From 4d64a05334632c473a3c821fd2d54ee62eb80eb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:12:59 -0500 Subject: [PATCH 1357/1626] ato --- esphome/components/usb_uart/ch34x.cpp | 6 ++--- esphome/components/usb_uart/cp210x.cpp | 4 +-- esphome/components/usb_uart/usb_uart.cpp | 32 ++++++++++++------------ esphome/components/usb_uart/usb_uart.h | 7 +++--- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 74e7933824..601bfe7366 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -16,12 +16,12 @@ using namespace bytebuffer; void USBUartTypeCH34X::enable_channels() { // enable the channels for (auto channel : this->channels_) { - if (!channel->initialised_) + if (!channel->initialised_.load()) continue; usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_ = false; + channel->initialised_.store(false); } }; @@ -48,7 +48,7 @@ void USBUartTypeCH34X::enable_channels() { auto factor = static_cast(clk / baud_rate); if (factor == 0 || factor == 0xFF) { ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate); - channel->initialised_ = false; + channel->initialised_.store(false); continue; } if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1))) diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index f7d60c307a..35834c7529 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -100,12 +100,12 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev void USBUartTypeCP210X::enable_channels() { // enable the channels for (auto channel : this->channels_) { - if (!channel->initialised_) + if (!channel->initialised_.load()) continue; usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_ = false; + channel->initialised_.store(false); } }; this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_, callback); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 35ebdf7b96..6d3c888bd9 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -130,7 +130,7 @@ size_t RingBuffer::pop(uint8_t *data, size_t len) { return len; } void USBUartChannel::write_array(const uint8_t *data, size_t len) { - if (!this->initialised_) { + if (!this->initialised_.load()) { ESP_LOGV(TAG, "Channel not initialised - write ignored"); return; } @@ -152,7 +152,7 @@ bool USBUartChannel::peek_byte(uint8_t *data) { return true; } bool USBUartChannel::read_array(uint8_t *data, size_t len) { - if (!this->initialised_) { + if (!this->initialised_.load()) { ESP_LOGV(TAG, "Channel not initialised - read ignored"); return false; } @@ -215,7 +215,7 @@ void USBUartComponent::dump_config() { } } void USBUartComponent::start_input(USBUartChannel *channel) { - if (!channel->initialised_ || channel->input_started_) + if (!channel->initialised_.load() || channel->input_started_.load()) return; // Note: We no longer check ring buffer space here since this may be called from USB task // The lock-free queue provides backpressure instead @@ -234,7 +234,7 @@ void USBUartComponent::start_input(USBUartChannel *channel) { if (chunk == nullptr) { ESP_LOGW(TAG, "No free chunks available, dropping %u bytes", status.data_len); // Mark input as not started so we can retry - channel->input_started_ = false; + channel->input_started_.store(false); return; } @@ -250,15 +250,15 @@ void USBUartComponent::start_input(USBUartChannel *channel) { // Always restart input immediately from USB task // The lock-free queue will handle backpressure - channel->input_started_ = false; + channel->input_started_.store(false); this->start_input(channel); }; - channel->input_started_ = true; + channel->input_started_.store(true); this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize); } void USBUartComponent::start_output(USBUartChannel *channel) { - if (channel->output_started_) + if (channel->output_started_.load()) return; if (channel->output_buffer_.is_empty()) { return; @@ -267,11 +267,11 @@ void USBUartComponent::start_output(USBUartChannel *channel) { // CALLBACK CONTEXT: This lambda is executed in USB task via transfer_callback auto callback = [this, channel](const usb_host::TransferStatus &status) { ESP_LOGV(TAG, "Output Transfer result: length: %u; status %X", status.data_len, status.error_code); - channel->output_started_ = false; + channel->output_started_.store(false); // Defer restart to main loop (defer is thread-safe) this->defer([this, channel] { this->start_output(channel); }); }; - channel->output_started_ = true; + channel->output_started_.store(true); uint8_t data[ep->wMaxPacketSize]; auto len = channel->output_buffer_.pop(data, ep->wMaxPacketSize); this->transfer_out(ep->bEndpointAddress, callback, data, len); @@ -314,7 +314,7 @@ void USBUartTypeCdcAcm::on_connected() { channel->cdc_dev_ = cdc_devs[i++]; fix_mps(channel->cdc_dev_.in_ep); fix_mps(channel->cdc_dev_.out_ep); - channel->initialised_ = true; + channel->initialised_.store(true); auto err = usb_host_interface_claim(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number, 0); if (err != ESP_OK) { @@ -343,9 +343,9 @@ void USBUartTypeCdcAcm::on_disconnected() { usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress); } usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number); - channel->initialised_ = false; - channel->input_started_ = false; - channel->output_started_ = false; + channel->initialised_.store(false); + channel->input_started_.store(false); + channel->output_started_.store(false); channel->input_buffer_.clear(); channel->output_buffer_.clear(); } @@ -354,10 +354,10 @@ void USBUartTypeCdcAcm::on_disconnected() { void USBUartTypeCdcAcm::enable_channels() { for (auto *channel : this->channels_) { - if (!channel->initialised_) + if (!channel->initialised_.load()) continue; - channel->input_started_ = false; - channel->output_started_ = false; + channel->input_started_.store(false); + channel->output_started_.store(false); this->start_input(channel); } } diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 0ac710e4c5..ccf998f84e 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -7,6 +7,7 @@ #include "esphome/components/usb_host/usb_host.h" #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" +#include namespace esphome { namespace usb_uart { @@ -111,12 +112,12 @@ class USBUartChannel : public uart::UARTComponent, public Parented input_started_{true}; + std::atomic output_started_{true}; CdcEps cdc_dev_{}; bool debug_{}; bool dummy_receiver_{}; - bool initialised_{}; + std::atomic initialised_{false}; }; class USBUartComponent : public usb_host::USBClient { From 0b5964053e28e033ad01b734d8ac3987d888d8d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:13:42 -0500 Subject: [PATCH 1358/1626] ato --- esphome/components/usb_uart/usb_uart.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 6d3c888bd9..04e1abbc50 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -198,6 +198,12 @@ void USBUartComponent::loop() { if (chunks_processed > LOG_CHUNK_THRESHOLD) { ESP_LOGV(TAG, "Processed %d chunks from USB queue", chunks_processed); } + + // Log dropped USB data periodically + uint16_t dropped = this->usb_data_queue_.get_and_reset_dropped_count(); + if (dropped > 0) { + ESP_LOGW(TAG, "Dropped %u USB data chunks due to buffer overflow", dropped); + } } void USBUartComponent::dump_config() { USBClient::dump_config(); @@ -232,7 +238,8 @@ void USBUartComponent::start_input(USBUartChannel *channel) { // Allocate a chunk from the pool UsbDataChunk *chunk = this->chunk_pool_.allocate(); if (chunk == nullptr) { - ESP_LOGW(TAG, "No free chunks available, dropping %u bytes", status.data_len); + // No chunks available - queue is full or we're out of memory + this->usb_data_queue_.increment_dropped_count(); // Mark input as not started so we can retry channel->input_started_.store(false); return; From fdb2e0b247364f82d0e97118ba6a2a97d44eb4e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:15:36 -0500 Subject: [PATCH 1359/1626] ato --- esphome/components/usb_host/usb_host.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index f1600aa0cf..183ee73a14 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -32,7 +32,9 @@ static const uint8_t USB_DIR_IN = 1 << 7; static const uint8_t USB_DIR_OUT = 0; static const size_t SETUP_PACKET_SIZE = 8; -static const size_t MAX_REQUESTS = 16; // maximum number of outstanding requests possible. +static const size_t MAX_REQUESTS = 16; // maximum number of outstanding requests possible. +static constexpr size_t USB_EVENT_QUEUE_SIZE = 32; // Size of event queue between USB task and main loop +static constexpr size_t USB_TASK_STACK_SIZE = 4096; // Stack size for USB task (same as ESP-IDF USB examples) // used to report a transfer status struct TransferStatus { From 971931b87784eea3ecd1f359052d795066e84fd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:15:53 -0500 Subject: [PATCH 1360/1626] ato --- esphome/components/usb_host/usb_host_client.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 1abc260bbf..7a3a53cb75 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -182,7 +182,7 @@ void USBClient::setup() { } // Create event queue for communication between USB task and main loop - this->event_queue_ = xQueueCreate(32, sizeof(UsbEvent)); + this->event_queue_ = xQueueCreate(USB_EVENT_QUEUE_SIZE, sizeof(UsbEvent)); if (this->event_queue_ == nullptr) { ESP_LOGE(TAG, "Failed to create event queue"); this->mark_failed(); @@ -191,9 +191,9 @@ void USBClient::setup() { // Create and start USB task xTaskCreatePinnedToCore(usb_task_fn, "usb_task", - 4096, // Stack size (same as ESP-IDF USB examples) - this, // Task parameter - 5, // Priority (higher than main loop) + USB_TASK_STACK_SIZE, // Stack size + this, // Task parameter + 5, // Priority (higher than main loop) &this->usb_task_handle_, 1 // Core 1 ); From efc0d86aa6ffe346cd59aef85720e6e420425890 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:18:43 -0500 Subject: [PATCH 1361/1626] ato --- esphome/components/usb_uart/usb_uart.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 04e1abbc50..5ad86037ed 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -223,8 +223,9 @@ void USBUartComponent::dump_config() { void USBUartComponent::start_input(USBUartChannel *channel) { if (!channel->initialised_.load() || channel->input_started_.load()) return; - // Note: We no longer check ring buffer space here since this may be called from USB task - // The lock-free queue provides backpressure instead + // Note: This function is called from both USB task and main loop, so we cannot + // directly check ring buffer space here. Backpressure is handled by the chunk pool: + // when exhausted, USB input stops until chunks are freed by the main loop const auto *ep = channel->cdc_dev_.in_ep; // CALLBACK CONTEXT: This lambda is executed in USB task via transfer_callback auto callback = [this, channel](const usb_host::TransferStatus &status) { From 6ba720d126672937f9c8c4604f073d83a3459d42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:21:44 -0500 Subject: [PATCH 1362/1626] ato --- esphome/components/usb_uart/usb_uart.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 5ad86037ed..c6a0e56adc 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -266,6 +266,9 @@ void USBUartComponent::start_input(USBUartChannel *channel) { } void USBUartComponent::start_output(USBUartChannel *channel) { + // IMPORTANT: This function must only be called from the main loop! + // The output_buffer_ is not thread-safe and can only be accessed from main loop. + // USB callbacks use defer() to ensure this function runs in the correct context. if (channel->output_started_.load()) return; if (channel->output_buffer_.is_empty()) { From 7388a2c9a312ef2b21ae414fbc6d5c4e42ea71ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:22:44 -0500 Subject: [PATCH 1363/1626] ato --- esphome/components/usb_uart/usb_uart.h | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index ccf998f84e..21e2d34439 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -108,16 +108,20 @@ class USBUartChannel : public uart::UARTComponent, public Parenteddummy_receiver_ = dummy_receiver; } protected: - const uint8_t index_; + // Larger structures first for better alignment RingBuffer input_buffer_; RingBuffer output_buffer_; + CdcEps cdc_dev_{}; + // Enum (likely 4 bytes) UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; + // Group atomics together (each 1 byte) std::atomic input_started_{true}; std::atomic output_started_{true}; - CdcEps cdc_dev_{}; + std::atomic initialised_{false}; + // Group regular bytes together to minimize padding + const uint8_t index_; bool debug_{}; bool dummy_receiver_{}; - std::atomic initialised_{false}; }; class USBUartComponent : public usb_host::USBClient { From 9f2f33fc89918586045c5a457dcf39bbed602b89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:36:48 -0500 Subject: [PATCH 1364/1626] lock free --- esphome/components/usb_host/usb_host.h | 14 ++-- .../components/usb_host/usb_host_client.cpp | 72 ++++++++++++------- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 183ee73a14..525457fd99 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -7,8 +7,8 @@ #include "usb/usb_host.h" #include #include -#include -#include +#include "esphome/core/lock_free_queue.h" +#include "esphome/core/event_pool.h" #include namespace esphome { @@ -79,6 +79,9 @@ struct UsbEvent { TransferRequest *trq; } transfer; } data; + + // Required for EventPool - no cleanup needed for POD types + void release() {} }; // callback function type. @@ -115,7 +118,11 @@ class USBClient : public Component { void release_trq(TransferRequest *trq); bool control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback, const std::vector &data = {}); - QueueHandle_t get_event_queue() { return event_queue_; } + + // Lock-free event queue and pool for USB task to main loop communication + // Must be public for access from static callbacks + LockFreeQueue event_queue; + EventPool event_pool; protected: bool register_(); @@ -129,7 +136,6 @@ class USBClient : public Component { void usb_task_loop(); TaskHandle_t usb_task_handle_{nullptr}; - QueueHandle_t event_queue_{nullptr}; // Queue of UsbEvent structs usb_host_client_handle_t handle_{}; usb_device_handle_t device_handle_{}; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 7a3a53cb75..77599cb263 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -142,28 +142,37 @@ static std::string get_descriptor_string(const usb_str_desc_t *desc) { // CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void *ptr) { auto *client = static_cast(ptr); - UsbEvent event; + + // Allocate event from pool + UsbEvent *event = client->event_pool.allocate(); + if (event == nullptr) { + // No events available - increment counter for periodic logging + client->event_queue.increment_dropped_count(); + return; + } // Queue events to be processed in main loop switch (event_msg->event) { case USB_HOST_CLIENT_EVENT_NEW_DEV: { ESP_LOGD(TAG, "New device %d", event_msg->new_dev.address); - event.type = EVENT_DEVICE_NEW; - event.data.device_new.address = event_msg->new_dev.address; - xQueueSend(client->get_event_queue(), &event, portMAX_DELAY); + event->type = EVENT_DEVICE_NEW; + event->data.device_new.address = event_msg->new_dev.address; break; } case USB_HOST_CLIENT_EVENT_DEV_GONE: { ESP_LOGD(TAG, "Device gone"); - event.type = EVENT_DEVICE_GONE; - event.data.device_gone.handle = event_msg->dev_gone.dev_hdl; - xQueueSend(client->get_event_queue(), &event, portMAX_DELAY); + event->type = EVENT_DEVICE_GONE; + event->data.device_gone.handle = event_msg->dev_gone.dev_hdl; break; } default: ESP_LOGD(TAG, "Unknown event %d", event_msg->event); - break; + client->event_pool.release(event); + return; } + + // Push to lock-free queue (always succeeds since pool size == queue size) + client->event_queue.push(event); } void USBClient::setup() { usb_host_client_config_t config{.is_synchronous = false, @@ -181,14 +190,6 @@ void USBClient::setup() { trq->client = this; } - // Create event queue for communication between USB task and main loop - this->event_queue_ = xQueueCreate(USB_EVENT_QUEUE_SIZE, sizeof(UsbEvent)); - if (this->event_queue_ == nullptr) { - ESP_LOGE(TAG, "Failed to create event queue"); - this->mark_failed(); - return; - } - // Create and start USB task xTaskCreatePinnedToCore(usb_task_fn, "usb_task", USB_TASK_STACK_SIZE, // Stack size @@ -219,23 +220,31 @@ void USBClient::usb_task_loop() { void USBClient::loop() { // Process any events from the USB task - UsbEvent event; - while (xQueueReceive(this->event_queue_, &event, 0) == pdTRUE) { - switch (event.type) { + UsbEvent *event; + while ((event = this->event_queue.pop()) != nullptr) { + switch (event->type) { case EVENT_DEVICE_NEW: - this->on_opened(event.data.device_new.address); + this->on_opened(event->data.device_new.address); break; case EVENT_DEVICE_GONE: - this->on_removed(event.data.device_gone.handle); + this->on_removed(event->data.device_gone.handle); break; case EVENT_TRANSFER_COMPLETE: case EVENT_CONTROL_COMPLETE: { - auto *trq = event.data.transfer.trq; + auto *trq = event->data.transfer.trq; // Callback was already executed in USB task, just cleanup this->release_trq(trq); break; } } + // Return event to pool for reuse + this->event_pool.release(event); + } + + // Log dropped events periodically + uint16_t dropped = this->event_queue.get_and_reset_dropped_count(); + if (dropped > 0) { + ESP_LOGW(TAG, "Dropped %u USB events due to queue overflow", dropped); } switch (this->state_) { @@ -309,10 +318,21 @@ void USBClient::on_removed(usb_device_handle_t handle) { // Helper to queue transfer cleanup to main loop static void queue_transfer_cleanup(TransferRequest *trq, EventType type) { - UsbEvent event; - event.type = type; - event.data.transfer.trq = trq; - xQueueSend(trq->client->get_event_queue(), &event, portMAX_DELAY); + auto *client = trq->client; + + // Allocate event from pool + UsbEvent *event = client->event_pool.allocate(); + if (event == nullptr) { + // No events available - increment counter for periodic logging + client->event_queue.increment_dropped_count(); + return; + } + + event->type = type; + event->data.transfer.trq = trq; + + // Push to lock-free queue (always succeeds since pool size == queue size) + client->event_queue.push(event); } // CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) From 5f17a95f2ebfab922744e1086a4d73a02df3f4b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:37:02 -0500 Subject: [PATCH 1365/1626] lock free --- esphome/components/usb_uart/usb_uart.h | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 21e2d34439..1c4fb34fed 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -79,11 +79,8 @@ struct UsbDataChunk { uint8_t length; // Max 64 bytes, so uint8_t is sufficient USBUartChannel *channel; - // Required for EventPool - reset to clean state - void release() { - this->length = 0; - this->channel = nullptr; - } + // Required for EventPool - no cleanup needed for POD types + void release() {} }; class USBUartChannel : public uart::UARTComponent, public Parented { From 7fbc7e3c37633b94121cd87931534371ecfe1eca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:41:43 -0500 Subject: [PATCH 1366/1626] lock free --- esphome/components/usb_host/usb_host_client.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 77599cb263..caad490124 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -299,7 +299,6 @@ void USBClient::loop() { } default: - // USB events are now handled in the dedicated task break; } } From 1e2785e38721094ff4686048210e6c90e31b7249 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:45:16 -0500 Subject: [PATCH 1367/1626] Update esphome/components/usb_host/usb_host_client.cpp --- esphome/components/usb_host/usb_host_client.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index caad490124..1abf386b54 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -447,7 +447,6 @@ void USBClient::transfer_in(uint8_t ep_address, const transfer_cb_t &callback, u trq->transfer->callback = transfer_callback; trq->transfer->bEndpointAddress = ep_address | USB_DIR_IN; trq->transfer->num_bytes = length; - auto err = usb_host_transfer_submit(trq->transfer); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to submit transfer, address=%x, length=%d, err=%x", ep_address, length, err); From 6403c6ee64fea4a0392228eb4f57458e41bf4f86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:45:30 -0500 Subject: [PATCH 1368/1626] preen --- esphome/components/usb_host/usb_host.h | 1 - esphome/components/usb_host/usb_host_client.cpp | 3 --- 2 files changed, 4 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 525457fd99..afd76595db 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -58,7 +58,6 @@ struct TransferRequest { USBClient *client; }; -// Lightweight event types for queue enum EventType : uint8_t { EVENT_DEVICE_NEW, EVENT_DEVICE_GONE, diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index caad490124..177a90eb52 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -211,8 +211,6 @@ void USBClient::usb_task_fn(void *arg) { } void USBClient::usb_task_loop() { - ESP_LOGI(TAG, "USB task started on core %d", xPortGetCoreID()); - while (true) { usb_host_client_handle_events(this->handle_, pdMS_TO_TICKS(10)); } @@ -232,7 +230,6 @@ void USBClient::loop() { case EVENT_TRANSFER_COMPLETE: case EVENT_CONTROL_COMPLETE: { auto *trq = event->data.transfer.trq; - // Callback was already executed in USB task, just cleanup this->release_trq(trq); break; } From b9a5c57b77bda9c37b9982330b0c017facaa16cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:47:17 -0500 Subject: [PATCH 1369/1626] preen --- esphome/components/usb_uart/usb_uart.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 1c4fb34fed..b41e0a52e9 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -137,9 +137,6 @@ class USBUartComponent : public usb_host::USBClient { // Lock-free data transfer from USB task to main loop static constexpr int USB_DATA_QUEUE_SIZE = 32; LockFreeQueue usb_data_queue_; - - // Pool for allocating data chunks (uses EventPool pattern like BLE) - // MUST be same size as queue to guarantee push always succeeds after allocate EventPool chunk_pool_; protected: From af031530ce03148c891f854790b67771d5d1de8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 22:48:52 -0500 Subject: [PATCH 1370/1626] remove debug --- esphome/components/usb_uart/usb_uart.cpp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index c6a0e56adc..aea8234b55 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -175,9 +175,7 @@ void USBUartComponent::loop() { // Process USB data from the lock-free queue UsbDataChunk *chunk; - int chunks_processed = 0; while ((chunk = this->usb_data_queue_.pop()) != nullptr) { - chunks_processed++; auto *channel = chunk->channel; #ifdef USE_UART_DEBUGGER @@ -194,11 +192,6 @@ void USBUartComponent::loop() { this->chunk_pool_.release(chunk); } - static constexpr int LOG_CHUNK_THRESHOLD = 5; - if (chunks_processed > LOG_CHUNK_THRESHOLD) { - ESP_LOGV(TAG, "Processed %d chunks from USB queue", chunks_processed); - } - // Log dropped USB data periodically uint16_t dropped = this->usb_data_queue_.get_and_reset_dropped_count(); if (dropped > 0) { From dca79872bfe32e9511e75982ee60a2eae991d525 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 23:03:25 -0500 Subject: [PATCH 1371/1626] simplify --- esphome/components/usb_host/usb_host.h | 1 + esphome/components/usb_host/usb_host_client.cpp | 12 +++++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index afd76595db..d2ff4da068 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -35,6 +35,7 @@ static const size_t SETUP_PACKET_SIZE = 8; static const size_t MAX_REQUESTS = 16; // maximum number of outstanding requests possible. static constexpr size_t USB_EVENT_QUEUE_SIZE = 32; // Size of event queue between USB task and main loop static constexpr size_t USB_TASK_STACK_SIZE = 4096; // Stack size for USB task (same as ESP-IDF USB examples) +static constexpr UBaseType_t USB_TASK_PRIORITY = 5; // Higher priority than main loop (tskIDLE_PRIORITY + 5) // used to report a transfer status struct TransferStatus { diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 450bd0a932..d8da440658 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -191,13 +191,11 @@ void USBClient::setup() { } // Create and start USB task - xTaskCreatePinnedToCore(usb_task_fn, "usb_task", - USB_TASK_STACK_SIZE, // Stack size - this, // Task parameter - 5, // Priority (higher than main loop) - &this->usb_task_handle_, - 1 // Core 1 - ); + xTaskCreate(usb_task_fn, "usb_task", + USB_TASK_STACK_SIZE, // Stack size + this, // Task parameter + USB_TASK_PRIORITY, // Priority (higher than main loop) + &this->usb_task_handle_); if (this->usb_task_handle_ == nullptr) { ESP_LOGE(TAG, "Failed to create USB task"); From 07e5ce78ebda7826e7a13e1fb6f88a899b22e2e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 23:04:20 -0500 Subject: [PATCH 1372/1626] simplify --- esphome/components/usb_host/usb_host_client.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index d8da440658..5c9d56c7f9 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -210,7 +210,7 @@ void USBClient::usb_task_fn(void *arg) { void USBClient::usb_task_loop() { while (true) { - usb_host_client_handle_events(this->handle_, pdMS_TO_TICKS(10)); + usb_host_client_handle_events(this->handle_, portMAX_DELAY); } } From 90921348e9e1c157ba7d836f6ac6c14545210086 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Sep 2025 23:08:17 -0500 Subject: [PATCH 1373/1626] cleanup --- esphome/components/usb_uart/usb_uart.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index aea8234b55..8603e28d62 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -225,6 +225,8 @@ void USBUartComponent::start_input(USBUartChannel *channel) { ESP_LOGV(TAG, "Transfer result: length: %u; status %X", status.data_len, status.error_code); if (!status.success) { ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); + // On failure, don't restart - let next read_array() trigger it + channel->input_started_.store(false); return; } @@ -249,7 +251,7 @@ void USBUartComponent::start_input(USBUartChannel *channel) { this->usb_data_queue_.push(chunk); } - // Always restart input immediately from USB task + // On success, restart input immediately from USB task for performance // The lock-free queue will handle backpressure channel->input_started_.store(false); this->start_input(channel); From 11ccf0e591465a69c74b488d7c3962295c51b669 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 08:58:42 -0500 Subject: [PATCH 1374/1626] [usb_host] Prevent USB data corruption from missed events (#10859) --- esphome/components/usb_host/usb_host.h | 50 +++++++- .../components/usb_host/usb_host_client.cpp | 117 ++++++++++++++++-- esphome/components/usb_uart/ch34x.cpp | 6 +- esphome/components/usb_uart/cp210x.cpp | 4 +- esphome/components/usb_uart/usb_uart.cpp | 106 +++++++++++----- esphome/components/usb_uart/usb_uart.h | 34 ++++- 6 files changed, 266 insertions(+), 51 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index c5466eb1f0..d2ff4da068 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -5,7 +5,10 @@ #include "esphome/core/component.h" #include #include "usb/usb_host.h" - +#include +#include +#include "esphome/core/lock_free_queue.h" +#include "esphome/core/event_pool.h" #include namespace esphome { @@ -13,6 +16,10 @@ namespace usb_host { static const char *const TAG = "usb_host"; +// Forward declarations +struct TransferRequest; +class USBClient; + // constants for setup packet type static const uint8_t USB_RECIP_DEVICE = 0; static const uint8_t USB_RECIP_INTERFACE = 1; @@ -25,7 +32,10 @@ static const uint8_t USB_DIR_IN = 1 << 7; static const uint8_t USB_DIR_OUT = 0; static const size_t SETUP_PACKET_SIZE = 8; -static const size_t MAX_REQUESTS = 16; // maximum number of outstanding requests possible. +static const size_t MAX_REQUESTS = 16; // maximum number of outstanding requests possible. +static constexpr size_t USB_EVENT_QUEUE_SIZE = 32; // Size of event queue between USB task and main loop +static constexpr size_t USB_TASK_STACK_SIZE = 4096; // Stack size for USB task (same as ESP-IDF USB examples) +static constexpr UBaseType_t USB_TASK_PRIORITY = 5; // Higher priority than main loop (tskIDLE_PRIORITY + 5) // used to report a transfer status struct TransferStatus { @@ -49,6 +59,31 @@ struct TransferRequest { USBClient *client; }; +enum EventType : uint8_t { + EVENT_DEVICE_NEW, + EVENT_DEVICE_GONE, + EVENT_TRANSFER_COMPLETE, + EVENT_CONTROL_COMPLETE, +}; + +struct UsbEvent { + EventType type; + union { + struct { + uint8_t address; + } device_new; + struct { + usb_device_handle_t handle; + } device_gone; + struct { + TransferRequest *trq; + } transfer; + } data; + + // Required for EventPool - no cleanup needed for POD types + void release() {} +}; + // callback function type. enum ClientState { @@ -84,6 +119,11 @@ class USBClient : public Component { bool control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback, const std::vector &data = {}); + // Lock-free event queue and pool for USB task to main loop communication + // Must be public for access from static callbacks + LockFreeQueue event_queue; + EventPool event_pool; + protected: bool register_(); TransferRequest *get_trq_(); @@ -91,6 +131,12 @@ class USBClient : public Component { virtual void on_connected() {} virtual void on_disconnected() { this->init_pool(); } + // USB task management + static void usb_task_fn(void *arg); + void usb_task_loop(); + + TaskHandle_t usb_task_handle_{nullptr}; + usb_host_client_handle_t handle_{}; usb_device_handle_t device_handle_{}; int device_addr_{-1}; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 4c0c12fa18..5c9d56c7f9 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -139,24 +139,40 @@ static std::string get_descriptor_string(const usb_str_desc_t *desc) { return {buffer}; } +// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void *ptr) { auto *client = static_cast(ptr); + + // Allocate event from pool + UsbEvent *event = client->event_pool.allocate(); + if (event == nullptr) { + // No events available - increment counter for periodic logging + client->event_queue.increment_dropped_count(); + return; + } + + // Queue events to be processed in main loop switch (event_msg->event) { case USB_HOST_CLIENT_EVENT_NEW_DEV: { - auto addr = event_msg->new_dev.address; ESP_LOGD(TAG, "New device %d", event_msg->new_dev.address); - client->on_opened(addr); + event->type = EVENT_DEVICE_NEW; + event->data.device_new.address = event_msg->new_dev.address; break; } case USB_HOST_CLIENT_EVENT_DEV_GONE: { - client->on_removed(event_msg->dev_gone.dev_hdl); - ESP_LOGD(TAG, "Device gone %d", event_msg->new_dev.address); + ESP_LOGD(TAG, "Device gone"); + event->type = EVENT_DEVICE_GONE; + event->data.device_gone.handle = event_msg->dev_gone.dev_hdl; break; } default: ESP_LOGD(TAG, "Unknown event %d", event_msg->event); - break; + client->event_pool.release(event); + return; } + + // Push to lock-free queue (always succeeds since pool size == queue size) + client->event_queue.push(event); } void USBClient::setup() { usb_host_client_config_t config{.is_synchronous = false, @@ -173,9 +189,59 @@ void USBClient::setup() { usb_host_transfer_alloc(64, 0, &trq->transfer); trq->client = this; } + + // Create and start USB task + xTaskCreate(usb_task_fn, "usb_task", + USB_TASK_STACK_SIZE, // Stack size + this, // Task parameter + USB_TASK_PRIORITY, // Priority (higher than main loop) + &this->usb_task_handle_); + + if (this->usb_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create USB task"); + this->mark_failed(); + } +} + +void USBClient::usb_task_fn(void *arg) { + auto *client = static_cast(arg); + client->usb_task_loop(); +} + +void USBClient::usb_task_loop() { + while (true) { + usb_host_client_handle_events(this->handle_, portMAX_DELAY); + } } void USBClient::loop() { + // Process any events from the USB task + UsbEvent *event; + while ((event = this->event_queue.pop()) != nullptr) { + switch (event->type) { + case EVENT_DEVICE_NEW: + this->on_opened(event->data.device_new.address); + break; + case EVENT_DEVICE_GONE: + this->on_removed(event->data.device_gone.handle); + break; + case EVENT_TRANSFER_COMPLETE: + case EVENT_CONTROL_COMPLETE: { + auto *trq = event->data.transfer.trq; + this->release_trq(trq); + break; + } + } + // Return event to pool for reuse + this->event_pool.release(event); + } + + // Log dropped events periodically + uint16_t dropped = this->event_queue.get_and_reset_dropped_count(); + if (dropped > 0) { + ESP_LOGW(TAG, "Dropped %u USB events due to queue overflow", dropped); + } + switch (this->state_) { case USB_CLIENT_OPEN: { int err; @@ -228,7 +294,6 @@ void USBClient::loop() { } default: - usb_host_client_handle_events(this->handle_, 0); break; } } @@ -245,6 +310,26 @@ void USBClient::on_removed(usb_device_handle_t handle) { } } +// Helper to queue transfer cleanup to main loop +static void queue_transfer_cleanup(TransferRequest *trq, EventType type) { + auto *client = trq->client; + + // Allocate event from pool + UsbEvent *event = client->event_pool.allocate(); + if (event == nullptr) { + // No events available - increment counter for periodic logging + client->event_queue.increment_dropped_count(); + return; + } + + event->type = type; + event->data.transfer.trq = trq; + + // Push to lock-free queue (always succeeds since pool size == queue size) + client->event_queue.push(event); +} + +// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) static void control_callback(const usb_transfer_t *xfer) { auto *trq = static_cast(xfer->context); trq->status.error_code = xfer->status; @@ -252,9 +337,14 @@ static void control_callback(const usb_transfer_t *xfer) { trq->status.endpoint = xfer->bEndpointAddress; trq->status.data = xfer->data_buffer; trq->status.data_len = xfer->actual_num_bytes; - if (trq->callback != nullptr) + + // Execute callback in USB task context + if (trq->callback != nullptr) { trq->callback(trq->status); - trq->client->release_trq(trq); + } + + // Queue cleanup to main loop + queue_transfer_cleanup(trq, EVENT_CONTROL_COMPLETE); } TransferRequest *USBClient::get_trq_() { @@ -315,6 +405,7 @@ bool USBClient::control_transfer(uint8_t type, uint8_t request, uint16_t value, return true; } +// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) static void transfer_callback(usb_transfer_t *xfer) { auto *trq = static_cast(xfer->context); trq->status.error_code = xfer->status; @@ -322,9 +413,15 @@ static void transfer_callback(usb_transfer_t *xfer) { trq->status.endpoint = xfer->bEndpointAddress; trq->status.data = xfer->data_buffer; trq->status.data_len = xfer->actual_num_bytes; - if (trq->callback != nullptr) + + // Always execute callback in USB task context + // Callbacks should be fast and non-blocking (e.g., copy data to queue) + if (trq->callback != nullptr) { trq->callback(trq->status); - trq->client->release_trq(trq); + } + + // Queue cleanup to main loop + queue_transfer_cleanup(trq, EVENT_TRANSFER_COMPLETE); } /** * Performs a transfer input operation. diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 74e7933824..601bfe7366 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -16,12 +16,12 @@ using namespace bytebuffer; void USBUartTypeCH34X::enable_channels() { // enable the channels for (auto channel : this->channels_) { - if (!channel->initialised_) + if (!channel->initialised_.load()) continue; usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_ = false; + channel->initialised_.store(false); } }; @@ -48,7 +48,7 @@ void USBUartTypeCH34X::enable_channels() { auto factor = static_cast(clk / baud_rate); if (factor == 0 || factor == 0xFF) { ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate); - channel->initialised_ = false; + channel->initialised_.store(false); continue; } if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1))) diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index f7d60c307a..35834c7529 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -100,12 +100,12 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev void USBUartTypeCP210X::enable_channels() { // enable the channels for (auto channel : this->channels_) { - if (!channel->initialised_) + if (!channel->initialised_.load()) continue; usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { if (!status.success) { ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_ = false; + channel->initialised_.store(false); } }; this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_, callback); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index bf1c9086f1..8603e28d62 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -130,7 +130,7 @@ size_t RingBuffer::pop(uint8_t *data, size_t len) { return len; } void USBUartChannel::write_array(const uint8_t *data, size_t len) { - if (!this->initialised_) { + if (!this->initialised_.load()) { ESP_LOGV(TAG, "Channel not initialised - write ignored"); return; } @@ -152,7 +152,7 @@ bool USBUartChannel::peek_byte(uint8_t *data) { return true; } bool USBUartChannel::read_array(uint8_t *data, size_t len) { - if (!this->initialised_) { + if (!this->initialised_.load()) { ESP_LOGV(TAG, "Channel not initialised - read ignored"); return false; } @@ -170,7 +170,34 @@ bool USBUartChannel::read_array(uint8_t *data, size_t len) { return status; } void USBUartComponent::setup() { USBClient::setup(); } -void USBUartComponent::loop() { USBClient::loop(); } +void USBUartComponent::loop() { + USBClient::loop(); + + // Process USB data from the lock-free queue + UsbDataChunk *chunk; + while ((chunk = this->usb_data_queue_.pop()) != nullptr) { + auto *channel = chunk->channel; + +#ifdef USE_UART_DEBUGGER + if (channel->debug_) { + uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX, std::vector(chunk->data, chunk->data + chunk->length), + ','); // NOLINT() + } +#endif + + // Push data to ring buffer (now safe in main loop) + channel->input_buffer_.push(chunk->data, chunk->length); + + // Return chunk to pool for reuse + this->chunk_pool_.release(chunk); + } + + // Log dropped USB data periodically + uint16_t dropped = this->usb_data_queue_.get_and_reset_dropped_count(); + if (dropped > 0) { + ESP_LOGW(TAG, "Dropped %u USB data chunks due to buffer overflow", dropped); + } +} void USBUartComponent::dump_config() { USBClient::dump_config(); for (auto &channel : this->channels_) { @@ -187,49 +214,70 @@ void USBUartComponent::dump_config() { } } void USBUartComponent::start_input(USBUartChannel *channel) { - if (!channel->initialised_ || channel->input_started_ || - channel->input_buffer_.get_free_space() < channel->cdc_dev_.in_ep->wMaxPacketSize) + if (!channel->initialised_.load() || channel->input_started_.load()) return; + // Note: This function is called from both USB task and main loop, so we cannot + // directly check ring buffer space here. Backpressure is handled by the chunk pool: + // when exhausted, USB input stops until chunks are freed by the main loop const auto *ep = channel->cdc_dev_.in_ep; + // CALLBACK CONTEXT: This lambda is executed in USB task via transfer_callback auto callback = [this, channel](const usb_host::TransferStatus &status) { ESP_LOGV(TAG, "Transfer result: length: %u; status %X", status.data_len, status.error_code); if (!status.success) { ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); + // On failure, don't restart - let next read_array() trigger it + channel->input_started_.store(false); return; } -#ifdef USE_UART_DEBUGGER - if (channel->debug_) { - uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX, - std::vector(status.data, status.data + status.data_len), ','); // NOLINT() - } -#endif - channel->input_started_ = false; - if (!channel->dummy_receiver_) { - for (size_t i = 0; i != status.data_len; i++) { - channel->input_buffer_.push(status.data[i]); + + if (!channel->dummy_receiver_ && status.data_len > 0) { + // Allocate a chunk from the pool + UsbDataChunk *chunk = this->chunk_pool_.allocate(); + if (chunk == nullptr) { + // No chunks available - queue is full or we're out of memory + this->usb_data_queue_.increment_dropped_count(); + // Mark input as not started so we can retry + channel->input_started_.store(false); + return; } + + // Copy data to chunk (this is fast, happens in USB task) + memcpy(chunk->data, status.data, status.data_len); + chunk->length = status.data_len; + chunk->channel = channel; + + // Push to lock-free queue for main loop processing + // Push always succeeds because pool size == queue size + this->usb_data_queue_.push(chunk); } - if (channel->input_buffer_.get_free_space() >= channel->cdc_dev_.in_ep->wMaxPacketSize) { - this->defer([this, channel] { this->start_input(channel); }); - } + + // On success, restart input immediately from USB task for performance + // The lock-free queue will handle backpressure + channel->input_started_.store(false); + this->start_input(channel); }; - channel->input_started_ = true; + channel->input_started_.store(true); this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize); } void USBUartComponent::start_output(USBUartChannel *channel) { - if (channel->output_started_) + // IMPORTANT: This function must only be called from the main loop! + // The output_buffer_ is not thread-safe and can only be accessed from main loop. + // USB callbacks use defer() to ensure this function runs in the correct context. + if (channel->output_started_.load()) return; if (channel->output_buffer_.is_empty()) { return; } const auto *ep = channel->cdc_dev_.out_ep; + // CALLBACK CONTEXT: This lambda is executed in USB task via transfer_callback auto callback = [this, channel](const usb_host::TransferStatus &status) { ESP_LOGV(TAG, "Output Transfer result: length: %u; status %X", status.data_len, status.error_code); - channel->output_started_ = false; + channel->output_started_.store(false); + // Defer restart to main loop (defer is thread-safe) this->defer([this, channel] { this->start_output(channel); }); }; - channel->output_started_ = true; + channel->output_started_.store(true); uint8_t data[ep->wMaxPacketSize]; auto len = channel->output_buffer_.pop(data, ep->wMaxPacketSize); this->transfer_out(ep->bEndpointAddress, callback, data, len); @@ -272,7 +320,7 @@ void USBUartTypeCdcAcm::on_connected() { channel->cdc_dev_ = cdc_devs[i++]; fix_mps(channel->cdc_dev_.in_ep); fix_mps(channel->cdc_dev_.out_ep); - channel->initialised_ = true; + channel->initialised_.store(true); auto err = usb_host_interface_claim(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number, 0); if (err != ESP_OK) { @@ -301,9 +349,9 @@ void USBUartTypeCdcAcm::on_disconnected() { usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress); } usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number); - channel->initialised_ = false; - channel->input_started_ = false; - channel->output_started_ = false; + channel->initialised_.store(false); + channel->input_started_.store(false); + channel->output_started_.store(false); channel->input_buffer_.clear(); channel->output_buffer_.clear(); } @@ -312,10 +360,10 @@ void USBUartTypeCdcAcm::on_disconnected() { void USBUartTypeCdcAcm::enable_channels() { for (auto *channel : this->channels_) { - if (!channel->initialised_) + if (!channel->initialised_.load()) continue; - channel->input_started_ = false; - channel->output_started_ = false; + channel->input_started_.store(false); + channel->output_started_.store(false); this->start_input(channel); } } diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index a103c51add..b41e0a52e9 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -5,11 +5,15 @@ #include "esphome/core/helpers.h" #include "esphome/components/uart/uart_component.h" #include "esphome/components/usb_host/usb_host.h" +#include "esphome/core/lock_free_queue.h" +#include "esphome/core/event_pool.h" +#include namespace esphome { namespace usb_uart { class USBUartTypeCdcAcm; class USBUartComponent; +class USBUartChannel; static const char *const TAG = "usb_uart"; @@ -68,6 +72,17 @@ class RingBuffer { uint8_t *buffer_; }; +// Structure for queuing received USB data chunks +struct UsbDataChunk { + static constexpr size_t MAX_CHUNK_SIZE = 64; // USB packet size + uint8_t data[MAX_CHUNK_SIZE]; + uint8_t length; // Max 64 bytes, so uint8_t is sufficient + USBUartChannel *channel; + + // Required for EventPool - no cleanup needed for POD types + void release() {} +}; + class USBUartChannel : public uart::UARTComponent, public Parented { friend class USBUartComponent; friend class USBUartTypeCdcAcm; @@ -90,16 +105,20 @@ class USBUartChannel : public uart::UARTComponent, public Parenteddummy_receiver_ = dummy_receiver; } protected: - const uint8_t index_; + // Larger structures first for better alignment RingBuffer input_buffer_; RingBuffer output_buffer_; - UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; - bool input_started_{true}; - bool output_started_{true}; CdcEps cdc_dev_{}; + // Enum (likely 4 bytes) + UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; + // Group atomics together (each 1 byte) + std::atomic input_started_{true}; + std::atomic output_started_{true}; + std::atomic initialised_{false}; + // Group regular bytes together to minimize padding + const uint8_t index_; bool debug_{}; bool dummy_receiver_{}; - bool initialised_{}; }; class USBUartComponent : public usb_host::USBClient { @@ -115,6 +134,11 @@ class USBUartComponent : public usb_host::USBClient { void start_input(USBUartChannel *channel); void start_output(USBUartChannel *channel); + // Lock-free data transfer from USB task to main loop + static constexpr int USB_DATA_QUEUE_SIZE = 32; + LockFreeQueue usb_data_queue_; + EventPool chunk_pool_; + protected: std::vector channels_{}; }; From 6d0f134ff1bb3f47a47c20ce42372d8a55388f10 Mon Sep 17 00:00:00 2001 From: Stuart Parmenter Date: Wed, 24 Sep 2025 06:59:16 -0700 Subject: [PATCH 1375/1626] Set color_order to RGB for the Waveshare ESP32-S3-TOUCH-LCD-4.3 and ESP32-S3-TOUCH-LCD-7-800X480 (#10835) --- esphome/components/mipi_rgb/models/waveshare.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/mipi_rgb/models/waveshare.py b/esphome/components/mipi_rgb/models/waveshare.py index 49a75da232..a38493e816 100644 --- a/esphome/components/mipi_rgb/models/waveshare.py +++ b/esphome/components/mipi_rgb/models/waveshare.py @@ -7,6 +7,7 @@ wave_4_3 = DriverChip( "ESP32-S3-TOUCH-LCD-4.3", swap_xy=UNDEFINED, initsequence=(), + color_order="RGB", width=800, height=480, pclk_frequency="16MHz", From a71c04b4b15c542ac665d2cb37d86e5815c4f8f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 11:47:05 -0500 Subject: [PATCH 1376/1626] [esp32_ble] Automatically disable unused GATT functionality to save RAM --- esphome/components/esp32_ble/__init__.py | 13 +++++++++++++ esphome/components/esp32_ble/ble.cpp | 10 ++++++++++ esphome/components/esp32_ble/ble.h | 4 ++++ 3 files changed, 27 insertions(+) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index dae9799028..0501d1c5ef 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -242,6 +242,19 @@ def final_validation(config): f"Name '{name}' is too long, maximum length is {max_length} characters" ) + # Set GATT Client/Server sdkconfig options based on which components are loaded + full_config = fv.full_config.get() + + # Check if BLE Server is needed + has_ble_server = "esp32_ble_server" in full_config + add_idf_sdkconfig_option("CONFIG_BT_GATTS_ENABLE", has_ble_server) + + # Check if BLE Client is needed (via esp32_ble_tracker or esp32_ble_client) + has_ble_client = ( + "esp32_ble_tracker" in full_config or "esp32_ble_client" in full_config + ) + add_idf_sdkconfig_option("CONFIG_BT_GATTC_ENABLE", has_ble_client) + return config diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index e22d43c0cc..591ee0e42f 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -167,6 +167,7 @@ bool ESP32BLE::ble_setup_() { } } +#ifdef USE_ESP32_BLE_SERVER if (!this->gatts_event_handlers_.empty()) { err = esp_ble_gatts_register_callback(ESP32BLE::gatts_event_handler); if (err != ESP_OK) { @@ -174,7 +175,9 @@ bool ESP32BLE::ble_setup_() { return false; } } +#endif +#ifdef USE_ESP32_BLE_CLIENT if (!this->gattc_event_handlers_.empty()) { err = esp_ble_gattc_register_callback(ESP32BLE::gattc_event_handler); if (err != ESP_OK) { @@ -182,6 +185,7 @@ bool ESP32BLE::ble_setup_() { return false; } } +#endif std::string name; if (this->name_.has_value()) { @@ -303,6 +307,7 @@ void ESP32BLE::loop() { BLEEvent *ble_event = this->ble_events_.pop(); while (ble_event != nullptr) { switch (ble_event->type_) { +#ifdef USE_ESP32_BLE_SERVER case BLEEvent::GATTS: { esp_gatts_cb_event_t event = ble_event->event_.gatts.gatts_event; esp_gatt_if_t gatts_if = ble_event->event_.gatts.gatts_if; @@ -313,6 +318,7 @@ void ESP32BLE::loop() { } break; } +#endif case BLEEvent::GATTC: { esp_gattc_cb_event_t event = ble_event->event_.gattc.gattc_event; esp_gatt_if_t gattc_if = ble_event->event_.gattc.gattc_if; @@ -484,15 +490,19 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa ESP_LOGW(TAG, "Ignoring unexpected GAP event type: %d", event); } +#ifdef USE_ESP32_BLE_SERVER void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) { enqueue_ble_event(event, gatts_if, param); } +#endif +#ifdef USE_ESP32_BLE_CLIENT void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { enqueue_ble_event(event, gattc_if, param); } +#endif float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 712787fe53..2232c0b06c 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -131,8 +131,12 @@ class ESP32BLE : public Component { void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } protected: +#ifdef USE_ESP32_BLE_SERVER static void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param); +#endif +#ifdef USE_ESP32_BLE_CLIENT static void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param); +#endif static void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); bool ble_setup_(); From 5e94b5e99732d744996f52a87889a18d4952c3a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 17:41:11 -0500 Subject: [PATCH 1377/1626] missing gattc guard --- esphome/components/esp32_ble/ble.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 591ee0e42f..b9e7078c52 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -319,6 +319,7 @@ void ESP32BLE::loop() { break; } #endif +#ifdef USE_ESP32_BLE_CLIENT case BLEEvent::GATTC: { esp_gattc_cb_event_t event = ble_event->event_.gattc.gattc_event; esp_gatt_if_t gattc_if = ble_event->event_.gattc.gattc_if; @@ -329,6 +330,7 @@ void ESP32BLE::loop() { } break; } +#endif case BLEEvent::GAP: { esp_gap_ble_cb_event_t gap_event = ble_event->event_.gap.gap_event; switch (gap_event) { From ce8ac8b89d8c5e66fc579792cc3a7ce4a33a2c58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Sep 2025 23:11:53 +0000 Subject: [PATCH 1378/1626] Bump aioesphomeapi from 41.9.1 to 41.10.0 (#10872) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 24c93fc4bc..927b831b1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==41.9.1 +aioesphomeapi==41.10.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 538941b3fdfbe38c9a115c6291c68388f5d42bf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Sep 2025 19:06:18 -0500 Subject: [PATCH 1379/1626] Bump actions/cache from 4.2.4 to 4.3.0 in /.github/actions/restore-python (#10869) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 5d290894a7..ac7a810576 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@v4.3.0 with: path: venv # yamllint disable-line rule:line-length From be92903a6f1155b94b66dd1f6ad9e0e9affdb274 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Sep 2025 19:06:31 -0500 Subject: [PATCH 1380/1626] Bump actions/cache from 4.2.4 to 4.3.0 (#10868) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a89a12e2e7..146da67e00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@v4.2.4 + uses: actions/cache@v4.3.0 with: path: venv # yamllint disable-line rule:line-length @@ -162,7 +162,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@v4.2.4 + uses: actions/cache/save@v4.3.0 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -223,7 +223,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@v4.2.4 + uses: actions/cache@v4.3.0 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -301,14 +301,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@v4.2.4 + uses: actions/cache@v4.3.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@v4.2.4 + uses: actions/cache/restore@v4.3.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} From f22143f0908b128a8e2b57dc3b466962c5534864 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 24 Sep 2025 19:08:29 -0500 Subject: [PATCH 1381/1626] Add external wake word message (#10850) --- esphome/components/api/api.proto | 12 ++++++ esphome/components/api/api_connection.cpp | 17 ++++++++ esphome/components/api/api_pb2.cpp | 46 ++++++++++++++++++++++ esphome/components/api/api_pb2.h | 31 ++++++++++++--- esphome/components/api/api_pb2_dump.cpp | 19 ++++++++- esphome/components/api/api_pb2_service.cpp | 2 +- 6 files changed, 119 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 21727df307..9273cca2d3 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1865,10 +1865,22 @@ message VoiceAssistantWakeWord { repeated string trained_languages = 3; } +message VoiceAssistantExternalWakeWord { + string id = 1; + string wake_word = 2; + repeated string trained_languages = 3; + string model_type = 4; + uint32 model_size = 5; + string model_hash = 6; + string url = 7; +} + message VoiceAssistantConfigurationRequest { option (id) = 121; option (source) = SOURCE_CLIENT; option (ifdef) = "USE_VOICE_ASSISTANT"; + + repeated VoiceAssistantExternalWakeWord external_wake_words = 1; } message VoiceAssistantConfigurationResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 357b6e1a32..30b98803d1 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1202,6 +1202,23 @@ bool APIConnection::send_voice_assistant_get_configuration_response(const VoiceA resp_wake_word.trained_languages.push_back(lang); } } + + // Filter external wake words + for (auto &wake_word : msg.external_wake_words) { + if (wake_word.model_type != "micro") { + // microWakeWord only + continue; + } + + resp.available_wake_words.emplace_back(); + auto &resp_wake_word = resp.available_wake_words.back(); + resp_wake_word.set_id(StringRef(wake_word.id)); + resp_wake_word.set_wake_word(StringRef(wake_word.wake_word)); + for (const auto &lang : wake_word.trained_languages) { + resp_wake_word.trained_languages.push_back(lang); + } + } + resp.active_wake_words = &config.active_wake_words; resp.max_active_wake_words = config.max_active_wake_words; return this->send_message(resp, VoiceAssistantConfigurationResponse::MESSAGE_TYPE); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 21641e7963..410ba2334e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2397,6 +2397,52 @@ void VoiceAssistantWakeWord::calculate_size(ProtoSize &size) const { } } } +bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 5: + this->model_size = value.as_uint32(); + break; + default: + return false; + } + return true; +} +bool VoiceAssistantExternalWakeWord::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 1: + this->id = value.as_string(); + break; + case 2: + this->wake_word = value.as_string(); + break; + case 3: + this->trained_languages.push_back(value.as_string()); + break; + case 4: + this->model_type = value.as_string(); + break; + case 6: + this->model_hash = value.as_string(); + break; + case 7: + this->url = value.as_string(); + break; + default: + return false; + } + return true; +} +bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 1: + this->external_wake_words.emplace_back(); + value.decode_to_message(this->external_wake_words.back()); + break; + default: + return false; + } + return true; +} void VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->available_wake_words) { buffer.encode_message(1, it, true); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 2d06ddbb08..ee8472b21c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -2456,18 +2456,37 @@ class VoiceAssistantWakeWord final : public ProtoMessage { protected: }; -class VoiceAssistantConfigurationRequest final : public ProtoMessage { +class VoiceAssistantExternalWakeWord final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 121; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "voice_assistant_configuration_request"; } -#endif + std::string id{}; + std::string wake_word{}; + std::vector trained_languages{}; + std::string model_type{}; + uint32_t model_size{0}; + std::string model_hash{}; + std::string url{}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 121; + static constexpr uint8_t ESTIMATED_SIZE = 34; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "voice_assistant_configuration_request"; } +#endif + std::vector external_wake_words{}; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; class VoiceAssistantConfigurationResponse final : public ProtoMessage { public: diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ca3b041456..a5494168f9 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1824,8 +1824,25 @@ void VoiceAssistantWakeWord::dump_to(std::string &out) const { dump_field(out, "trained_languages", it, 4); } } +void VoiceAssistantExternalWakeWord::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "VoiceAssistantExternalWakeWord"); + dump_field(out, "id", this->id); + dump_field(out, "wake_word", this->wake_word); + for (const auto &it : this->trained_languages) { + dump_field(out, "trained_languages", it, 4); + } + dump_field(out, "model_type", this->model_type); + dump_field(out, "model_size", this->model_size); + dump_field(out, "model_hash", this->model_hash); + dump_field(out, "url", this->url); +} void VoiceAssistantConfigurationRequest::dump_to(std::string &out) const { - out.append("VoiceAssistantConfigurationRequest {}"); + MessageDumpHelper helper(out, "VoiceAssistantConfigurationRequest"); + for (const auto &it : this->external_wake_words) { + out.append(" external_wake_words: "); + it.dump_to(out); + out.append("\n"); + } } void VoiceAssistantConfigurationResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "VoiceAssistantConfigurationResponse"); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 24a7740ec0..ccbd781431 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -548,7 +548,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, #ifdef USE_VOICE_ASSISTANT case VoiceAssistantConfigurationRequest::MESSAGE_TYPE: { VoiceAssistantConfigurationRequest msg; - // Empty message: no decode needed + msg.decode(msg_data, msg_size); #ifdef HAS_PROTO_MESSAGE_DUMP ESP_LOGVV(TAG, "on_voice_assistant_configuration_request: %s", msg.dump().c_str()); #endif From e177905bde4c26a9acd8452d37eb8a61cde2b36e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 21:20:29 -0500 Subject: [PATCH 1382/1626] more --- esphome/components/esp32_ble/ble.cpp | 8 ++++++++ esphome/components/esp32_ble/ble.h | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index b9e7078c52..6b6b19e079 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -424,13 +424,17 @@ void load_ble_event(BLEEvent *event, esp_gap_ble_cb_event_t e, esp_ble_gap_cb_pa event->load_gap_event(e, p); } +#ifdef USE_ESP32_BLE_CLIENT void load_ble_event(BLEEvent *event, esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) { event->load_gattc_event(e, i, p); } +#endif +#ifdef USE_ESP32_BLE_SERVER void load_ble_event(BLEEvent *event, esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) { event->load_gatts_event(e, i, p); } +#endif template void enqueue_ble_event(Args... args) { // Allocate an event from the pool @@ -451,8 +455,12 @@ template void enqueue_ble_event(Args... args) { // Explicit template instantiations for the friend function template void enqueue_ble_event(esp_gap_ble_cb_event_t, esp_ble_gap_cb_param_t *); +#ifdef USE_ESP32_BLE_SERVER template void enqueue_ble_event(esp_gatts_cb_event_t, esp_gatt_if_t, esp_ble_gatts_cb_param_t *); +#endif +#ifdef USE_ESP32_BLE_CLIENT template void enqueue_ble_event(esp_gattc_cb_event_t, esp_gatt_if_t, esp_ble_gattc_cb_param_t *); +#endif void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { switch (event) { diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 2232c0b06c..368ac644cf 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -74,17 +74,21 @@ class GAPScanEventHandler { virtual void gap_scan_event_handler(const BLEScanResult &scan_result) = 0; }; +#ifdef USE_ESP32_BLE_CLIENT class GATTcEventHandler { public: virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) = 0; }; +#endif +#ifdef USE_ESP32_BLE_SERVER class GATTsEventHandler { public: virtual void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) = 0; }; +#endif class BLEStatusEventHandler { public: @@ -123,8 +127,12 @@ class ESP32BLE : public Component { void register_gap_scan_event_handler(GAPScanEventHandler *handler) { this->gap_scan_event_handlers_.push_back(handler); } +#ifdef USE_ESP32_BLE_CLIENT void register_gattc_event_handler(GATTcEventHandler *handler) { this->gattc_event_handlers_.push_back(handler); } +#endif +#ifdef USE_ESP32_BLE_SERVER void register_gatts_event_handler(GATTsEventHandler *handler) { this->gatts_event_handlers_.push_back(handler); } +#endif void register_ble_status_event_handler(BLEStatusEventHandler *handler) { this->ble_status_event_handlers_.push_back(handler); } @@ -152,8 +160,12 @@ class ESP32BLE : public Component { // Vectors (12 bytes each on 32-bit, naturally aligned to 4 bytes) std::vector gap_event_handlers_; std::vector gap_scan_event_handlers_; +#ifdef USE_ESP32_BLE_CLIENT std::vector gattc_event_handlers_; +#endif +#ifdef USE_ESP32_BLE_SERVER std::vector gatts_event_handlers_; +#endif std::vector ble_status_event_handlers_; // Large objects (size depends on template parameters, but typically aligned to 4 bytes) From 1922b7b3ed99e18f1f1565687a7e3dcead3aa467 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 22:20:35 -0500 Subject: [PATCH 1383/1626] [api] Make password and encryption mutually exclusive, add deprecation warning for password auth (#10871) --- esphome/components/api/__init__.py | 31 ++++++++++++++++++++++++++++++ tests/components/api/common.yaml | 1 - 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 5fb84d3c21..394d3ead43 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,4 +1,5 @@ import base64 +import logging from esphome import automation from esphome.automation import Condition @@ -25,6 +26,9 @@ from esphome.const import ( CONF_VARIABLES, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) DOMAIN = "api" DEPENDENCIES = ["network"] @@ -101,6 +105,32 @@ def _encryption_schema(config): return ENCRYPTION_SCHEMA(config) +def _validate_api_config(config: ConfigType) -> ConfigType: + """Validate API configuration with mutual exclusivity check and deprecation warning.""" + # Check if both password and encryption are configured + has_password = CONF_PASSWORD in config and config[CONF_PASSWORD] + has_encryption = CONF_ENCRYPTION in config + + if has_password and has_encryption: + raise cv.Invalid( + "The 'password' and 'encryption' options are mutually exclusive. " + "The API client only supports one authentication method at a time. " + "Please remove one of them. " + "Note: 'password' authentication is deprecated and will be removed in version 2026.1.0. " + "We strongly recommend using 'encryption' instead for better security." + ) + + # Warn about password deprecation + if has_password: + _LOGGER.warning( + "API 'password' authentication has been deprecated since May 2022 and will be removed in version 2026.1.0. " + "Please migrate to the 'encryption' configuration. " + "See https://esphome.io/components/api.html#configuration-variables" + ) + + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -131,6 +161,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), + _validate_api_config, ) diff --git a/tests/components/api/common.yaml b/tests/components/api/common.yaml index 7ac11e4da6..4f1693dac8 100644 --- a/tests/components/api/common.yaml +++ b/tests/components/api/common.yaml @@ -13,7 +13,6 @@ esphome: api: port: 8000 - password: pwd reboot_timeout: 0min encryption: key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU= From 0cc03dfe327a577d624005381b105f69fa200900 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 25 Sep 2025 16:35:19 +1200 Subject: [PATCH 1384/1626] [json] Parsing json without a lambda (#10838) --- esphome/components/json/json_util.cpp | 19 +++++++++++++------ esphome/components/json/json_util.h | 2 ++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 51c0fcf9cb..643f23f499 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -19,6 +19,15 @@ std::string build_json(const json_build_t &f) { bool parse_json(const std::string &data, const json_parse_t &f) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson + JsonDocument doc = parse_json(data); + if (doc.overflowed() || doc.isNull()) + return false; + return f(doc.as()); + // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) +} + +JsonDocument parse_json(const std::string &data) { + // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson #ifdef USE_PSRAM auto doc_allocator = SpiRamAllocator(); JsonDocument json_document(&doc_allocator); @@ -27,20 +36,18 @@ bool parse_json(const std::string &data, const json_parse_t &f) { #endif if (json_document.overflowed()) { ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); - return false; + return JsonObject(); // return unbound object } DeserializationError err = deserializeJson(json_document, data); - JsonObject root = json_document.as(); - if (err == DeserializationError::Ok) { - return f(root); + return json_document; } else if (err == DeserializationError::NoMemory) { ESP_LOGE(TAG, "Can not allocate more memory for deserialization. Consider making source string smaller"); - return false; + return JsonObject(); // return unbound object } ESP_LOGE(TAG, "Parse error: %s", err.c_str()); - return false; + return JsonObject(); // return unbound object // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 69b809ec49..0349833342 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -49,6 +49,8 @@ std::string build_json(const json_build_t &f); /// Parse a JSON string and run the provided json parse function if it's valid. bool parse_json(const std::string &data, const json_parse_t &f); +/// Parse a JSON string and return the root JsonDocument (or an unbound object on error) +JsonDocument parse_json(const std::string &data); /// Builder class for creating JSON documents without lambdas class JsonBuilder { From 44767c32cf6c707df4918a9b1aed5b4da9e129cb Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 26 Sep 2025 01:08:16 +1200 Subject: [PATCH 1385/1626] Pin SHA for github actions (#10879) --- .github/actions/build-image/action.yaml | 4 +- .github/actions/restore-python/action.yml | 4 +- .github/workflows/auto-label-pr.yml | 6 +-- .github/workflows/ci-api-proto.yml | 10 ++--- .github/workflows/ci-clang-tidy-hash.yml | 8 ++-- .github/workflows/ci-docker.yml | 6 +-- .github/workflows/ci.yml | 42 +++++++++---------- .../workflows/codeowner-review-request.yml | 2 +- .github/workflows/codeql.yml | 6 +-- .github/workflows/external-component-bot.yml | 2 +- .github/workflows/issue-codeowner-notify.yml | 2 +- .github/workflows/release.yml | 34 +++++++-------- .github/workflows/stale.yml | 4 +- .github/workflows/status-check-labels.yml | 2 +- .github/workflows/sync-device-classes.yml | 8 ++-- 15 files changed, 70 insertions(+), 70 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 403b9d8c2a..9c7f051e05 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -47,7 +47,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@v6.18.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -73,7 +73,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@v6.18.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index ac7a810576..f314e79ad9 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,12 +17,12 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.3.0 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv # yamllint disable-line rule:line-length diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 66369c706f..1670bd1821 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -22,17 +22,17 @@ jobs: if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' steps: - name: Checkout - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Generate a token id: generate-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - name: Auto Label PR - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index ec214d1a77..c122859442 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,9 +21,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: "3.11" @@ -47,7 +47,7 @@ jobs: fi - if: failure() name: Review PR - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | await github.rest.pulls.createReview({ @@ -62,7 +62,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: generated-proto-files path: | @@ -70,7 +70,7 @@ jobs: esphome/components/api/api_pb2_service.* - if: success() name: Dismiss review - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | let reviews = await github.rest.pulls.listReviews({ diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 2f47386abf..8760a1aaa5 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -20,10 +20,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: "3.11" @@ -41,7 +41,7 @@ jobs: - if: failure() name: Request changes - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | await github.rest.pulls.createReview({ @@ -54,7 +54,7 @@ jobs: - if: success() name: Dismiss review - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | let reviews = await github.rest.pulls.listReviews({ diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 915a4dfb7e..7111c61dda 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -43,13 +43,13 @@ jobs: - "docker" # - "lint" steps: - - uses: actions/checkout@v5.0.0 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: "3.11" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.11.1 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Set TAG run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 146da67e00..f4f7f8bd82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,18 +36,18 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@v4.3.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv # yamllint disable-line rule:line-length @@ -70,7 +70,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -91,7 +91,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -137,7 +137,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -157,12 +157,12 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5.5.1 + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@v4.3.0 + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -180,7 +180,7 @@ jobs: component-test-count: ${{ steps.determine.outputs.component-test-count }} steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -215,15 +215,15 @@ jobs: if: needs.determine-jobs.outputs.integration-tests == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python 3.13 id: python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@v4.3.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -288,7 +288,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -301,14 +301,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@v4.3.0 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@v4.3.0 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -375,7 +375,7 @@ jobs: sudo apt-get install libsdl2-dev - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -401,7 +401,7 @@ jobs: matrix: ${{ steps.split.outputs.components }} steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Split components into 20 groups id: split run: | @@ -431,7 +431,7 @@ jobs: sudo apt-get install libsdl2-dev - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -460,16 +460,16 @@ jobs: if: github.event_name == 'pull_request' && github.base_ref != 'beta' && github.base_ref != 'release' steps: - name: Check out code from GitHub - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - uses: pre-commit/action@v3.0.1 + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 env: SKIP: pylint,clang-tidy-hash - - uses: pre-commit-ci/lite-action@v1.1.0 + - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() ci-status: diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 475e05b970..563d55f42b 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Request reviews from component codeowners - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const owner = context.repo.owner; diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7a7c39aeec..943a35b32a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -54,11 +54,11 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 736c986f7e..4fa020f63d 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Add external component comment - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/issue-codeowner-notify.yml b/.github/workflows/issue-codeowner-notify.yml index ab9b96b45a..6faf956c87 100644 --- a/.github/workflows/issue-codeowner-notify.yml +++ b/.github/workflows/issue-codeowner-notify.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify codeowners for component issues - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const owner = context.repo.owner; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efc8424cd6..4d003df5ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@v5.0.0 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,9 +60,9 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v5.0.0 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: "3.x" - name: Build @@ -70,7 +70,7 @@ jobs: pip3 install build python3 -m build - name: Publish - uses: pypa/gh-action-pypi-publish@v1.13.0 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: skip-existing: true @@ -92,22 +92,22 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@v5.0.0 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: "3.11" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.11.1 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Log in to docker hub - uses: docker/login-action@v3.5.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@v3.5.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -138,7 +138,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests @@ -168,27 +168,27 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@v5.0.0 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Download digests - uses: actions/download-artifact@v5.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: pattern: digests-* path: /tmp/digests merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.11.1 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@v3.5.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@v3.5.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -220,7 +220,7 @@ jobs: - deploy-manifest steps: - name: Trigger Workflow - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }} script: | @@ -246,7 +246,7 @@ jobs: environment: ${{ needs.init.outputs.deploy_env }} steps: - name: Trigger Workflow - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ secrets.DEPLOY_ESPHOME_SCHEMA_REPO_TOKEN }} script: | diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 88e07d3f58..da1e62c8e1 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v10.0.0 + - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: days-before-pr-stale: 90 days-before-pr-close: 7 @@ -37,7 +37,7 @@ jobs: close-issues: runs-on: ubuntu-latest steps: - - uses: actions/stale@v10.0.0 + - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: days-before-pr-stale: -1 days-before-pr-close: -1 diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index 675be49c27..e44fd18132 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -16,7 +16,7 @@ jobs: - merge-after-release steps: - name: Check for ${{ matrix.label }} label - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const { data: labels } = await github.rest.issues.listLabelsOnIssue({ diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index c14071ea62..9479645ccc 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -13,16 +13,16 @@ jobs: if: github.repository == 'esphome/esphome' steps: - name: Checkout - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Checkout Home Assistant - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: repository: home-assistant/core path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@v6.0.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: 3.13 @@ -41,7 +41,7 @@ jobs: python script/run-in-env.py pre-commit run --all-files - name: Commit changes - uses: peter-evans/create-pull-request@v7.0.8 + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 with: commit-message: "Synchronise Device Classes from Home Assistant" committer: esphomebot From f7ed127182368419f4ecf235e5852864627a2bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20L=C3=A9p=C3=A9e?= <205357+alepee@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:38:31 +0200 Subject: [PATCH 1386/1626] Add WTS01 temperature sensor component (#8539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Antoine Lépée Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/wts01/__init__.py | 0 esphome/components/wts01/sensor.py | 41 +++++++++ esphome/components/wts01/wts01.cpp | 91 +++++++++++++++++++ esphome/components/wts01/wts01.h | 27 ++++++ tests/components/wts01/common.yaml | 7 ++ tests/components/wts01/test.esp32-ard.yaml | 5 + tests/components/wts01/test.esp32-c3-ard.yaml | 5 + tests/components/wts01/test.esp32-c3-idf.yaml | 5 + tests/components/wts01/test.esp32-idf.yaml | 5 + tests/components/wts01/test.esp8266-ard.yaml | 5 + tests/components/wts01/test.rp2040-ard.yaml | 5 + 12 files changed, 197 insertions(+) create mode 100644 esphome/components/wts01/__init__.py create mode 100644 esphome/components/wts01/sensor.py create mode 100644 esphome/components/wts01/wts01.cpp create mode 100644 esphome/components/wts01/wts01.h create mode 100644 tests/components/wts01/common.yaml create mode 100644 tests/components/wts01/test.esp32-ard.yaml create mode 100644 tests/components/wts01/test.esp32-c3-ard.yaml create mode 100644 tests/components/wts01/test.esp32-c3-idf.yaml create mode 100644 tests/components/wts01/test.esp32-idf.yaml create mode 100644 tests/components/wts01/test.esp8266-ard.yaml create mode 100644 tests/components/wts01/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index e91116795a..b35b11c705 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -533,6 +533,7 @@ esphome/components/wk2204_spi/* @DrCoolZic esphome/components/wk2212_i2c/* @DrCoolZic esphome/components/wk2212_spi/* @DrCoolZic esphome/components/wl_134/* @hobbypunk90 +esphome/components/wts01/* @alepee esphome/components/x9c/* @EtienneMD esphome/components/xgzp68xx/* @gcormier esphome/components/xiaomi_hhccjcy10/* @fariouche diff --git a/esphome/components/wts01/__init__.py b/esphome/components/wts01/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/wts01/sensor.py b/esphome/components/wts01/sensor.py new file mode 100644 index 0000000000..bf4f0262ad --- /dev/null +++ b/esphome/components/wts01/sensor.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import sensor, uart +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) + +CONF_WTS01_ID = "wts01_id" +CODEOWNERS = ["@alepee"] +DEPENDENCIES = ["uart"] + +wts01_ns = cg.esphome_ns.namespace("wts01") +WTS01Sensor = wts01_ns.class_( + "WTS01Sensor", cg.Component, uart.UARTDevice, sensor.Sensor +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + WTS01Sensor, + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "wts01", + baud_rate=9600, + require_rx=True, +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/wts01/wts01.cpp b/esphome/components/wts01/wts01.cpp new file mode 100644 index 0000000000..cb910d89cf --- /dev/null +++ b/esphome/components/wts01/wts01.cpp @@ -0,0 +1,91 @@ +#include "wts01.h" +#include "esphome/core/log.h" +#include + +namespace esphome { +namespace wts01 { + +constexpr uint8_t HEADER_1 = 0x55; +constexpr uint8_t HEADER_2 = 0x01; +constexpr uint8_t HEADER_3 = 0x01; +constexpr uint8_t HEADER_4 = 0x04; + +static const char *const TAG = "wts01"; + +void WTS01Sensor::loop() { + // Process all available data at once + while (this->available()) { + uint8_t c; + if (this->read_byte(&c)) { + this->handle_char_(c); + } + } +} + +void WTS01Sensor::dump_config() { LOG_SENSOR("", "WTS01 Sensor", this); } + +void WTS01Sensor::handle_char_(uint8_t c) { + // State machine for processing the header. Reset if something doesn't match. + if (this->buffer_pos_ == 0 && c != HEADER_1) { + return; + } + + if (this->buffer_pos_ == 1 && c != HEADER_2) { + this->buffer_pos_ = 0; + return; + } + + if (this->buffer_pos_ == 2 && c != HEADER_3) { + this->buffer_pos_ = 0; + return; + } + + if (this->buffer_pos_ == 3 && c != HEADER_4) { + this->buffer_pos_ = 0; + return; + } + + // Add byte to buffer + this->buffer_[this->buffer_pos_++] = c; + + // Process complete packet + if (this->buffer_pos_ >= PACKET_SIZE) { + this->process_packet_(); + this->buffer_pos_ = 0; + } +} + +void WTS01Sensor::process_packet_() { + // Based on Tasmota implementation + // Format: 55 01 01 04 01 11 16 12 95 + // header T Td Ck - T = Temperature, Td = Temperature decimal, Ck = Checksum + uint8_t calculated_checksum = 0; + for (uint8_t i = 0; i < PACKET_SIZE - 1; i++) { + calculated_checksum += this->buffer_[i]; + } + + uint8_t received_checksum = this->buffer_[PACKET_SIZE - 1]; + if (calculated_checksum != received_checksum) { + ESP_LOGW(TAG, "WTS01 Checksum doesn't match: 0x%02X != 0x%02X", received_checksum, calculated_checksum); + return; + } + + // Extract temperature value + int8_t temp = this->buffer_[6]; + int32_t sign = 1; + + // Handle negative temperatures + if (temp < 0) { + sign = -1; + } + + // Calculate temperature (temp + decimal/100) + float temperature = static_cast(temp) + (sign * static_cast(this->buffer_[7]) / 100.0f); + + ESP_LOGV(TAG, "Received new temperature: %.2f°C", temperature); + + this->publish_state(temperature); +} + +} // namespace wts01 +} // namespace esphome diff --git a/esphome/components/wts01/wts01.h b/esphome/components/wts01/wts01.h new file mode 100644 index 0000000000..298595a5d6 --- /dev/null +++ b/esphome/components/wts01/wts01.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + +namespace esphome { +namespace wts01 { + +constexpr uint8_t PACKET_SIZE = 9; + +class WTS01Sensor : public sensor::Sensor, public uart::UARTDevice, public Component { + public: + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + uint8_t buffer_[PACKET_SIZE]; + uint8_t buffer_pos_{0}; + + void handle_char_(uint8_t c); + void process_packet_(); +}; + +} // namespace wts01 +} // namespace esphome diff --git a/tests/components/wts01/common.yaml b/tests/components/wts01/common.yaml new file mode 100644 index 0000000000..c26cc3e475 --- /dev/null +++ b/tests/components/wts01/common.yaml @@ -0,0 +1,7 @@ +uart: + rx_pin: ${rx_pin} + baud_rate: 9600 + +sensor: + - platform: wts01 + id: wts01_sensor diff --git a/tests/components/wts01/test.esp32-ard.yaml b/tests/components/wts01/test.esp32-ard.yaml new file mode 100644 index 0000000000..4904e1f54f --- /dev/null +++ b/tests/components/wts01/test.esp32-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO16 + rx_pin: GPIO17 + +<<: !include common.yaml diff --git a/tests/components/wts01/test.esp32-c3-ard.yaml b/tests/components/wts01/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..00cec5b3b8 --- /dev/null +++ b/tests/components/wts01/test.esp32-c3-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO6 + rx_pin: GPIO7 + +<<: !include common.yaml diff --git a/tests/components/wts01/test.esp32-c3-idf.yaml b/tests/components/wts01/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..00cec5b3b8 --- /dev/null +++ b/tests/components/wts01/test.esp32-c3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO6 + rx_pin: GPIO7 + +<<: !include common.yaml diff --git a/tests/components/wts01/test.esp32-idf.yaml b/tests/components/wts01/test.esp32-idf.yaml new file mode 100644 index 0000000000..4904e1f54f --- /dev/null +++ b/tests/components/wts01/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO16 + rx_pin: GPIO17 + +<<: !include common.yaml diff --git a/tests/components/wts01/test.esp8266-ard.yaml b/tests/components/wts01/test.esp8266-ard.yaml new file mode 100644 index 0000000000..3b44f9c9c3 --- /dev/null +++ b/tests/components/wts01/test.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO1 + rx_pin: GPIO3 + +<<: !include common.yaml diff --git a/tests/components/wts01/test.rp2040-ard.yaml b/tests/components/wts01/test.rp2040-ard.yaml new file mode 100644 index 0000000000..16b2a4b006 --- /dev/null +++ b/tests/components/wts01/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +<<: !include common.yaml From 7899d4256c55793830d511c6927517b9983a4338 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 08:54:52 -0500 Subject: [PATCH 1387/1626] Add sha256 support This is a breakout from https://github.com/esphome/esphome/pull/10809 --- esphome/components/sha256/__init__.py | 22 +++++ esphome/components/sha256/sha256.cpp | 83 +++++++++++++++++++ esphome/components/sha256/sha256.h | 56 +++++++++++++ esphome/core/defines.h | 1 + esphome/core/hash_base.h | 56 +++++++++++++ esphome/core/helpers.h | 11 +++ tests/components/sha256/common.yaml | 32 +++++++ tests/components/sha256/test.bk72xx-ard.yaml | 1 + tests/components/sha256/test.esp32-idf.yaml | 1 + tests/components/sha256/test.esp8266-ard.yaml | 1 + tests/components/sha256/test.host.yaml | 1 + tests/components/sha256/test.rp2040-ard.yaml | 1 + 12 files changed, 266 insertions(+) create mode 100644 esphome/components/sha256/__init__.py create mode 100644 esphome/components/sha256/sha256.cpp create mode 100644 esphome/components/sha256/sha256.h create mode 100644 esphome/core/hash_base.h create mode 100644 tests/components/sha256/common.yaml create mode 100644 tests/components/sha256/test.bk72xx-ard.yaml create mode 100644 tests/components/sha256/test.esp32-idf.yaml create mode 100644 tests/components/sha256/test.esp8266-ard.yaml create mode 100644 tests/components/sha256/test.host.yaml create mode 100644 tests/components/sha256/test.rp2040-ard.yaml diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py new file mode 100644 index 0000000000..f07157416d --- /dev/null +++ b/esphome/components/sha256/__init__.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.helpers import IS_MACOS +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] + +sha256_ns = cg.esphome_ns.namespace("sha256") + +CONFIG_SCHEMA = cv.Schema({}) + + +async def to_code(config: ConfigType) -> None: + # Add OpenSSL library for host platform + if not CORE.is_host: + return + if IS_MACOS: + # macOS needs special handling for Homebrew OpenSSL + cg.add_build_flag("-I/opt/homebrew/opt/openssl/include") + cg.add_build_flag("-L/opt/homebrew/opt/openssl/lib") + cg.add_build_flag("-lcrypto") diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp new file mode 100644 index 0000000000..199460acbc --- /dev/null +++ b/esphome/components/sha256/sha256.cpp @@ -0,0 +1,83 @@ +#include "sha256.h" + +// Only compile SHA256 implementation on platforms that support it +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) + +#include "esphome/core/helpers.h" +#include + +namespace esphome::sha256 { + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +SHA256::~SHA256() { mbedtls_sha256_free(&this->ctx_); } + +void SHA256::init() { + mbedtls_sha256_init(&this->ctx_); + mbedtls_sha256_starts(&this->ctx_, 0); // 0 = SHA256, not SHA224 +} + +void SHA256::add(const uint8_t *data, size_t len) { mbedtls_sha256_update(&this->ctx_, data, len); } + +void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_, this->digest_); } + +#elif defined(USE_ESP8266) || defined(USE_RP2040) + +SHA256::~SHA256() = default; + +void SHA256::init() { + br_sha256_init(&this->ctx_); + this->calculated_ = false; +} + +void SHA256::add(const uint8_t *data, size_t len) { br_sha256_update(&this->ctx_, data, len); } + +void SHA256::calculate() { + if (!this->calculated_) { + br_sha256_out(&this->ctx_, this->digest_); + this->calculated_ = true; + } +} + +#elif defined(USE_HOST) + +SHA256::~SHA256() { + if (this->ctx_) { + EVP_MD_CTX_free(this->ctx_); + } +} + +void SHA256::init() { + if (this->ctx_) { + EVP_MD_CTX_free(this->ctx_); + } + this->ctx_ = EVP_MD_CTX_new(); + EVP_DigestInit_ex(this->ctx_, EVP_sha256(), nullptr); + this->calculated_ = false; +} + +void SHA256::add(const uint8_t *data, size_t len) { + if (!this->ctx_) { + this->init(); + } + EVP_DigestUpdate(this->ctx_, data, len); +} + +void SHA256::calculate() { + if (!this->ctx_) { + this->init(); + } + if (!this->calculated_) { + unsigned int len = 32; + EVP_DigestFinal_ex(this->ctx_, this->digest_, &len); + this->calculated_ = true; + } +} + +#else +#error "SHA256 not supported on this platform" +#endif + +} // namespace esphome::sha256 + +#endif // Platform check diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h new file mode 100644 index 0000000000..bb089bc314 --- /dev/null +++ b/esphome/components/sha256/sha256.h @@ -0,0 +1,56 @@ +#pragma once + +#include "esphome/core/defines.h" + +// Only define SHA256 on platforms that support it +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) + +#include +#include +#include +#include "esphome/core/hash_base.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#include "mbedtls/sha256.h" +#elif defined(USE_ESP8266) || defined(USE_RP2040) +#include +#elif defined(USE_HOST) +#include +#else +#error "SHA256 not supported on this platform" +#endif + +namespace esphome::sha256 { + +class SHA256 : public esphome::HashBase { + public: + SHA256() = default; + ~SHA256() override; + + void init() override; + void add(const uint8_t *data, size_t len) override; + using HashBase::add; // Bring base class overload into scope + void add(const std::string &data) { this->add((const uint8_t *) data.c_str(), data.length()); } + + void calculate() override; + + /// Get the size of the hash in bytes (32 for SHA256) + size_t get_size() const override { return 32; } + + protected: +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + mbedtls_sha256_context ctx_{}; +#elif defined(USE_ESP8266) || defined(USE_RP2040) + br_sha256_context ctx_{}; + bool calculated_{false}; +#elif defined(USE_HOST) + EVP_MD_CTX *ctx_{nullptr}; + bool calculated_{false}; +#else +#error "SHA256 not supported on this platform" +#endif +}; + +} // namespace esphome::sha256 + +#endif // Platform check diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 784e8cd2b3..ef93fd0b65 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -116,6 +116,7 @@ #define USE_API_PLAINTEXT #define USE_API_SERVICES #define USE_MD5 +#define USE_SHA256 #define USE_MQTT #define USE_NETWORK #define USE_ONLINE_IMAGE_BMP_SUPPORT diff --git a/esphome/core/hash_base.h b/esphome/core/hash_base.h new file mode 100644 index 0000000000..1af2fd8907 --- /dev/null +++ b/esphome/core/hash_base.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include "esphome/core/helpers.h" + +namespace esphome { + +/// Base class for hash algorithms +class HashBase { + public: + virtual ~HashBase() = default; + + /// Initialize a new hash computation + virtual void init() = 0; + + /// Add bytes of data for the hash + virtual void add(const uint8_t *data, size_t len) = 0; + void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + + /// Compute the hash based on provided data + virtual void calculate() = 0; + + /// Retrieve the hash as bytes + void get_bytes(uint8_t *output) { memcpy(output, this->digest_, this->get_size()); } + + /// Retrieve the hash as hex characters + void get_hex(char *output) { + for (size_t i = 0; i < this->get_size(); i++) { + uint8_t byte = this->digest_[i]; + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); + } + } + + /// Compare the hash against a provided byte-encoded hash + bool equals_bytes(const uint8_t *expected) { return memcmp(this->digest_, expected, this->get_size()) == 0; } + + /// Compare the hash against a provided hex-encoded hash + bool equals_hex(const char *expected) { + uint8_t parsed[32]; // Max size for SHA256 + if (!parse_hex(expected, parsed, this->get_size())) { + return false; + } + return this->equals_bytes(parsed); + } + + /// Get the size of the hash in bytes (16 for MD5, 32 for SHA256) + virtual size_t get_size() const = 0; + + protected: + uint8_t digest_[32]; // Common digest storage, sized for largest hash (SHA256) +}; + +} // namespace esphome diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 21aa159b25..a28718de5a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -82,6 +82,16 @@ template constexpr T byteswap(T n) { return m; } template<> constexpr uint8_t byteswap(uint8_t n) { return n; } +#ifdef USE_LIBRETINY +// LibreTiny's Beken framework redefines __builtin_bswap functions as non-constexpr +template<> inline uint16_t byteswap(uint16_t n) { return __builtin_bswap16(n); } +template<> inline uint32_t byteswap(uint32_t n) { return __builtin_bswap32(n); } +template<> inline uint64_t byteswap(uint64_t n) { return __builtin_bswap64(n); } +template<> inline int8_t byteswap(int8_t n) { return n; } +template<> inline int16_t byteswap(int16_t n) { return __builtin_bswap16(n); } +template<> inline int32_t byteswap(int32_t n) { return __builtin_bswap32(n); } +template<> inline int64_t byteswap(int64_t n) { return __builtin_bswap64(n); } +#else template<> constexpr uint16_t byteswap(uint16_t n) { return __builtin_bswap16(n); } template<> constexpr uint32_t byteswap(uint32_t n) { return __builtin_bswap32(n); } template<> constexpr uint64_t byteswap(uint64_t n) { return __builtin_bswap64(n); } @@ -89,6 +99,7 @@ template<> constexpr int8_t byteswap(int8_t n) { return n; } template<> constexpr int16_t byteswap(int16_t n) { return __builtin_bswap16(n); } template<> constexpr int32_t byteswap(int32_t n) { return __builtin_bswap32(n); } template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); } +#endif ///@} diff --git a/tests/components/sha256/common.yaml b/tests/components/sha256/common.yaml new file mode 100644 index 0000000000..fa884c1958 --- /dev/null +++ b/tests/components/sha256/common.yaml @@ -0,0 +1,32 @@ +esphome: + on_boot: + - lambda: |- + // Test SHA256 functionality + #ifdef USE_SHA256 + using esphome::sha256::SHA256; + SHA256 hasher; + hasher.init(); + + // Test with "Hello World" - known SHA256 + const char* test_string = "Hello World"; + hasher.add(test_string, strlen(test_string)); + hasher.calculate(); + + char hex_output[65]; + hasher.get_hex(hex_output); + hex_output[64] = '\0'; + + ESP_LOGD("SHA256", "SHA256('Hello World') = %s", hex_output); + + // Expected: a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e + const char* expected = "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e"; + if (strcmp(hex_output, expected) == 0) { + ESP_LOGI("SHA256", "Test PASSED"); + } else { + ESP_LOGE("SHA256", "Test FAILED. Expected %s", expected); + } + #else + ESP_LOGW("SHA256", "SHA256 not available on this platform"); + #endif + +sha256: diff --git a/tests/components/sha256/test.bk72xx-ard.yaml b/tests/components/sha256/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha256/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha256/test.esp32-idf.yaml b/tests/components/sha256/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha256/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha256/test.esp8266-ard.yaml b/tests/components/sha256/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha256/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha256/test.host.yaml b/tests/components/sha256/test.host.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha256/test.host.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha256/test.rp2040-ard.yaml b/tests/components/sha256/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha256/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 2bc1cc2ae7d0f522fa3d0d10e74641291d45be96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 08:55:43 -0500 Subject: [PATCH 1388/1626] Add sha256 support This is a breakout from https://github.com/esphome/esphome/pull/10809 --- esphome/components/md5/md5.cpp | 26 -------------------------- esphome/components/md5/md5.h | 30 ++++++++++-------------------- 2 files changed, 10 insertions(+), 46 deletions(-) diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index 21bd2e1cab..866f00eda4 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -39,32 +39,6 @@ void MD5Digest::add(const uint8_t *data, size_t len) { br_md5_update(&this->ctx_ void MD5Digest::calculate() { br_md5_out(&this->ctx_, this->digest_); } #endif // USE_RP2040 -void MD5Digest::get_bytes(uint8_t *output) { memcpy(output, this->digest_, 16); } - -void MD5Digest::get_hex(char *output) { - for (size_t i = 0; i < 16; i++) { - uint8_t byte = this->digest_[i]; - output[i * 2] = format_hex_char(byte >> 4); - output[i * 2 + 1] = format_hex_char(byte & 0x0F); - } -} - -bool MD5Digest::equals_bytes(const uint8_t *expected) { - for (size_t i = 0; i < 16; i++) { - if (expected[i] != this->digest_[i]) { - return false; - } - } - return true; -} - -bool MD5Digest::equals_hex(const char *expected) { - uint8_t parsed[16]; - if (!parse_hex(expected, parsed, 16)) - return false; - return equals_bytes(parsed); -} - } // namespace md5 } // namespace esphome #endif diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index be1df40423..b0da2c0a3b 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -3,6 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_MD5 +#include "esphome/core/hash_base.h" + #ifdef USE_ESP32 #include "esp_rom_md5.h" #define MD5_CTX_TYPE md5_context_t @@ -26,38 +28,26 @@ namespace esphome { namespace md5 { -class MD5Digest { +class MD5Digest : public HashBase { public: MD5Digest() = default; - ~MD5Digest() = default; + ~MD5Digest() override = default; /// Initialize a new MD5 digest computation. - void init(); + void init() override; /// Add bytes of data for the digest. - void add(const uint8_t *data, size_t len); - void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + void add(const uint8_t *data, size_t len) override; + using HashBase::add; // Bring base class overload into scope /// Compute the digest, based on the provided data. - void calculate(); + void calculate() override; - /// Retrieve the MD5 digest as bytes. - /// The output must be able to hold 16 bytes or more. - void get_bytes(uint8_t *output); - - /// Retrieve the MD5 digest as hex characters. - /// The output must be able to hold 32 bytes or more. - void get_hex(char *output); - - /// Compare the digest against a provided byte-encoded digest (16 bytes). - bool equals_bytes(const uint8_t *expected); - - /// Compare the digest against a provided hex-encoded digest (32 bytes). - bool equals_hex(const char *expected); + /// Get the size of the hash in bytes (16 for MD5) + size_t get_size() const override { return 16; } protected: MD5_CTX_TYPE ctx_{}; - uint8_t digest_[16]; }; } // namespace md5 From 136c95656c71c97054ac9ad8a560e2a470bdfd7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 08:57:27 -0500 Subject: [PATCH 1389/1626] codeowners --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/CODEOWNERS b/CODEOWNERS index e91116795a..77a837df0d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -407,6 +407,7 @@ esphome/components/sensor/* @esphome/core esphome/components/sfa30/* @ghsensdev esphome/components/sgp40/* @SenexCrenshaw esphome/components/sgp4x/* @martgras @SenexCrenshaw +esphome/components/sha256/* @esphome/core esphome/components/shelly_dimmer/* @edge90 @rnauber esphome/components/sht3xd/* @mrtoy-me esphome/components/sht4x/* @sjtrny From 65a1d2b2ff02728008808024cefbdb4e57277a48 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:13:27 -0400 Subject: [PATCH 1390/1626] [scd30] Fix temp offset (#10847) --- esphome/components/scd30/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index 6981af4de9..194df8ec4f 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -66,7 +66,7 @@ CONFIG_SCHEMA = ( ), cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION, default=0): cv.pressure, cv.Optional(CONF_TEMPERATURE_OFFSET): cv.All( - cv.temperature, + cv.temperature_delta, cv.float_range(min=0, max=655.35), ), cv.Optional(CONF_UPDATE_INTERVAL, default="60s"): cv.All( From 640d98bb6f4243a785e307b7dd7147f31306005d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 10:31:42 -0500 Subject: [PATCH 1391/1626] address review comments --- esphome/core/hash_base.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/core/hash_base.h b/esphome/core/hash_base.h index 1af2fd8907..4eb6a89f53 100644 --- a/esphome/core/hash_base.h +++ b/esphome/core/hash_base.h @@ -39,7 +39,7 @@ class HashBase { /// Compare the hash against a provided hex-encoded hash bool equals_hex(const char *expected) { - uint8_t parsed[32]; // Max size for SHA256 + uint8_t parsed[this->get_size()]; if (!parse_hex(expected, parsed, this->get_size())) { return false; } @@ -50,7 +50,7 @@ class HashBase { virtual size_t get_size() const = 0; protected: - uint8_t digest_[32]; // Common digest storage, sized for largest hash (SHA256) + uint8_t digest_[32]; // Storage sized for max(MD5=16, SHA256=32) bytes }; } // namespace esphome From 549626bee2b352508a486091b0fe0bfd3b6deebc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 10:39:56 -0500 Subject: [PATCH 1392/1626] Fix flakey password auth failure integration test (#10883) --- .../test_host_mode_api_password.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/integration/test_host_mode_api_password.py b/tests/integration/test_host_mode_api_password.py index 825c2c55f2..5c5e689e45 100644 --- a/tests/integration/test_host_mode_api_password.py +++ b/tests/integration/test_host_mode_api_password.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from aioesphomeapi import APIConnectionError +from aioesphomeapi import APIConnectionError, InvalidAuthAPIError import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -48,6 +48,22 @@ async def test_host_mode_api_password( assert len(states) > 0 # Test with wrong password - should fail - with pytest.raises(APIConnectionError, match="Invalid password"): - async with api_client_connected(password="wrong_password"): - pass # Should not reach here + # Try connecting with wrong password + try: + async with api_client_connected( + password="wrong_password", timeout=5 + ) as client: + # If we get here without exception, try to use the connection + # which should fail if auth failed + await client.device_info_and_list_entities() + # If we successfully got device info and entities, auth didn't fail properly + pytest.fail("Connection succeeded with wrong password") + except (InvalidAuthAPIError, APIConnectionError) as e: + # Expected - auth should fail + # Accept either InvalidAuthAPIError or generic APIConnectionError + # since the client might not always distinguish + assert ( + "password" in str(e).lower() + or "auth" in str(e).lower() + or "invalid" in str(e).lower() + ) From 74f09a2b5986d1b05d71db22e38cf6250bdaa970 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Sep 2025 11:55:43 -0400 Subject: [PATCH 1393/1626] [core] Rename to clean-platform to clean-all (#10876) --- esphome/__main__.py | 19 ++++----- esphome/dashboard/web_server.py | 10 +++-- esphome/writer.py | 10 +++-- tests/unit_tests/test_main.py | 74 +++++++++++++++------------------ tests/unit_tests/test_writer.py | 61 +++++++++++++++------------ 5 files changed, 89 insertions(+), 85 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 27aced5f33..42880e6cfc 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -731,11 +731,11 @@ def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None: return clean_mqtt(config, args) -def command_clean_platform(args: ArgsProtocol, config: ConfigType) -> int | None: +def command_clean_all(args: ArgsProtocol) -> int | None: try: - writer.clean_platform() + writer.clean_all(args.configuration) except OSError as err: - _LOGGER.error("Error deleting platform files: %s", err) + _LOGGER.error("Error cleaning all files: %s", err) return 1 _LOGGER.info("Done!") return 0 @@ -931,6 +931,7 @@ PRE_CONFIG_ACTIONS = { "dashboard": command_dashboard, "vscode": command_vscode, "update-all": command_update_all, + "clean-all": command_clean_all, } POST_CONFIG_ACTIONS = { @@ -941,7 +942,6 @@ POST_CONFIG_ACTIONS = { "run": command_run, "clean": command_clean, "clean-mqtt": command_clean_mqtt, - "clean-platform": command_clean_platform, "mqtt-fingerprint": command_mqtt_fingerprint, "idedata": command_idedata, "rename": command_rename, @@ -951,7 +951,6 @@ POST_CONFIG_ACTIONS = { SIMPLE_CONFIG_ACTIONS = [ "clean", "clean-mqtt", - "clean-platform", "config", ] @@ -1156,11 +1155,9 @@ def parse_args(argv): "configuration", help="Your YAML configuration file(s).", nargs="+" ) - parser_clean = subparsers.add_parser( - "clean-platform", help="Delete all platform files." - ) - parser_clean.add_argument( - "configuration", help="Your YAML configuration file(s).", nargs="+" + parser_clean_all = subparsers.add_parser("clean-all", help="Clean all files.") + parser_clean_all.add_argument( + "configuration", help="Your YAML configuration directory.", nargs="*" ) parser_dashboard = subparsers.add_parser( @@ -1209,7 +1206,7 @@ def parse_args(argv): parser_update = subparsers.add_parser("update-all") parser_update.add_argument( - "configuration", help="Your YAML configuration file directories.", nargs="+" + "configuration", help="Your YAML configuration file or directory.", nargs="+" ) parser_idedata = subparsers.add_parser("idedata") diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index a4c24369a3..e3a0013e2f 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -479,10 +479,12 @@ class EsphomeCleanMqttHandler(EsphomeCommandWebSocket): return [*DASHBOARD_COMMAND, "clean-mqtt", config_file] -class EsphomeCleanPlatformHandler(EsphomeCommandWebSocket): +class EsphomeCleanAllHandler(EsphomeCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - return [*DASHBOARD_COMMAND, "clean-platform", config_file] + clean_build_dir = json_message.get("clean_build_dir", True) + if clean_build_dir: + return [*DASHBOARD_COMMAND, "clean-all", settings.config_dir] + return [*DASHBOARD_COMMAND, "clean-all"] class EsphomeCleanHandler(EsphomeCommandWebSocket): @@ -1319,7 +1321,7 @@ def make_app(debug=get_bool_env(ENV_DEV)) -> tornado.web.Application: (f"{rel}compile", EsphomeCompileHandler), (f"{rel}validate", EsphomeValidateHandler), (f"{rel}clean-mqtt", EsphomeCleanMqttHandler), - (f"{rel}clean-platform", EsphomeCleanPlatformHandler), + (f"{rel}clean-all", EsphomeCleanAllHandler), (f"{rel}clean", EsphomeCleanHandler), (f"{rel}vscode", EsphomeVscodeHandler), (f"{rel}ace", EsphomeAceEditorHandler), diff --git a/esphome/writer.py b/esphome/writer.py index 718041876a..403cd8165d 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -335,13 +335,15 @@ def clean_build(): shutil.rmtree(cache_dir) -def clean_platform(): +def clean_all(configuration: list[str]): import shutil # Clean entire build dir - if CORE.build_path.is_dir(): - _LOGGER.info("Deleting %s", CORE.build_path) - shutil.rmtree(CORE.build_path) + for dir in configuration: + buid_dir = Path(dir) / ".esphome" + if buid_dir.is_dir(): + _LOGGER.info("Deleting %s", buid_dir) + shutil.rmtree(buid_dir) # Clean PlatformIO project files try: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 8799ac56ff..e35378145a 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -17,7 +17,7 @@ from esphome import platformio_api from esphome.__main__ import ( Purpose, choose_upload_log_host, - command_clean_platform, + command_clean_all, command_rename, command_update_all, command_wizard, @@ -1857,33 +1857,31 @@ esp32: assert "can only concatenate str" not in clean_output -def test_command_clean_platform_success( +def test_command_clean_all_success( caplog: pytest.LogCaptureFixture, ) -> None: - """Test command_clean_platform when writer.clean_platform() succeeds.""" - args = MockArgs() - config = {} + """Test command_clean_all when writer.clean_all() succeeds.""" + args = MockArgs(configuration=["/path/to/config1", "/path/to/config2"]) # Set logger level to capture INFO messages with ( caplog.at_level(logging.INFO), - patch("esphome.writer.clean_platform") as mock_clean_platform, + patch("esphome.writer.clean_all") as mock_clean_all, ): - result = command_clean_platform(args, config) + result = command_clean_all(args) assert result == 0 - mock_clean_platform.assert_called_once() + mock_clean_all.assert_called_once_with(["/path/to/config1", "/path/to/config2"]) # Check that success message was logged assert "Done!" in caplog.text -def test_command_clean_platform_oserror( +def test_command_clean_all_oserror( caplog: pytest.LogCaptureFixture, ) -> None: - """Test command_clean_platform when writer.clean_platform() raises OSError.""" - args = MockArgs() - config = {} + """Test command_clean_all when writer.clean_all() raises OSError.""" + args = MockArgs(configuration=["/path/to/config1"]) # Create a mock OSError with a specific message mock_error = OSError("Permission denied: cannot delete directory") @@ -1891,30 +1889,27 @@ def test_command_clean_platform_oserror( # Set logger level to capture ERROR and INFO messages with ( caplog.at_level(logging.INFO), - patch( - "esphome.writer.clean_platform", side_effect=mock_error - ) as mock_clean_platform, + patch("esphome.writer.clean_all", side_effect=mock_error) as mock_clean_all, ): - result = command_clean_platform(args, config) + result = command_clean_all(args) assert result == 1 - mock_clean_platform.assert_called_once() + mock_clean_all.assert_called_once_with(["/path/to/config1"]) # Check that error message was logged assert ( - "Error deleting platform files: Permission denied: cannot delete directory" + "Error cleaning all files: Permission denied: cannot delete directory" in caplog.text ) # Should not have success message assert "Done!" not in caplog.text -def test_command_clean_platform_oserror_no_message( +def test_command_clean_all_oserror_no_message( caplog: pytest.LogCaptureFixture, ) -> None: - """Test command_clean_platform when writer.clean_platform() raises OSError without message.""" - args = MockArgs() - config = {} + """Test command_clean_all when writer.clean_all() raises OSError without message.""" + args = MockArgs(configuration=["/path/to/config1"]) # Create a mock OSError without a message mock_error = OSError() @@ -1922,34 +1917,33 @@ def test_command_clean_platform_oserror_no_message( # Set logger level to capture ERROR and INFO messages with ( caplog.at_level(logging.INFO), - patch( - "esphome.writer.clean_platform", side_effect=mock_error - ) as mock_clean_platform, + patch("esphome.writer.clean_all", side_effect=mock_error) as mock_clean_all, ): - result = command_clean_platform(args, config) + result = command_clean_all(args) assert result == 1 - mock_clean_platform.assert_called_once() + mock_clean_all.assert_called_once_with(["/path/to/config1"]) # Check that error message was logged (should show empty string for OSError without message) - assert "Error deleting platform files:" in caplog.text + assert "Error cleaning all files:" in caplog.text # Should not have success message assert "Done!" not in caplog.text -def test_command_clean_platform_args_and_config_ignored() -> None: - """Test that command_clean_platform ignores args and config parameters.""" - # Test with various args and config to ensure they don't affect the function - args1 = MockArgs(name="test1", file="test.bin") - config1 = {"wifi": {"ssid": "test"}} +def test_command_clean_all_args_used() -> None: + """Test that command_clean_all uses args.configuration parameter.""" + # Test with different configuration paths + args1 = MockArgs(configuration=["/path/to/config1"]) + args2 = MockArgs(configuration=["/path/to/config2", "/path/to/config3"]) - args2 = MockArgs(name="test2", dashboard=True) - config2 = {"api": {}, "ota": {}} - - with patch("esphome.writer.clean_platform") as mock_clean_platform: - result1 = command_clean_platform(args1, config1) - result2 = command_clean_platform(args2, config2) + with patch("esphome.writer.clean_all") as mock_clean_all: + result1 = command_clean_all(args1) + result2 = command_clean_all(args2) assert result1 == 0 assert result2 == 0 - assert mock_clean_platform.call_count == 2 + assert mock_clean_all.call_count == 2 + + # Verify the correct configuration paths were passed + mock_clean_all.assert_any_call(["/path/to/config1"]) + mock_clean_all.assert_any_call(["/path/to/config2", "/path/to/config3"]) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index dc5fbf8db5..66e0b6cb67 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -738,16 +738,24 @@ def test_write_cpp_with_duplicate_markers( @patch("esphome.writer.CORE") -def test_clean_platform( +def test_clean_all( mock_core: MagicMock, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: - """Test clean_platform removes build and PlatformIO dirs.""" - # Create build directory - build_dir = tmp_path / "build" - build_dir.mkdir() - (build_dir / "dummy.txt").write_text("x") + """Test clean_all removes build and PlatformIO dirs.""" + # Create build directories for multiple configurations + config1_dir = tmp_path / "config1" + config2_dir = tmp_path / "config2" + config1_dir.mkdir() + config2_dir.mkdir() + + build_dir1 = config1_dir / ".esphome" + build_dir2 = config2_dir / ".esphome" + build_dir1.mkdir() + build_dir2.mkdir() + (build_dir1 / "dummy.txt").write_text("x") + (build_dir2 / "dummy.txt").write_text("x") # Create PlatformIO directories pio_cache = tmp_path / "pio_cache" @@ -758,9 +766,6 @@ def test_clean_platform( d.mkdir() (d / "keep").write_text("x") - # Setup CORE - mock_core.build_path = build_dir - # Mock ProjectConfig with patch( "platformio.project.config.ProjectConfig.get_instance" @@ -780,13 +785,14 @@ def test_clean_platform( mock_config.get.side_effect = cfg_get # Call - from esphome.writer import clean_platform + from esphome.writer import clean_all with caplog.at_level("INFO"): - clean_platform() + clean_all([str(config1_dir), str(config2_dir)]) # Verify deletions - assert not build_dir.exists() + assert not build_dir1.exists() + assert not build_dir2.exists() assert not pio_cache.exists() assert not pio_packages.exists() assert not pio_platforms.exists() @@ -794,7 +800,8 @@ def test_clean_platform( # Verify logging mentions each assert "Deleting" in caplog.text - assert str(build_dir) in caplog.text + assert str(build_dir1) in caplog.text + assert str(build_dir2) in caplog.text assert "PlatformIO cache" in caplog.text assert "PlatformIO packages" in caplog.text assert "PlatformIO platforms" in caplog.text @@ -802,28 +809,29 @@ def test_clean_platform( @patch("esphome.writer.CORE") -def test_clean_platform_platformio_not_available( +def test_clean_all_platformio_not_available( mock_core: MagicMock, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: - """Test clean_platform when PlatformIO is not available.""" - # Build dir - build_dir = tmp_path / "build" + """Test clean_all when PlatformIO is not available.""" + # Build dirs + config_dir = tmp_path / "config" + config_dir.mkdir() + build_dir = config_dir / ".esphome" build_dir.mkdir() - mock_core.build_path = build_dir # PlatformIO dirs that should remain untouched pio_cache = tmp_path / "pio_cache" pio_cache.mkdir() - from esphome.writer import clean_platform + from esphome.writer import clean_all with ( patch.dict("sys.modules", {"platformio.project.config": None}), caplog.at_level("INFO"), ): - clean_platform() + clean_all([str(config_dir)]) # Build dir removed, PlatformIO dirs remain assert not build_dir.exists() @@ -834,14 +842,15 @@ def test_clean_platform_platformio_not_available( @patch("esphome.writer.CORE") -def test_clean_platform_partial_exists( +def test_clean_all_partial_exists( mock_core: MagicMock, tmp_path: Path, ) -> None: - """Test clean_platform when only build dir exists.""" - build_dir = tmp_path / "build" + """Test clean_all when only some build dirs exist.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + build_dir = config_dir / ".esphome" build_dir.mkdir() - mock_core.build_path = build_dir with patch( "platformio.project.config.ProjectConfig.get_instance" @@ -853,8 +862,8 @@ def test_clean_platform_partial_exists( tmp_path / "does_not_exist" ) - from esphome.writer import clean_platform + from esphome.writer import clean_all - clean_platform() + clean_all([str(config_dir)]) assert not build_dir.exists() From 56c16e68938f1e460f29e36f1dc0a8d6d404b82c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 11:08:45 -0500 Subject: [PATCH 1394/1626] [text_sensor] Convert LOG_TEXT_SENSOR macro to function to reduce flash usage --- esphome/components/ccs811/ccs811.cpp | 2 +- esphome/components/text_sensor/text_sensor.cpp | 17 +++++++++++++++++ esphome/components/text_sensor/text_sensor.h | 13 +++---------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index 40c5318339..84355f2793 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -155,7 +155,7 @@ void CCS811Component::dump_config() { LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "CO2 Sensor", this->co2_); LOG_SENSOR(" ", "TVOC Sensor", this->tvoc_); - LOG_TEXT_SENSOR(" ", "Firmware Version Sensor", this->version_) + LOG_TEXT_SENSOR(" ", "Firmware Version Sensor", this->version_); if (this->baseline_) { ESP_LOGCONFIG(TAG, " Baseline: %04X", *this->baseline_); } else { diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 72b540b84c..ead04a9899 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -6,6 +6,23 @@ namespace text_sensor { static const char *const TAG = "text_sensor"; +// Function implementation of LOG_TEXT_SENSOR macro to reduce code size +void log_text_sensor(const char *tag, const char *prefix, const char *type, TextSensor *obj) { + if (obj == nullptr) { + return; + } + + ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); + + if (!obj->get_device_class_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class_ref().c_str()); + } + + if (!obj->get_icon_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon_ref().c_str()); + } +} + void TextSensor::publish_state(const std::string &state) { this->raw_state = state; if (this->raw_callback_) { diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 3ab88e2d91..abbea27b59 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -11,16 +11,9 @@ namespace esphome { namespace text_sensor { -#define LOG_TEXT_SENSOR(prefix, type, obj) \ - if ((obj) != nullptr) { \ - ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_device_class_ref().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class_ref().c_str()); \ - } \ - if (!(obj)->get_icon_ref().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ - } \ - } +void log_text_sensor(const char *tag, const char *prefix, const char *type, TextSensor *obj); + +#define LOG_TEXT_SENSOR(prefix, type, obj) log_text_sensor(TAG, prefix, LOG_STR_LITERAL(type), obj) #define SUB_TEXT_SENSOR(name) \ protected: \ From f9c494ad9fa237f9b475475a651f0b0cc1c7de4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 11:09:32 -0500 Subject: [PATCH 1395/1626] Update esphome/components/text_sensor/text_sensor.cpp --- esphome/components/text_sensor/text_sensor.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index ead04a9899..17bf20466e 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -6,7 +6,6 @@ namespace text_sensor { static const char *const TAG = "text_sensor"; -// Function implementation of LOG_TEXT_SENSOR macro to reduce code size void log_text_sensor(const char *tag, const char *prefix, const char *type, TextSensor *obj) { if (obj == nullptr) { return; From 62a466c0136d1ae48c668b5b1054c9e3aea6e3a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 14:20:05 -0500 Subject: [PATCH 1396/1626] [select] Remove STL algorithm overhead to reduce flash usage --- esphome/components/select/select.cpp | 9 +++++---- esphome/components/select/select_call.cpp | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index beb72aa320..16e8288ca1 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -34,11 +34,12 @@ size_t Select::size() const { optional Select::index_of(const std::string &option) const { const auto &options = traits.get_options(); - auto it = std::find(options.begin(), options.end(), option); - if (it == options.end()) { - return {}; + for (size_t i = 0; i < options.size(); i++) { + if (options[i] == option) { + return i; + } } - return std::distance(options.begin(), it); + return {}; } optional Select::active_index() const { diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index a8272f8622..dd398b4052 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -107,7 +107,7 @@ void SelectCall::perform() { } } - if (std::find(options.begin(), options.end(), target_value) == options.end()) { + if (!parent->has_option(target_value)) { ESP_LOGW(TAG, "'%s' - Option %s is not a valid option", name, target_value.c_str()); return; } From 1da9345af085b3b949205a049be5166f514582de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 14:24:25 -0500 Subject: [PATCH 1397/1626] [climate] Remove STL algorithm overhead in save_state() method --- esphome/components/climate/climate.cpp | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index be56310b35..c820831f90 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -367,9 +367,11 @@ void Climate::save_state_() { state.uses_custom_fan_mode = true; const auto &supported = traits.get_supported_custom_fan_modes(); std::vector vec{supported.begin(), supported.end()}; - auto it = std::find(vec.begin(), vec.end(), custom_fan_mode); - if (it != vec.end()) { - state.custom_fan_mode = std::distance(vec.begin(), it); + for (size_t i = 0; i < vec.size(); i++) { + if (vec[i] == custom_fan_mode.value()) { + state.custom_fan_mode = i; + break; + } } } if (traits.get_supports_presets() && preset.has_value()) { @@ -380,10 +382,11 @@ void Climate::save_state_() { state.uses_custom_preset = true; const auto &supported = traits.get_supported_custom_presets(); std::vector vec{supported.begin(), supported.end()}; - auto it = std::find(vec.begin(), vec.end(), custom_preset); - // only set custom preset if value exists, otherwise leave it as is - if (it != vec.cend()) { - state.custom_preset = std::distance(vec.begin(), it); + for (size_t i = 0; i < vec.size(); i++) { + if (vec[i] == custom_preset.value()) { + state.custom_preset = i; + break; + } } } if (traits.get_supports_swing_modes()) { From f62e66e52b3ad651ce5f03133b95555d5a8c82b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 14:31:08 -0500 Subject: [PATCH 1398/1626] [web_server] Remove std::find_if overhead matching IDF implementation --- esphome/components/web_server/web_server.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 0a97e542c3..03bc17f4fa 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -108,14 +108,14 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) void DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) { DeferredEvent item(source, message_generator); - auto iter = std::find_if(this->deferred_queue_.begin(), this->deferred_queue_.end(), - [&item](const DeferredEvent &test) -> bool { return test == item; }); - - if (iter != this->deferred_queue_.end()) { - (*iter) = item; - } else { - this->deferred_queue_.push_back(item); + // Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size + for (auto &event : this->deferred_queue_) { + if (event == item) { + event = item; + return; + } } + this->deferred_queue_.push_back(item); } void DeferredUpdateEventSource::process_deferred_queue_() { From 829b6cfe6ad2a319208cbc11eee310188bed9750 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 14:33:19 -0500 Subject: [PATCH 1399/1626] review --- esphome/components/climate/climate.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index c820831f90..e7a454d459 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -368,7 +368,7 @@ void Climate::save_state_() { const auto &supported = traits.get_supported_custom_fan_modes(); std::vector vec{supported.begin(), supported.end()}; for (size_t i = 0; i < vec.size(); i++) { - if (vec[i] == custom_fan_mode.value()) { + if (vec[i] == custom_fan_mode) { state.custom_fan_mode = i; break; } @@ -383,7 +383,7 @@ void Climate::save_state_() { const auto &supported = traits.get_supported_custom_presets(); std::vector vec{supported.begin(), supported.end()}; for (size_t i = 0; i < vec.size(); i++) { - if (vec[i] == custom_preset.value()) { + if (vec[i] == custom_preset) { state.custom_preset = i; break; } From 1c229947a862f4e47c6341ce6a92bc1107e28c09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Sep 2025 14:34:23 -0500 Subject: [PATCH 1400/1626] Bump github/codeql-action from 3.30.3 to 3.30.4 (#10886) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 943a35b32a..e31e547b75 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 + uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 + uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 with: category: "/language:${{matrix.language}}" From 7af77d0f827aed421e3a9bba001b3786e8b6f0e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Sep 2025 19:39:14 +0000 Subject: [PATCH 1401/1626] Bump ruff from 0.13.1 to 0.13.2 (#10885) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cab433c7f9..818f360860 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.13.1 + rev: v0.13.2 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 2c78eadf45..59ea77fd2d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.8 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.13.1 # also change in .pre-commit-config.yaml when updating +ruff==0.13.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From c3266db03de4a443375f539fc5eb75e10355c282 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 14:47:32 -0500 Subject: [PATCH 1402/1626] [version] Reduce flash usage by optimizing string concatenation in setup() --- esphome/components/version/version_text_sensor.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index ed093595cc..65dbfd27cf 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -2,6 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" #include "esphome/core/version.h" +#include "esphome/core/helpers.h" namespace esphome { namespace version { @@ -12,7 +13,7 @@ void VersionTextSensor::setup() { if (this->hide_timestamp_) { this->publish_state(ESPHOME_VERSION); } else { - this->publish_state(ESPHOME_VERSION " " + App.get_compilation_time()); + this->publish_state(str_sprintf(ESPHOME_VERSION " %s", App.get_compilation_time().c_str())); } } float VersionTextSensor::get_setup_priority() const { return setup_priority::DATA; } From 3eb502b32868ba17763e5925f9c9c84be12917a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 15:53:21 -0500 Subject: [PATCH 1403/1626] Add sha256 support (#10882) --- CODEOWNERS | 1 + esphome/components/md5/md5.cpp | 26 ------ esphome/components/md5/md5.h | 30 +++---- esphome/components/sha256/__init__.py | 22 +++++ esphome/components/sha256/sha256.cpp | 83 +++++++++++++++++++ esphome/components/sha256/sha256.h | 56 +++++++++++++ esphome/core/defines.h | 1 + esphome/core/hash_base.h | 56 +++++++++++++ esphome/core/helpers.h | 11 +++ tests/components/sha256/common.yaml | 32 +++++++ tests/components/sha256/test.bk72xx-ard.yaml | 1 + tests/components/sha256/test.esp32-idf.yaml | 1 + tests/components/sha256/test.esp8266-ard.yaml | 1 + tests/components/sha256/test.host.yaml | 1 + tests/components/sha256/test.rp2040-ard.yaml | 1 + 15 files changed, 277 insertions(+), 46 deletions(-) create mode 100644 esphome/components/sha256/__init__.py create mode 100644 esphome/components/sha256/sha256.cpp create mode 100644 esphome/components/sha256/sha256.h create mode 100644 esphome/core/hash_base.h create mode 100644 tests/components/sha256/common.yaml create mode 100644 tests/components/sha256/test.bk72xx-ard.yaml create mode 100644 tests/components/sha256/test.esp32-idf.yaml create mode 100644 tests/components/sha256/test.esp8266-ard.yaml create mode 100644 tests/components/sha256/test.host.yaml create mode 100644 tests/components/sha256/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b35b11c705..3747acd2b5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -407,6 +407,7 @@ esphome/components/sensor/* @esphome/core esphome/components/sfa30/* @ghsensdev esphome/components/sgp40/* @SenexCrenshaw esphome/components/sgp4x/* @martgras @SenexCrenshaw +esphome/components/sha256/* @esphome/core esphome/components/shelly_dimmer/* @edge90 @rnauber esphome/components/sht3xd/* @mrtoy-me esphome/components/sht4x/* @sjtrny diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index 21bd2e1cab..866f00eda4 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -39,32 +39,6 @@ void MD5Digest::add(const uint8_t *data, size_t len) { br_md5_update(&this->ctx_ void MD5Digest::calculate() { br_md5_out(&this->ctx_, this->digest_); } #endif // USE_RP2040 -void MD5Digest::get_bytes(uint8_t *output) { memcpy(output, this->digest_, 16); } - -void MD5Digest::get_hex(char *output) { - for (size_t i = 0; i < 16; i++) { - uint8_t byte = this->digest_[i]; - output[i * 2] = format_hex_char(byte >> 4); - output[i * 2 + 1] = format_hex_char(byte & 0x0F); - } -} - -bool MD5Digest::equals_bytes(const uint8_t *expected) { - for (size_t i = 0; i < 16; i++) { - if (expected[i] != this->digest_[i]) { - return false; - } - } - return true; -} - -bool MD5Digest::equals_hex(const char *expected) { - uint8_t parsed[16]; - if (!parse_hex(expected, parsed, 16)) - return false; - return equals_bytes(parsed); -} - } // namespace md5 } // namespace esphome #endif diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index be1df40423..b0da2c0a3b 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -3,6 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_MD5 +#include "esphome/core/hash_base.h" + #ifdef USE_ESP32 #include "esp_rom_md5.h" #define MD5_CTX_TYPE md5_context_t @@ -26,38 +28,26 @@ namespace esphome { namespace md5 { -class MD5Digest { +class MD5Digest : public HashBase { public: MD5Digest() = default; - ~MD5Digest() = default; + ~MD5Digest() override = default; /// Initialize a new MD5 digest computation. - void init(); + void init() override; /// Add bytes of data for the digest. - void add(const uint8_t *data, size_t len); - void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + void add(const uint8_t *data, size_t len) override; + using HashBase::add; // Bring base class overload into scope /// Compute the digest, based on the provided data. - void calculate(); + void calculate() override; - /// Retrieve the MD5 digest as bytes. - /// The output must be able to hold 16 bytes or more. - void get_bytes(uint8_t *output); - - /// Retrieve the MD5 digest as hex characters. - /// The output must be able to hold 32 bytes or more. - void get_hex(char *output); - - /// Compare the digest against a provided byte-encoded digest (16 bytes). - bool equals_bytes(const uint8_t *expected); - - /// Compare the digest against a provided hex-encoded digest (32 bytes). - bool equals_hex(const char *expected); + /// Get the size of the hash in bytes (16 for MD5) + size_t get_size() const override { return 16; } protected: MD5_CTX_TYPE ctx_{}; - uint8_t digest_[16]; }; } // namespace md5 diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py new file mode 100644 index 0000000000..f07157416d --- /dev/null +++ b/esphome/components/sha256/__init__.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.helpers import IS_MACOS +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] + +sha256_ns = cg.esphome_ns.namespace("sha256") + +CONFIG_SCHEMA = cv.Schema({}) + + +async def to_code(config: ConfigType) -> None: + # Add OpenSSL library for host platform + if not CORE.is_host: + return + if IS_MACOS: + # macOS needs special handling for Homebrew OpenSSL + cg.add_build_flag("-I/opt/homebrew/opt/openssl/include") + cg.add_build_flag("-L/opt/homebrew/opt/openssl/lib") + cg.add_build_flag("-lcrypto") diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp new file mode 100644 index 0000000000..199460acbc --- /dev/null +++ b/esphome/components/sha256/sha256.cpp @@ -0,0 +1,83 @@ +#include "sha256.h" + +// Only compile SHA256 implementation on platforms that support it +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) + +#include "esphome/core/helpers.h" +#include + +namespace esphome::sha256 { + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +SHA256::~SHA256() { mbedtls_sha256_free(&this->ctx_); } + +void SHA256::init() { + mbedtls_sha256_init(&this->ctx_); + mbedtls_sha256_starts(&this->ctx_, 0); // 0 = SHA256, not SHA224 +} + +void SHA256::add(const uint8_t *data, size_t len) { mbedtls_sha256_update(&this->ctx_, data, len); } + +void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_, this->digest_); } + +#elif defined(USE_ESP8266) || defined(USE_RP2040) + +SHA256::~SHA256() = default; + +void SHA256::init() { + br_sha256_init(&this->ctx_); + this->calculated_ = false; +} + +void SHA256::add(const uint8_t *data, size_t len) { br_sha256_update(&this->ctx_, data, len); } + +void SHA256::calculate() { + if (!this->calculated_) { + br_sha256_out(&this->ctx_, this->digest_); + this->calculated_ = true; + } +} + +#elif defined(USE_HOST) + +SHA256::~SHA256() { + if (this->ctx_) { + EVP_MD_CTX_free(this->ctx_); + } +} + +void SHA256::init() { + if (this->ctx_) { + EVP_MD_CTX_free(this->ctx_); + } + this->ctx_ = EVP_MD_CTX_new(); + EVP_DigestInit_ex(this->ctx_, EVP_sha256(), nullptr); + this->calculated_ = false; +} + +void SHA256::add(const uint8_t *data, size_t len) { + if (!this->ctx_) { + this->init(); + } + EVP_DigestUpdate(this->ctx_, data, len); +} + +void SHA256::calculate() { + if (!this->ctx_) { + this->init(); + } + if (!this->calculated_) { + unsigned int len = 32; + EVP_DigestFinal_ex(this->ctx_, this->digest_, &len); + this->calculated_ = true; + } +} + +#else +#error "SHA256 not supported on this platform" +#endif + +} // namespace esphome::sha256 + +#endif // Platform check diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h new file mode 100644 index 0000000000..bb089bc314 --- /dev/null +++ b/esphome/components/sha256/sha256.h @@ -0,0 +1,56 @@ +#pragma once + +#include "esphome/core/defines.h" + +// Only define SHA256 on platforms that support it +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) + +#include +#include +#include +#include "esphome/core/hash_base.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#include "mbedtls/sha256.h" +#elif defined(USE_ESP8266) || defined(USE_RP2040) +#include +#elif defined(USE_HOST) +#include +#else +#error "SHA256 not supported on this platform" +#endif + +namespace esphome::sha256 { + +class SHA256 : public esphome::HashBase { + public: + SHA256() = default; + ~SHA256() override; + + void init() override; + void add(const uint8_t *data, size_t len) override; + using HashBase::add; // Bring base class overload into scope + void add(const std::string &data) { this->add((const uint8_t *) data.c_str(), data.length()); } + + void calculate() override; + + /// Get the size of the hash in bytes (32 for SHA256) + size_t get_size() const override { return 32; } + + protected: +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + mbedtls_sha256_context ctx_{}; +#elif defined(USE_ESP8266) || defined(USE_RP2040) + br_sha256_context ctx_{}; + bool calculated_{false}; +#elif defined(USE_HOST) + EVP_MD_CTX *ctx_{nullptr}; + bool calculated_{false}; +#else +#error "SHA256 not supported on this platform" +#endif +}; + +} // namespace esphome::sha256 + +#endif // Platform check diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 784e8cd2b3..ef93fd0b65 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -116,6 +116,7 @@ #define USE_API_PLAINTEXT #define USE_API_SERVICES #define USE_MD5 +#define USE_SHA256 #define USE_MQTT #define USE_NETWORK #define USE_ONLINE_IMAGE_BMP_SUPPORT diff --git a/esphome/core/hash_base.h b/esphome/core/hash_base.h new file mode 100644 index 0000000000..4eb6a89f53 --- /dev/null +++ b/esphome/core/hash_base.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include "esphome/core/helpers.h" + +namespace esphome { + +/// Base class for hash algorithms +class HashBase { + public: + virtual ~HashBase() = default; + + /// Initialize a new hash computation + virtual void init() = 0; + + /// Add bytes of data for the hash + virtual void add(const uint8_t *data, size_t len) = 0; + void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + + /// Compute the hash based on provided data + virtual void calculate() = 0; + + /// Retrieve the hash as bytes + void get_bytes(uint8_t *output) { memcpy(output, this->digest_, this->get_size()); } + + /// Retrieve the hash as hex characters + void get_hex(char *output) { + for (size_t i = 0; i < this->get_size(); i++) { + uint8_t byte = this->digest_[i]; + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); + } + } + + /// Compare the hash against a provided byte-encoded hash + bool equals_bytes(const uint8_t *expected) { return memcmp(this->digest_, expected, this->get_size()) == 0; } + + /// Compare the hash against a provided hex-encoded hash + bool equals_hex(const char *expected) { + uint8_t parsed[this->get_size()]; + if (!parse_hex(expected, parsed, this->get_size())) { + return false; + } + return this->equals_bytes(parsed); + } + + /// Get the size of the hash in bytes (16 for MD5, 32 for SHA256) + virtual size_t get_size() const = 0; + + protected: + uint8_t digest_[32]; // Storage sized for max(MD5=16, SHA256=32) bytes +}; + +} // namespace esphome diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 21aa159b25..a28718de5a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -82,6 +82,16 @@ template constexpr T byteswap(T n) { return m; } template<> constexpr uint8_t byteswap(uint8_t n) { return n; } +#ifdef USE_LIBRETINY +// LibreTiny's Beken framework redefines __builtin_bswap functions as non-constexpr +template<> inline uint16_t byteswap(uint16_t n) { return __builtin_bswap16(n); } +template<> inline uint32_t byteswap(uint32_t n) { return __builtin_bswap32(n); } +template<> inline uint64_t byteswap(uint64_t n) { return __builtin_bswap64(n); } +template<> inline int8_t byteswap(int8_t n) { return n; } +template<> inline int16_t byteswap(int16_t n) { return __builtin_bswap16(n); } +template<> inline int32_t byteswap(int32_t n) { return __builtin_bswap32(n); } +template<> inline int64_t byteswap(int64_t n) { return __builtin_bswap64(n); } +#else template<> constexpr uint16_t byteswap(uint16_t n) { return __builtin_bswap16(n); } template<> constexpr uint32_t byteswap(uint32_t n) { return __builtin_bswap32(n); } template<> constexpr uint64_t byteswap(uint64_t n) { return __builtin_bswap64(n); } @@ -89,6 +99,7 @@ template<> constexpr int8_t byteswap(int8_t n) { return n; } template<> constexpr int16_t byteswap(int16_t n) { return __builtin_bswap16(n); } template<> constexpr int32_t byteswap(int32_t n) { return __builtin_bswap32(n); } template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); } +#endif ///@} diff --git a/tests/components/sha256/common.yaml b/tests/components/sha256/common.yaml new file mode 100644 index 0000000000..fa884c1958 --- /dev/null +++ b/tests/components/sha256/common.yaml @@ -0,0 +1,32 @@ +esphome: + on_boot: + - lambda: |- + // Test SHA256 functionality + #ifdef USE_SHA256 + using esphome::sha256::SHA256; + SHA256 hasher; + hasher.init(); + + // Test with "Hello World" - known SHA256 + const char* test_string = "Hello World"; + hasher.add(test_string, strlen(test_string)); + hasher.calculate(); + + char hex_output[65]; + hasher.get_hex(hex_output); + hex_output[64] = '\0'; + + ESP_LOGD("SHA256", "SHA256('Hello World') = %s", hex_output); + + // Expected: a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e + const char* expected = "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e"; + if (strcmp(hex_output, expected) == 0) { + ESP_LOGI("SHA256", "Test PASSED"); + } else { + ESP_LOGE("SHA256", "Test FAILED. Expected %s", expected); + } + #else + ESP_LOGW("SHA256", "SHA256 not available on this platform"); + #endif + +sha256: diff --git a/tests/components/sha256/test.bk72xx-ard.yaml b/tests/components/sha256/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha256/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha256/test.esp32-idf.yaml b/tests/components/sha256/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha256/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha256/test.esp8266-ard.yaml b/tests/components/sha256/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha256/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha256/test.host.yaml b/tests/components/sha256/test.host.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha256/test.host.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha256/test.rp2040-ard.yaml b/tests/components/sha256/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha256/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 28f09f9ed172a45d6e5a3f7bc9d1587174385762 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Sep 2025 16:53:34 -0400 Subject: [PATCH 1404/1626] [dashboard] Fix progress bars on Windows (#10858) --- esphome/dashboard/web_server.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index e3a0013e2f..b5601c9e0f 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -283,11 +283,23 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): def _stdout_thread(self) -> None: if not self._use_popen: return + line = b"" + cr = False while True: - data = self._proc.stdout.readline() + data = self._proc.stdout.read(1) if data: - data = data.replace(b"\r", b"") - self._queue.put_nowait(data) + if data == b"\r": + cr = True + elif data == b"\n": + self._queue.put_nowait(line + b"\n") + line = b"" + cr = False + elif cr: + self._queue.put_nowait(line + b"\r") + line = data + cr = False + else: + line += data if self._proc.poll() is not None: break self._proc.wait(1.0) From cef9cf49bf4f1a7037c5c3e7bb5e160be150dece Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 25 Sep 2025 16:54:38 -0400 Subject: [PATCH 1405/1626] [htu21d] Fix I2C NACK issue and buffer overrun (#10801) --- esphome/components/htu21d/htu21d.cpp | 31 ++++++++++++---------------- esphome/components/htu21d/htu21d.h | 2 +- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/esphome/components/htu21d/htu21d.cpp b/esphome/components/htu21d/htu21d.cpp index f2e7ae93cb..a7aae16f17 100644 --- a/esphome/components/htu21d/htu21d.cpp +++ b/esphome/components/htu21d/htu21d.cpp @@ -9,8 +9,8 @@ static const char *const TAG = "htu21d"; static const uint8_t HTU21D_ADDRESS = 0x40; static const uint8_t HTU21D_REGISTER_RESET = 0xFE; -static const uint8_t HTU21D_REGISTER_TEMPERATURE = 0xF3; -static const uint8_t HTU21D_REGISTER_HUMIDITY = 0xF5; +static const uint8_t HTU21D_REGISTER_TEMPERATURE = 0xE3; +static const uint8_t HTU21D_REGISTER_HUMIDITY = 0xE5; static const uint8_t HTU21D_WRITERHT_REG_CMD = 0xE6; /**< Write RH/T User Register 1 */ static const uint8_t HTU21D_REGISTER_STATUS = 0xE7; static const uint8_t HTU21D_WRITEHEATER_REG_CMD = 0x51; /**< Write Heater Control Register */ @@ -57,7 +57,6 @@ void HTU21DComponent::update() { if (this->temperature_ != nullptr) this->temperature_->publish_state(temperature); - this->status_clear_warning(); if (this->write(&HTU21D_REGISTER_HUMIDITY, 1) != i2c::ERROR_OK) { this->status_set_warning(); @@ -79,10 +78,11 @@ void HTU21DComponent::update() { if (this->humidity_ != nullptr) this->humidity_->publish_state(humidity); - int8_t heater_level; + this->status_clear_warning(); // HTU21D does have a heater module but does not have heater level // Setting heater level to 1 in case the heater is ON + uint8_t heater_level = 0; if (this->sensor_model_ == HTU21D_SENSOR_MODEL_HTU21D) { if (this->is_heater_enabled()) { heater_level = 1; @@ -97,34 +97,30 @@ void HTU21DComponent::update() { if (this->heater_ != nullptr) this->heater_->publish_state(heater_level); - this->status_clear_warning(); }); }); } bool HTU21DComponent::is_heater_enabled() { uint8_t raw_heater; - if (this->read_register(HTU21D_REGISTER_STATUS, reinterpret_cast(&raw_heater), 2) != i2c::ERROR_OK) { + if (this->read_register(HTU21D_REGISTER_STATUS, &raw_heater, 1) != i2c::ERROR_OK) { this->status_set_warning(); return false; } - raw_heater = i2c::i2ctohs(raw_heater); - return (bool) (((raw_heater) >> (HTU21D_REG_HTRE_BIT)) & 0x01); + return (bool) ((raw_heater >> HTU21D_REG_HTRE_BIT) & 0x01); } void HTU21DComponent::set_heater(bool status) { uint8_t raw_heater; - if (this->read_register(HTU21D_REGISTER_STATUS, reinterpret_cast(&raw_heater), 2) != i2c::ERROR_OK) { + if (this->read_register(HTU21D_REGISTER_STATUS, &raw_heater, 1) != i2c::ERROR_OK) { this->status_set_warning(); return; } - raw_heater = i2c::i2ctohs(raw_heater); if (status) { - raw_heater |= (1 << (HTU21D_REG_HTRE_BIT)); + raw_heater |= (1 << HTU21D_REG_HTRE_BIT); } else { - raw_heater &= ~(1 << (HTU21D_REG_HTRE_BIT)); + raw_heater &= ~(1 << HTU21D_REG_HTRE_BIT); } - if (this->write_register(HTU21D_WRITERHT_REG_CMD, &raw_heater, 1) != i2c::ERROR_OK) { this->status_set_warning(); return; @@ -138,14 +134,13 @@ void HTU21DComponent::set_heater_level(uint8_t level) { } } -int8_t HTU21DComponent::get_heater_level() { - int8_t raw_heater; - if (this->read_register(HTU21D_READHEATER_REG_CMD, reinterpret_cast(&raw_heater), 2) != i2c::ERROR_OK) { +uint8_t HTU21DComponent::get_heater_level() { + uint8_t raw_heater; + if (this->read_register(HTU21D_READHEATER_REG_CMD, &raw_heater, 1) != i2c::ERROR_OK) { this->status_set_warning(); return 0; } - raw_heater = i2c::i2ctohs(raw_heater); - return raw_heater; + return raw_heater & 0xF; } float HTU21DComponent::get_setup_priority() const { return setup_priority::DATA; } diff --git a/esphome/components/htu21d/htu21d.h b/esphome/components/htu21d/htu21d.h index 8533875d43..9b3831b784 100644 --- a/esphome/components/htu21d/htu21d.h +++ b/esphome/components/htu21d/htu21d.h @@ -26,7 +26,7 @@ class HTU21DComponent : public PollingComponent, public i2c::I2CDevice { bool is_heater_enabled(); void set_heater(bool status); void set_heater_level(uint8_t level); - int8_t get_heater_level(); + uint8_t get_heater_level(); float get_setup_priority() const override; From 460eb219ba139a5eff179b8896999b7fde42931c Mon Sep 17 00:00:00 2001 From: Patrick Van Oosterwijck Date: Thu, 25 Sep 2025 15:45:07 -0600 Subject: [PATCH 1406/1626] [ethernet] Add LAN8670 PHY support (#10874) Co-authored-by: J. Nick Koston --- esphome/components/ethernet/__init__.py | 23 ++++++++++++++++++- .../ethernet/ethernet_component.cpp | 16 +++++++++++++ .../components/ethernet/ethernet_component.h | 1 + esphome/idf_component.yml | 4 ++++ tests/components/ethernet/common-lan8670.yaml | 14 +++++++++++ .../ethernet/test-lan8670.esp32-ard.yaml | 1 + .../ethernet/test-lan8670.esp32-idf.yaml | 1 + 7 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 tests/components/ethernet/common-lan8670.yaml create mode 100644 tests/components/ethernet/test-lan8670.esp32-ard.yaml create mode 100644 tests/components/ethernet/test-lan8670.esp32-idf.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index ef8e82a843..75b09dc8e4 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -2,9 +2,15 @@ import logging from esphome import pins import esphome.codegen as cg -from esphome.components.esp32 import add_idf_sdkconfig_option, get_esp32_variant +from esphome.components.esp32 import ( + add_idf_component, + add_idf_sdkconfig_option, + get_esp32_variant, +) from esphome.components.esp32.const import ( + VARIANT_ESP32, VARIANT_ESP32C3, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, ) @@ -75,12 +81,14 @@ ETHERNET_TYPES = { "W5500": EthernetType.ETHERNET_TYPE_W5500, "OPENETH": EthernetType.ETHERNET_TYPE_OPENETH, "DM9051": EthernetType.ETHERNET_TYPE_DM9051, + "LAN8670": EthernetType.ETHERNET_TYPE_LAN8670, } # PHY types that need compile-time defines for conditional compilation _PHY_TYPE_TO_DEFINE = { "KSZ8081": "USE_ETHERNET_KSZ8081", "KSZ8081RNA": "USE_ETHERNET_KSZ8081", + "LAN8670": "USE_ETHERNET_LAN8670", # Add other PHY types here only if they need conditional compilation } @@ -136,6 +144,14 @@ def _validate(config): else: use_address = CORE.name + config[CONF_DOMAIN] config[CONF_USE_ADDRESS] = use_address + + # Validate LAN8670 is only used with ESP32 classic or ESP32-P4 + if config[CONF_TYPE] == "LAN8670": + variant = get_esp32_variant() + if variant not in (VARIANT_ESP32, VARIANT_ESP32P4): + raise cv.Invalid( + f"LAN8670 PHY is only supported on ESP32 classic and ESP32-P4, not {variant}" + ) if config[CONF_TYPE] in SPI_ETHERNET_TYPES: if _is_framework_spi_polling_mode_supported(): if CONF_POLLING_INTERVAL in config and CONF_INTERRUPT_PIN in config: @@ -248,6 +264,7 @@ CONFIG_SCHEMA = cv.All( "W5500": SPI_SCHEMA, "OPENETH": BASE_SCHEMA, "DM9051": SPI_SCHEMA, + "LAN8670": RMII_SCHEMA, }, upper=True, ), @@ -356,5 +373,9 @@ async def to_code(config): # Also disable WiFi/BT coexistence since WiFi is disabled add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) + if config[CONF_TYPE] == "LAN8670": + # Add LAN867x 10BASE-T1S PHY support component + add_idf_component(name="espressif/lan867x", ref="2.0.0") + if CORE.using_arduino: cg.add_library("WiFi", None) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index ff14d19427..cb43b2c83c 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -9,6 +9,10 @@ #include #include "esp_event.h" +#ifdef USE_ETHERNET_LAN8670 +#include "esp_eth_phy_lan867x.h" +#endif + #ifdef USE_ETHERNET_SPI #include #include @@ -200,6 +204,12 @@ void EthernetComponent::setup() { this->phy_ = esp_eth_phy_new_ksz80xx(&phy_config); break; } +#ifdef USE_ETHERNET_LAN8670 + case ETHERNET_TYPE_LAN8670: { + this->phy_ = esp_eth_phy_new_lan867x(&phy_config); + break; + } +#endif #endif #ifdef USE_ETHERNET_SPI #if CONFIG_ETH_SPI_ETHERNET_W5500 @@ -353,6 +363,12 @@ void EthernetComponent::dump_config() { eth_type = "DM9051"; break; +#ifdef USE_ETHERNET_LAN8670 + case ETHERNET_TYPE_LAN8670: + eth_type = "LAN8670"; + break; +#endif + default: eth_type = "Unknown"; break; diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index bbb9d7fb60..fae5bb1257 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -28,6 +28,7 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_W5500, ETHERNET_TYPE_OPENETH, ETHERNET_TYPE_DM9051, + ETHERNET_TYPE_LAN8670, }; struct ManualIP { diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 687efd2b49..1a6dc8b97d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -19,3 +19,7 @@ dependencies: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: version: 1.0.1 + espressif/lan867x: + version: "2.0.0" + rules: + - if: "target in [esp32, esp32p4]" diff --git a/tests/components/ethernet/common-lan8670.yaml b/tests/components/ethernet/common-lan8670.yaml new file mode 100644 index 0000000000..ec2f24273d --- /dev/null +++ b/tests/components/ethernet/common-lan8670.yaml @@ -0,0 +1,14 @@ +ethernet: + type: LAN8670 + mdc_pin: 23 + mdio_pin: 25 + clk: + pin: 0 + mode: CLK_EXT_IN + phy_addr: 0 + power_pin: 26 + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local diff --git a/tests/components/ethernet/test-lan8670.esp32-ard.yaml b/tests/components/ethernet/test-lan8670.esp32-ard.yaml new file mode 100644 index 0000000000..914a06ae88 --- /dev/null +++ b/tests/components/ethernet/test-lan8670.esp32-ard.yaml @@ -0,0 +1 @@ +<<: !include common-lan8670.yaml diff --git a/tests/components/ethernet/test-lan8670.esp32-idf.yaml b/tests/components/ethernet/test-lan8670.esp32-idf.yaml new file mode 100644 index 0000000000..914a06ae88 --- /dev/null +++ b/tests/components/ethernet/test-lan8670.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-lan8670.yaml From eeb3ccaef76ac2df852990a7ac29bcb6f5b63d46 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:13:23 +1200 Subject: [PATCH 1407/1626] Update stale bot rules (#10891) --- .github/workflows/stale.yml | 48 +++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index da1e62c8e1..eebe583fe5 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -15,14 +15,21 @@ concurrency: jobs: stale: + if: github.repository_owner == 'esphome' runs-on: ubuntu-latest steps: - - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + # The 90 day stale policy for PRs + # - PRs + # - No PRs marked as "not-stale" + # - No Issues (see below) + - name: 90 days stale PRs + uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: - days-before-pr-stale: 90 - days-before-pr-close: 7 + days-before-stale: 90 + days-before-close: 7 days-before-issue-stale: -1 days-before-issue-close: -1 + operations-per-run: 150 remove-stale-when-updated: true stale-pr-label: "stale" exempt-pr-labels: "not-stale" @@ -30,21 +37,36 @@ jobs: There hasn't been any activity on this pull request recently. This pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days. - Thank you for your contributions. - # Use stale to automatically close issues with a - # reference to the issue tracker - close-issues: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + If you are the author of this PR, please leave a comment if you want + to keep it open. Also, please rebase your PR onto the latest dev + branch to ensure that it's up to date with the latest changes. + + Thank you for your contribution! + + # The 90 day stale policy for Issues + # - Issues + # - No Issues marked as "not-stale" + # - No PRs (see above) + - name: 90 days stale issues + uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: + days-before-stale: 90 + days-before-close: 7 days-before-pr-stale: -1 days-before-pr-close: -1 - days-before-issue-stale: 1 - days-before-issue-close: 1 remove-stale-when-updated: true stale-issue-label: "stale" exempt-issue-labels: "not-stale" stale-issue-message: > - https://github.com/esphome/esphome/issues/430 + There hasn't been any activity on this issue recently. Due to the + high number of incoming GitHub notifications, we have to clean some + of the old issues, as many of them have already been resolved with + the latest updates. + + Please make sure to update to the latest ESPHome version and + check if that solves the issue. Let us know if that works for you by + adding a comment 👍 + + This issue has now been marked as stale and will be closed if no + further activity occurs. Thank you for your contributions. From 28199c1cf8a241a29535251339f1b71eaf8647d2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:02:51 +1200 Subject: [PATCH 1408/1626] [stale] Clean up stale job (#10892) --- .github/workflows/stale.yml | 38 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index eebe583fe5..f57f0987ec 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,19 +18,19 @@ jobs: if: github.repository_owner == 'esphome' runs-on: ubuntu-latest steps: - # The 90 day stale policy for PRs - # - PRs - # - No PRs marked as "not-stale" - # - No Issues (see below) - - name: 90 days stale PRs + - name: Stale uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: - days-before-stale: 90 - days-before-close: 7 - days-before-issue-stale: -1 - days-before-issue-close: -1 - operations-per-run: 150 + debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch remove-stale-when-updated: true + operations-per-run: 150 + + # The 90 day stale policy for PRs + # - PRs + # - No PRs marked as "not-stale" + # - No Issues (see below) + days-before-pr-stale: 90 + days-before-pr-close: 7 stale-pr-label: "stale" exempt-pr-labels: "not-stale" stale-pr-message: > @@ -44,18 +44,12 @@ jobs: Thank you for your contribution! - # The 90 day stale policy for Issues - # - Issues - # - No Issues marked as "not-stale" - # - No PRs (see above) - - name: 90 days stale issues - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 - with: - days-before-stale: 90 - days-before-close: 7 - days-before-pr-stale: -1 - days-before-pr-close: -1 - remove-stale-when-updated: true + # The 90 day stale policy for Issues + # - Issues + # - No Issues marked as "not-stale" + # - No PRs (see above) + days-before-issue-stale: 90 + days-before-issue-close: 7 stale-issue-label: "stale" exempt-issue-labels: "not-stale" stale-issue-message: > From 250b94d113d4be151bd6ebdfa75e94284825db73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 19:10:31 -0500 Subject: [PATCH 1409/1626] [text_sensor] Convert LOG_TEXT_SENSOR macro to function to reduce flash usage (#10884) --- esphome/components/ccs811/ccs811.cpp | 2 +- esphome/components/text_sensor/text_sensor.cpp | 16 ++++++++++++++++ esphome/components/text_sensor/text_sensor.h | 13 +++---------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index 40c5318339..84355f2793 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -155,7 +155,7 @@ void CCS811Component::dump_config() { LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "CO2 Sensor", this->co2_); LOG_SENSOR(" ", "TVOC Sensor", this->tvoc_); - LOG_TEXT_SENSOR(" ", "Firmware Version Sensor", this->version_) + LOG_TEXT_SENSOR(" ", "Firmware Version Sensor", this->version_); if (this->baseline_) { ESP_LOGCONFIG(TAG, " Baseline: %04X", *this->baseline_); } else { diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 72b540b84c..17bf20466e 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -6,6 +6,22 @@ namespace text_sensor { static const char *const TAG = "text_sensor"; +void log_text_sensor(const char *tag, const char *prefix, const char *type, TextSensor *obj) { + if (obj == nullptr) { + return; + } + + ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); + + if (!obj->get_device_class_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, obj->get_device_class_ref().c_str()); + } + + if (!obj->get_icon_ref().empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj->get_icon_ref().c_str()); + } +} + void TextSensor::publish_state(const std::string &state) { this->raw_state = state; if (this->raw_callback_) { diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 3ab88e2d91..abbea27b59 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -11,16 +11,9 @@ namespace esphome { namespace text_sensor { -#define LOG_TEXT_SENSOR(prefix, type, obj) \ - if ((obj) != nullptr) { \ - ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ - if (!(obj)->get_device_class_ref().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Device Class: '%s'", prefix, (obj)->get_device_class_ref().c_str()); \ - } \ - if (!(obj)->get_icon_ref().empty()) { \ - ESP_LOGCONFIG(TAG, "%s Icon: '%s'", prefix, (obj)->get_icon_ref().c_str()); \ - } \ - } +void log_text_sensor(const char *tag, const char *prefix, const char *type, TextSensor *obj); + +#define LOG_TEXT_SENSOR(prefix, type, obj) log_text_sensor(TAG, prefix, LOG_STR_LITERAL(type), obj) #define SUB_TEXT_SENSOR(name) \ protected: \ From 2b12ff58746bfd6836890205b60267e1eea9d683 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 19:11:13 -0500 Subject: [PATCH 1410/1626] [esp32_ble] Reduce RAM usage and firmware size by disabling unused GATT functionality (#10862) --- esphome/components/esp32_ble/__init__.py | 13 +++++++++++++ esphome/components/esp32_ble/ble.cpp | 20 ++++++++++++++++++++ esphome/components/esp32_ble/ble.h | 16 ++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index dae9799028..0501d1c5ef 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -242,6 +242,19 @@ def final_validation(config): f"Name '{name}' is too long, maximum length is {max_length} characters" ) + # Set GATT Client/Server sdkconfig options based on which components are loaded + full_config = fv.full_config.get() + + # Check if BLE Server is needed + has_ble_server = "esp32_ble_server" in full_config + add_idf_sdkconfig_option("CONFIG_BT_GATTS_ENABLE", has_ble_server) + + # Check if BLE Client is needed (via esp32_ble_tracker or esp32_ble_client) + has_ble_client = ( + "esp32_ble_tracker" in full_config or "esp32_ble_client" in full_config + ) + add_idf_sdkconfig_option("CONFIG_BT_GATTC_ENABLE", has_ble_client) + return config diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index e22d43c0cc..6b6b19e079 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -167,6 +167,7 @@ bool ESP32BLE::ble_setup_() { } } +#ifdef USE_ESP32_BLE_SERVER if (!this->gatts_event_handlers_.empty()) { err = esp_ble_gatts_register_callback(ESP32BLE::gatts_event_handler); if (err != ESP_OK) { @@ -174,7 +175,9 @@ bool ESP32BLE::ble_setup_() { return false; } } +#endif +#ifdef USE_ESP32_BLE_CLIENT if (!this->gattc_event_handlers_.empty()) { err = esp_ble_gattc_register_callback(ESP32BLE::gattc_event_handler); if (err != ESP_OK) { @@ -182,6 +185,7 @@ bool ESP32BLE::ble_setup_() { return false; } } +#endif std::string name; if (this->name_.has_value()) { @@ -303,6 +307,7 @@ void ESP32BLE::loop() { BLEEvent *ble_event = this->ble_events_.pop(); while (ble_event != nullptr) { switch (ble_event->type_) { +#ifdef USE_ESP32_BLE_SERVER case BLEEvent::GATTS: { esp_gatts_cb_event_t event = ble_event->event_.gatts.gatts_event; esp_gatt_if_t gatts_if = ble_event->event_.gatts.gatts_if; @@ -313,6 +318,8 @@ void ESP32BLE::loop() { } break; } +#endif +#ifdef USE_ESP32_BLE_CLIENT case BLEEvent::GATTC: { esp_gattc_cb_event_t event = ble_event->event_.gattc.gattc_event; esp_gatt_if_t gattc_if = ble_event->event_.gattc.gattc_if; @@ -323,6 +330,7 @@ void ESP32BLE::loop() { } break; } +#endif case BLEEvent::GAP: { esp_gap_ble_cb_event_t gap_event = ble_event->event_.gap.gap_event; switch (gap_event) { @@ -416,13 +424,17 @@ void load_ble_event(BLEEvent *event, esp_gap_ble_cb_event_t e, esp_ble_gap_cb_pa event->load_gap_event(e, p); } +#ifdef USE_ESP32_BLE_CLIENT void load_ble_event(BLEEvent *event, esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) { event->load_gattc_event(e, i, p); } +#endif +#ifdef USE_ESP32_BLE_SERVER void load_ble_event(BLEEvent *event, esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) { event->load_gatts_event(e, i, p); } +#endif template void enqueue_ble_event(Args... args) { // Allocate an event from the pool @@ -443,8 +455,12 @@ template void enqueue_ble_event(Args... args) { // Explicit template instantiations for the friend function template void enqueue_ble_event(esp_gap_ble_cb_event_t, esp_ble_gap_cb_param_t *); +#ifdef USE_ESP32_BLE_SERVER template void enqueue_ble_event(esp_gatts_cb_event_t, esp_gatt_if_t, esp_ble_gatts_cb_param_t *); +#endif +#ifdef USE_ESP32_BLE_CLIENT template void enqueue_ble_event(esp_gattc_cb_event_t, esp_gatt_if_t, esp_ble_gattc_cb_param_t *); +#endif void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { switch (event) { @@ -484,15 +500,19 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa ESP_LOGW(TAG, "Ignoring unexpected GAP event type: %d", event); } +#ifdef USE_ESP32_BLE_SERVER void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) { enqueue_ble_event(event, gatts_if, param); } +#endif +#ifdef USE_ESP32_BLE_CLIENT void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { enqueue_ble_event(event, gattc_if, param); } +#endif float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 712787fe53..368ac644cf 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -74,17 +74,21 @@ class GAPScanEventHandler { virtual void gap_scan_event_handler(const BLEScanResult &scan_result) = 0; }; +#ifdef USE_ESP32_BLE_CLIENT class GATTcEventHandler { public: virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) = 0; }; +#endif +#ifdef USE_ESP32_BLE_SERVER class GATTsEventHandler { public: virtual void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) = 0; }; +#endif class BLEStatusEventHandler { public: @@ -123,16 +127,24 @@ class ESP32BLE : public Component { void register_gap_scan_event_handler(GAPScanEventHandler *handler) { this->gap_scan_event_handlers_.push_back(handler); } +#ifdef USE_ESP32_BLE_CLIENT void register_gattc_event_handler(GATTcEventHandler *handler) { this->gattc_event_handlers_.push_back(handler); } +#endif +#ifdef USE_ESP32_BLE_SERVER void register_gatts_event_handler(GATTsEventHandler *handler) { this->gatts_event_handlers_.push_back(handler); } +#endif void register_ble_status_event_handler(BLEStatusEventHandler *handler) { this->ble_status_event_handlers_.push_back(handler); } void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } protected: +#ifdef USE_ESP32_BLE_SERVER static void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param); +#endif +#ifdef USE_ESP32_BLE_CLIENT static void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param); +#endif static void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); bool ble_setup_(); @@ -148,8 +160,12 @@ class ESP32BLE : public Component { // Vectors (12 bytes each on 32-bit, naturally aligned to 4 bytes) std::vector gap_event_handlers_; std::vector gap_scan_event_handlers_; +#ifdef USE_ESP32_BLE_CLIENT std::vector gattc_event_handlers_; +#endif +#ifdef USE_ESP32_BLE_SERVER std::vector gatts_event_handlers_; +#endif std::vector ble_status_event_handlers_; // Large objects (size depends on template parameters, but typically aligned to 4 bytes) From 195d1be4a968f0d365c47ea4b357c21283cac0ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 19:12:39 -0500 Subject: [PATCH 1411/1626] [version] Reduce flash usage by optimizing string concatenation in setup() (#10890) --- esphome/components/version/version_text_sensor.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index ed093595cc..65dbfd27cf 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -2,6 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" #include "esphome/core/version.h" +#include "esphome/core/helpers.h" namespace esphome { namespace version { @@ -12,7 +13,7 @@ void VersionTextSensor::setup() { if (this->hide_timestamp_) { this->publish_state(ESPHOME_VERSION); } else { - this->publish_state(ESPHOME_VERSION " " + App.get_compilation_time()); + this->publish_state(str_sprintf(ESPHOME_VERSION " %s", App.get_compilation_time().c_str())); } } float VersionTextSensor::get_setup_priority() const { return setup_priority::DATA; } From 875ada86b0553ccdb070581b1fdc4576c856eb18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 19:14:16 -0500 Subject: [PATCH 1412/1626] [web_server] Remove std::find_if overhead matching IDF implementation (#10889) --- esphome/components/web_server/web_server.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 0a97e542c3..03bc17f4fa 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -108,14 +108,14 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) void DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) { DeferredEvent item(source, message_generator); - auto iter = std::find_if(this->deferred_queue_.begin(), this->deferred_queue_.end(), - [&item](const DeferredEvent &test) -> bool { return test == item; }); - - if (iter != this->deferred_queue_.end()) { - (*iter) = item; - } else { - this->deferred_queue_.push_back(item); + // Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size + for (auto &event : this->deferred_queue_) { + if (event == item) { + event = item; + return; + } } + this->deferred_queue_.push_back(item); } void DeferredUpdateEventSource::process_deferred_queue_() { From b26776fad4b0b245a05d47ddd0ba169957e0932a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 19:19:06 -0500 Subject: [PATCH 1413/1626] [select] Remove STL algorithm overhead to reduce flash usage (#10887) --- esphome/components/select/select.cpp | 9 +++++---- esphome/components/select/select_call.cpp | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index beb72aa320..16e8288ca1 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -34,11 +34,12 @@ size_t Select::size() const { optional Select::index_of(const std::string &option) const { const auto &options = traits.get_options(); - auto it = std::find(options.begin(), options.end(), option); - if (it == options.end()) { - return {}; + for (size_t i = 0; i < options.size(); i++) { + if (options[i] == option) { + return i; + } } - return std::distance(options.begin(), it); + return {}; } optional Select::active_index() const { diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index a8272f8622..dd398b4052 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -107,7 +107,7 @@ void SelectCall::perform() { } } - if (std::find(options.begin(), options.end(), target_value) == options.end()) { + if (!parent->has_option(target_value)) { ESP_LOGW(TAG, "'%s' - Option %s is not a valid option", name, target_value.c_str()); return; } From 1560b8b8e25907a36fb95fdd425a6acb24c90651 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 19:19:48 -0500 Subject: [PATCH 1414/1626] [climate] Remove STL algorithm overhead in save_state() method (#10888) --- esphome/components/climate/climate.cpp | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index be56310b35..e7a454d459 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -367,9 +367,11 @@ void Climate::save_state_() { state.uses_custom_fan_mode = true; const auto &supported = traits.get_supported_custom_fan_modes(); std::vector vec{supported.begin(), supported.end()}; - auto it = std::find(vec.begin(), vec.end(), custom_fan_mode); - if (it != vec.end()) { - state.custom_fan_mode = std::distance(vec.begin(), it); + for (size_t i = 0; i < vec.size(); i++) { + if (vec[i] == custom_fan_mode) { + state.custom_fan_mode = i; + break; + } } } if (traits.get_supports_presets() && preset.has_value()) { @@ -380,10 +382,11 @@ void Climate::save_state_() { state.uses_custom_preset = true; const auto &supported = traits.get_supported_custom_presets(); std::vector vec{supported.begin(), supported.end()}; - auto it = std::find(vec.begin(), vec.end(), custom_preset); - // only set custom preset if value exists, otherwise leave it as is - if (it != vec.cend()) { - state.custom_preset = std::distance(vec.begin(), it); + for (size_t i = 0; i < vec.size(); i++) { + if (vec[i] == custom_preset) { + state.custom_preset = i; + break; + } } } if (traits.get_supports_swing_modes()) { From f33819bb8e3b5aeeb51c1288789b69c54a99a25b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:12:24 +1200 Subject: [PATCH 1415/1626] Add some more defines for dev/ci --- esphome/core/defines.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ef93fd0b65..067ef4a4d0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -123,7 +123,9 @@ #define USE_ONLINE_IMAGE_PNG_SUPPORT #define USE_ONLINE_IMAGE_JPEG_SUPPORT #define USE_OTA +#define USE_OTA_MD5 #define USE_OTA_PASSWORD +#define USE_OTA_SHA256 #define USE_OTA_STATE_CALLBACK #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE From ba73061a4f8a948d518328d19a35804d97ac9efb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 20:36:04 -0500 Subject: [PATCH 1416/1626] random_bytes --- esphome/components/esphome/ota/ota_esphome.cpp | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 405633b990..6ffeeedb1a 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -15,6 +15,7 @@ #include "esphome/components/ota/ota_backend_esp_idf.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" @@ -528,14 +529,6 @@ void ESPHomeOTAComponent::log_auth_warning_(const LogString *action, const LogSt ESP_LOGW(TAG, "Auth: %s %s failed", LOG_STR_ARG(action), LOG_STR_ARG(hash_name)); } -// Helper to convert uint32 to big-endian bytes -static inline void uint32_to_bytes(uint32_t value, uint8_t *bytes) { - bytes[0] = (value >> 24) & 0xFF; - bytes[1] = (value >> 16) & 0xFF; - bytes[2] = (value >> 8) & 0xFF; - bytes[3] = value & 0xFF; -} - // Non-template function definition to reduce binary size bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, const LogString *name, char *buf) { @@ -553,10 +546,10 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string hasher->init(); - // Generate nonce seed bytes - uint32_to_bytes(random_uint32(), nonce_bytes); - if (nonce_len > 4) { - uint32_to_bytes(random_uint32(), nonce_bytes + 4); + // Generate nonce seed bytes using random_bytes + if (!random_bytes(nonce_bytes, nonce_len)) { + this->log_auth_warning_(LOG_STR("Random bytes generation failed"), name); + return false; } hasher->add(nonce_bytes, nonce_len); hasher->calculate(); From 1eaa121ad252d5ba2d2eab422709dcdb84d3ea0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Sep 2025 23:59:16 -0500 Subject: [PATCH 1417/1626] [esp32_ble_server] Optimize service storage: 1KB flash savings, 84x-241x faster lookups --- .../esp32_ble_server/ble_server.cpp | 44 +++++++++---------- .../components/esp32_ble_server/ble_server.h | 9 +++- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 5339bf8aed..89299bb417 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -70,11 +70,11 @@ void BLEServer::loop() { // it is at the top of the GATT table this->device_information_service_->do_create(this); // Create all services previously created - for (auto &pair : this->services_) { - if (pair.second == this->device_information_service_) { + for (auto &entry : this->services_) { + if (entry.service == this->device_information_service_) { continue; } - pair.second->do_create(this); + entry.service->do_create(this); } this->state_ = STARTING_SERVICE; } @@ -118,7 +118,7 @@ BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t n } BLEService *service = // NOLINT(cppcoreguidelines-owning-memory) new BLEService(uuid, num_handles, inst_id, advertise); - this->services_.emplace(BLEServer::get_service_key(uuid, inst_id), service); + this->services_.push_back({uuid, inst_id, service}); if (this->parent_->is_active() && this->registered_) { service->do_create(this); } @@ -127,26 +127,24 @@ BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t n void BLEServer::remove_service(ESPBTUUID uuid, uint8_t inst_id) { ESP_LOGV(TAG, "Removing BLE service - %s %d", uuid.to_string().c_str(), inst_id); - BLEService *service = this->get_service(uuid, inst_id); - if (service == nullptr) { - ESP_LOGW(TAG, "BLE service %s %d does not exist", uuid.to_string().c_str(), inst_id); - return; + for (auto it = this->services_.begin(); it != this->services_.end(); ++it) { + if (it->uuid == uuid && it->inst_id == inst_id) { + it->service->do_delete(); + delete it->service; // NOLINT(cppcoreguidelines-owning-memory) + this->services_.erase(it); + return; + } } - service->do_delete(); - delete service; // NOLINT(cppcoreguidelines-owning-memory) - this->services_.erase(BLEServer::get_service_key(uuid, inst_id)); + ESP_LOGW(TAG, "BLE service %s %d does not exist", uuid.to_string().c_str(), inst_id); } BLEService *BLEServer::get_service(ESPBTUUID uuid, uint8_t inst_id) { - BLEService *service = nullptr; - if (this->services_.count(BLEServer::get_service_key(uuid, inst_id)) > 0) { - service = this->services_.at(BLEServer::get_service_key(uuid, inst_id)); + for (auto &entry : this->services_) { + if (entry.uuid == uuid && entry.inst_id == inst_id) { + return entry.service; + } } - return service; -} - -std::string BLEServer::get_service_key(ESPBTUUID uuid, uint8_t inst_id) { - return uuid.to_string() + std::to_string(inst_id); + return nullptr; } void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, @@ -174,8 +172,8 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga break; } - for (const auto &pair : this->services_) { - pair.second->gatts_event_handler(event, gatts_if, param); + for (auto &entry : this->services_) { + entry.service->gatts_event_handler(event, gatts_if, param); } } @@ -183,8 +181,8 @@ void BLEServer::ble_before_disabled_event_handler() { // Delete all clients this->clients_.clear(); // Delete all services - for (auto &pair : this->services_) { - pair.second->do_delete(); + for (auto &entry : this->services_) { + entry.service->do_delete(); } this->registered_ = false; this->state_ = INIT; diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 531b52d6b9..b5973ed099 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -66,7 +66,12 @@ class BLEServer : public Component, void ble_before_disabled_event_handler() override; protected: - static std::string get_service_key(ESPBTUUID uuid, uint8_t inst_id); + struct ServiceEntry { + ESPBTUUID uuid; + uint8_t inst_id; + BLEService *service; + }; + void restart_advertising_(); void add_client_(uint16_t conn_id) { this->clients_.insert(conn_id); } @@ -77,7 +82,7 @@ class BLEServer : public Component, bool registered_{false}; std::unordered_set clients_; - std::unordered_map services_{}; + std::vector services_{}; std::vector services_to_start_{}; BLEService *device_information_service_{}; From baf09e2eedf89c7fa145499330c907d5eb6f2eed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 09:14:29 -0500 Subject: [PATCH 1418/1626] [esp32_ble_server] Optimize notification and action managers for typical use cases --- .../esp32_ble_server/ble_characteristic.cpp | 39 ++++++++++++++--- .../esp32_ble_server/ble_characteristic.h | 11 ++++- .../ble_server_automations.cpp | 43 ++++++++++++++----- .../esp32_ble_server/ble_server_automations.h | 18 ++++++-- 4 files changed, 89 insertions(+), 22 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 373d57436e..4d0ada0ac2 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -51,11 +51,11 @@ void BLECharacteristic::notify() { for (auto &client : this->service_->get_server()->get_clients()) { size_t length = this->value_.size(); - // If the client is not in the list of clients to notify, skip it - if (this->clients_to_notify_.count(client) == 0) + // Find the client in the list of clients to notify + auto *entry = this->find_client_in_notify_list_(client); + if (entry == nullptr) continue; - // If the client is in the list of clients to notify, check if it requires an ack (i.e. INDICATE) - bool require_ack = this->clients_to_notify_[client]; + bool require_ack = entry->indicate; // TODO: Remove this block when INDICATE acknowledgment is supported if (require_ack) { ESP_LOGW(TAG, "INDICATE acknowledgment is not yet supported (i.e. it works as a NOTIFY)"); @@ -79,10 +79,11 @@ void BLECharacteristic::add_descriptor(BLEDescriptor *descriptor) { uint16_t cccd = encode_uint16(value[1], value[0]); bool notify = (cccd & 1) != 0; bool indicate = (cccd & 2) != 0; + // Remove existing entry if present + this->remove_client_from_notify_list_(conn_id); + // Add new entry if needed if (notify || indicate) { - this->clients_to_notify_[conn_id] = indicate; - } else { - this->clients_to_notify_.erase(conn_id); + this->clients_to_notify_.push_back({conn_id, indicate}); } }); } @@ -307,6 +308,30 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt } } +void BLECharacteristic::remove_client_from_notify_list_(uint16_t conn_id) { + // Since we typically have very few clients (often just 1), we can optimize + // for the common case by swapping with the last element and popping + for (size_t i = 0; i < this->clients_to_notify_.size(); i++) { + if (this->clients_to_notify_[i].conn_id == conn_id) { + // Swap with last element and pop + if (i != this->clients_to_notify_.size() - 1) { + this->clients_to_notify_[i] = this->clients_to_notify_.back(); + } + this->clients_to_notify_.pop_back(); + return; + } + } +} + +BLECharacteristic::ClientNotificationEntry *BLECharacteristic::find_client_in_notify_list_(uint16_t conn_id) { + for (auto &entry : this->clients_to_notify_) { + if (entry.conn_id == conn_id) { + return &entry; + } + } + return nullptr; +} + } // namespace esp32_ble_server } // namespace esphome diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 3698b8c4aa..97b3af2a21 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -6,7 +6,6 @@ #include "esphome/components/bytebuffer/bytebuffer.h" #include -#include #ifdef USE_ESP32 @@ -89,7 +88,15 @@ class BLECharacteristic : public EventEmitter descriptors_; - std::unordered_map clients_to_notify_; + + struct ClientNotificationEntry { + uint16_t conn_id; + bool indicate; // true = indicate, false = notify + }; + std::vector clients_to_notify_; + + void remove_client_from_notify_list_(uint16_t conn_id); + ClientNotificationEntry *find_client_in_notify_list_(uint16_t conn_id); esp_gatt_perm_t permissions_ = ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.cpp b/esphome/components/esp32_ble_server/ble_server_automations.cpp index 41ef2b8bfe..ea6a074daa 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.cpp +++ b/esphome/components/esp32_ble_server/ble_server_automations.cpp @@ -45,17 +45,16 @@ Trigger *BLETriggers::create_server_on_disconnect_trigger(BLEServer *s void BLECharacteristicSetValueActionManager::set_listener(BLECharacteristic *characteristic, EventEmitterListenerID listener_id, const std::function &pre_notify_listener) { - // Check if there is already a listener for this characteristic - if (this->listeners_.count(characteristic) > 0) { - // Unpack the pair listener_id, pre_notify_listener_id - auto listener_pairs = this->listeners_[characteristic]; - EventEmitterListenerID old_listener_id = listener_pairs.first; - EventEmitterListenerID old_pre_notify_listener_id = listener_pairs.second; + // Find and remove existing listener for this characteristic + auto *existing = this->find_listener_(characteristic); + if (existing != nullptr) { // Remove the previous listener characteristic->EventEmitter::off(BLECharacteristicEvt::EmptyEvt::ON_READ, - old_listener_id); + existing->listener_id); // Remove the pre-notify listener - this->off(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, old_pre_notify_listener_id); + this->off(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, existing->pre_notify_listener_id); + // Remove from vector + this->remove_listener_(characteristic); } // Create a new listener for the pre-notify event EventEmitterListenerID pre_notify_listener_id = @@ -66,8 +65,32 @@ void BLECharacteristicSetValueActionManager::set_listener(BLECharacteristic *cha pre_notify_listener(); } }); - // Save the pair listener_id, pre_notify_listener_id to the map - this->listeners_[characteristic] = std::make_pair(listener_id, pre_notify_listener_id); + // Save the entry to the vector + this->listeners_.push_back({characteristic, listener_id, pre_notify_listener_id}); +} + +BLECharacteristicSetValueActionManager::ListenerEntry *BLECharacteristicSetValueActionManager::find_listener_( + BLECharacteristic *characteristic) { + for (auto &entry : this->listeners_) { + if (entry.characteristic == characteristic) { + return &entry; + } + } + return nullptr; +} + +void BLECharacteristicSetValueActionManager::remove_listener_(BLECharacteristic *characteristic) { + // Since we typically have very few listeners, optimize by swapping with back and popping + for (size_t i = 0; i < this->listeners_.size(); i++) { + if (this->listeners_[i].characteristic == characteristic) { + // Swap with last element and pop + if (i != this->listeners_.size() - 1) { + this->listeners_[i] = this->listeners_.back(); + } + this->listeners_.pop_back(); + return; + } + } } } // namespace esp32_ble_server_automations diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index eab6b05f05..54bc0f2632 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -8,7 +8,6 @@ #include "esphome/core/automation.h" #include -#include #include #ifdef USE_ESP32 @@ -46,14 +45,27 @@ class BLECharacteristicSetValueActionManager void set_listener(BLECharacteristic *characteristic, EventEmitterListenerID listener_id, const std::function &pre_notify_listener); EventEmitterListenerID get_listener(BLECharacteristic *characteristic) { - return this->listeners_[characteristic].first; + for (const auto &entry : this->listeners_) { + if (entry.characteristic == characteristic) { + return entry.listener_id; + } + } + return 0; } void emit_pre_notify(BLECharacteristic *characteristic) { this->emit_(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, characteristic); } private: - std::unordered_map> listeners_; + struct ListenerEntry { + BLECharacteristic *characteristic; + EventEmitterListenerID listener_id; + EventEmitterListenerID pre_notify_listener_id; + }; + std::vector listeners_; + + ListenerEntry *find_listener_(BLECharacteristic *characteristic); + void remove_listener_(BLECharacteristic *characteristic); }; template class BLECharacteristicSetValueAction : public Action { From fb3ce6c783d36f1b7c034616bc7813e725ecf946 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 09:43:36 -0500 Subject: [PATCH 1419/1626] bot comments --- esphome/components/esp32_ble_server/ble_characteristic.cpp | 6 ++---- .../components/esp32_ble_server/ble_server_automations.cpp | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 4d0ada0ac2..fabcc75321 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -313,10 +313,8 @@ void BLECharacteristic::remove_client_from_notify_list_(uint16_t conn_id) { // for the common case by swapping with the last element and popping for (size_t i = 0; i < this->clients_to_notify_.size(); i++) { if (this->clients_to_notify_[i].conn_id == conn_id) { - // Swap with last element and pop - if (i != this->clients_to_notify_.size() - 1) { - this->clients_to_notify_[i] = this->clients_to_notify_.back(); - } + // Swap with last element and pop (safe even when i is the last element) + this->clients_to_notify_[i] = this->clients_to_notify_.back(); this->clients_to_notify_.pop_back(); return; } diff --git a/esphome/components/esp32_ble_server/ble_server_automations.cpp b/esphome/components/esp32_ble_server/ble_server_automations.cpp index ea6a074daa..b140e08b46 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.cpp +++ b/esphome/components/esp32_ble_server/ble_server_automations.cpp @@ -83,10 +83,8 @@ void BLECharacteristicSetValueActionManager::remove_listener_(BLECharacteristic // Since we typically have very few listeners, optimize by swapping with back and popping for (size_t i = 0; i < this->listeners_.size(); i++) { if (this->listeners_[i].characteristic == characteristic) { - // Swap with last element and pop - if (i != this->listeners_.size() - 1) { - this->listeners_[i] = this->listeners_.back(); - } + // Swap with last element and pop (safe even when i is the last element) + this->listeners_[i] = this->listeners_.back(); this->listeners_.pop_back(); return; } From 70685f2939532aa5c7b834d7335f66da0638965a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 09:46:38 -0500 Subject: [PATCH 1420/1626] bot comments --- esphome/components/esp32_ble_server/ble_server_automations.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index 54bc0f2632..910335826c 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -20,6 +20,9 @@ namespace esp32_ble_server_automations { using namespace esp32_ble; using namespace event_emitter; +// Invalid listener ID constant - 0 is used as sentinel value in EventEmitter +static constexpr EventEmitterListenerID INVALID_LISTENER_ID = 0; + class BLETriggers { public: static Trigger, uint16_t> *create_characteristic_on_write_trigger( @@ -50,7 +53,7 @@ class BLECharacteristicSetValueActionManager return entry.listener_id; } } - return 0; + return INVALID_LISTENER_ID; } void emit_pre_notify(BLECharacteristic *characteristic) { this->emit_(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, characteristic); From eeff69d50bc65d1faa51d5f5b4d3503e13c08f87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 10:14:34 -0500 Subject: [PATCH 1421/1626] [event_emitter] Replace unordered_map with vector - saves 2.6KB flash, 2.3x faster --- .../components/event_emitter/event_emitter.h | 97 +++++++++++++++---- 1 file changed, 76 insertions(+), 21 deletions(-) diff --git a/esphome/components/event_emitter/event_emitter.h b/esphome/components/event_emitter/event_emitter.h index 3876a2cc14..a810187922 100644 --- a/esphome/components/event_emitter/event_emitter.h +++ b/esphome/components/event_emitter/event_emitter.h @@ -1,5 +1,4 @@ #pragma once -#include #include #include #include @@ -10,6 +9,7 @@ namespace esphome { namespace event_emitter { using EventEmitterListenerID = uint32_t; +static constexpr EventEmitterListenerID INVALID_LISTENER_ID = 0; void raise_event_emitter_full_error(); // EventEmitter class that can emit events with a specific name (it is highly recommended to use an enum class for this) @@ -17,45 +17,100 @@ void raise_event_emitter_full_error(); template class EventEmitter { public: EventEmitterListenerID on(EvtType event, std::function listener) { - EventEmitterListenerID listener_id = get_next_id_(event); - listeners_[event][listener_id] = listener; + EventEmitterListenerID listener_id = get_next_id_(); + + // Find or create event entry + EventEntry *entry = find_or_create_event_(event); + entry->listeners.push_back({listener_id, listener}); + return listener_id; } void off(EvtType event, EventEmitterListenerID id) { - if (listeners_.count(event) == 0) + EventEntry *entry = find_event_(event); + if (entry == nullptr) return; - listeners_[event].erase(id); + + // Remove listener with given id + for (auto it = entry->listeners.begin(); it != entry->listeners.end(); ++it) { + if (it->id == id) { + // Swap with last and pop for efficient removal + *it = entry->listeners.back(); + entry->listeners.pop_back(); + + // Remove event entry if no more listeners + if (entry->listeners.empty()) { + remove_event_(event); + } + return; + } + } } protected: void emit_(EvtType event, Args... args) { - if (listeners_.count(event) == 0) + EventEntry *entry = find_event_(event); + if (entry == nullptr) return; - for (const auto &listener : listeners_[event]) { - listener.second(args...); + + // Call all listeners for this event + for (const auto &listener : entry->listeners) { + listener.callback(args...); } } - EventEmitterListenerID get_next_id_(EvtType event) { - // Check if the map is full - if (listeners_[event].size() == std::numeric_limits::max()) { - // Raise an error if the map is full - raise_event_emitter_full_error(); - off(event, 0); - return 0; + private: + struct Listener { + EventEmitterListenerID id; + std::function callback; + }; + + struct EventEntry { + EvtType event; + std::vector listeners; + }; + + EventEntry *find_event_(EvtType event) { + for (auto &entry : events_) { + if (entry.event == event) { + return &entry; + } } - // Get the next ID for the given event. - EventEmitterListenerID next_id = (current_id_ + 1) % std::numeric_limits::max(); - while (listeners_[event].count(next_id) > 0) { - next_id = (next_id + 1) % std::numeric_limits::max(); + return nullptr; + } + + EventEntry *find_or_create_event_(EvtType event) { + EventEntry *entry = find_event_(event); + if (entry != nullptr) + return entry; + + // Create new event entry + events_.push_back({event, {}}); + return &events_.back(); + } + + void remove_event_(EvtType event) { + for (auto it = events_.begin(); it != events_.end(); ++it) { + if (it->event == event) { + // Swap with last and pop + *it = events_.back(); + events_.pop_back(); + return; + } + } + } + + EventEmitterListenerID get_next_id_() { + // Simple incrementing ID, wrapping around at max + EventEmitterListenerID next_id = (current_id_ + 1); + if (next_id == 0) { // Skip 0 as it's often used as "invalid" + next_id = 1; } current_id_ = next_id; return current_id_; } - private: - std::unordered_map>> listeners_; + std::vector events_; EventEmitterListenerID current_id_ = 0; }; From 4110d926dd8320d44a68bcc52d31c7ffbb1f34d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 10:17:48 -0500 Subject: [PATCH 1422/1626] preen --- esphome/components/event_emitter/event_emitter.cpp | 14 -------------- esphome/components/event_emitter/event_emitter.h | 1 - 2 files changed, 15 deletions(-) delete mode 100644 esphome/components/event_emitter/event_emitter.cpp diff --git a/esphome/components/event_emitter/event_emitter.cpp b/esphome/components/event_emitter/event_emitter.cpp deleted file mode 100644 index 8487e19c2f..0000000000 --- a/esphome/components/event_emitter/event_emitter.cpp +++ /dev/null @@ -1,14 +0,0 @@ -#include "event_emitter.h" - -namespace esphome { -namespace event_emitter { - -static const char *const TAG = "event_emitter"; - -void raise_event_emitter_full_error() { - ESP_LOGE(TAG, "EventEmitter has reached the maximum number of listeners for event"); - ESP_LOGW(TAG, "Removing listener to make space for new listener"); -} - -} // namespace event_emitter -} // namespace esphome diff --git a/esphome/components/event_emitter/event_emitter.h b/esphome/components/event_emitter/event_emitter.h index a810187922..4ea8930a33 100644 --- a/esphome/components/event_emitter/event_emitter.h +++ b/esphome/components/event_emitter/event_emitter.h @@ -10,7 +10,6 @@ namespace event_emitter { using EventEmitterListenerID = uint32_t; static constexpr EventEmitterListenerID INVALID_LISTENER_ID = 0; -void raise_event_emitter_full_error(); // EventEmitter class that can emit events with a specific name (it is highly recommended to use an enum class for this) // and a list of arguments. Supports multiple listeners for each event. From a3f8173436233adc50e31bdf8ec19d87633a3427 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 10:23:17 -0500 Subject: [PATCH 1423/1626] prefer this-> --- .../components/event_emitter/event_emitter.h | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/esphome/components/event_emitter/event_emitter.h b/esphome/components/event_emitter/event_emitter.h index 4ea8930a33..4ad1ac2edc 100644 --- a/esphome/components/event_emitter/event_emitter.h +++ b/esphome/components/event_emitter/event_emitter.h @@ -16,17 +16,17 @@ static constexpr EventEmitterListenerID INVALID_LISTENER_ID = 0; template class EventEmitter { public: EventEmitterListenerID on(EvtType event, std::function listener) { - EventEmitterListenerID listener_id = get_next_id_(); + EventEmitterListenerID listener_id = this->get_next_id_(); // Find or create event entry - EventEntry *entry = find_or_create_event_(event); + EventEntry *entry = this->find_or_create_event_(event); entry->listeners.push_back({listener_id, listener}); return listener_id; } void off(EvtType event, EventEmitterListenerID id) { - EventEntry *entry = find_event_(event); + EventEntry *entry = this->find_event_(event); if (entry == nullptr) return; @@ -39,7 +39,7 @@ template class EventEmitter { // Remove event entry if no more listeners if (entry->listeners.empty()) { - remove_event_(event); + this->remove_event_(event); } return; } @@ -48,7 +48,7 @@ template class EventEmitter { protected: void emit_(EvtType event, Args... args) { - EventEntry *entry = find_event_(event); + EventEntry *entry = this->find_event_(event); if (entry == nullptr) return; @@ -70,7 +70,7 @@ template class EventEmitter { }; EventEntry *find_event_(EvtType event) { - for (auto &entry : events_) { + for (auto &entry : this->events_) { if (entry.event == event) { return &entry; } @@ -79,21 +79,21 @@ template class EventEmitter { } EventEntry *find_or_create_event_(EvtType event) { - EventEntry *entry = find_event_(event); + EventEntry *entry = this->find_event_(event); if (entry != nullptr) return entry; // Create new event entry - events_.push_back({event, {}}); - return &events_.back(); + this->events_.push_back({event, {}}); + return &this->events_.back(); } void remove_event_(EvtType event) { - for (auto it = events_.begin(); it != events_.end(); ++it) { + for (auto it = this->events_.begin(); it != this->events_.end(); ++it) { if (it->event == event) { // Swap with last and pop - *it = events_.back(); - events_.pop_back(); + *it = this->events_.back(); + this->events_.pop_back(); return; } } @@ -101,12 +101,12 @@ template class EventEmitter { EventEmitterListenerID get_next_id_() { // Simple incrementing ID, wrapping around at max - EventEmitterListenerID next_id = (current_id_ + 1); + EventEmitterListenerID next_id = (this->current_id_ + 1); if (next_id == 0) { // Skip 0 as it's often used as "invalid" next_id = 1; } - current_id_ = next_id; - return current_id_; + this->current_id_ = next_id; + return this->current_id_; } std::vector events_; From 2b0af0df842960df54ebf5548db6f0970a3260c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 10:32:05 -0500 Subject: [PATCH 1424/1626] preen --- .../components/event_emitter/event_emitter.h | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/event_emitter/event_emitter.h b/esphome/components/event_emitter/event_emitter.h index 4ad1ac2edc..74afde03c0 100644 --- a/esphome/components/event_emitter/event_emitter.h +++ b/esphome/components/event_emitter/event_emitter.h @@ -69,6 +69,16 @@ template class EventEmitter { std::vector listeners; }; + EventEmitterListenerID get_next_id_() { + // Simple incrementing ID, wrapping around at max + EventEmitterListenerID next_id = (this->current_id_ + 1); + if (next_id == INVALID_LISTENER_ID) { + next_id = 1; + } + this->current_id_ = next_id; + return this->current_id_; + } + EventEntry *find_event_(EvtType event) { for (auto &entry : this->events_) { if (entry.event == event) { @@ -99,16 +109,6 @@ template class EventEmitter { } } - EventEmitterListenerID get_next_id_() { - // Simple incrementing ID, wrapping around at max - EventEmitterListenerID next_id = (this->current_id_ + 1); - if (next_id == 0) { // Skip 0 as it's often used as "invalid" - next_id = 1; - } - this->current_id_ = next_id; - return this->current_id_; - } - std::vector events_; EventEmitterListenerID current_id_ = 0; }; From 33ff0c59c485dea93e7656dc1ce8b6d2b275dde9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 12:29:42 -0500 Subject: [PATCH 1425/1626] [esp32_improv] Fix null pointer crashes and incorrect state advertising --- .../esp32_improv/esp32_improv_component.cpp | 70 ++++++++++++++----- .../esp32_improv/esp32_improv_component.h | 1 + 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index d47cc50a00..e821950542 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -15,6 +15,8 @@ using namespace bytebuffer; static const char *const TAG = "esp32_improv.component"; static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; +static constexpr uint16_t STOP_ADVERTISING_DELAY = + 10000; // Delay (ms) before stopping service to allow BLE clients to read the final state ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } @@ -193,6 +195,23 @@ void ESP32ImprovComponent::set_status_indicator_state_(bool state) { #endif } +const char *ESP32ImprovComponent::state_to_string_(improv::State state) { + switch (state) { + case improv::STATE_STOPPED: + return "STOPPED"; + case improv::STATE_AWAITING_AUTHORIZATION: + return "AWAITING_AUTHORIZATION"; + case improv::STATE_AUTHORIZED: + return "AUTHORIZED"; + case improv::STATE_PROVISIONING: + return "PROVISIONING"; + case improv::STATE_PROVISIONED: + return "PROVISIONED"; + default: + return "UNKNOWN"; + } +} + bool ESP32ImprovComponent::check_identify_() { uint32_t now = millis(); @@ -206,31 +225,40 @@ bool ESP32ImprovComponent::check_identify_() { } void ESP32ImprovComponent::set_state_(improv::State state) { - ESP_LOGV(TAG, "Setting state: %d", state); + if (this->state_ != state) { + ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_, + this->state_to_string_(state), state); + } this->state_ = state; - if (this->status_->get_value().empty() || this->status_->get_value()[0] != state) { + if (this->status_ != nullptr && (this->status_->get_value().empty() || this->status_->get_value()[0] != state)) { this->status_->set_value(ByteBuffer::wrap(static_cast(state))); if (state != improv::STATE_STOPPED) this->status_->notify(); } - std::vector service_data(8, 0); - service_data[0] = 0x77; // PR - service_data[1] = 0x46; // IM - service_data[2] = static_cast(state); + // Only advertise valid Improv states (0x01-0x04). + // STATE_STOPPED (0x00) is internal only and not part of the Improv spec. + // Advertising 0x00 causes undefined behavior in some clients and makes them + // repeatedly connect trying to determine the actual state. + if (state != improv::STATE_STOPPED) { + std::vector service_data(8, 0); + service_data[0] = 0x77; // PR + service_data[1] = 0x46; // IM + service_data[2] = static_cast(state); - uint8_t capabilities = 0x00; + uint8_t capabilities = 0x00; #ifdef USE_OUTPUT - if (this->status_indicator_ != nullptr) - capabilities |= improv::CAPABILITY_IDENTIFY; + if (this->status_indicator_ != nullptr) + capabilities |= improv::CAPABILITY_IDENTIFY; #endif - service_data[3] = capabilities; - service_data[4] = 0x00; // Reserved - service_data[5] = 0x00; // Reserved - service_data[6] = 0x00; // Reserved - service_data[7] = 0x00; // Reserved + service_data[3] = capabilities; + service_data[4] = 0x00; // Reserved + service_data[5] = 0x00; // Reserved + service_data[6] = 0x00; // Reserved + service_data[7] = 0x00; // Reserved - esp32_ble::global_ble->advertising_set_service_data(service_data); + esp32_ble::global_ble->advertising_set_service_data(service_data); + } #ifdef USE_ESP32_IMPROV_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif @@ -240,7 +268,12 @@ void ESP32ImprovComponent::set_error_(improv::Error error) { if (error != improv::ERROR_NONE) { ESP_LOGE(TAG, "Error: %d", error); } - if (this->error_->get_value().empty() || this->error_->get_value()[0] != error) { + // The error_ characteristic is initialized in setup_characteristics() which is called + // from the loop, while the BLE disconnect callback is registered in setup(). + // error_ can be nullptr if: + // 1. A client connects/disconnects before setup_characteristics() is called + // 2. The device is already provisioned so the service never starts (should_start_ is false) + if (this->error_ != nullptr && (this->error_->get_value().empty() || this->error_->get_value()[0] != error)) { this->error_->set_value(ByteBuffer::wrap(static_cast(error))); if (this->state_ != improv::STATE_STOPPED) this->error_->notify(); @@ -264,7 +297,10 @@ void ESP32ImprovComponent::start() { void ESP32ImprovComponent::stop() { this->should_start_ = false; - this->set_timeout("end-service", 1000, [this] { + // Wait before stopping the service to ensure all BLE clients see the state change. + // This prevents clients from repeatedly reconnecting and wasting resources by allowing + // them to observe that the device is provisioned before the service disappears. + this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] { if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr) return; this->service_->stop(); diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 87cec23876..b4b2097ee8 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -108,6 +108,7 @@ class ESP32ImprovComponent : public Component { void process_incoming_data_(); void on_wifi_connect_timeout_(); bool check_identify_(); + const char *state_to_string_(improv::State state); }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) From d1bd6492ad2e6f05dbbc0f63439bbacbd9f2591c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 14:26:54 -0500 Subject: [PATCH 1426/1626] missing nullptr --- .../components/esp32_improv/esp32_improv_component.h | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index b4b2097ee8..cc4ea24a42 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -79,12 +79,12 @@ class ESP32ImprovComponent : public Component { std::vector incoming_data_; wifi::WiFiAP connecting_sta_; - BLEService *service_ = nullptr; - BLECharacteristic *status_; - BLECharacteristic *error_; - BLECharacteristic *rpc_; - BLECharacteristic *rpc_response_; - BLECharacteristic *capabilities_; + BLEService *service_{nullptr}; + BLECharacteristic *status_{nullptr}; + BLECharacteristic *error_{nullptr}; + BLECharacteristic *rpc_{nullptr}; + BLECharacteristic *rpc_response_{nullptr}; + BLECharacteristic *capabilities_{nullptr}; #ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *authorizer_{nullptr}; From 886baab266745cdcb9559b62344c28764dfde75b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 15:00:43 -0500 Subject: [PATCH 1427/1626] guard --- esphome/components/esp32_improv/esp32_improv_component.cpp | 4 ++++ esphome/components/esp32_improv/esp32_improv_component.h | 2 ++ 2 files changed, 6 insertions(+) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index e821950542..c5a0b89f99 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -195,6 +195,7 @@ void ESP32ImprovComponent::set_status_indicator_state_(bool state) { #endif } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG const char *ESP32ImprovComponent::state_to_string_(improv::State state) { switch (state) { case improv::STATE_STOPPED: @@ -211,6 +212,7 @@ const char *ESP32ImprovComponent::state_to_string_(improv::State state) { return "UNKNOWN"; } } +#endif bool ESP32ImprovComponent::check_identify_() { uint32_t now = millis(); @@ -225,10 +227,12 @@ bool ESP32ImprovComponent::check_identify_() { } void ESP32ImprovComponent::set_state_(improv::State state) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG if (this->state_ != state) { ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_, this->state_to_string_(state), state); } +#endif this->state_ = state; if (this->status_ != nullptr && (this->status_->get_value().empty() || this->status_->get_value()[0] != state)) { this->status_->set_value(ByteBuffer::wrap(static_cast(state))); diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index cc4ea24a42..686da08111 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -108,7 +108,9 @@ class ESP32ImprovComponent : public Component { void process_incoming_data_(); void on_wifi_connect_timeout_(); bool check_identify_(); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG const char *state_to_string_(improv::State state); +#endif }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) From 303b47cf00f83059c2fc9058b334834123fc548e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 16:05:34 -0500 Subject: [PATCH 1428/1626] [core] Fix platform component normalization happening too late in validation pipeline --- esphome/config.py | 12 +- tests/unit_tests/fixtures/ota_empty_dict.yaml | 17 ++ .../unit_tests/fixtures/ota_no_platform.yaml | 17 ++ .../fixtures/ota_with_platform_list.yaml | 19 ++ tests/unit_tests/test_config.py | 271 ++++++++++++++++++ 5 files changed, 330 insertions(+), 6 deletions(-) create mode 100644 tests/unit_tests/fixtures/ota_empty_dict.yaml create mode 100644 tests/unit_tests/fixtures/ota_no_platform.yaml create mode 100644 tests/unit_tests/fixtures/ota_with_platform_list.yaml create mode 100644 tests/unit_tests/test_config.py diff --git a/esphome/config.py b/esphome/config.py index a7e47f646b..a5297a53cb 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -382,6 +382,12 @@ class LoadValidationStep(ConfigValidationStep): result.add_str_error(f"Component not found: {self.domain}", path) return CORE.loaded_integrations.add(self.domain) + # For platform components, normalize conf before creating MetadataValidationStep + if component.is_platform_component: + if not self.conf: + result[self.domain] = self.conf = [] + elif not isinstance(self.conf, list): + result[self.domain] = self.conf = [self.conf] # Process AUTO_LOAD for load in component.auto_load: @@ -399,12 +405,6 @@ class LoadValidationStep(ConfigValidationStep): # Remove this is as an output path result.remove_output_path([self.domain], self.domain) - # Ensure conf is a list - if not self.conf: - result[self.domain] = self.conf = [] - elif not isinstance(self.conf, list): - result[self.domain] = self.conf = [self.conf] - for i, p_config in enumerate(self.conf): path = [self.domain, i] # Construct temporary unknown output path diff --git a/tests/unit_tests/fixtures/ota_empty_dict.yaml b/tests/unit_tests/fixtures/ota_empty_dict.yaml new file mode 100644 index 0000000000..cf9b166afa --- /dev/null +++ b/tests/unit_tests/fixtures/ota_empty_dict.yaml @@ -0,0 +1,17 @@ +esphome: + name: test-device2 + +esp32: + board: esp32dev + framework: + type: esp-idf + +# OTA with empty dict - should be normalized +ota: {} + +wifi: + ssid: "test" + password: "test" + +# Captive portal auto-loads ota.web_server which triggers the issue +captive_portal: diff --git a/tests/unit_tests/fixtures/ota_no_platform.yaml b/tests/unit_tests/fixtures/ota_no_platform.yaml new file mode 100644 index 0000000000..0b09c836fb --- /dev/null +++ b/tests/unit_tests/fixtures/ota_no_platform.yaml @@ -0,0 +1,17 @@ +esphome: + name: test-device + +esp32: + board: esp32dev + framework: + type: esp-idf + +# OTA with no value - this should be normalized to empty list +ota: + +wifi: + ssid: "test" + password: "test" + +# Captive portal auto-loads ota.web_server which triggers the issue +captive_portal: diff --git a/tests/unit_tests/fixtures/ota_with_platform_list.yaml b/tests/unit_tests/fixtures/ota_with_platform_list.yaml new file mode 100644 index 0000000000..b1b03743ae --- /dev/null +++ b/tests/unit_tests/fixtures/ota_with_platform_list.yaml @@ -0,0 +1,19 @@ +esphome: + name: test-device3 + +esp32: + board: esp32dev + framework: + type: esp-idf + +# OTA with proper list format +ota: + - platform: esphome + password: "test123" + +wifi: + ssid: "test" + password: "test" + +# Captive portal auto-loads ota.web_server +captive_portal: diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py new file mode 100644 index 0000000000..1d420c2d14 --- /dev/null +++ b/tests/unit_tests/test_config.py @@ -0,0 +1,271 @@ +"""Unit tests for esphome.config module.""" + +from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from esphome import config, yaml_util +from esphome.core import CORE + + +@pytest.fixture +def mock_get_component() -> Generator[Mock, None, None]: + """Fixture for mocking get_component.""" + with patch("esphome.config.get_component") as mock_get_component: + yield mock_get_component + + +@pytest.fixture +def mock_get_platform() -> Generator[Mock, None, None]: + """Fixture for mocking get_platform.""" + with patch("esphome.config.get_platform") as mock_get_platform: + # Default mock platform + mock_get_platform.return_value = MagicMock() + yield mock_get_platform + + +@pytest.fixture +def fixtures_dir() -> Path: + """Get the fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +def test_iter_components_handles_non_list_platform_component( + mock_get_component: Mock, +) -> None: + """Test that iter_components handles platform components that have been normalized to empty list.""" + # After LoadValidationStep normalization, platform components without config + # are converted to empty list + test_config = { + "ota": [], # Normalized from None/dict to empty list by LoadValidationStep + "wifi": {"ssid": "test"}, + } + + # Set up mock components + components = { + "ota": MagicMock(is_platform_component=True), + "wifi": MagicMock(is_platform_component=False), + } + + mock_get_component.side_effect = lambda domain: components.get( + domain, MagicMock(is_platform_component=False) + ) + + # This should not raise TypeError + components_list = list(config.iter_components(test_config)) + + # Verify we got the expected components + assert len(components_list) == 2 # ota and wifi (ota has no platforms) + + +def test_iter_component_configs_handles_non_list_platform_component( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test that iter_component_configs handles platform components that have been normalized.""" + + # After LoadValidationStep normalization + test_config = { + "ota": [], # Normalized from None/dict to empty list by LoadValidationStep + "one_wire": [ # List config for platform component + {"platform": "gpio", "pin": 10} + ], + } + + # Set up mock components + components: dict[str, Mock] = { + "ota": MagicMock(is_platform_component=True, multi_conf=False), + "one_wire": MagicMock(is_platform_component=True, multi_conf=False), + } + + # Default mock for unknown components + default_mock = MagicMock(is_platform_component=False, multi_conf=False) + + mock_get_component.side_effect = lambda domain: components.get(domain, default_mock) + + # This should not raise TypeError + configs = list(config.iter_component_configs(test_config)) + + # Should have 3 items: ota (empty list), one_wire, and one_wire.gpio + assert len(configs) == 3 + + # Check the domains + domains = [c[0] for c in configs] + assert "ota" in domains + assert "one_wire" in domains + assert "one_wire.gpio" in domains + + +def test_iter_components_with_valid_platform_list( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test that iter_components works correctly with valid platform component list.""" + + # Create a mock component that is a platform component + mock_component = MagicMock() + mock_component.is_platform_component = True + + # Create test config with proper list format + test_config = { + "sensor": [ + {"platform": "dht", "pin": 5}, + {"platform": "bme280", "address": 0x76}, + ], + } + + mock_get_component.return_value = mock_component + + # Get all components + components = list(config.iter_components(test_config)) + + # Should have 3 items: sensor, sensor.dht, sensor.bme280 + assert len(components) == 3 + + # Check the domains + domains = [c[0] for c in components] + assert "sensor" in domains + assert "sensor.dht" in domains + assert "sensor.bme280" in domains + + +def test_ota_with_proper_platform_list( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test that OTA component works correctly when configured as a list with platforms.""" + + # Create test config where ota is properly configured as a list + test_config = { + "ota": [ + {"platform": "esphome", "password": "test123"}, + ], + } + + mock_get_component.return_value = MagicMock(is_platform_component=True) + + # This should work without TypeError + components = list(config.iter_components(test_config)) + + # Should have 2 items: ota and ota.esphome + assert len(components) == 2 + + # Check the domains + domains = [c[0] for c in components] + assert "ota" in domains + assert "ota.esphome" in domains + + +def test_ota_component_configs_with_proper_platform_list( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test that iter_component_configs handles OTA properly configured as a list.""" + + # Create test config where ota is properly configured as a list + test_config = { + "ota": [ + {"platform": "esphome", "password": "test123", "id": "my_ota"}, + ], + } + + mock_get_component.return_value = MagicMock( + is_platform_component=True, multi_conf=False + ) + + # This should work without TypeError + configs = list(config.iter_component_configs(test_config)) + + # Should have 2 items: ota config and ota.esphome platform config + assert len(configs) == 2 + + # Check the domains and configs + assert configs[0][0] == "ota" + assert configs[0][2] == test_config["ota"] # The list itself + + assert configs[1][0] == "ota.esphome" + assert configs[1][2]["platform"] == "esphome" + assert configs[1][2]["password"] == "test123" + + +def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: + """Test that iter_component_configs handles multi_conf components correctly.""" + # Create test config + test_config = { + "switch": [ + {"name": "Switch 1"}, + {"name": "Switch 2"}, + ], + } + + # Set up mock component with multi_conf + mock_get_component.return_value = MagicMock( + is_platform_component=False, multi_conf=True + ) + + # Get all configs + configs = list(config.iter_component_configs(test_config)) + + # Should have 2 items (one for each switch) + assert len(configs) == 2 + + # Both should be for "switch" domain + for domain, component, conf in configs: + assert domain == "switch" + assert "name" in conf + + +def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" + # Set up CORE config path + CORE.config_path = fixtures_dir / "dummy.yaml" + + # Load config with OTA having no value (ota:) + config_file = fixtures_dir / "ota_no_platform.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + # Check that OTA was normalized to a list and captive_portal added web_server + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + # After captive_portal auto-loads, OTA should have web_server platform + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" + # Set up CORE config path + CORE.config_path = fixtures_dir / "dummy.yaml" + + # Load config with OTA having empty dict (ota: {}) + config_file = fixtures_dir / "ota_empty_dict.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + # Check that OTA was normalized to a list and captive_portal added web_server + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + # The empty dict gets normalized and web_server is added + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" + # Set up CORE config path + CORE.config_path = fixtures_dir / "dummy.yaml" + + # Load config with OTA having proper list format + config_file = fixtures_dir / "ota_with_platform_list.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + # Check that OTA remains a list with both esphome and web_server platforms + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "esphome" in platforms, f"Expected esphome platform in {platforms}" + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" From 7de2ed7658ac3a2825c31607d0de9722aec648d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 16:08:42 -0500 Subject: [PATCH 1429/1626] [core] Fix platform component normalization happening too late in validation pipeline --- tests/unit_tests/test_config.py | 56 +-------------------------------- 1 file changed, 1 insertion(+), 55 deletions(-) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 1d420c2d14..c0be847904 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -36,14 +36,11 @@ def test_iter_components_handles_non_list_platform_component( mock_get_component: Mock, ) -> None: """Test that iter_components handles platform components that have been normalized to empty list.""" - # After LoadValidationStep normalization, platform components without config - # are converted to empty list test_config = { "ota": [], # Normalized from None/dict to empty list by LoadValidationStep "wifi": {"ssid": "test"}, } - # Set up mock components components = { "ota": MagicMock(is_platform_component=True), "wifi": MagicMock(is_platform_component=False), @@ -53,10 +50,7 @@ def test_iter_components_handles_non_list_platform_component( domain, MagicMock(is_platform_component=False) ) - # This should not raise TypeError components_list = list(config.iter_components(test_config)) - - # Verify we got the expected components assert len(components_list) == 2 # ota and wifi (ota has no platforms) @@ -65,33 +59,22 @@ def test_iter_component_configs_handles_non_list_platform_component( mock_get_platform: Mock, ) -> None: """Test that iter_component_configs handles platform components that have been normalized.""" - - # After LoadValidationStep normalization test_config = { "ota": [], # Normalized from None/dict to empty list by LoadValidationStep - "one_wire": [ # List config for platform component - {"platform": "gpio", "pin": 10} - ], + "one_wire": [{"platform": "gpio", "pin": 10}], } - # Set up mock components components: dict[str, Mock] = { "ota": MagicMock(is_platform_component=True, multi_conf=False), "one_wire": MagicMock(is_platform_component=True, multi_conf=False), } - # Default mock for unknown components default_mock = MagicMock(is_platform_component=False, multi_conf=False) - mock_get_component.side_effect = lambda domain: components.get(domain, default_mock) - # This should not raise TypeError configs = list(config.iter_component_configs(test_config)) - - # Should have 3 items: ota (empty list), one_wire, and one_wire.gpio assert len(configs) == 3 - # Check the domains domains = [c[0] for c in configs] assert "ota" in domains assert "one_wire" in domains @@ -103,12 +86,9 @@ def test_iter_components_with_valid_platform_list( mock_get_platform: Mock, ) -> None: """Test that iter_components works correctly with valid platform component list.""" - - # Create a mock component that is a platform component mock_component = MagicMock() mock_component.is_platform_component = True - # Create test config with proper list format test_config = { "sensor": [ {"platform": "dht", "pin": 5}, @@ -118,13 +98,9 @@ def test_iter_components_with_valid_platform_list( mock_get_component.return_value = mock_component - # Get all components components = list(config.iter_components(test_config)) - - # Should have 3 items: sensor, sensor.dht, sensor.bme280 assert len(components) == 3 - # Check the domains domains = [c[0] for c in components] assert "sensor" in domains assert "sensor.dht" in domains @@ -136,8 +112,6 @@ def test_ota_with_proper_platform_list( mock_get_platform: Mock, ) -> None: """Test that OTA component works correctly when configured as a list with platforms.""" - - # Create test config where ota is properly configured as a list test_config = { "ota": [ {"platform": "esphome", "password": "test123"}, @@ -145,14 +119,9 @@ def test_ota_with_proper_platform_list( } mock_get_component.return_value = MagicMock(is_platform_component=True) - - # This should work without TypeError components = list(config.iter_components(test_config)) - # Should have 2 items: ota and ota.esphome assert len(components) == 2 - - # Check the domains domains = [c[0] for c in components] assert "ota" in domains assert "ota.esphome" in domains @@ -163,8 +132,6 @@ def test_ota_component_configs_with_proper_platform_list( mock_get_platform: Mock, ) -> None: """Test that iter_component_configs handles OTA properly configured as a list.""" - - # Create test config where ota is properly configured as a list test_config = { "ota": [ {"platform": "esphome", "password": "test123", "id": "my_ota"}, @@ -175,13 +142,9 @@ def test_ota_component_configs_with_proper_platform_list( is_platform_component=True, multi_conf=False ) - # This should work without TypeError configs = list(config.iter_component_configs(test_config)) - - # Should have 2 items: ota config and ota.esphome platform config assert len(configs) == 2 - # Check the domains and configs assert configs[0][0] == "ota" assert configs[0][2] == test_config["ota"] # The list itself @@ -192,7 +155,6 @@ def test_ota_component_configs_with_proper_platform_list( def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: """Test that iter_component_configs handles multi_conf components correctly.""" - # Create test config test_config = { "switch": [ {"name": "Switch 1"}, @@ -200,18 +162,13 @@ def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> Non ], } - # Set up mock component with multi_conf mock_get_component.return_value = MagicMock( is_platform_component=False, multi_conf=True ) - # Get all configs configs = list(config.iter_component_configs(test_config)) - - # Should have 2 items (one for each switch) assert len(configs) == 2 - # Both should be for "switch" domain for domain, component, conf in configs: assert domain == "switch" assert "name" in conf @@ -219,51 +176,40 @@ def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> Non def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" - # Set up CORE config path CORE.config_path = fixtures_dir / "dummy.yaml" - # Load config with OTA having no value (ota:) config_file = fixtures_dir / "ota_no_platform.yaml" raw_config = yaml_util.load_yaml(config_file) result = config.validate_config(raw_config, {}) - # Check that OTA was normalized to a list and captive_portal added web_server assert "ota" in result assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - # After captive_portal auto-loads, OTA should have web_server platform platforms = {p.get("platform") for p in result["ota"]} assert "web_server" in platforms, f"Expected web_server platform in {platforms}" def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" - # Set up CORE config path CORE.config_path = fixtures_dir / "dummy.yaml" - # Load config with OTA having empty dict (ota: {}) config_file = fixtures_dir / "ota_empty_dict.yaml" raw_config = yaml_util.load_yaml(config_file) result = config.validate_config(raw_config, {}) - # Check that OTA was normalized to a list and captive_portal added web_server assert "ota" in result assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - # The empty dict gets normalized and web_server is added platforms = {p.get("platform") for p in result["ota"]} assert "web_server" in platforms, f"Expected web_server platform in {platforms}" def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" - # Set up CORE config path CORE.config_path = fixtures_dir / "dummy.yaml" - # Load config with OTA having proper list format config_file = fixtures_dir / "ota_with_platform_list.yaml" raw_config = yaml_util.load_yaml(config_file) result = config.validate_config(raw_config, {}) - # Check that OTA remains a list with both esphome and web_server platforms assert "ota" in result assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" platforms = {p.get("platform") for p in result["ota"]} From 3f202c291ae6dbb57f8658d5439131b41646089e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 16:10:04 -0500 Subject: [PATCH 1430/1626] [core] Fix platform component normalization happening too late in validation pipeline --- tests/unit_tests/test_config.py | 72 +-------------------------------- 1 file changed, 2 insertions(+), 70 deletions(-) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index c0be847904..0306d57fea 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -32,33 +32,11 @@ def fixtures_dir() -> Path: return Path(__file__).parent / "fixtures" -def test_iter_components_handles_non_list_platform_component( - mock_get_component: Mock, -) -> None: - """Test that iter_components handles platform components that have been normalized to empty list.""" - test_config = { - "ota": [], # Normalized from None/dict to empty list by LoadValidationStep - "wifi": {"ssid": "test"}, - } - - components = { - "ota": MagicMock(is_platform_component=True), - "wifi": MagicMock(is_platform_component=False), - } - - mock_get_component.side_effect = lambda domain: components.get( - domain, MagicMock(is_platform_component=False) - ) - - components_list = list(config.iter_components(test_config)) - assert len(components_list) == 2 # ota and wifi (ota has no platforms) - - def test_iter_component_configs_handles_non_list_platform_component( mock_get_component: Mock, mock_get_platform: Mock, ) -> None: - """Test that iter_component_configs handles platform components that have been normalized.""" + """Test iter_component_configs handles normalized platform components.""" test_config = { "ota": [], # Normalized from None/dict to empty list by LoadValidationStep "one_wire": [{"platform": "gpio", "pin": 10}], @@ -81,57 +59,11 @@ def test_iter_component_configs_handles_non_list_platform_component( assert "one_wire.gpio" in domains -def test_iter_components_with_valid_platform_list( - mock_get_component: Mock, - mock_get_platform: Mock, -) -> None: - """Test that iter_components works correctly with valid platform component list.""" - mock_component = MagicMock() - mock_component.is_platform_component = True - - test_config = { - "sensor": [ - {"platform": "dht", "pin": 5}, - {"platform": "bme280", "address": 0x76}, - ], - } - - mock_get_component.return_value = mock_component - - components = list(config.iter_components(test_config)) - assert len(components) == 3 - - domains = [c[0] for c in components] - assert "sensor" in domains - assert "sensor.dht" in domains - assert "sensor.bme280" in domains - - -def test_ota_with_proper_platform_list( - mock_get_component: Mock, - mock_get_platform: Mock, -) -> None: - """Test that OTA component works correctly when configured as a list with platforms.""" - test_config = { - "ota": [ - {"platform": "esphome", "password": "test123"}, - ], - } - - mock_get_component.return_value = MagicMock(is_platform_component=True) - components = list(config.iter_components(test_config)) - - assert len(components) == 2 - domains = [c[0] for c in components] - assert "ota" in domains - assert "ota.esphome" in domains - - def test_ota_component_configs_with_proper_platform_list( mock_get_component: Mock, mock_get_platform: Mock, ) -> None: - """Test that iter_component_configs handles OTA properly configured as a list.""" + """Test iter_component_configs handles OTA properly configured as a list.""" test_config = { "ota": [ {"platform": "esphome", "password": "test123", "id": "my_ota"}, From b134f40201eef391cc2ee6c0a5f427074d5459b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 16:10:53 -0500 Subject: [PATCH 1431/1626] [core] Fix platform component normalization happening too late in validation pipeline --- tests/unit_tests/test_config.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 0306d57fea..4b79ddd426 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -32,33 +32,6 @@ def fixtures_dir() -> Path: return Path(__file__).parent / "fixtures" -def test_iter_component_configs_handles_non_list_platform_component( - mock_get_component: Mock, - mock_get_platform: Mock, -) -> None: - """Test iter_component_configs handles normalized platform components.""" - test_config = { - "ota": [], # Normalized from None/dict to empty list by LoadValidationStep - "one_wire": [{"platform": "gpio", "pin": 10}], - } - - components: dict[str, Mock] = { - "ota": MagicMock(is_platform_component=True, multi_conf=False), - "one_wire": MagicMock(is_platform_component=True, multi_conf=False), - } - - default_mock = MagicMock(is_platform_component=False, multi_conf=False) - mock_get_component.side_effect = lambda domain: components.get(domain, default_mock) - - configs = list(config.iter_component_configs(test_config)) - assert len(configs) == 3 - - domains = [c[0] for c in configs] - assert "ota" in domains - assert "one_wire" in domains - assert "one_wire.gpio" in domains - - def test_ota_component_configs_with_proper_platform_list( mock_get_component: Mock, mock_get_platform: Mock, From f5bba6f8ccac0cbe618033c61324007e767e45eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 16:13:38 -0500 Subject: [PATCH 1432/1626] rename to workaround the test conflict --- tests/unit_tests/test_config.py | 122 ------ tests/unit_tests/test_config_validation.py | 449 +++++---------------- 2 files changed, 96 insertions(+), 475 deletions(-) delete mode 100644 tests/unit_tests/test_config.py diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py deleted file mode 100644 index 4b79ddd426..0000000000 --- a/tests/unit_tests/test_config.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Unit tests for esphome.config module.""" - -from collections.abc import Generator -from pathlib import Path -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from esphome import config, yaml_util -from esphome.core import CORE - - -@pytest.fixture -def mock_get_component() -> Generator[Mock, None, None]: - """Fixture for mocking get_component.""" - with patch("esphome.config.get_component") as mock_get_component: - yield mock_get_component - - -@pytest.fixture -def mock_get_platform() -> Generator[Mock, None, None]: - """Fixture for mocking get_platform.""" - with patch("esphome.config.get_platform") as mock_get_platform: - # Default mock platform - mock_get_platform.return_value = MagicMock() - yield mock_get_platform - - -@pytest.fixture -def fixtures_dir() -> Path: - """Get the fixtures directory.""" - return Path(__file__).parent / "fixtures" - - -def test_ota_component_configs_with_proper_platform_list( - mock_get_component: Mock, - mock_get_platform: Mock, -) -> None: - """Test iter_component_configs handles OTA properly configured as a list.""" - test_config = { - "ota": [ - {"platform": "esphome", "password": "test123", "id": "my_ota"}, - ], - } - - mock_get_component.return_value = MagicMock( - is_platform_component=True, multi_conf=False - ) - - configs = list(config.iter_component_configs(test_config)) - assert len(configs) == 2 - - assert configs[0][0] == "ota" - assert configs[0][2] == test_config["ota"] # The list itself - - assert configs[1][0] == "ota.esphome" - assert configs[1][2]["platform"] == "esphome" - assert configs[1][2]["password"] == "test123" - - -def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: - """Test that iter_component_configs handles multi_conf components correctly.""" - test_config = { - "switch": [ - {"name": "Switch 1"}, - {"name": "Switch 2"}, - ], - } - - mock_get_component.return_value = MagicMock( - is_platform_component=False, multi_conf=True - ) - - configs = list(config.iter_component_configs(test_config)) - assert len(configs) == 2 - - for domain, component, conf in configs: - assert domain == "switch" - assert "name" in conf - - -def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: - """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" - CORE.config_path = fixtures_dir / "dummy.yaml" - - config_file = fixtures_dir / "ota_no_platform.yaml" - raw_config = yaml_util.load_yaml(config_file) - result = config.validate_config(raw_config, {}) - - assert "ota" in result - assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - platforms = {p.get("platform") for p in result["ota"]} - assert "web_server" in platforms, f"Expected web_server platform in {platforms}" - - -def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: - """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" - CORE.config_path = fixtures_dir / "dummy.yaml" - - config_file = fixtures_dir / "ota_empty_dict.yaml" - raw_config = yaml_util.load_yaml(config_file) - result = config.validate_config(raw_config, {}) - - assert "ota" in result - assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - platforms = {p.get("platform") for p in result["ota"]} - assert "web_server" in platforms, f"Expected web_server platform in {platforms}" - - -def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: - """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" - CORE.config_path = fixtures_dir / "dummy.yaml" - - config_file = fixtures_dir / "ota_with_platform_list.yaml" - raw_config = yaml_util.load_yaml(config_file) - result = config.validate_config(raw_config, {}) - - assert "ota" in result - assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - platforms = {p.get("platform") for p in result["ota"]} - assert "esphome" in platforms, f"Expected esphome platform in {platforms}" - assert "web_server" in platforms, f"Expected web_server platform in {platforms}" diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 2928c5c83a..4b79ddd426 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,379 +1,122 @@ -import string +"""Unit tests for esphome.config module.""" + +from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch -from hypothesis import example, given -from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest -from esphome import config_validation -from esphome.components.esp32.const import ( - VARIANT_ESP32, - VARIANT_ESP32C2, - VARIANT_ESP32C3, - VARIANT_ESP32C6, - VARIANT_ESP32H2, - VARIANT_ESP32S2, - VARIANT_ESP32S3, -) -from esphome.config_validation import Invalid -from esphome.const import ( - PLATFORM_BK72XX, - PLATFORM_ESP32, - PLATFORM_ESP8266, - PLATFORM_HOST, - PLATFORM_LN882X, - PLATFORM_RP2040, - PLATFORM_RTL87XX, -) -from esphome.core import CORE, HexInt, Lambda +from esphome import config, yaml_util +from esphome.core import CORE -def test_check_not_templatable__invalid(): - with pytest.raises(Invalid, match="This option is not templatable!"): - config_validation.check_not_templatable(Lambda("")) +@pytest.fixture +def mock_get_component() -> Generator[Mock, None, None]: + """Fixture for mocking get_component.""" + with patch("esphome.config.get_component") as mock_get_component: + yield mock_get_component -@pytest.mark.parametrize("value", ("foo", 1, "D12", False)) -def test_alphanumeric__valid(value): - actual = config_validation.alphanumeric(value) - - assert actual == str(value) +@pytest.fixture +def mock_get_platform() -> Generator[Mock, None, None]: + """Fixture for mocking get_platform.""" + with patch("esphome.config.get_platform") as mock_get_platform: + # Default mock platform + mock_get_platform.return_value = MagicMock() + yield mock_get_platform -@pytest.mark.parametrize("value", ("£23", "Foo!")) -def test_alphanumeric__invalid(value): - with pytest.raises(Invalid): - config_validation.alphanumeric(value) +@pytest.fixture +def fixtures_dir() -> Path: + """Get the fixtures directory.""" + return Path(__file__).parent / "fixtures" -@given(value=text(alphabet=string.ascii_lowercase + string.digits + "-_")) -def test_valid_name__valid(value): - actual = config_validation.valid_name(value) - - assert actual == value - - -@pytest.mark.parametrize("value", ("foo bar", "FooBar", "foo::bar")) -def test_valid_name__invalid(value): - with pytest.raises(Invalid): - config_validation.valid_name(value) - - -@pytest.mark.parametrize("value", ("${name}", "${NAME}", "$NAME", "${name}_name")) -def test_valid_name__substitution_valid(value): - CORE.vscode = True - actual = config_validation.valid_name(value) - assert actual == value - - CORE.vscode = False - with pytest.raises(Invalid): - actual = config_validation.valid_name(value) - - -@pytest.mark.parametrize("value", ("{NAME}", "${A NAME}")) -def test_valid_name__substitution_like_invalid(value): - with pytest.raises(Invalid): - config_validation.valid_name(value) - - -@pytest.mark.parametrize("value", ("myid", "anID", "SOME_ID_test", "MYID_99")) -def test_validate_id_name__valid(value): - actual = config_validation.validate_id_name(value) - - assert actual == value - - -@pytest.mark.parametrize("value", ("id of mine", "id-4", "{name_id}", "id::name")) -def test_validate_id_name__invalid(value): - with pytest.raises(Invalid): - config_validation.validate_id_name(value) - - -@pytest.mark.parametrize("value", ("${id}", "${ID}", "${ID}_test_1", "$MYID")) -def test_validate_id_name__substitution_valid(value): - CORE.vscode = True - actual = config_validation.validate_id_name(value) - assert actual == value - - CORE.vscode = False - with pytest.raises(Invalid): - config_validation.validate_id_name(value) - - -@given(one_of(integers(), text())) -def test_string__valid(value): - actual = config_validation.string(value) - - assert actual == str(value) - - -@pytest.mark.parametrize("value", ({}, [], True, False, None)) -def test_string__invalid(value): - with pytest.raises(Invalid): - config_validation.string(value) - - -@given(text()) -def test_strict_string__valid(value): - actual = config_validation.string_strict(value) - - assert actual == value - - -@pytest.mark.parametrize("value", (None, 123)) -def test_string_string__invalid(value): - with pytest.raises(Invalid, match="Must be string, got"): - config_validation.string_strict(value) - - -@given( - builds( - lambda v: "mdi:" + v, - text( - alphabet=string.ascii_letters + string.digits + "-_", - min_size=1, - max_size=20, - ), - ) -) -@example("") -def test_icon__valid(value): - actual = config_validation.icon(value) - - assert actual == value - - -def test_icon__invalid(): - with pytest.raises(Invalid, match="Icons must match the format "): - config_validation.icon("foo") - - -@pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) -def test_boolean__valid_true(value): - assert config_validation.boolean(value) is True - - -@pytest.mark.parametrize("value", ("False", "NO", "off", "disAblE", False)) -def test_boolean__valid_false(value): - assert config_validation.boolean(value) is False - - -@pytest.mark.parametrize("value", (None, 1, 0, "foo")) -def test_boolean__invalid(value): - with pytest.raises(Invalid, match="Expected boolean value"): - config_validation.boolean(value) - - -@given(value=ip_addresses(v=4).map(str)) -def test_ipv4__valid(value): - config_validation.ipv4address(value) - - -@pytest.mark.parametrize("value", ("127.0.0", "localhost", "")) -def test_ipv4__invalid(value): - with pytest.raises(Invalid, match="is not a valid IPv4 address"): - config_validation.ipv4address(value) - - -@given(value=ip_addresses(v=6).map(str)) -def test_ipv6__valid(value): - config_validation.ipaddress(value) - - -@pytest.mark.parametrize("value", ("127.0.0", "localhost", "", "2001:db8::2::3")) -def test_ipv6__invalid(value): - with pytest.raises(Invalid, match="is not a valid IP address"): - config_validation.ipaddress(value) - - -# TODO: ensure_list -@given(integers()) -def hex_int__valid(value): - actual = config_validation.hex_int(value) - - assert isinstance(actual, HexInt) - assert actual == value - - -@pytest.mark.parametrize( - "framework, platform, variant, full, idf, arduino, simple", - [ - ("arduino", PLATFORM_ESP8266, None, "1", "1", "1", "1"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32, "3", "2", "3", "2"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32, "4", "4", "2", "2"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32C2, "3", "2", "3", "2"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C2, "4", "4", "2", "2"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32S2, "6", "5", "6", "5"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32S2, "7", "7", "5", "5"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32S3, "9", "8", "9", "8"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32S3, "10", "10", "8", "8"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32C3, "12", "11", "12", "11"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C3, "13", "13", "11", "11"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32C6, "15", "14", "15", "14"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C6, "16", "16", "14", "14"), - ("arduino", PLATFORM_ESP32, VARIANT_ESP32H2, "18", "17", "18", "17"), - ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32H2, "19", "19", "17", "17"), - ("arduino", PLATFORM_RP2040, None, "20", "20", "20", "20"), - ("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"), - ("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"), - ("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"), - ("host", PLATFORM_HOST, None, "24", "24", "24", "24"), - ], -) -def test_split_default(framework, platform, variant, full, idf, arduino, simple): - from esphome.components.esp32.const import KEY_ESP32 - from esphome.const import ( - KEY_CORE, - KEY_TARGET_FRAMEWORK, - KEY_TARGET_PLATFORM, - KEY_VARIANT, - ) - - CORE.data[KEY_CORE] = {} - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform - CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework - if platform == PLATFORM_ESP32: - CORE.data[KEY_ESP32] = {} - CORE.data[KEY_ESP32][KEY_VARIANT] = variant - - common_mappings = { - "esp8266": "1", - "esp32": "2", - "esp32_s2": "5", - "esp32_s3": "8", - "esp32_c3": "11", - "esp32_c6": "14", - "esp32_h2": "17", - "rp2040": "20", - "bk72xx": "21", - "rtl87xx": "22", - "ln882x": "23", - "host": "24", +def test_ota_component_configs_with_proper_platform_list( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test iter_component_configs handles OTA properly configured as a list.""" + test_config = { + "ota": [ + {"platform": "esphome", "password": "test123", "id": "my_ota"}, + ], } - idf_mappings = { - "esp32_idf": "4", - "esp32_s2_idf": "7", - "esp32_s3_idf": "10", - "esp32_c3_idf": "13", - "esp32_c6_idf": "16", - "esp32_h2_idf": "19", + mock_get_component.return_value = MagicMock( + is_platform_component=True, multi_conf=False + ) + + configs = list(config.iter_component_configs(test_config)) + assert len(configs) == 2 + + assert configs[0][0] == "ota" + assert configs[0][2] == test_config["ota"] # The list itself + + assert configs[1][0] == "ota.esphome" + assert configs[1][2]["platform"] == "esphome" + assert configs[1][2]["password"] == "test123" + + +def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: + """Test that iter_component_configs handles multi_conf components correctly.""" + test_config = { + "switch": [ + {"name": "Switch 1"}, + {"name": "Switch 2"}, + ], } - arduino_mappings = { - "esp32_arduino": "3", - "esp32_s2_arduino": "6", - "esp32_s3_arduino": "9", - "esp32_c3_arduino": "12", - "esp32_c6_arduino": "15", - "esp32_h2_arduino": "18", - } - - schema = config_validation.Schema( - { - config_validation.SplitDefault( - "full", **common_mappings, **idf_mappings, **arduino_mappings - ): str, - config_validation.SplitDefault( - "idf", **common_mappings, **idf_mappings - ): str, - config_validation.SplitDefault( - "arduino", **common_mappings, **arduino_mappings - ): str, - config_validation.SplitDefault("simple", **common_mappings): str, - } + mock_get_component.return_value = MagicMock( + is_platform_component=False, multi_conf=True ) - assert schema({}).get("full") == full - assert schema({}).get("idf") == idf - assert schema({}).get("arduino") == arduino - assert schema({}).get("simple") == simple + configs = list(config.iter_component_configs(test_config)) + assert len(configs) == 2 + + for domain, component, conf in configs: + assert domain == "switch" + assert "name" in conf -@pytest.mark.parametrize( - "framework, platform, message", - [ - ("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"), - ("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"), - ("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"), - ("arduino", PLATFORM_RP2040, "RP2040 using arduino framework"), - ("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"), - ("host", PLATFORM_HOST, "HOST using host framework"), - ], -) -def test_require_framework_version(framework, platform, message): - import voluptuous as vol +def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" - from esphome.const import ( - KEY_CORE, - KEY_FRAMEWORK_VERSION, - KEY_TARGET_FRAMEWORK, - KEY_TARGET_PLATFORM, - ) + config_file = fixtures_dir / "ota_no_platform.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) - CORE.data[KEY_CORE] = {} - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform - CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework - CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = config_validation.Version(1, 0, 0) + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" - assert ( - config_validation.require_framework_version( - esp_idf=config_validation.Version(0, 5, 0), - esp32_arduino=config_validation.Version(0, 5, 0), - esp8266_arduino=config_validation.Version(0, 5, 0), - rp2040_arduino=config_validation.Version(0, 5, 0), - bk72xx_arduino=config_validation.Version(0, 5, 0), - host=config_validation.Version(0, 5, 0), - extra_message="test 1", - )("test") - == "test" - ) - with pytest.raises( - vol.error.Invalid, - match="This feature requires at least framework version 2.0.0. test 2", - ): - config_validation.require_framework_version( - esp_idf=config_validation.Version(2, 0, 0), - esp32_arduino=config_validation.Version(2, 0, 0), - esp8266_arduino=config_validation.Version(2, 0, 0), - rp2040_arduino=config_validation.Version(2, 0, 0), - bk72xx_arduino=config_validation.Version(2, 0, 0), - host=config_validation.Version(2, 0, 0), - extra_message="test 2", - )("test") +def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" - assert ( - config_validation.require_framework_version( - esp_idf=config_validation.Version(1, 5, 0), - esp32_arduino=config_validation.Version(1, 5, 0), - esp8266_arduino=config_validation.Version(1, 5, 0), - rp2040_arduino=config_validation.Version(1, 5, 0), - bk72xx_arduino=config_validation.Version(1, 5, 0), - host=config_validation.Version(1, 5, 0), - max_version=True, - extra_message="test 3", - )("test") - == "test" - ) + config_file = fixtures_dir / "ota_empty_dict.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) - with pytest.raises( - vol.error.Invalid, - match="This feature requires framework version 0.5.0 or lower. test 4", - ): - config_validation.require_framework_version( - esp_idf=config_validation.Version(0, 5, 0), - esp32_arduino=config_validation.Version(0, 5, 0), - esp8266_arduino=config_validation.Version(0, 5, 0), - rp2040_arduino=config_validation.Version(0, 5, 0), - bk72xx_arduino=config_validation.Version(0, 5, 0), - host=config_validation.Version(0, 5, 0), - max_version=True, - extra_message="test 4", - )("test") + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" - with pytest.raises( - vol.error.Invalid, match=f"This feature is incompatible with {message}. test 5" - ): - config_validation.require_framework_version( - extra_message="test 5", - )("test") + +def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" + + config_file = fixtures_dir / "ota_with_platform_list.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "esphome" in platforms, f"Expected esphome platform in {platforms}" + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" From ae2773a7a738389e508bc75abeb4194aeab2b5d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 16:14:43 -0500 Subject: [PATCH 1433/1626] fixes --- tests/unit_tests/test_config_normalization.py | 122 +++++ tests/unit_tests/test_config_validation.py | 445 ++++++++++++++---- 2 files changed, 473 insertions(+), 94 deletions(-) create mode 100644 tests/unit_tests/test_config_normalization.py diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py new file mode 100644 index 0000000000..4b79ddd426 --- /dev/null +++ b/tests/unit_tests/test_config_normalization.py @@ -0,0 +1,122 @@ +"""Unit tests for esphome.config module.""" + +from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from esphome import config, yaml_util +from esphome.core import CORE + + +@pytest.fixture +def mock_get_component() -> Generator[Mock, None, None]: + """Fixture for mocking get_component.""" + with patch("esphome.config.get_component") as mock_get_component: + yield mock_get_component + + +@pytest.fixture +def mock_get_platform() -> Generator[Mock, None, None]: + """Fixture for mocking get_platform.""" + with patch("esphome.config.get_platform") as mock_get_platform: + # Default mock platform + mock_get_platform.return_value = MagicMock() + yield mock_get_platform + + +@pytest.fixture +def fixtures_dir() -> Path: + """Get the fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +def test_ota_component_configs_with_proper_platform_list( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test iter_component_configs handles OTA properly configured as a list.""" + test_config = { + "ota": [ + {"platform": "esphome", "password": "test123", "id": "my_ota"}, + ], + } + + mock_get_component.return_value = MagicMock( + is_platform_component=True, multi_conf=False + ) + + configs = list(config.iter_component_configs(test_config)) + assert len(configs) == 2 + + assert configs[0][0] == "ota" + assert configs[0][2] == test_config["ota"] # The list itself + + assert configs[1][0] == "ota.esphome" + assert configs[1][2]["platform"] == "esphome" + assert configs[1][2]["password"] == "test123" + + +def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: + """Test that iter_component_configs handles multi_conf components correctly.""" + test_config = { + "switch": [ + {"name": "Switch 1"}, + {"name": "Switch 2"}, + ], + } + + mock_get_component.return_value = MagicMock( + is_platform_component=False, multi_conf=True + ) + + configs = list(config.iter_component_configs(test_config)) + assert len(configs) == 2 + + for domain, component, conf in configs: + assert domain == "switch" + assert "name" in conf + + +def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" + + config_file = fixtures_dir / "ota_no_platform.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" + + config_file = fixtures_dir / "ota_empty_dict.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" + + config_file = fixtures_dir / "ota_with_platform_list.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "esphome" in platforms, f"Expected esphome platform in {platforms}" + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 4b79ddd426..2928c5c83a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,122 +1,379 @@ -"""Unit tests for esphome.config module.""" - -from collections.abc import Generator -from pathlib import Path -from unittest.mock import MagicMock, Mock, patch +import string +from hypothesis import example, given +from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest -from esphome import config, yaml_util -from esphome.core import CORE +from esphome import config_validation +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +from esphome.config_validation import Invalid +from esphome.const import ( + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_HOST, + PLATFORM_LN882X, + PLATFORM_RP2040, + PLATFORM_RTL87XX, +) +from esphome.core import CORE, HexInt, Lambda -@pytest.fixture -def mock_get_component() -> Generator[Mock, None, None]: - """Fixture for mocking get_component.""" - with patch("esphome.config.get_component") as mock_get_component: - yield mock_get_component +def test_check_not_templatable__invalid(): + with pytest.raises(Invalid, match="This option is not templatable!"): + config_validation.check_not_templatable(Lambda("")) -@pytest.fixture -def mock_get_platform() -> Generator[Mock, None, None]: - """Fixture for mocking get_platform.""" - with patch("esphome.config.get_platform") as mock_get_platform: - # Default mock platform - mock_get_platform.return_value = MagicMock() - yield mock_get_platform +@pytest.mark.parametrize("value", ("foo", 1, "D12", False)) +def test_alphanumeric__valid(value): + actual = config_validation.alphanumeric(value) + + assert actual == str(value) -@pytest.fixture -def fixtures_dir() -> Path: - """Get the fixtures directory.""" - return Path(__file__).parent / "fixtures" +@pytest.mark.parametrize("value", ("£23", "Foo!")) +def test_alphanumeric__invalid(value): + with pytest.raises(Invalid): + config_validation.alphanumeric(value) -def test_ota_component_configs_with_proper_platform_list( - mock_get_component: Mock, - mock_get_platform: Mock, -) -> None: - """Test iter_component_configs handles OTA properly configured as a list.""" - test_config = { - "ota": [ - {"platform": "esphome", "password": "test123", "id": "my_ota"}, - ], - } +@given(value=text(alphabet=string.ascii_lowercase + string.digits + "-_")) +def test_valid_name__valid(value): + actual = config_validation.valid_name(value) - mock_get_component.return_value = MagicMock( - is_platform_component=True, multi_conf=False + assert actual == value + + +@pytest.mark.parametrize("value", ("foo bar", "FooBar", "foo::bar")) +def test_valid_name__invalid(value): + with pytest.raises(Invalid): + config_validation.valid_name(value) + + +@pytest.mark.parametrize("value", ("${name}", "${NAME}", "$NAME", "${name}_name")) +def test_valid_name__substitution_valid(value): + CORE.vscode = True + actual = config_validation.valid_name(value) + assert actual == value + + CORE.vscode = False + with pytest.raises(Invalid): + actual = config_validation.valid_name(value) + + +@pytest.mark.parametrize("value", ("{NAME}", "${A NAME}")) +def test_valid_name__substitution_like_invalid(value): + with pytest.raises(Invalid): + config_validation.valid_name(value) + + +@pytest.mark.parametrize("value", ("myid", "anID", "SOME_ID_test", "MYID_99")) +def test_validate_id_name__valid(value): + actual = config_validation.validate_id_name(value) + + assert actual == value + + +@pytest.mark.parametrize("value", ("id of mine", "id-4", "{name_id}", "id::name")) +def test_validate_id_name__invalid(value): + with pytest.raises(Invalid): + config_validation.validate_id_name(value) + + +@pytest.mark.parametrize("value", ("${id}", "${ID}", "${ID}_test_1", "$MYID")) +def test_validate_id_name__substitution_valid(value): + CORE.vscode = True + actual = config_validation.validate_id_name(value) + assert actual == value + + CORE.vscode = False + with pytest.raises(Invalid): + config_validation.validate_id_name(value) + + +@given(one_of(integers(), text())) +def test_string__valid(value): + actual = config_validation.string(value) + + assert actual == str(value) + + +@pytest.mark.parametrize("value", ({}, [], True, False, None)) +def test_string__invalid(value): + with pytest.raises(Invalid): + config_validation.string(value) + + +@given(text()) +def test_strict_string__valid(value): + actual = config_validation.string_strict(value) + + assert actual == value + + +@pytest.mark.parametrize("value", (None, 123)) +def test_string_string__invalid(value): + with pytest.raises(Invalid, match="Must be string, got"): + config_validation.string_strict(value) + + +@given( + builds( + lambda v: "mdi:" + v, + text( + alphabet=string.ascii_letters + string.digits + "-_", + min_size=1, + max_size=20, + ), + ) +) +@example("") +def test_icon__valid(value): + actual = config_validation.icon(value) + + assert actual == value + + +def test_icon__invalid(): + with pytest.raises(Invalid, match="Icons must match the format "): + config_validation.icon("foo") + + +@pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) +def test_boolean__valid_true(value): + assert config_validation.boolean(value) is True + + +@pytest.mark.parametrize("value", ("False", "NO", "off", "disAblE", False)) +def test_boolean__valid_false(value): + assert config_validation.boolean(value) is False + + +@pytest.mark.parametrize("value", (None, 1, 0, "foo")) +def test_boolean__invalid(value): + with pytest.raises(Invalid, match="Expected boolean value"): + config_validation.boolean(value) + + +@given(value=ip_addresses(v=4).map(str)) +def test_ipv4__valid(value): + config_validation.ipv4address(value) + + +@pytest.mark.parametrize("value", ("127.0.0", "localhost", "")) +def test_ipv4__invalid(value): + with pytest.raises(Invalid, match="is not a valid IPv4 address"): + config_validation.ipv4address(value) + + +@given(value=ip_addresses(v=6).map(str)) +def test_ipv6__valid(value): + config_validation.ipaddress(value) + + +@pytest.mark.parametrize("value", ("127.0.0", "localhost", "", "2001:db8::2::3")) +def test_ipv6__invalid(value): + with pytest.raises(Invalid, match="is not a valid IP address"): + config_validation.ipaddress(value) + + +# TODO: ensure_list +@given(integers()) +def hex_int__valid(value): + actual = config_validation.hex_int(value) + + assert isinstance(actual, HexInt) + assert actual == value + + +@pytest.mark.parametrize( + "framework, platform, variant, full, idf, arduino, simple", + [ + ("arduino", PLATFORM_ESP8266, None, "1", "1", "1", "1"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32, "3", "2", "3", "2"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32, "4", "4", "2", "2"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32C2, "3", "2", "3", "2"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C2, "4", "4", "2", "2"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32S2, "6", "5", "6", "5"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32S2, "7", "7", "5", "5"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32S3, "9", "8", "9", "8"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32S3, "10", "10", "8", "8"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32C3, "12", "11", "12", "11"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C3, "13", "13", "11", "11"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32C6, "15", "14", "15", "14"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C6, "16", "16", "14", "14"), + ("arduino", PLATFORM_ESP32, VARIANT_ESP32H2, "18", "17", "18", "17"), + ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32H2, "19", "19", "17", "17"), + ("arduino", PLATFORM_RP2040, None, "20", "20", "20", "20"), + ("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"), + ("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"), + ("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"), + ("host", PLATFORM_HOST, None, "24", "24", "24", "24"), + ], +) +def test_split_default(framework, platform, variant, full, idf, arduino, simple): + from esphome.components.esp32.const import KEY_ESP32 + from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + KEY_VARIANT, ) - configs = list(config.iter_component_configs(test_config)) - assert len(configs) == 2 + CORE.data[KEY_CORE] = {} + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework + if platform == PLATFORM_ESP32: + CORE.data[KEY_ESP32] = {} + CORE.data[KEY_ESP32][KEY_VARIANT] = variant - assert configs[0][0] == "ota" - assert configs[0][2] == test_config["ota"] # The list itself - - assert configs[1][0] == "ota.esphome" - assert configs[1][2]["platform"] == "esphome" - assert configs[1][2]["password"] == "test123" - - -def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: - """Test that iter_component_configs handles multi_conf components correctly.""" - test_config = { - "switch": [ - {"name": "Switch 1"}, - {"name": "Switch 2"}, - ], + common_mappings = { + "esp8266": "1", + "esp32": "2", + "esp32_s2": "5", + "esp32_s3": "8", + "esp32_c3": "11", + "esp32_c6": "14", + "esp32_h2": "17", + "rp2040": "20", + "bk72xx": "21", + "rtl87xx": "22", + "ln882x": "23", + "host": "24", } - mock_get_component.return_value = MagicMock( - is_platform_component=False, multi_conf=True + idf_mappings = { + "esp32_idf": "4", + "esp32_s2_idf": "7", + "esp32_s3_idf": "10", + "esp32_c3_idf": "13", + "esp32_c6_idf": "16", + "esp32_h2_idf": "19", + } + + arduino_mappings = { + "esp32_arduino": "3", + "esp32_s2_arduino": "6", + "esp32_s3_arduino": "9", + "esp32_c3_arduino": "12", + "esp32_c6_arduino": "15", + "esp32_h2_arduino": "18", + } + + schema = config_validation.Schema( + { + config_validation.SplitDefault( + "full", **common_mappings, **idf_mappings, **arduino_mappings + ): str, + config_validation.SplitDefault( + "idf", **common_mappings, **idf_mappings + ): str, + config_validation.SplitDefault( + "arduino", **common_mappings, **arduino_mappings + ): str, + config_validation.SplitDefault("simple", **common_mappings): str, + } ) - configs = list(config.iter_component_configs(test_config)) - assert len(configs) == 2 - - for domain, component, conf in configs: - assert domain == "switch" - assert "name" in conf + assert schema({}).get("full") == full + assert schema({}).get("idf") == idf + assert schema({}).get("arduino") == arduino + assert schema({}).get("simple") == simple -def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: - """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" - CORE.config_path = fixtures_dir / "dummy.yaml" +@pytest.mark.parametrize( + "framework, platform, message", + [ + ("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"), + ("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"), + ("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"), + ("arduino", PLATFORM_RP2040, "RP2040 using arduino framework"), + ("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"), + ("host", PLATFORM_HOST, "HOST using host framework"), + ], +) +def test_require_framework_version(framework, platform, message): + import voluptuous as vol - config_file = fixtures_dir / "ota_no_platform.yaml" - raw_config = yaml_util.load_yaml(config_file) - result = config.validate_config(raw_config, {}) + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + ) - assert "ota" in result - assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - platforms = {p.get("platform") for p in result["ota"]} - assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + CORE.data[KEY_CORE] = {} + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = config_validation.Version(1, 0, 0) + assert ( + config_validation.require_framework_version( + esp_idf=config_validation.Version(0, 5, 0), + esp32_arduino=config_validation.Version(0, 5, 0), + esp8266_arduino=config_validation.Version(0, 5, 0), + rp2040_arduino=config_validation.Version(0, 5, 0), + bk72xx_arduino=config_validation.Version(0, 5, 0), + host=config_validation.Version(0, 5, 0), + extra_message="test 1", + )("test") + == "test" + ) -def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: - """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" - CORE.config_path = fixtures_dir / "dummy.yaml" + with pytest.raises( + vol.error.Invalid, + match="This feature requires at least framework version 2.0.0. test 2", + ): + config_validation.require_framework_version( + esp_idf=config_validation.Version(2, 0, 0), + esp32_arduino=config_validation.Version(2, 0, 0), + esp8266_arduino=config_validation.Version(2, 0, 0), + rp2040_arduino=config_validation.Version(2, 0, 0), + bk72xx_arduino=config_validation.Version(2, 0, 0), + host=config_validation.Version(2, 0, 0), + extra_message="test 2", + )("test") - config_file = fixtures_dir / "ota_empty_dict.yaml" - raw_config = yaml_util.load_yaml(config_file) - result = config.validate_config(raw_config, {}) + assert ( + config_validation.require_framework_version( + esp_idf=config_validation.Version(1, 5, 0), + esp32_arduino=config_validation.Version(1, 5, 0), + esp8266_arduino=config_validation.Version(1, 5, 0), + rp2040_arduino=config_validation.Version(1, 5, 0), + bk72xx_arduino=config_validation.Version(1, 5, 0), + host=config_validation.Version(1, 5, 0), + max_version=True, + extra_message="test 3", + )("test") + == "test" + ) - assert "ota" in result - assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - platforms = {p.get("platform") for p in result["ota"]} - assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + with pytest.raises( + vol.error.Invalid, + match="This feature requires framework version 0.5.0 or lower. test 4", + ): + config_validation.require_framework_version( + esp_idf=config_validation.Version(0, 5, 0), + esp32_arduino=config_validation.Version(0, 5, 0), + esp8266_arduino=config_validation.Version(0, 5, 0), + rp2040_arduino=config_validation.Version(0, 5, 0), + bk72xx_arduino=config_validation.Version(0, 5, 0), + host=config_validation.Version(0, 5, 0), + max_version=True, + extra_message="test 4", + )("test") - -def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: - """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" - CORE.config_path = fixtures_dir / "dummy.yaml" - - config_file = fixtures_dir / "ota_with_platform_list.yaml" - raw_config = yaml_util.load_yaml(config_file) - result = config.validate_config(raw_config, {}) - - assert "ota" in result - assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" - platforms = {p.get("platform") for p in result["ota"]} - assert "esphome" in platforms, f"Expected esphome platform in {platforms}" - assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + with pytest.raises( + vol.error.Invalid, match=f"This feature is incompatible with {message}. test 5" + ): + config_validation.require_framework_version( + extra_message="test 5", + )("test") From 1f90d8973144e55221a91bb081754d30c1a59a84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:22:09 -0500 Subject: [PATCH 1434/1626] Bump pyyaml from 6.0.2 to 6.0.3 (#10904) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 927b831b1b..42db2cc56f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ cryptography==45.0.1 voluptuous==0.15.2 -PyYAML==6.0.2 +PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 From 55593628ef7b4513297e2116bdd475814976ceda Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:22:32 -0500 Subject: [PATCH 1435/1626] Bump github/codeql-action from 3.30.4 to 3.30.5 (#10905) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e31e547b75..5453dae9a7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 + uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4 + uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 with: category: "/language:${{matrix.language}}" From 25fc16163b1cbee2d745e0e844932a02462044b3 Mon Sep 17 00:00:00 2001 From: Patrick Van Oosterwijck Date: Fri, 26 Sep 2025 16:34:22 -0600 Subject: [PATCH 1436/1626] [ethernet] Fix Ethernet RMII capable variant validation (#10909) --- esphome/components/ethernet/__init__.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 75b09dc8e4..1723280bc7 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -145,13 +145,6 @@ def _validate(config): use_address = CORE.name + config[CONF_DOMAIN] config[CONF_USE_ADDRESS] = use_address - # Validate LAN8670 is only used with ESP32 classic or ESP32-P4 - if config[CONF_TYPE] == "LAN8670": - variant = get_esp32_variant() - if variant not in (VARIANT_ESP32, VARIANT_ESP32P4): - raise cv.Invalid( - f"LAN8670 PHY is only supported on ESP32 classic and ESP32-P4, not {variant}" - ) if config[CONF_TYPE] in SPI_ETHERNET_TYPES: if _is_framework_spi_polling_mode_supported(): if CONF_POLLING_INTERVAL in config and CONF_INTERRUPT_PIN in config: @@ -184,6 +177,12 @@ def _validate(config): del config[CONF_CLK_MODE] elif CONF_CLK not in config: raise cv.Invalid("'clk' is a required option for [ethernet].") + variant = get_esp32_variant() + if variant not in (VARIANT_ESP32, VARIANT_ESP32P4): + raise cv.Invalid( + f"{config[CONF_TYPE]} PHY requires RMII interface and is only supported " + f"on ESP32 classic and ESP32-P4, not {variant}" + ) return config From abf522bbb9edb4ede8141628cfc28dfd911817ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 17:50:27 -0500 Subject: [PATCH 1437/1626] [ota] Add SHA256 password authentication with backward compatibility (#10809) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esphome/ota/__init__.py | 23 +- .../components/esphome/ota/ota_esphome.cpp | 199 +++-- esphome/components/esphome/ota/ota_esphome.h | 6 + esphome/components/ota/ota_backend.h | 1 + esphome/core/defines.h | 2 + esphome/espota2.py | 129 ++- tests/unit_tests/test_espota2.py | 738 ++++++++++++++++++ 7 files changed, 1022 insertions(+), 76 deletions(-) create mode 100644 tests/unit_tests/test_espota2.py diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 93216f9425..e6f249e021 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,7 +16,7 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv @@ -24,9 +24,22 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] -AUTO_LOAD = ["md5", "socket"] DEPENDENCIES = ["network"] + +def supports_sha256() -> bool: + """Check if the current platform supports SHA256 for OTA authentication.""" + return bool(CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny) + + +def AUTO_LOAD() -> list[str]: + """Conditionally auto-load sha256 only on platforms that support it.""" + base_components = ["md5", "socket"] + if supports_sha256(): + return base_components + ["sha256"] + return base_components + + esphome = cg.esphome_ns.namespace("esphome") ESPHomeOTAComponent = esphome.class_("ESPHomeOTAComponent", OTAComponent) @@ -126,9 +139,15 @@ FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_port(config[CONF_PORT])) + if CONF_PASSWORD in config: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_PASSWORD") + # Only include hash algorithms when password is configured + cg.add_define("USE_OTA_MD5") + # Only include SHA256 support on platforms that have it + if supports_sha256(): + cg.add_define("USE_OTA_SHA256") cg.add_define("USE_OTA_VERSION", config[CONF_VERSION]) await cg.register_component(var, config) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6654ef8748..6ffeeedb1a 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,6 +1,11 @@ #include "ota_esphome.h" #ifdef USE_OTA +#ifdef USE_OTA_MD5 #include "esphome/components/md5/md5.h" +#endif +#ifdef USE_OTA_SHA256 +#include "esphome/components/sha256/sha256.h" +#endif #include "esphome/components/network/util.h" #include "esphome/components/ota/ota_backend.h" #include "esphome/components/ota/ota_backend_arduino_esp32.h" @@ -10,6 +15,7 @@ #include "esphome/components/ota/ota_backend_esp_idf.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" @@ -95,6 +101,15 @@ void ESPHomeOTAComponent::loop() { } static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; +#ifdef USE_OTA_SHA256 +static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +#endif + +// Temporary flag to allow MD5 downgrade for ~3 versions (until 2026.1.0) +// This allows users to downgrade via OTA if they encounter issues after updating. +// Without this, users would need to do a serial flash to downgrade. +// TODO: Remove this flag and all associated code in 2026.1.0 +#define ALLOW_OTA_DOWNGRADE_MD5 void ESPHomeOTAComponent::handle_handshake_() { /// Handle the initial OTA handshake. @@ -225,57 +240,67 @@ void ESPHomeOTAComponent::handle_data_() { #ifdef USE_OTA_PASSWORD if (!this->password_.empty()) { - buf[0] = ota::OTA_RESPONSE_REQUEST_AUTH; - this->writeall_(buf, 1); - md5::MD5Digest md5{}; - md5.init(); - sprintf(sbuf, "%08" PRIx32, random_uint32()); - md5.add(sbuf, 8); - md5.calculate(); - md5.get_hex(sbuf); - ESP_LOGV(TAG, "Auth: Nonce is %s", sbuf); + bool auth_success = false; - // Send nonce, 32 bytes hex MD5 - if (!this->writeall_(reinterpret_cast(sbuf), 32)) { - ESP_LOGW(TAG, "Auth: Writing nonce failed"); +#ifdef USE_OTA_SHA256 + // SECURITY HARDENING: Prefer SHA256 authentication on platforms that support it. + // + // This is a hardening measure to prevent future downgrade attacks where an attacker + // could force the use of MD5 authentication by manipulating the feature flags. + // + // While MD5 is currently still acceptable for our OTA authentication use case + // (where the password is a shared secret and we're only authenticating, not + // encrypting), at some point in the future MD5 will likely become so weak that + // it could be practically attacked. + // + // We enforce SHA256 now on capable platforms because: + // 1. We can't retroactively update device firmware in the field + // 2. Clients (like esphome CLI) can always be updated to support SHA256 + // 3. This prevents any possibility of downgrade attacks in the future + // + // Devices that don't support SHA256 (due to platform limitations) will + // continue to use MD5 as their only option (see #else branch below). + + bool client_supports_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + +#ifdef ALLOW_OTA_DOWNGRADE_MD5 + // Temporary compatibility mode: Allow MD5 for ~3 versions to enable OTA downgrades + // This prevents users from being locked out if they need to downgrade after updating + // TODO: Remove this entire ifdef block in 2026.1.0 + if (client_supports_sha256) { + sha256::SHA256 sha_hasher; + auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, + LOG_STR("SHA256"), sbuf); + } else { +#ifdef USE_OTA_MD5 + ESP_LOGW(TAG, "Using MD5 auth for compatibility (deprecated)"); + md5::MD5Digest md5_hasher; + auth_success = + this->perform_hash_auth_(&md5_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); +#endif // USE_OTA_MD5 + } +#else + // Strict mode: SHA256 required on capable platforms (future default) + if (!client_supports_sha256) { + ESP_LOGW(TAG, "Client requires SHA256"); + error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } + sha256::SHA256 sha_hasher; + auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, + LOG_STR("SHA256"), sbuf); +#endif // ALLOW_OTA_DOWNGRADE_MD5 +#else + // Platform only supports MD5 - use it as the only available option + // This is not a security downgrade as the platform cannot support SHA256 +#ifdef USE_OTA_MD5 + md5::MD5Digest md5_hasher; + auth_success = + this->perform_hash_auth_(&md5_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); +#endif // USE_OTA_MD5 +#endif // USE_OTA_SHA256 - // prepare challenge - md5.init(); - md5.add(this->password_.c_str(), this->password_.length()); - // add nonce - md5.add(sbuf, 32); - - // Receive cnonce, 32 bytes hex MD5 - if (!this->readall_(buf, 32)) { - ESP_LOGW(TAG, "Auth: Reading cnonce failed"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - sbuf[32] = '\0'; - ESP_LOGV(TAG, "Auth: CNonce is %s", sbuf); - // add cnonce - md5.add(sbuf, 32); - - // calculate result - md5.calculate(); - md5.get_hex(sbuf); - ESP_LOGV(TAG, "Auth: Result is %s", sbuf); - - // Receive result, 32 bytes hex MD5 - if (!this->readall_(buf + 64, 32)) { - ESP_LOGW(TAG, "Auth: Reading response failed"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - sbuf[64 + 32] = '\0'; - ESP_LOGV(TAG, "Auth: Response is %s", sbuf + 64); - - bool matches = true; - for (uint8_t i = 0; i < 32; i++) - matches = matches && buf[i] == buf[64 + i]; - - if (!matches) { - ESP_LOGW(TAG, "Auth failed! Passwords do not match"); + if (!auth_success) { error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -499,5 +524,85 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { delay(1); } +#ifdef USE_OTA_PASSWORD +void ESPHomeOTAComponent::log_auth_warning_(const LogString *action, const LogString *hash_name) { + ESP_LOGW(TAG, "Auth: %s %s failed", LOG_STR_ARG(action), LOG_STR_ARG(hash_name)); +} + +// Non-template function definition to reduce binary size +bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, + const LogString *name, char *buf) { + // Get sizes from the hasher + const size_t hex_size = hasher->get_size() * 2; // Hex is twice the byte size + const size_t nonce_len = hasher->get_size() / 4; // Nonce is 1/4 of hash size in bytes + + // Use the provided buffer for all hex operations + + // Small stack buffer for nonce seed bytes + uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) + + // Send auth request type + this->writeall_(&auth_request, 1); + + hasher->init(); + + // Generate nonce seed bytes using random_bytes + if (!random_bytes(nonce_bytes, nonce_len)) { + this->log_auth_warning_(LOG_STR("Random bytes generation failed"), name); + return false; + } + hasher->add(nonce_bytes, nonce_len); + hasher->calculate(); + + // Generate and send nonce + hasher->get_hex(buf); + buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Nonce is %s", LOG_STR_ARG(name), buf); + + if (!this->writeall_(reinterpret_cast(buf), hex_size)) { + this->log_auth_warning_(LOG_STR("Writing nonce"), name); + return false; + } + + // Start challenge: password + nonce + hasher->init(); + hasher->add(password.c_str(), password.length()); + hasher->add(buf, hex_size); + + // Read cnonce and add to hash + if (!this->readall_(reinterpret_cast(buf), hex_size)) { + this->log_auth_warning_(LOG_STR("Reading cnonce"), name); + return false; + } + buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s CNonce is %s", LOG_STR_ARG(name), buf); + + hasher->add(buf, hex_size); + hasher->calculate(); + + // Log expected result (digest is already in hasher) + hasher->get_hex(buf); + buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Result is %s", LOG_STR_ARG(name), buf); + + // Read response into the buffer + if (!this->readall_(reinterpret_cast(buf), hex_size)) { + this->log_auth_warning_(LOG_STR("Reading response"), name); + return false; + } + buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Response is %s", LOG_STR_ARG(name), buf); + + // Compare response directly with digest in hasher + bool matches = hasher->equals_hex(buf); + + if (!matches) { + this->log_auth_warning_(LOG_STR("Password mismatch"), name); + } + + return matches; +} +#endif // USE_OTA_PASSWORD + } // namespace esphome #endif diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index f5a3e43ae3..5bacb60706 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -7,6 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences.h" +#include "esphome/core/hash_base.h" namespace esphome { @@ -30,6 +31,11 @@ class ESPHomeOTAComponent : public ota::OTAComponent { protected: void handle_handshake_(); void handle_data_(); +#ifdef USE_OTA_PASSWORD + bool perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, const LogString *name, + char *buf); + void log_auth_warning_(const LogString *action, const LogString *hash_name); +#endif // USE_OTA_PASSWORD bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); void log_socket_error_(const LogString *msg); diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 372f24df5e..64ee0b9f7c 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -14,6 +14,7 @@ namespace ota { enum OTAResponseTypes { OTA_RESPONSE_OK = 0x00, OTA_RESPONSE_REQUEST_AUTH = 0x01, + OTA_RESPONSE_REQUEST_SHA256_AUTH = 0x02, OTA_RESPONSE_HEADER_OK = 0x40, OTA_RESPONSE_AUTH_OK = 0x41, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ef93fd0b65..067ef4a4d0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -123,7 +123,9 @@ #define USE_ONLINE_IMAGE_PNG_SUPPORT #define USE_ONLINE_IMAGE_JPEG_SUPPORT #define USE_OTA +#define USE_OTA_MD5 #define USE_OTA_PASSWORD +#define USE_OTA_SHA256 #define USE_OTA_STATE_CALLBACK #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE diff --git a/esphome/espota2.py b/esphome/espota2.py index 84f53377e7..2712d00127 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable import gzip import hashlib import io @@ -9,12 +10,14 @@ import random import socket import sys import time +from typing import Any from esphome.core import EsphomeError from esphome.helpers import resolve_ip_address RESPONSE_OK = 0x00 RESPONSE_REQUEST_AUTH = 0x01 +RESPONSE_REQUEST_SHA256_AUTH = 0x02 RESPONSE_HEADER_OK = 0x40 RESPONSE_AUTH_OK = 0x41 @@ -45,6 +48,7 @@ OTA_VERSION_2_0 = 2 MAGIC_BYTES = [0x6C, 0x26, 0xF7, 0x5C, 0x45] FEATURE_SUPPORTS_COMPRESSION = 0x01 +FEATURE_SUPPORTS_SHA256_AUTH = 0x02 UPLOAD_BLOCK_SIZE = 8192 @@ -52,6 +56,12 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 _LOGGER = logging.getLogger(__name__) +# Authentication method lookup table: response -> (hash_func, nonce_size, name) +_AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { + RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), + RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), +} + class ProgressBar: def __init__(self): @@ -81,18 +91,43 @@ class OTAError(EsphomeError): pass -def recv_decode(sock, amount, decode=True): +def recv_decode( + sock: socket.socket, amount: int, decode: bool = True +) -> bytes | list[int]: + """Receive data from socket and optionally decode to list of integers. + + :param sock: Socket to receive data from. + :param amount: Number of bytes to receive. + :param decode: If True, convert bytes to list of integers, otherwise return raw bytes. + :return: List of integers if decode=True, otherwise raw bytes. + """ data = sock.recv(amount) if not decode: return data return list(data) -def receive_exactly(sock, amount, msg, expect, decode=True): - data = [] if decode else b"" +def receive_exactly( + sock: socket.socket, + amount: int, + msg: str, + expect: int | list[int] | None, + decode: bool = True, +) -> list[int] | bytes: + """Receive exactly the specified amount of data from socket with error checking. + + :param sock: Socket to receive data from. + :param amount: Exact number of bytes to receive. + :param msg: Description of what is being received for error messages. + :param expect: Expected response code(s) for validation, None to skip validation. + :param decode: If True, return list of integers, otherwise return raw bytes. + :return: List of integers if decode=True, otherwise raw bytes. + :raises OTAError: If receiving fails or response doesn't match expected. + """ + data: list[int] | bytes = [] if decode else b"" try: - data += recv_decode(sock, 1, decode=decode) + data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] except OSError as err: raise OTAError(f"Error receiving acknowledge {msg}: {err}") from err @@ -104,13 +139,19 @@ def receive_exactly(sock, amount, msg, expect, decode=True): while len(data) < amount: try: - data += recv_decode(sock, amount - len(data), decode=decode) + data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator] except OSError as err: raise OTAError(f"Error receiving {msg}: {err}") from err return data -def check_error(data, expect): +def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None: + """Check response data for error codes and validate against expected response. + + :param data: Response data from device (first byte is the response code). + :param expect: Expected response code(s), None to skip validation. + :raises OTAError: If an error code is detected or response doesn't match expected. + """ if not expect: return dat = data[0] @@ -125,7 +166,7 @@ def check_error(data, expect): raise OTAError("Error: Authentication invalid. Is the password correct?") if dat == RESPONSE_ERROR_WRITING_FLASH: raise OTAError( - "Error: Wring OTA data to flash memory failed. See USB logs for more " + "Error: Writing OTA data to flash memory failed. See USB logs for more " "information." ) if dat == RESPONSE_ERROR_UPDATE_END: @@ -177,7 +218,16 @@ def check_error(data, expect): raise OTAError(f"Unexpected response from ESP: 0x{data[0]:02X}") -def send_check(sock, data, msg): +def send_check( + sock: socket.socket, data: list[int] | tuple[int, ...] | int | str | bytes, msg: str +) -> None: + """Send data to socket with error handling. + + :param sock: Socket to send data to. + :param data: Data to send (can be list/tuple of ints, single int, string, or bytes). + :param msg: Description of what is being sent for error messages. + :raises OTAError: If sending fails. + """ try: if isinstance(data, (list, tuple)): data = bytes(data) @@ -210,10 +260,14 @@ def perform_ota( f"Device uses unsupported OTA version {version}, this ESPHome supports {supported_versions}" ) - # Features - send_check(sock, FEATURE_SUPPORTS_COMPRESSION, "features") + # Features - send both compression and SHA256 auth support + features_to_send = FEATURE_SUPPORTS_COMPRESSION | FEATURE_SUPPORTS_SHA256_AUTH + send_check(sock, features_to_send, "features") features = receive_exactly( - sock, 1, "features", [RESPONSE_HEADER_OK, RESPONSE_SUPPORTS_COMPRESSION] + sock, + 1, + "features", + None, # Accept any response )[0] if features == RESPONSE_SUPPORTS_COMPRESSION: @@ -222,31 +276,52 @@ def perform_ota( else: upload_contents = file_contents - (auth,) = receive_exactly( - sock, 1, "auth", [RESPONSE_REQUEST_AUTH, RESPONSE_AUTH_OK] - ) - if auth == RESPONSE_REQUEST_AUTH: + def perform_auth( + sock: socket.socket, + password: str, + hash_func: Callable[..., Any], + nonce_size: int, + hash_name: str, + ) -> None: + """Perform challenge-response authentication using specified hash algorithm.""" if not password: raise OTAError("ESP requests password, but no password given!") - nonce = receive_exactly( - sock, 32, "authentication nonce", [], decode=False - ).decode() - _LOGGER.debug("Auth: Nonce is %s", nonce) - cnonce = hashlib.md5(str(random.random()).encode()).hexdigest() - _LOGGER.debug("Auth: CNonce is %s", cnonce) + + nonce_bytes = receive_exactly( + sock, nonce_size, f"{hash_name} authentication nonce", [], decode=False + ) + assert isinstance(nonce_bytes, bytes) + nonce = nonce_bytes.decode() + _LOGGER.debug("Auth: %s Nonce is %s", hash_name, nonce) + + # Generate cnonce + cnonce = hash_func(str(random.random()).encode()).hexdigest() + _LOGGER.debug("Auth: %s CNonce is %s", hash_name, cnonce) send_check(sock, cnonce, "auth cnonce") - result_md5 = hashlib.md5() - result_md5.update(password.encode("utf-8")) - result_md5.update(nonce.encode()) - result_md5.update(cnonce.encode()) - result = result_md5.hexdigest() - _LOGGER.debug("Auth: Result is %s", result) + # Calculate challenge response + hasher = hash_func() + hasher.update(password.encode("utf-8")) + hasher.update(nonce.encode()) + hasher.update(cnonce.encode()) + result = hasher.hexdigest() + _LOGGER.debug("Auth: %s Result is %s", hash_name, result) send_check(sock, result, "auth result") receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK) + (auth,) = receive_exactly( + sock, + 1, + "auth", + [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], + ) + + if auth != RESPONSE_AUTH_OK: + hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] + perform_auth(sock, password, hash_func, nonce_size, hash_name) + # Set higher timeout during upload sock.settimeout(30.0) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py new file mode 100644 index 0000000000..bd1a6bde81 --- /dev/null +++ b/tests/unit_tests/test_espota2.py @@ -0,0 +1,738 @@ +"""Unit tests for esphome.espota2 module.""" + +from __future__ import annotations + +from collections.abc import Generator +import gzip +import hashlib +import io +from pathlib import Path +import socket +import struct +from unittest.mock import Mock, call, patch + +import pytest +from pytest import CaptureFixture + +from esphome import espota2 +from esphome.core import EsphomeError + +# Test constants +MOCK_RANDOM_VALUE = 0.123456 +MOCK_RANDOM_BYTES = b"0.123456" +MOCK_MD5_NONCE = b"12345678901234567890123456789012" # 32 char nonce for MD5 +MOCK_SHA256_NONCE = b"1234567890123456789012345678901234567890123456789012345678901234" # 64 char nonce for SHA256 + + +@pytest.fixture +def mock_socket() -> Mock: + """Create a mock socket for testing.""" + socket_mock = Mock() + socket_mock.close = Mock() + socket_mock.recv = Mock() + socket_mock.sendall = Mock() + socket_mock.settimeout = Mock() + socket_mock.connect = Mock() + socket_mock.setsockopt = Mock() + return socket_mock + + +@pytest.fixture +def mock_file() -> io.BytesIO: + """Create a mock firmware file for testing.""" + return io.BytesIO(b"firmware content here") + + +@pytest.fixture +def mock_time() -> Generator[None]: + """Mock time-related functions for consistent testing.""" + # Provide enough values for multiple calls (tests may call perform_ota multiple times) + with ( + patch("time.sleep"), + patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]), + ): + yield + + +@pytest.fixture +def mock_random() -> Generator[Mock]: + """Mock random for predictable test values.""" + with patch("random.random", return_value=MOCK_RANDOM_VALUE) as mock_rand: + yield mock_rand + + +@pytest.fixture +def mock_resolve_ip() -> Generator[Mock]: + """Mock resolve_ip_address for testing.""" + with patch("esphome.espota2.resolve_ip_address") as mock: + mock.return_value = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) + ] + yield mock + + +@pytest.fixture +def mock_perform_ota() -> Generator[Mock]: + """Mock perform_ota function for testing.""" + with patch("esphome.espota2.perform_ota") as mock: + yield mock + + +@pytest.fixture +def mock_run_ota_impl() -> Generator[Mock]: + """Mock run_ota_impl_ function for testing.""" + with patch("esphome.espota2.run_ota_impl_") as mock: + mock.return_value = (0, "192.168.1.100") + yield mock + + +@pytest.fixture +def mock_socket_constructor(mock_socket: Mock) -> Generator[Mock]: + """Mock socket.socket constructor to return our mock socket.""" + with patch("socket.socket", return_value=mock_socket) as mock_constructor: + yield mock_constructor + + +def test_recv_decode_with_decode(mock_socket: Mock) -> None: + """Test recv_decode with decode=True returns list.""" + mock_socket.recv.return_value = b"\x01\x02\x03" + + result = espota2.recv_decode(mock_socket, 3, decode=True) + + assert result == [1, 2, 3] + mock_socket.recv.assert_called_once_with(3) + + +def test_recv_decode_without_decode(mock_socket: Mock) -> None: + """Test recv_decode with decode=False returns bytes.""" + mock_socket.recv.return_value = b"\x01\x02\x03" + + result = espota2.recv_decode(mock_socket, 3, decode=False) + + assert result == b"\x01\x02\x03" + mock_socket.recv.assert_called_once_with(3) + + +def test_receive_exactly_success(mock_socket: Mock) -> None: + """Test receive_exactly successfully receives expected data.""" + mock_socket.recv.side_effect = [b"\x00", b"\x01\x02"] + + result = espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) + + assert result == [0, 1, 2] + assert mock_socket.recv.call_count == 2 + + +def test_receive_exactly_with_error_response(mock_socket: Mock) -> None: + """Test receive_exactly raises OTAError on error response.""" + mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]) + + with pytest.raises(espota2.OTAError, match="Error auth:.*Authentication invalid"): + espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) + + mock_socket.close.assert_called_once() + + +def test_receive_exactly_socket_error(mock_socket: Mock) -> None: + """Test receive_exactly handles socket errors.""" + mock_socket.recv.side_effect = OSError("Connection reset") + + with pytest.raises(espota2.OTAError, match="Error receiving acknowledge test"): + espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) + + +@pytest.mark.parametrize( + ("error_code", "expected_msg"), + [ + (espota2.RESPONSE_ERROR_MAGIC, "Error: Invalid magic byte"), + (espota2.RESPONSE_ERROR_UPDATE_PREPARE, "Error: Couldn't prepare flash memory"), + (espota2.RESPONSE_ERROR_AUTH_INVALID, "Error: Authentication invalid"), + ( + espota2.RESPONSE_ERROR_WRITING_FLASH, + "Error: Writing OTA data to flash memory failed", + ), + (espota2.RESPONSE_ERROR_UPDATE_END, "Error: Finishing update failed"), + ( + espota2.RESPONSE_ERROR_INVALID_BOOTSTRAPPING, + "Error: Please press the reset button", + ), + ( + espota2.RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG, + "Error: ESP has been flashed with wrong flash size", + ), + ( + espota2.RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG, + "Error: ESP does not have the requested flash size", + ), + ( + espota2.RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE, + "Error: ESP does not have enough space", + ), + ( + espota2.RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE, + "Error: The OTA partition on the ESP is too small", + ), + ( + espota2.RESPONSE_ERROR_NO_UPDATE_PARTITION, + "Error: The OTA partition on the ESP couldn't be found", + ), + (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"), + (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), + ], +) +def test_check_error_with_various_errors(error_code: int, expected_msg: str) -> None: + """Test check_error raises appropriate errors for different error codes.""" + with pytest.raises(espota2.OTAError, match=expected_msg): + espota2.check_error([error_code], [espota2.RESPONSE_OK]) + + +def test_check_error_unexpected_response() -> None: + """Test check_error raises error for unexpected response.""" + with pytest.raises(espota2.OTAError, match="Unexpected response from ESP: 0x7F"): + espota2.check_error([0x7F], [espota2.RESPONSE_OK, espota2.RESPONSE_AUTH_OK]) + + +def test_send_check_with_various_data_types(mock_socket: Mock) -> None: + """Test send_check handles different data types.""" + + # Test with list/tuple + espota2.send_check(mock_socket, [0x01, 0x02], "list") + mock_socket.sendall.assert_called_with(b"\x01\x02") + + # Test with int + espota2.send_check(mock_socket, 0x42, "int") + mock_socket.sendall.assert_called_with(b"\x42") + + # Test with string + espota2.send_check(mock_socket, "hello", "string") + mock_socket.sendall.assert_called_with(b"hello") + + # Test with bytes (should pass through) + espota2.send_check(mock_socket, b"\xaa\xbb", "bytes") + mock_socket.sendall.assert_called_with(b"\xaa\xbb") + + +def test_send_check_socket_error(mock_socket: Mock) -> None: + """Test send_check handles socket errors.""" + mock_socket.sendall.side_effect = OSError("Broken pipe") + + with pytest.raises(espota2.OTAError, match="Error sending test"): + espota2.send_check(mock_socket, b"data", "test") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_successful_md5_auth( + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock +) -> None: + """Test successful OTA with MD5 authentication.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request + MOCK_MD5_NONCE, # 32 char hex nonce + bytes([espota2.RESPONSE_AUTH_OK]), # Auth result + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + # Run OTA + espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) + + # Verify features were sent (compression + SHA256 support) + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.FEATURE_SUPPORTS_COMPRESSION + | espota2.FEATURE_SUPPORTS_SHA256_AUTH + ] + ) + ) + + # Verify cnonce was sent (MD5 of random.random()) + cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest() + assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) + + # Verify auth result was computed correctly + expected_hash = hashlib.md5() + expected_hash.update(b"testpass") + expected_hash.update(MOCK_MD5_NONCE) + expected_hash.update(cnonce.encode()) + expected_result = expected_hash.hexdigest() + assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: + """Test OTA without authentication.""" + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_1_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + # Should not send any auth-related data + auth_calls = [ + call + for call in mock_socket.sendall.call_args_list + if "cnonce" in str(call) or "result" in str(call) + ] + assert len(auth_calls) == 0 + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_with_compression(mock_socket: Mock) -> None: + """Test OTA with compression support.""" + original_content = b"firmware" * 100 # Repeating content for compression + mock_file = io.BytesIO(original_content) + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_SUPPORTS_COMPRESSION]), # Device supports compression + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + # Verify compressed content was sent + # Get the binary size that was sent (4 bytes after features) + size_bytes = mock_socket.sendall.call_args_list[2][0][0] + sent_size = struct.unpack(">I", size_bytes)[0] + + # Size should be less than original due to compression + assert sent_size < len(original_content) + + # Verify the content sent was gzipped + compressed = gzip.compress(original_content, compresslevel=9) + assert sent_size == len(compressed) + + +def test_perform_ota_auth_without_password(mock_socket: Mock) -> None: + """Test OTA fails when auth is required but no password provided.""" + mock_file = io.BytesIO(b"firmware") + + responses = [ + bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_HEADER_OK]), + bytes([espota2.RESPONSE_REQUEST_AUTH]), + ] + + mock_socket.recv.side_effect = responses + + with pytest.raises( + espota2.OTAError, match="ESP requests password, but no password given" + ): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_md5_auth_wrong_password( + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock +) -> None: + """Test OTA fails when MD5 authentication is rejected due to wrong password.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request + MOCK_MD5_NONCE, # 32 char hex nonce + bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]), # Auth rejected! + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"): + espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin") + + # Verify the socket was closed after auth failure + mock_socket.close.assert_called() + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_sha256_auth_wrong_password( + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock +) -> None: + """Test OTA fails when SHA256 authentication is rejected due to wrong password.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), # SHA256 Auth request + MOCK_SHA256_NONCE, # 64 char hex nonce + bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]), # Auth rejected! + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"): + espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin") + + # Verify the socket was closed after auth failure + mock_socket.close.assert_called() + + +def test_perform_ota_sha256_auth_without_password(mock_socket: Mock) -> None: + """Test OTA fails when SHA256 auth is required but no password provided.""" + mock_file = io.BytesIO(b"firmware") + + responses = [ + bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_HEADER_OK]), + bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), + ] + + mock_socket.recv.side_effect = responses + + with pytest.raises( + espota2.OTAError, match="ESP requests password, but no password given" + ): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + +def test_perform_ota_unexpected_auth_response(mock_socket: Mock) -> None: + """Test OTA fails when device sends an unexpected auth response.""" + mock_file = io.BytesIO(b"firmware") + + # Use 0x03 which is not in the expected auth responses + # This will be caught by check_error and raise "Unexpected response from ESP" + UNKNOWN_AUTH_METHOD = 0x03 + + responses = [ + bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_HEADER_OK]), + bytes([UNKNOWN_AUTH_METHOD]), # Unknown auth method + ] + + mock_socket.recv.side_effect = responses + + # This will actually raise "Unexpected response from ESP" from check_error + with pytest.raises( + espota2.OTAError, match=r"Error auth: Unexpected response from ESP: 0x03" + ): + espota2.perform_ota(mock_socket, "password", mock_file, "test.bin") + + +def test_perform_ota_unsupported_version(mock_socket: Mock) -> None: + """Test OTA fails with unsupported version.""" + mock_file = io.BytesIO(b"firmware") + + responses = [ + bytes([espota2.RESPONSE_OK, 99]), # Unsupported version + ] + + mock_socket.recv.side_effect = responses + + with pytest.raises(espota2.OTAError, match="Device uses unsupported OTA version"): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> None: + """Test OTA handles upload errors.""" + # Setup responses - provide enough for the recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + # Add OSError to recv to simulate connection loss during chunk read + recv_responses.append(OSError("Connection lost")) + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises(espota2.OTAError, match="Error receiving acknowledge chunk OK"): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_successful( + mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock +) -> None: + """Test run_ota_impl_ with successful upload.""" + # Create a real firmware file + firmware_file = tmp_path / "firmware.bin" + firmware_file.write_bytes(b"firmware content") + + # Run OTA with real file path + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + # Verify success + assert result_code == 0 + assert result_host == "192.168.1.100" + + # Verify socket was configured correctly + mock_socket.settimeout.assert_called_with(10.0) + mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232)) + mock_socket.close.assert_called_once() + + # Verify perform_ota was called with real file + mock_perform_ota.assert_called_once() + call_args = mock_perform_ota.call_args[0] + assert call_args[0] == mock_socket + assert call_args[1] == "password" + # Verify the file object is a proper file handle + assert isinstance(call_args[2], io.IOBase) + assert call_args[3] == str(firmware_file) + + +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None: + """Test run_ota_impl_ when connection fails.""" + mock_socket.connect.side_effect = OSError("Connection refused") + + # Create a real firmware file + firmware_file = tmp_path / "firmware.bin" + firmware_file.write_bytes(b"firmware content") + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) + + assert result_code == 1 + assert result_host is None + mock_socket.close.assert_called_once() + + +def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: + """Test run_ota_impl_ when DNS resolution fails.""" + # Create a real firmware file + firmware_file = tmp_path / "firmware.bin" + firmware_file.write_bytes(b"firmware content") + + mock_resolve_ip.side_effect = EsphomeError("DNS resolution failed") + + with pytest.raises(espota2.OTAError, match="DNS resolution failed"): + result_code, result_host = espota2.run_ota_impl_( + "unknown.host", 3232, "password", str(firmware_file) + ) + + +def test_run_ota_wrapper(mock_run_ota_impl: Mock) -> None: + """Test run_ota wrapper function.""" + # Test successful case + mock_run_ota_impl.return_value = (0, "192.168.1.100") + result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") + assert result == (0, "192.168.1.100") + + # Test error case + mock_run_ota_impl.side_effect = espota2.OTAError("Test error") + result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") + assert result == (1, None) + + +def test_progress_bar(capsys: CaptureFixture[str]) -> None: + """Test ProgressBar functionality.""" + progress = espota2.ProgressBar() + + # Test initial update + progress.update(0.0) + captured = capsys.readouterr() + assert "0%" in captured.err + assert "[" in captured.err + + # Test progress update + progress.update(0.5) + captured = capsys.readouterr() + assert "50%" in captured.err + + # Test completion + progress.update(1.0) + captured = capsys.readouterr() + assert "100%" in captured.err + assert "Done" in captured.err + + # Test done method + progress.done() + captured = capsys.readouterr() + assert captured.err == "\n" + + # Test same progress doesn't update + progress.update(0.5) + progress.update(0.5) + captured = capsys.readouterr() + # Should only see one update (second call shouldn't write) + assert captured.err.count("50%") == 1 + + +# Tests for SHA256 authentication +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_successful_sha256_auth( + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock +) -> None: + """Test successful OTA with SHA256 authentication.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), # SHA256 Auth request + MOCK_SHA256_NONCE, # 64 char hex nonce + bytes([espota2.RESPONSE_AUTH_OK]), # Auth result + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + # Run OTA + espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) + + # Verify features were sent (compression + SHA256 support) + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.FEATURE_SUPPORTS_COMPRESSION + | espota2.FEATURE_SUPPORTS_SHA256_AUTH + ] + ) + ) + + # Verify cnonce was sent (SHA256 of random.random()) + cnonce = hashlib.sha256(MOCK_RANDOM_BYTES).hexdigest() + assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) + + # Verify auth result was computed correctly with SHA256 + expected_hash = hashlib.sha256() + expected_hash.update(b"testpass") + expected_hash.update(MOCK_SHA256_NONCE) + expected_hash.update(cnonce.encode()) + expected_result = expected_hash.hexdigest() + assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_sha256_fallback_to_md5( + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock +) -> None: + """Test SHA256-capable client falls back to MD5 for compatibility.""" + # This test verifies the temporary backward compatibility + # where a SHA256-capable client can still authenticate with MD5 + # This compatibility will be removed in 2026.1.0 + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes( + [espota2.RESPONSE_REQUEST_AUTH] + ), # MD5 Auth request (device doesn't support SHA256) + MOCK_MD5_NONCE, # 32 char hex nonce for MD5 + bytes([espota2.RESPONSE_AUTH_OK]), # Auth result + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + # Run OTA - should work even though device requested MD5 + espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + + # Verify client still advertised SHA256 support + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.FEATURE_SUPPORTS_COMPRESSION + | espota2.FEATURE_SUPPORTS_SHA256_AUTH + ] + ) + ) + + # But authentication was done with MD5 + cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest() + expected_hash = hashlib.md5() + expected_hash.update(b"testpass") + expected_hash.update(MOCK_MD5_NONCE) + expected_hash.update(cnonce.encode()) + expected_result = expected_hash.hexdigest() + assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_version_differences( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: + """Test OTA behavior differences between version 1.0 and 2.0.""" + # Test version 1.0 - no chunk acknowledgments + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_1_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + # No RESPONSE_CHUNK_OK for v1 + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + # For v1.0, verify that we only get the expected number of recv calls + # v1.0 doesn't have chunk acknowledgments, so fewer recv calls + assert mock_socket.recv.call_count == 8 # v1.0 has 8 recv calls + + # Reset mock for v2.0 test + mock_socket.reset_mock() + + # Reset file position for second test + mock_file.seek(0) + + # Test version 2.0 - with chunk acknowledgments + recv_responses_v2 = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # v2.0 has chunk acknowledgment + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses_v2 + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + # For v2.0, verify more recv calls due to chunk acknowledgments + assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK) From 581be5680e580ebb16831bfd0ff64add4a8bf2b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 17:54:44 -0500 Subject: [PATCH 1438/1626] cond --- .../components/esp32_ble_server/__init__.py | 37 +++++++++++++------ .../ble_server_automations.cpp | 10 +++++ .../esp32_ble_server/ble_server_automations.h | 16 ++++++++ esphome/core/defines.h | 7 ++++ 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index a8bb99b745..316d690656 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -488,6 +488,7 @@ async def to_code_descriptor(descriptor_conf, char_var): cg.add(desc_var.set_value(value)) if CONF_ON_WRITE in descriptor_conf: on_write_conf = descriptor_conf[CONF_ON_WRITE] + cg.add_define("USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE") await automation.build_automation( BLETriggers_ns.create_descriptor_on_write_trigger(desc_var), [(cg.std_vector.template(cg.uint8), "x"), (cg.uint16, "id")], @@ -505,23 +506,32 @@ async def to_code_characteristic(service_var, char_conf): ) if CONF_ON_WRITE in char_conf: on_write_conf = char_conf[CONF_ON_WRITE] + cg.add_define("USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE") await automation.build_automation( BLETriggers_ns.create_characteristic_on_write_trigger(char_var), [(cg.std_vector.template(cg.uint8), "x"), (cg.uint16, "id")], on_write_conf, ) if CONF_VALUE in char_conf: - action_conf = { - CONF_ID: char_conf[CONF_ID], - CONF_VALUE: char_conf[CONF_VALUE], - } - value_action = await ble_server_characteristic_set_value( - action_conf, - char_conf[CONF_CHAR_VALUE_ACTION_ID_], - cg.TemplateArguments(), - {}, - ) - cg.add(value_action.play()) + # Check if the value is templated (Lambda) + value_data = char_conf[CONF_VALUE].get(CONF_DATA) + if isinstance(value_data, cv.Lambda): + # Templated value - need the full action infrastructure + action_conf = { + CONF_ID: char_conf[CONF_ID], + CONF_VALUE: char_conf[CONF_VALUE], + } + value_action = await ble_server_characteristic_set_value( + action_conf, + char_conf[CONF_CHAR_VALUE_ACTION_ID_], + cg.TemplateArguments(), + {}, + ) + cg.add(value_action.play()) + else: + # Static value - just set it directly without action infrastructure + value = await parse_value(char_conf[CONF_VALUE], {}) + cg.add(char_var.set_value(value)) for descriptor_conf in char_conf[CONF_DESCRIPTORS]: await to_code_descriptor(descriptor_conf, char_var) @@ -560,12 +570,14 @@ async def to_code(config): else: cg.add(var.enqueue_start_service(service_var)) if CONF_ON_CONNECT in config: + cg.add_define("USE_ESP32_BLE_SERVER_ON_CONNECT") await automation.build_automation( BLETriggers_ns.create_server_on_connect_trigger(var), [(cg.uint16, "id")], config[CONF_ON_CONNECT], ) if CONF_ON_DISCONNECT in config: + cg.add_define("USE_ESP32_BLE_SERVER_ON_DISCONNECT") await automation.build_automation( BLETriggers_ns.create_server_on_disconnect_trigger(var), [(cg.uint16, "id")], @@ -594,6 +606,7 @@ async def ble_server_characteristic_set_value(config, action_id, template_arg, a var = cg.new_Pvariable(action_id, template_arg, paren) value = await parse_value(config[CONF_VALUE], args) cg.add(var.set_buffer(value)) + cg.add_define("USE_ESP32_BLE_SERVER_SET_VALUE_ACTION") return var @@ -612,6 +625,7 @@ async def ble_server_descriptor_set_value(config, action_id, template_arg, args) var = cg.new_Pvariable(action_id, template_arg, paren) value = await parse_value(config[CONF_VALUE], args) cg.add(var.set_buffer(value)) + cg.add_define("USE_ESP32_BLE_SERVER_DESCRIPTOR_SET_VALUE_ACTION") return var @@ -629,4 +643,5 @@ async def ble_server_descriptor_set_value(config, action_id, template_arg, args) ) async def ble_server_characteristic_notify(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) + cg.add_define("USE_ESP32_BLE_SERVER_NOTIFY_ACTION") return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.cpp b/esphome/components/esp32_ble_server/ble_server_automations.cpp index 41ef2b8bfe..b9adf01c84 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.cpp +++ b/esphome/components/esp32_ble_server/ble_server_automations.cpp @@ -9,6 +9,7 @@ namespace esp32_ble_server_automations { using namespace esp32_ble; +#ifdef USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE Trigger, uint16_t> *BLETriggers::create_characteristic_on_write_trigger( BLECharacteristic *characteristic) { Trigger, uint16_t> *on_write_trigger = // NOLINT(cppcoreguidelines-owning-memory) @@ -18,7 +19,9 @@ Trigger, uint16_t> *BLETriggers::create_characteristic_on_w [on_write_trigger](const std::vector &data, uint16_t id) { on_write_trigger->trigger(data, id); }); return on_write_trigger; } +#endif +#ifdef USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE Trigger, uint16_t> *BLETriggers::create_descriptor_on_write_trigger(BLEDescriptor *descriptor) { Trigger, uint16_t> *on_write_trigger = // NOLINT(cppcoreguidelines-owning-memory) new Trigger, uint16_t>(); @@ -27,21 +30,27 @@ Trigger, uint16_t> *BLETriggers::create_descriptor_on_write [on_write_trigger](const std::vector &data, uint16_t id) { on_write_trigger->trigger(data, id); }); return on_write_trigger; } +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_CONNECT Trigger *BLETriggers::create_server_on_connect_trigger(BLEServer *server) { Trigger *on_connect_trigger = new Trigger(); // NOLINT(cppcoreguidelines-owning-memory) server->on(BLEServerEvt::EmptyEvt::ON_CONNECT, [on_connect_trigger](uint16_t conn_id) { on_connect_trigger->trigger(conn_id); }); return on_connect_trigger; } +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_DISCONNECT Trigger *BLETriggers::create_server_on_disconnect_trigger(BLEServer *server) { Trigger *on_disconnect_trigger = new Trigger(); // NOLINT(cppcoreguidelines-owning-memory) server->on(BLEServerEvt::EmptyEvt::ON_DISCONNECT, [on_disconnect_trigger](uint16_t conn_id) { on_disconnect_trigger->trigger(conn_id); }); return on_disconnect_trigger; } +#endif +#ifdef USE_ESP32_BLE_SERVER_SET_VALUE_ACTION void BLECharacteristicSetValueActionManager::set_listener(BLECharacteristic *characteristic, EventEmitterListenerID listener_id, const std::function &pre_notify_listener) { @@ -69,6 +78,7 @@ void BLECharacteristicSetValueActionManager::set_listener(BLECharacteristic *cha // Save the pair listener_id, pre_notify_listener_id to the map this->listeners_[characteristic] = std::make_pair(listener_id, pre_notify_listener_id); } +#endif } // namespace esp32_ble_server_automations } // namespace esp32_ble_server diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index eab6b05f05..7b8563c936 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -23,13 +23,22 @@ using namespace event_emitter; class BLETriggers { public: +#ifdef USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE static Trigger, uint16_t> *create_characteristic_on_write_trigger( BLECharacteristic *characteristic); +#endif +#ifdef USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE static Trigger, uint16_t> *create_descriptor_on_write_trigger(BLEDescriptor *descriptor); +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_CONNECT static Trigger *create_server_on_connect_trigger(BLEServer *server); +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_DISCONNECT static Trigger *create_server_on_disconnect_trigger(BLEServer *server); +#endif }; +#ifdef USE_ESP32_BLE_SERVER_SET_VALUE_ACTION enum BLECharacteristicSetValueActionEvt { PRE_NOTIFY, }; @@ -82,13 +91,17 @@ template class BLECharacteristicSetValueAction : public Action class BLECharacteristicNotifyAction : public Action { public: BLECharacteristicNotifyAction(BLECharacteristic *characteristic) : parent_(characteristic) {} void play(Ts... x) override { +#ifdef USE_ESP32_BLE_SERVER_SET_VALUE_ACTION // Call the pre-notify event BLECharacteristicSetValueActionManager::get_instance()->emit_pre_notify(this->parent_); +#endif // Notify the characteristic this->parent_->notify(); } @@ -96,7 +109,9 @@ template class BLECharacteristicNotifyAction : public Action class BLEDescriptorSetValueAction : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} @@ -107,6 +122,7 @@ template class BLEDescriptorSetValueAction : public Action Date: Fri, 26 Sep 2025 21:14:56 -0500 Subject: [PATCH 1439/1626] safe a write --- .../components/esphome/ota/ota_esphome.cpp | 23 ++++++++++--------- esphome/core/defines.h | 1 + 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6ffeeedb1a..ef86131e66 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -541,9 +541,6 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string // Small stack buffer for nonce seed bytes uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) - // Send auth request type - this->writeall_(&auth_request, 1); - hasher->init(); // Generate nonce seed bytes using random_bytes @@ -554,20 +551,24 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string hasher->add(nonce_bytes, nonce_len); hasher->calculate(); - // Generate and send nonce - hasher->get_hex(buf); - buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Nonce is %s", LOG_STR_ARG(name), buf); + // Prepare buffer: auth_type (1 byte) + nonce (hex_size bytes) + buf[0] = auth_request; + hasher->get_hex(buf + 1); - if (!this->writeall_(reinterpret_cast(buf), hex_size)) { - this->log_auth_warning_(LOG_STR("Writing nonce"), name); + // Log nonce for debugging + buf[1 + hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Nonce is %s", LOG_STR_ARG(name), buf + 1); + + // Send auth_type + nonce in a single write + if (!this->writeall_(reinterpret_cast(buf), 1 + hex_size)) { + this->log_auth_warning_(LOG_STR("Writing auth type and nonce"), name); return false; } - // Start challenge: password + nonce + // Start challenge: password + nonce (nonce is at buf + 1) hasher->init(); hasher->add(password.c_str(), password.length()); - hasher->add(buf, hex_size); + hasher->add(buf + 1, hex_size); // Read cnonce and add to hash if (!this->readall_(reinterpret_cast(buf), hex_size)) { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 067ef4a4d0..261b6863ca 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -126,6 +126,7 @@ #define USE_OTA_MD5 #define USE_OTA_PASSWORD #define USE_OTA_SHA256 +#define ALLOW_OTA_DOWNGRADE_MD5 #define USE_OTA_STATE_CALLBACK #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE From a12283ba35c8f5caeb16da61dffc34452f6cd633 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 21:27:40 -0500 Subject: [PATCH 1440/1626] optimize --- .../components/esphome/ota/ota_esphome.cpp | 53 ++++++++++--------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ef86131e66..11795aaf2f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -536,19 +536,16 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string const size_t hex_size = hasher->get_size() * 2; // Hex is twice the byte size const size_t nonce_len = hasher->get_size() / 4; // Nonce is 1/4 of hash size in bytes - // Use the provided buffer for all hex operations - - // Small stack buffer for nonce seed bytes - uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) - - hasher->init(); + // Use the provided buffer for all operations // Generate nonce seed bytes using random_bytes - if (!random_bytes(nonce_bytes, nonce_len)) { + if (!random_bytes(reinterpret_cast(buf), nonce_len)) { this->log_auth_warning_(LOG_STR("Random bytes generation failed"), name); return false; } - hasher->add(nonce_bytes, nonce_len); + + hasher->init(); + hasher->add(buf, nonce_len); hasher->calculate(); // Prepare buffer: auth_type (1 byte) + nonce (hex_size bytes) @@ -571,31 +568,37 @@ bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string hasher->add(buf + 1, hex_size); // Read cnonce and add to hash - if (!this->readall_(reinterpret_cast(buf), hex_size)) { - this->log_auth_warning_(LOG_STR("Reading cnonce"), name); + if (!this->readall_(reinterpret_cast(buf), hex_size * 2)) { + this->log_auth_warning_(LOG_STR("Reading cnonce response"), name); return false; } - buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s CNonce is %s", LOG_STR_ARG(name), buf); - hasher->add(buf, hex_size); + // Response is located after CNonce in the buffer + const char *response = buf + hex_size; + + hasher->add(buf, hex_size); // add CNonce in binary hasher->calculate(); - // Log expected result (digest is already in hasher) - hasher->get_hex(buf); - buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Result is %s", LOG_STR_ARG(name), buf); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char log_buf[hex_size + 1]; + // Log CNonce for debugging + memcpy(log_buf, buf, hex_size); // Save CNonce for logging + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s CNonce is %s", LOG_STR_ARG(name), log_buf); - // Read response into the buffer - if (!this->readall_(reinterpret_cast(buf), hex_size)) { - this->log_auth_warning_(LOG_STR("Reading response"), name); - return false; - } - buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Response is %s", LOG_STR_ARG(name), buf); + // Log computed hash for debugging + hasher->get_hex(log_buf); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Result is %s", LOG_STR_ARG(name), log_buf); + + // Log received response + memcpy(log_buf, response, hex_size); // Save response for logging + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: %s Response is %s", LOG_STR_ARG(name), log_buf); +#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE // Compare response directly with digest in hasher - bool matches = hasher->equals_hex(buf); + bool matches = hasher->equals_hex(response); if (!matches) { this->log_auth_warning_(LOG_STR("Password mismatch"), name); From cc4c059429e5e2e5884915aeb149319af7ec970e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 21:52:00 -0500 Subject: [PATCH 1441/1626] optimize --- .../components/esphome/ota/ota_esphome.cpp | 221 ++++++++++++------ esphome/components/esphome/ota/ota_esphome.h | 15 +- 2 files changed, 164 insertions(+), 72 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 11795aaf2f..445167f13e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -141,7 +141,8 @@ void ESPHomeOTAComponent::handle_handshake_() { } this->log_start_(LOG_STR("handshake")); this->client_connect_time_ = App.get_loop_component_start_time(); - this->magic_buf_pos_ = 0; // Reset magic buffer position + this->handshake_buf_pos_ = 0; // Reset handshake buffer position + this->ota_state_ = OTAState::MAGIC_READ; } // Check for handshake timeout @@ -152,46 +153,143 @@ void ESPHomeOTAComponent::handle_handshake_() { return; } - // Try to read remaining magic bytes - if (this->magic_buf_pos_ < 5) { - // Read as many bytes as available - uint8_t bytes_to_read = 5 - this->magic_buf_pos_; - ssize_t read = this->client_->read(this->magic_buf_ + this->magic_buf_pos_, bytes_to_read); + while (true) { + switch (this->ota_state_) { + case OTAState::MAGIC_READ: { + // Try to read remaining magic bytes + if (this->handshake_buf_pos_ < 5) { + // Read as many bytes as available + uint8_t bytes_to_read = 5 - this->handshake_buf_pos_; + ssize_t read = this->client_->read(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_read); - if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { - return; // No data yet, try again next loop - } + if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + return; // No data yet, try again next loop + } - if (read <= 0) { - // Error or connection closed - if (read == -1) { - this->log_socket_error_(LOG_STR("reading magic bytes")); - } else { - ESP_LOGW(TAG, "Remote closed during handshake"); + if (read <= 0) { + // Error or connection closed + if (read == -1) { + this->log_socket_error_(LOG_STR("reading magic bytes")); + } else { + ESP_LOGW(TAG, "Remote closed during handshake"); + } + this->cleanup_connection_(); + return; + } + + this->handshake_buf_pos_ += read; + } + + // Check if we have all 5 magic bytes + if (this->handshake_buf_pos_ != 5) { + break; + } + + // Validate magic bytes + static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; + if (memcmp(this->handshake_buf_, MAGIC_BYTES, 5) != 0) { + ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->handshake_buf_[0], + this->handshake_buf_[1], this->handshake_buf_[2], this->handshake_buf_[3], this->handshake_buf_[4]); + // Send error response (non-blocking, best effort) + uint8_t error = static_cast(ota::OTA_RESPONSE_ERROR_MAGIC); + this->client_->write(&error, 1); + this->cleanup_connection_(); + return; + } + + // Magic bytes valid, move to next state + this->ota_state_ = OTAState::MAGIC_ACK; + this->handshake_buf_pos_ = 0; // Reset for reuse + continue; } - this->cleanup_connection_(); - return; + + case OTAState::MAGIC_ACK: { + // Send OK and version - 2 bytes + // Prepare response in handshake buffer if not already done + if (this->handshake_buf_pos_ == 0) { + this->handshake_buf_[0] = ota::OTA_RESPONSE_OK; + this->handshake_buf_[1] = USE_OTA_VERSION; + } + + // Write remaining bytes (2 total) + size_t bytes_to_write = 2 - this->handshake_buf_pos_; + ssize_t written = this->client_->write(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_write); + + if (written == -1) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return; // Try again next loop + } + this->log_socket_error_(LOG_STR("writing magic ack")); + this->cleanup_connection_(); + return; + } + + this->handshake_buf_pos_ += written; + if (this->handshake_buf_pos_ != 2) { + return; + } + // All bytes sent, create backend and move to next state + this->backend_ = ota::make_ota_backend(); + this->ota_state_ = OTAState::FEATURE_READ; + this->handshake_buf_pos_ = 0; // Reset for reuse + continue; + } + + case OTAState::FEATURE_READ: { + // Read features - 1 byte + ssize_t read = this->client_->read(this->handshake_buf_, 1); + + if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + return; // No data yet, try again next loop + } + + if (read <= 0) { + if (read == -1) { + this->log_socket_error_(LOG_STR("reading features")); + } else { + ESP_LOGW(TAG, "Remote closed during feature read"); + } + this->cleanup_connection_(); + return; + } + + this->ota_features_ = this->handshake_buf_[0]; + ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); + this->ota_state_ = OTAState::FEATURE_ACK; + this->handshake_buf_pos_ = 0; // Reset for reuse + continue; + } + + case OTAState::FEATURE_ACK: { + // Acknowledge header - 1 byte + uint8_t ack = + ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) + ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION + : ota::OTA_RESPONSE_HEADER_OK; + + ssize_t written = this->client_->write(&ack, 1); + if (written == -1) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return; // Try again next loop + } + this->log_socket_error_(LOG_STR("writing feature ack")); + this->cleanup_connection_(); + return; + } + + // Handshake complete, move to data phase + this->ota_state_ = OTAState::DATA; + continue; + } + + case OTAState::DATA: + this->handle_data_(); + return; + + case OTAState::IDLE: + // This shouldn't happen + return; } - - this->magic_buf_pos_ += read; - } - - // Check if we have all 5 magic bytes - if (this->magic_buf_pos_ == 5) { - // Validate magic bytes - static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; - if (memcmp(this->magic_buf_, MAGIC_BYTES, 5) != 0) { - ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->magic_buf_[0], - this->magic_buf_[1], this->magic_buf_[2], this->magic_buf_[3], this->magic_buf_[4]); - // Send error response (non-blocking, best effort) - uint8_t error = static_cast(ota::OTA_RESPONSE_ERROR_MAGIC); - this->client_->write(&error, 1); - this->cleanup_connection_(); - return; - } - - // All 5 magic bytes are valid, continue with data handling - this->handle_data_(); } } @@ -208,35 +306,15 @@ void ESPHomeOTAComponent::handle_data_() { uint8_t buf[1024]; char *sbuf = reinterpret_cast(buf); size_t ota_size; - uint8_t ota_features; - std::unique_ptr backend; - (void) ota_features; #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif - // Send OK and version - 2 bytes - buf[0] = ota::OTA_RESPONSE_OK; - buf[1] = USE_OTA_VERSION; - this->writeall_(buf, 2); - - backend = ota::make_ota_backend(); - - // Read features - 1 byte - if (!this->readall_(buf, 1)) { - this->log_read_error_(LOG_STR("features")); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - ota_features = buf[0]; // NOLINT - ESP_LOGV(TAG, "Features: 0x%02X", ota_features); - - // Acknowledge header - 1 byte - buf[0] = ota::OTA_RESPONSE_HEADER_OK; - if ((ota_features & FEATURE_SUPPORTS_COMPRESSION) != 0 && backend->supports_compression()) { - buf[0] = ota::OTA_RESPONSE_SUPPORTS_COMPRESSION; - } - - this->writeall_(buf, 1); + // The handshake has already been completed in handle_handshake_() + // We already have: + // - this->backend_ created + // - this->ota_features_ set + // - Feature acknowledgment sent #ifdef USE_OTA_PASSWORD if (!this->password_.empty()) { @@ -261,7 +339,7 @@ void ESPHomeOTAComponent::handle_data_() { // Devices that don't support SHA256 (due to platform limitations) will // continue to use MD5 as their only option (see #else branch below). - bool client_supports_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; #ifdef ALLOW_OTA_DOWNGRADE_MD5 // Temporary compatibility mode: Allow MD5 for ~3 versions to enable OTA downgrades @@ -334,7 +412,7 @@ void ESPHomeOTAComponent::handle_data_() { #endif // This will block for a few seconds as it locks flash - error_code = backend->begin(ota_size); + error_code = this->backend_->begin(ota_size); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) update_started = true; @@ -350,7 +428,7 @@ void ESPHomeOTAComponent::handle_data_() { } sbuf[32] = '\0'; ESP_LOGV(TAG, "Update: Binary MD5 is %s", sbuf); - backend->set_update_md5(sbuf); + this->backend_->set_update_md5(sbuf); // Acknowledge MD5 OK - 1 byte buf[0] = ota::OTA_RESPONSE_BIN_MD5_OK; @@ -375,7 +453,7 @@ void ESPHomeOTAComponent::handle_data_() { goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - error_code = backend->write(buf, read); + error_code = this->backend_->write(buf, read); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "Flash write error, code: %d", error_code); goto error; // NOLINT(cppcoreguidelines-avoid-goto) @@ -406,7 +484,7 @@ void ESPHomeOTAComponent::handle_data_() { buf[0] = ota::OTA_RESPONSE_RECEIVE_OK; this->writeall_(buf, 1); - error_code = backend->end(); + error_code = this->backend_->end(); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "Error ending update! code: %d", error_code); goto error; // NOLINT(cppcoreguidelines-avoid-goto) @@ -437,8 +515,8 @@ error: this->writeall_(buf, 1); this->cleanup_connection_(); - if (backend != nullptr && update_started) { - backend->abort(); + if (this->backend_ != nullptr && update_started) { + this->backend_->abort(); } this->status_momentary_error("onerror", 5000); @@ -516,7 +594,10 @@ void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; this->client_connect_time_ = 0; - this->magic_buf_pos_ = 0; + this->handshake_buf_pos_ = 0; + this->ota_state_ = OTAState::IDLE; + this->ota_features_ = 0; + this->backend_ = nullptr; } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 5bacb60706..02e759c2ba 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -14,6 +14,14 @@ namespace esphome { /// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA. class ESPHomeOTAComponent : public ota::OTAComponent { public: + enum class OTAState : uint8_t { + IDLE, + MAGIC_READ, // Reading magic bytes + MAGIC_ACK, // Sending OK and version after magic bytes + FEATURE_READ, // Reading feature flags from client + FEATURE_ACK, // Sending feature acknowledgment + DATA, // Processing OTA data (authentication, update, etc.) + }; #ifdef USE_OTA_PASSWORD void set_auth_password(const std::string &password) { password_ = password; } #endif // USE_OTA_PASSWORD @@ -51,10 +59,13 @@ class ESPHomeOTAComponent : public ota::OTAComponent { std::unique_ptr server_; std::unique_ptr client_; + OTAState ota_state_{OTAState::IDLE}; uint32_t client_connect_time_{0}; uint16_t port_; - uint8_t magic_buf_[5]; - uint8_t magic_buf_pos_{0}; + uint8_t handshake_buf_[5]; + uint8_t handshake_buf_pos_{0}; + uint8_t ota_features_{0}; + std::unique_ptr backend_; }; } // namespace esphome From e7b9f17bbed304f1a6a93681cc6dcfe921bcf9ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 21:54:58 -0500 Subject: [PATCH 1442/1626] optimize --- esphome/components/esphome/ota/ota_esphome.cpp | 8 ++++++-- esphome/components/esphome/ota/ota_esphome.h | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 445167f13e..8edcba99c9 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -171,7 +171,7 @@ void ESPHomeOTAComponent::handle_handshake_() { if (read == -1) { this->log_socket_error_(LOG_STR("reading magic bytes")); } else { - ESP_LOGW(TAG, "Remote closed during handshake"); + this->log_remote_closed_(LOG_STR("handshake")); } this->cleanup_connection_(); return; @@ -247,7 +247,7 @@ void ESPHomeOTAComponent::handle_handshake_() { if (read == -1) { this->log_socket_error_(LOG_STR("reading features")); } else { - ESP_LOGW(TAG, "Remote closed during feature read"); + this->log_remote_closed_(LOG_STR("feature read")); } this->cleanup_connection_(); return; @@ -590,6 +590,10 @@ void ESPHomeOTAComponent::log_start_(const LogString *phase) { ESP_LOGD(TAG, "Starting %s from %s", LOG_STR_ARG(phase), this->client_->getpeername().c_str()); } +void ESPHomeOTAComponent::log_remote_closed_(const LogString *during) { + ESP_LOGW(TAG, "Remote closed during %s", LOG_STR_ARG(during)); +} + void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 02e759c2ba..f50444c6ce 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -49,6 +49,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { void log_socket_error_(const LogString *msg); void log_read_error_(const LogString *what); void log_start_(const LogString *phase); + void log_remote_closed_(const LogString *during); void cleanup_connection_(); void yield_and_feed_watchdog_(); From 10c5a19503bec70af4825bfc466dc921c7d3ebf5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 22:01:44 -0500 Subject: [PATCH 1443/1626] optimize --- .../components/esphome/ota/ota_esphome.cpp | 69 ++++++++----------- esphome/components/esphome/ota/ota_esphome.h | 3 + 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 8edcba99c9..e231cf336e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -156,33 +156,9 @@ void ESPHomeOTAComponent::handle_handshake_() { while (true) { switch (this->ota_state_) { case OTAState::MAGIC_READ: { - // Try to read remaining magic bytes - if (this->handshake_buf_pos_ < 5) { - // Read as many bytes as available - uint8_t bytes_to_read = 5 - this->handshake_buf_pos_; - ssize_t read = this->client_->read(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_read); - - if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { - return; // No data yet, try again next loop - } - - if (read <= 0) { - // Error or connection closed - if (read == -1) { - this->log_socket_error_(LOG_STR("reading magic bytes")); - } else { - this->log_remote_closed_(LOG_STR("handshake")); - } - this->cleanup_connection_(); - return; - } - - this->handshake_buf_pos_ += read; - } - - // Check if we have all 5 magic bytes - if (this->handshake_buf_pos_ != 5) { - break; + // Try to read remaining magic bytes (5 total) + if (!this->try_read_(5, LOG_STR("reading magic bytes"), LOG_STR("handshake"))) { + return; } // Validate magic bytes @@ -237,19 +213,7 @@ void ESPHomeOTAComponent::handle_handshake_() { case OTAState::FEATURE_READ: { // Read features - 1 byte - ssize_t read = this->client_->read(this->handshake_buf_, 1); - - if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { - return; // No data yet, try again next loop - } - - if (read <= 0) { - if (read == -1) { - this->log_socket_error_(LOG_STR("reading features")); - } else { - this->log_remote_closed_(LOG_STR("feature read")); - } - this->cleanup_connection_(); + if (!this->try_read_(1, LOG_STR("reading features"), LOG_STR("feature read"))) { return; } @@ -594,6 +558,31 @@ void ESPHomeOTAComponent::log_remote_closed_(const LogString *during) { ESP_LOGW(TAG, "Remote closed during %s", LOG_STR_ARG(during)); } +bool ESPHomeOTAComponent::try_read_(size_t to_read, const LogString *error_desc, const LogString *close_desc) { + // Read bytes into handshake buffer, starting at handshake_buf_pos_ + size_t bytes_to_read = to_read - this->handshake_buf_pos_; + ssize_t read = this->client_->read(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_read); + + if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + return false; // No data yet, try again next loop + } + + if (read <= 0) { + // Error or connection closed + if (read == -1) { + this->log_socket_error_(error_desc); + } else { + this->log_remote_closed_(close_desc); + } + this->cleanup_connection_(); + return false; + } + + this->handshake_buf_pos_ += read; + // Return true only if we have all the requested bytes + return this->handshake_buf_pos_ >= to_read; +} + void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index f50444c6ce..c73fe7e732 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -46,6 +46,9 @@ class ESPHomeOTAComponent : public ota::OTAComponent { #endif // USE_OTA_PASSWORD bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); + + bool try_read_(size_t to_read, const LogString *error_desc, const LogString *close_desc); + void log_socket_error_(const LogString *msg); void log_read_error_(const LogString *what); void log_start_(const LogString *phase); From a08a99e3f4f06e65600542650f193754141b2888 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 22:05:06 -0500 Subject: [PATCH 1444/1626] optimize --- .../components/esphome/ota/ota_esphome.cpp | 69 ++++++++++--------- esphome/components/esphome/ota/ota_esphome.h | 2 + 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e231cf336e..1ab15983a0 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -174,8 +174,7 @@ void ESPHomeOTAComponent::handle_handshake_() { } // Magic bytes valid, move to next state - this->ota_state_ = OTAState::MAGIC_ACK; - this->handshake_buf_pos_ = 0; // Reset for reuse + this->transition_ota_state_(OTAState::MAGIC_ACK); continue; } @@ -187,27 +186,13 @@ void ESPHomeOTAComponent::handle_handshake_() { this->handshake_buf_[1] = USE_OTA_VERSION; } - // Write remaining bytes (2 total) - size_t bytes_to_write = 2 - this->handshake_buf_pos_; - ssize_t written = this->client_->write(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_write); - - if (written == -1) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - return; // Try again next loop - } - this->log_socket_error_(LOG_STR("writing magic ack")); - this->cleanup_connection_(); + if (!this->try_write_(2, LOG_STR("writing magic ack"))) { return; } - this->handshake_buf_pos_ += written; - if (this->handshake_buf_pos_ != 2) { - return; - } // All bytes sent, create backend and move to next state this->backend_ = ota::make_ota_backend(); - this->ota_state_ = OTAState::FEATURE_READ; - this->handshake_buf_pos_ = 0; // Reset for reuse + this->transition_ota_state_(OTAState::FEATURE_READ); continue; } @@ -219,30 +204,26 @@ void ESPHomeOTAComponent::handle_handshake_() { this->ota_features_ = this->handshake_buf_[0]; ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); - this->ota_state_ = OTAState::FEATURE_ACK; - this->handshake_buf_pos_ = 0; // Reset for reuse + this->transition_ota_state_(OTAState::FEATURE_ACK); continue; } case OTAState::FEATURE_ACK: { // Acknowledge header - 1 byte - uint8_t ack = - ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) - ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION - : ota::OTA_RESPONSE_HEADER_OK; + // Prepare response in handshake buffer if not already done + if (this->handshake_buf_pos_ == 0) { + this->handshake_buf_[0] = + ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) + ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION + : ota::OTA_RESPONSE_HEADER_OK; + } - ssize_t written = this->client_->write(&ack, 1); - if (written == -1) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - return; // Try again next loop - } - this->log_socket_error_(LOG_STR("writing feature ack")); - this->cleanup_connection_(); + if (!this->try_write_(1, LOG_STR("writing feature ack"))) { return; } // Handshake complete, move to data phase - this->ota_state_ = OTAState::DATA; + this->transition_ota_state_(OTAState::DATA); continue; } @@ -583,6 +564,30 @@ bool ESPHomeOTAComponent::try_read_(size_t to_read, const LogString *error_desc, return this->handshake_buf_pos_ >= to_read; } +bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *error_desc) { + // Write bytes from handshake buffer, starting at handshake_buf_pos_ + size_t bytes_to_write = to_write - this->handshake_buf_pos_; + ssize_t written = this->client_->write(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_write); + + if (written == -1) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return false; // Try again next loop + } + this->log_socket_error_(error_desc); + this->cleanup_connection_(); + return false; + } + + this->handshake_buf_pos_ += written; + // Return true only if we have written all the requested bytes + return this->handshake_buf_pos_ >= to_write; +} + +void ESPHomeOTAComponent::transition_ota_state_(OTAState next_state) { + this->ota_state_ = next_state; + this->handshake_buf_pos_ = 0; // Reset buffer position for next state +} + void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c73fe7e732..b7491df752 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -48,6 +48,8 @@ class ESPHomeOTAComponent : public ota::OTAComponent { bool writeall_(const uint8_t *buf, size_t len); bool try_read_(size_t to_read, const LogString *error_desc, const LogString *close_desc); + bool try_write_(size_t to_write, const LogString *error_desc); + void transition_ota_state_(OTAState next_state); void log_socket_error_(const LogString *msg); void log_read_error_(const LogString *what); From e0f99e059602d0616e4e5a0d9e280032c1fe9a06 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 22:09:44 -0500 Subject: [PATCH 1445/1626] optimize --- .../components/esphome/ota/ota_esphome.cpp | 154 +++++++++--------- 1 file changed, 76 insertions(+), 78 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 1ab15983a0..70e3693cb7 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -153,88 +153,86 @@ void ESPHomeOTAComponent::handle_handshake_() { return; } - while (true) { - switch (this->ota_state_) { - case OTAState::MAGIC_READ: { - // Try to read remaining magic bytes (5 total) - if (!this->try_read_(5, LOG_STR("reading magic bytes"), LOG_STR("handshake"))) { - return; - } - - // Validate magic bytes - static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; - if (memcmp(this->handshake_buf_, MAGIC_BYTES, 5) != 0) { - ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->handshake_buf_[0], - this->handshake_buf_[1], this->handshake_buf_[2], this->handshake_buf_[3], this->handshake_buf_[4]); - // Send error response (non-blocking, best effort) - uint8_t error = static_cast(ota::OTA_RESPONSE_ERROR_MAGIC); - this->client_->write(&error, 1); - this->cleanup_connection_(); - return; - } - - // Magic bytes valid, move to next state - this->transition_ota_state_(OTAState::MAGIC_ACK); - continue; - } - - case OTAState::MAGIC_ACK: { - // Send OK and version - 2 bytes - // Prepare response in handshake buffer if not already done - if (this->handshake_buf_pos_ == 0) { - this->handshake_buf_[0] = ota::OTA_RESPONSE_OK; - this->handshake_buf_[1] = USE_OTA_VERSION; - } - - if (!this->try_write_(2, LOG_STR("writing magic ack"))) { - return; - } - - // All bytes sent, create backend and move to next state - this->backend_ = ota::make_ota_backend(); - this->transition_ota_state_(OTAState::FEATURE_READ); - continue; - } - - case OTAState::FEATURE_READ: { - // Read features - 1 byte - if (!this->try_read_(1, LOG_STR("reading features"), LOG_STR("feature read"))) { - return; - } - - this->ota_features_ = this->handshake_buf_[0]; - ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); - this->transition_ota_state_(OTAState::FEATURE_ACK); - continue; - } - - case OTAState::FEATURE_ACK: { - // Acknowledge header - 1 byte - // Prepare response in handshake buffer if not already done - if (this->handshake_buf_pos_ == 0) { - this->handshake_buf_[0] = - ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) - ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION - : ota::OTA_RESPONSE_HEADER_OK; - } - - if (!this->try_write_(1, LOG_STR("writing feature ack"))) { - return; - } - - // Handshake complete, move to data phase - this->transition_ota_state_(OTAState::DATA); - continue; - } - - case OTAState::DATA: - this->handle_data_(); + switch (this->ota_state_) { + case OTAState::MAGIC_READ: { + // Try to read remaining magic bytes (5 total) + if (!this->try_read_(5, LOG_STR("reading magic bytes"), LOG_STR("handshake"))) { return; + } - case OTAState::IDLE: - // This shouldn't happen + // Validate magic bytes + static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; + if (memcmp(this->handshake_buf_, MAGIC_BYTES, 5) != 0) { + ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->handshake_buf_[0], + this->handshake_buf_[1], this->handshake_buf_[2], this->handshake_buf_[3], this->handshake_buf_[4]); + // Send error response (non-blocking, best effort) + uint8_t error = static_cast(ota::OTA_RESPONSE_ERROR_MAGIC); + this->client_->write(&error, 1); + this->cleanup_connection_(); return; + } + + // Magic bytes valid, move to next state + this->transition_ota_state_(OTAState::MAGIC_ACK); + [[fallthrough]]; } + + case OTAState::MAGIC_ACK: { + // Send OK and version - 2 bytes + // Prepare response in handshake buffer if not already done + if (this->handshake_buf_pos_ == 0) { + this->handshake_buf_[0] = ota::OTA_RESPONSE_OK; + this->handshake_buf_[1] = USE_OTA_VERSION; + } + + if (!this->try_write_(2, LOG_STR("writing magic ack"))) { + return; + } + + // All bytes sent, create backend and move to next state + this->backend_ = ota::make_ota_backend(); + this->transition_ota_state_(OTAState::FEATURE_READ); + [[fallthrough]]; + } + + case OTAState::FEATURE_READ: { + // Read features - 1 byte + if (!this->try_read_(1, LOG_STR("reading features"), LOG_STR("feature read"))) { + return; + } + + this->ota_features_ = this->handshake_buf_[0]; + ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); + this->transition_ota_state_(OTAState::FEATURE_ACK); + [[fallthrough]]; + } + + case OTAState::FEATURE_ACK: { + // Acknowledge header - 1 byte + // Prepare response in handshake buffer if not already done + if (this->handshake_buf_pos_ == 0) { + this->handshake_buf_[0] = + ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) + ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION + : ota::OTA_RESPONSE_HEADER_OK; + } + + if (!this->try_write_(1, LOG_STR("writing feature ack"))) { + return; + } + + // Handshake complete, move to data phase + this->transition_ota_state_(OTAState::DATA); + [[fallthrough]]; + } + + case OTAState::DATA: + this->handle_data_(); + return; + + case OTAState::IDLE: + // This shouldn't happen + return; } } From 3bec6efdc379f25ac698ed911f4e27d6d2398cb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 22:10:41 -0500 Subject: [PATCH 1446/1626] optimize --- esphome/components/esphome/ota/ota_esphome.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index b7491df752..7210d78fa9 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -64,14 +64,14 @@ class ESPHomeOTAComponent : public ota::OTAComponent { std::unique_ptr server_; std::unique_ptr client_; + std::unique_ptr backend_; - OTAState ota_state_{OTAState::IDLE}; uint32_t client_connect_time_{0}; uint16_t port_; uint8_t handshake_buf_[5]; + OTAState ota_state_{OTAState::IDLE}; uint8_t handshake_buf_pos_{0}; uint8_t ota_features_{0}; - std::unique_ptr backend_; }; } // namespace esphome From abcc2d483b184332c93e98fd383e62787bdfbfd9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 22:33:41 -0500 Subject: [PATCH 1447/1626] optimize --- .../components/esphome/ota/ota_esphome.cpp | 343 +++++++++++------- esphome/components/esphome/ota/ota_esphome.h | 20 +- 2 files changed, 231 insertions(+), 132 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 70e3693cb7..cabc14f1c1 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,11 +1,13 @@ #include "ota_esphome.h" #ifdef USE_OTA +#ifdef USE_OTA_PASSWORD #ifdef USE_OTA_MD5 #include "esphome/components/md5/md5.h" #endif #ifdef USE_OTA_SHA256 #include "esphome/components/sha256/sha256.h" #endif +#endif #include "esphome/components/network/util.h" #include "esphome/components/ota/ota_backend.h" #include "esphome/components/ota/ota_backend_arduino_esp32.h" @@ -165,10 +167,7 @@ void ESPHomeOTAComponent::handle_handshake_() { if (memcmp(this->handshake_buf_, MAGIC_BYTES, 5) != 0) { ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->handshake_buf_[0], this->handshake_buf_[1], this->handshake_buf_[2], this->handshake_buf_[3], this->handshake_buf_[4]); - // Send error response (non-blocking, best effort) - uint8_t error = static_cast(ota::OTA_RESPONSE_ERROR_MAGIC); - this->client_->write(&error, 1); - this->cleanup_connection_(); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_MAGIC); return; } @@ -221,10 +220,39 @@ void ESPHomeOTAComponent::handle_handshake_() { return; } - // Handshake complete, move to data phase +#ifdef USE_OTA_PASSWORD + // If password is set, move to auth phase + if (!this->password_.empty()) { + this->transition_ota_state_(OTAState::AUTH_SEND); + [[fallthrough]]; + } else +#endif + { + // No password, move directly to data phase + this->transition_ota_state_(OTAState::DATA); + [[fallthrough]]; + } + } + +#ifdef USE_OTA_PASSWORD + case OTAState::AUTH_SEND: { + // Non-blocking authentication send + if (!this->handle_auth_send_()) { + return; + } + this->transition_ota_state_(OTAState::AUTH_READ); + [[fallthrough]]; + } + + case OTAState::AUTH_READ: { + // Non-blocking authentication read & verify + if (!this->handle_auth_read_()) { + return; + } this->transition_ota_state_(OTAState::DATA); [[fallthrough]]; } +#endif case OTAState::DATA: this->handle_data_(); @@ -240,8 +268,10 @@ void ESPHomeOTAComponent::handle_data_() { /// Handle the OTA data transfer and update process. /// /// This method is blocking and will not return until the OTA update completes, - /// fails, or times out. It handles authentication, receives the firmware data, - /// writes it to flash, and reboots on success. + /// fails, or times out. It receives the firmware data, writes it to flash, + /// and reboots on success. + /// + /// Authentication has already been handled in the non-blocking states AUTH_SEND/AUTH_READ. ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; bool update_started = false; size_t total = 0; @@ -253,80 +283,12 @@ void ESPHomeOTAComponent::handle_data_() { size_t size_acknowledged = 0; #endif - // The handshake has already been completed in handle_handshake_() + // The handshake and auth have already been completed // We already have: // - this->backend_ created // - this->ota_features_ set // - Feature acknowledgment sent - -#ifdef USE_OTA_PASSWORD - if (!this->password_.empty()) { - bool auth_success = false; - -#ifdef USE_OTA_SHA256 - // SECURITY HARDENING: Prefer SHA256 authentication on platforms that support it. - // - // This is a hardening measure to prevent future downgrade attacks where an attacker - // could force the use of MD5 authentication by manipulating the feature flags. - // - // While MD5 is currently still acceptable for our OTA authentication use case - // (where the password is a shared secret and we're only authenticating, not - // encrypting), at some point in the future MD5 will likely become so weak that - // it could be practically attacked. - // - // We enforce SHA256 now on capable platforms because: - // 1. We can't retroactively update device firmware in the field - // 2. Clients (like esphome CLI) can always be updated to support SHA256 - // 3. This prevents any possibility of downgrade attacks in the future - // - // Devices that don't support SHA256 (due to platform limitations) will - // continue to use MD5 as their only option (see #else branch below). - - bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; - -#ifdef ALLOW_OTA_DOWNGRADE_MD5 - // Temporary compatibility mode: Allow MD5 for ~3 versions to enable OTA downgrades - // This prevents users from being locked out if they need to downgrade after updating - // TODO: Remove this entire ifdef block in 2026.1.0 - if (client_supports_sha256) { - sha256::SHA256 sha_hasher; - auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, - LOG_STR("SHA256"), sbuf); - } else { -#ifdef USE_OTA_MD5 - ESP_LOGW(TAG, "Using MD5 auth for compatibility (deprecated)"); - md5::MD5Digest md5_hasher; - auth_success = - this->perform_hash_auth_(&md5_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); -#endif // USE_OTA_MD5 - } -#else - // Strict mode: SHA256 required on capable platforms (future default) - if (!client_supports_sha256) { - ESP_LOGW(TAG, "Client requires SHA256"); - error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - sha256::SHA256 sha_hasher; - auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, - LOG_STR("SHA256"), sbuf); -#endif // ALLOW_OTA_DOWNGRADE_MD5 -#else - // Platform only supports MD5 - use it as the only available option - // This is not a security downgrade as the platform cannot support SHA256 -#ifdef USE_OTA_MD5 - md5::MD5Digest md5_hasher; - auth_success = - this->perform_hash_auth_(&md5_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); -#endif // USE_OTA_MD5 -#endif // USE_OTA_SHA256 - - if (!auth_success) { - error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - } -#endif // USE_OTA_PASSWORD + // - Authentication completed (if password was set) // Acknowledge auth OK - 1 byte buf[0] = ota::OTA_RESPONSE_AUTH_OK; @@ -594,6 +556,15 @@ void ESPHomeOTAComponent::cleanup_connection_() { this->ota_state_ = OTAState::IDLE; this->ota_features_ = 0; this->backend_ = nullptr; +#ifdef USE_OTA_PASSWORD + this->cleanup_auth_(); +#endif +} + +void ESPHomeOTAComponent::send_error_and_cleanup_(ota::OTAResponseTypes error) { + uint8_t error_byte = static_cast(error); + this->client_->write(&error_byte, 1); // Best effort, non-blocking + this->cleanup_connection_(); } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { @@ -602,86 +573,202 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { } #ifdef USE_OTA_PASSWORD -void ESPHomeOTAComponent::log_auth_warning_(const LogString *action, const LogString *hash_name) { - ESP_LOGW(TAG, "Auth: %s %s failed", LOG_STR_ARG(action), LOG_STR_ARG(hash_name)); +void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG, "Auth: %s", LOG_STR_ARG(msg)); } + +bool ESPHomeOTAComponent::handle_auth_send_() { + // Determine which auth type to use based on platform capabilities and client support + uint8_t auth_type; + +#ifdef USE_OTA_SHA256 + bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + +#ifdef ALLOW_OTA_DOWNGRADE_MD5 + if (client_supports_sha256) { + auth_type = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; + if (!this->auth_hasher_) { + this->auth_hasher_ = std::make_unique(); + } + } else { +#ifdef USE_OTA_MD5 + this->log_auth_warning_(LOG_STR("Using MD5 for compatibility (deprecated)")); + auth_type = ota::OTA_RESPONSE_REQUEST_AUTH; + if (!this->auth_hasher_) { + this->auth_hasher_ = std::make_unique(); + } +#else + this->log_auth_warning_(LOG_STR("Client doesn't support SHA256 and MD5 is disabled")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; +#endif // USE_OTA_MD5 + } +#else // !ALLOW_OTA_DOWNGRADE_MD5 + if (!client_supports_sha256) { + this->log_auth_warning_(LOG_STR("Client requires SHA256")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; + } + auth_type = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; + if (!this->auth_hasher_) { + this->auth_hasher_ = std::make_unique(); + } +#endif // ALLOW_OTA_DOWNGRADE_MD5 +#else // !USE_OTA_SHA256 +#ifdef USE_OTA_MD5 + auth_type = ota::OTA_RESPONSE_REQUEST_AUTH; + if (!this->auth_hasher_) { + this->auth_hasher_ = std::make_unique(); + } +#else + this->log_auth_warning_(LOG_STR("No auth methods available")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; +#endif // USE_OTA_MD5 +#endif // USE_OTA_SHA256 + + // Initialize auth buffer if not already done + if (!this->auth_buf_) { + // Calculate required buffer size + const size_t hex_size = this->auth_hasher_->get_size() * 2; + const size_t nonce_len = this->auth_hasher_->get_size() / 4; + // Buffer needs: 1 (auth_type) + hex_size (nonce) + hex_size*2 (cnonce+response) + this->auth_buf_size_ = 1 + hex_size + hex_size * 2; + this->auth_buf_ = std::make_unique(this->auth_buf_size_); + this->auth_buf_pos_ = 0; + + // Generate nonce + char *buf = reinterpret_cast(this->auth_buf_.get() + 1); + if (!random_bytes(reinterpret_cast(buf), nonce_len)) { + this->log_auth_warning_(LOG_STR("Random bytes generation failed")); + this->cleanup_connection_(); + return false; + } + + this->auth_hasher_->init(); + this->auth_hasher_->add(buf, nonce_len); + this->auth_hasher_->calculate(); + + // Prepare buffer: auth_type (1 byte) + nonce (hex_size bytes) + this->auth_buf_[0] = auth_type; + this->auth_hasher_->get_hex(buf); + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + // Log nonce for debugging + char log_buf[hex_size + 1]; + memcpy(log_buf, buf, hex_size); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: Nonce is %s", log_buf); +#endif + } + + // Try to write auth_type + nonce + const size_t hex_size = this->auth_hasher_->get_size() * 2; + const size_t to_write = 1 + hex_size; + size_t remaining = to_write - this->auth_buf_pos_; + + ssize_t written = this->client_->write(this->auth_buf_.get() + this->auth_buf_pos_, remaining); + if (written == -1) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return false; // Try again next loop + } + this->log_auth_warning_(LOG_STR("Writing auth type and nonce failed")); + this->cleanup_connection_(); + return false; + } + + this->auth_buf_pos_ += written; + + // Check if we still have more to write + if (this->auth_buf_pos_ < to_write) { + return false; // More to write, try again next loop + } + + // All written, prepare for reading phase + this->auth_buf_pos_ = 0; + + // Start challenge hash: password + nonce + this->auth_hasher_->init(); + this->auth_hasher_->add(this->password_.c_str(), this->password_.length()); + this->auth_hasher_->add(reinterpret_cast(this->auth_buf_.get() + 1), hex_size); + + return true; } -// Non-template function definition to reduce binary size -bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, - const LogString *name, char *buf) { - // Get sizes from the hasher - const size_t hex_size = hasher->get_size() * 2; // Hex is twice the byte size - const size_t nonce_len = hasher->get_size() / 4; // Nonce is 1/4 of hash size in bytes +bool ESPHomeOTAComponent::handle_auth_read_() { + const size_t hex_size = this->auth_hasher_->get_size() * 2; + const size_t to_read = hex_size * 2; // CNonce + Response - // Use the provided buffer for all operations + // Try to read remaining bytes + size_t remaining = to_read - this->auth_buf_pos_; + ssize_t read = this->client_->read(this->auth_buf_.get() + this->auth_buf_pos_, remaining); - // Generate nonce seed bytes using random_bytes - if (!random_bytes(reinterpret_cast(buf), nonce_len)) { - this->log_auth_warning_(LOG_STR("Random bytes generation failed"), name); + if (read == -1) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return false; // Try again next loop + } + this->log_auth_warning_(LOG_STR("Reading cnonce response failed")); + this->cleanup_connection_(); return false; } - hasher->init(); - hasher->add(buf, nonce_len); - hasher->calculate(); - - // Prepare buffer: auth_type (1 byte) + nonce (hex_size bytes) - buf[0] = auth_request; - hasher->get_hex(buf + 1); - - // Log nonce for debugging - buf[1 + hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Nonce is %s", LOG_STR_ARG(name), buf + 1); - - // Send auth_type + nonce in a single write - if (!this->writeall_(reinterpret_cast(buf), 1 + hex_size)) { - this->log_auth_warning_(LOG_STR("Writing auth type and nonce"), name); + if (read == 0) { + this->log_auth_warning_(LOG_STR("Remote closed during auth read")); + this->cleanup_connection_(); return false; } - // Start challenge: password + nonce (nonce is at buf + 1) - hasher->init(); - hasher->add(password.c_str(), password.length()); - hasher->add(buf + 1, hex_size); + this->auth_buf_pos_ += read; - // Read cnonce and add to hash - if (!this->readall_(reinterpret_cast(buf), hex_size * 2)) { - this->log_auth_warning_(LOG_STR("Reading cnonce response"), name); - return false; + // Check if we still need more data + if (this->auth_buf_pos_ < to_read) { + return false; // More to read, try again next loop } - // Response is located after CNonce in the buffer + // We have all the data, verify it + char *buf = reinterpret_cast(this->auth_buf_.get()); const char *response = buf + hex_size; - hasher->add(buf, hex_size); // add CNonce in binary - hasher->calculate(); + // Add CNonce to hash + this->auth_hasher_->add(buf, hex_size); + this->auth_hasher_->calculate(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char log_buf[hex_size + 1]; - // Log CNonce for debugging - memcpy(log_buf, buf, hex_size); // Save CNonce for logging + // Log CNonce + memcpy(log_buf, buf, hex_size); log_buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s CNonce is %s", LOG_STR_ARG(name), log_buf); + ESP_LOGV(TAG, "Auth: CNonce is %s", log_buf); - // Log computed hash for debugging - hasher->get_hex(log_buf); + // Log computed hash + this->auth_hasher_->get_hex(log_buf); log_buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Result is %s", LOG_STR_ARG(name), log_buf); + ESP_LOGV(TAG, "Auth: Result is %s", log_buf); // Log received response - memcpy(log_buf, response, hex_size); // Save response for logging + memcpy(log_buf, response, hex_size); log_buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Response is %s", LOG_STR_ARG(name), log_buf); -#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, "Auth: Response is %s", log_buf); +#endif - // Compare response directly with digest in hasher - bool matches = hasher->equals_hex(response); + // Compare response + bool matches = this->auth_hasher_->equals_hex(response); if (!matches) { - this->log_auth_warning_(LOG_STR("Password mismatch"), name); + this->log_auth_warning_(LOG_STR("Password mismatch")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; } - return matches; + // Authentication successful - clean up auth state + this->cleanup_auth_(); + + return true; +} + +void ESPHomeOTAComponent::cleanup_auth_() { + this->auth_hasher_ = nullptr; + this->auth_buf_ = nullptr; + this->auth_buf_size_ = 0; + this->auth_buf_pos_ = 0; } #endif // USE_OTA_PASSWORD diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 7210d78fa9..55ae34d3af 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -20,7 +20,11 @@ class ESPHomeOTAComponent : public ota::OTAComponent { MAGIC_ACK, // Sending OK and version after magic bytes FEATURE_READ, // Reading feature flags from client FEATURE_ACK, // Sending feature acknowledgment - DATA, // Processing OTA data (authentication, update, etc.) +#ifdef USE_OTA_PASSWORD + AUTH_SEND, // Sending authentication request + AUTH_READ, // Reading authentication data +#endif // USE_OTA_PASSWORD + DATA, // BLOCKING! Processing OTA data (update, etc.) }; #ifdef USE_OTA_PASSWORD void set_auth_password(const std::string &password) { password_ = password; } @@ -40,9 +44,10 @@ class ESPHomeOTAComponent : public ota::OTAComponent { void handle_handshake_(); void handle_data_(); #ifdef USE_OTA_PASSWORD - bool perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, const LogString *name, - char *buf); - void log_auth_warning_(const LogString *action, const LogString *hash_name); + bool handle_auth_send_(); + bool handle_auth_read_(); + void cleanup_auth_(); + void log_auth_warning_(const LogString *msg); #endif // USE_OTA_PASSWORD bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); @@ -56,6 +61,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { void log_start_(const LogString *phase); void log_remote_closed_(const LogString *during); void cleanup_connection_(); + void send_error_and_cleanup_(ota::OTAResponseTypes error); void yield_and_feed_watchdog_(); #ifdef USE_OTA_PASSWORD @@ -72,6 +78,12 @@ class ESPHomeOTAComponent : public ota::OTAComponent { OTAState ota_state_{OTAState::IDLE}; uint8_t handshake_buf_pos_{0}; uint8_t ota_features_{0}; +#ifdef USE_OTA_PASSWORD + std::unique_ptr auth_hasher_; + std::unique_ptr auth_buf_; + size_t auth_buf_size_{0}; + size_t auth_buf_pos_{0}; +#endif // USE_OTA_PASSWORD }; } // namespace esphome From 7251f7edec2fc0ec14ec3ae0a403a907fccf5b28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:13:21 -0500 Subject: [PATCH 1448/1626] stack it --- .../components/esphome/ota/ota_esphome.cpp | 221 ++++++++++++------ esphome/components/esphome/ota/ota_esphome.h | 4 +- 2 files changed, 151 insertions(+), 74 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index cabc14f1c1..e2425e0181 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -576,60 +576,59 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG, "Auth: %s", LOG_STR_ARG(msg)); } bool ESPHomeOTAComponent::handle_auth_send_() { - // Determine which auth type to use based on platform capabilities and client support - uint8_t auth_type; + // Initialize auth buffer if not already done + if (!this->auth_buf_) { + // Determine which auth type to use and create hasher on stack + HashBase *hasher = nullptr; #ifdef USE_OTA_SHA256 - bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + sha256::SHA256 sha256_hasher; +#endif +#ifdef USE_OTA_MD5 + md5::MD5Digest md5_hasher; +#endif + +#ifdef USE_OTA_SHA256 + bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; #ifdef ALLOW_OTA_DOWNGRADE_MD5 - if (client_supports_sha256) { - auth_type = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; - if (!this->auth_hasher_) { - this->auth_hasher_ = std::make_unique(); - } - } else { + if (client_supports_sha256) { + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; + hasher = &sha256_hasher; + } else { #ifdef USE_OTA_MD5 - this->log_auth_warning_(LOG_STR("Using MD5 for compatibility (deprecated)")); - auth_type = ota::OTA_RESPONSE_REQUEST_AUTH; - if (!this->auth_hasher_) { - this->auth_hasher_ = std::make_unique(); - } + this->log_auth_warning_(LOG_STR("Using MD5 for compatibility (deprecated)")); + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; + hasher = &md5_hasher; #else - this->log_auth_warning_(LOG_STR("Client doesn't support SHA256 and MD5 is disabled")); - this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); - return false; + this->log_auth_warning_(LOG_STR("Client doesn't support SHA256 and MD5 is disabled")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; #endif // USE_OTA_MD5 - } + } #else // !ALLOW_OTA_DOWNGRADE_MD5 - if (!client_supports_sha256) { - this->log_auth_warning_(LOG_STR("Client requires SHA256")); - this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); - return false; - } - auth_type = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; - if (!this->auth_hasher_) { - this->auth_hasher_ = std::make_unique(); - } + if (!client_supports_sha256) { + this->log_auth_warning_(LOG_STR("Client requires SHA256")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; + } + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; + hasher = &sha256_hasher; #endif // ALLOW_OTA_DOWNGRADE_MD5 #else // !USE_OTA_SHA256 #ifdef USE_OTA_MD5 - auth_type = ota::OTA_RESPONSE_REQUEST_AUTH; - if (!this->auth_hasher_) { - this->auth_hasher_ = std::make_unique(); - } + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; + hasher = &md5_hasher; #else - this->log_auth_warning_(LOG_STR("No auth methods available")); - this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); - return false; + this->log_auth_warning_(LOG_STR("No auth methods available")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; #endif // USE_OTA_MD5 #endif // USE_OTA_SHA256 - // Initialize auth buffer if not already done - if (!this->auth_buf_) { - // Calculate required buffer size - const size_t hex_size = this->auth_hasher_->get_size() * 2; - const size_t nonce_len = this->auth_hasher_->get_size() / 4; + // Calculate required buffer size using the hasher + const size_t hex_size = hasher->get_size() * 2; + const size_t nonce_len = hasher->get_size() / 4; // Buffer needs: 1 (auth_type) + hex_size (nonce) + hex_size*2 (cnonce+response) this->auth_buf_size_ = 1 + hex_size + hex_size * 2; this->auth_buf_ = std::make_unique(this->auth_buf_size_); @@ -643,13 +642,13 @@ bool ESPHomeOTAComponent::handle_auth_send_() { return false; } - this->auth_hasher_->init(); - this->auth_hasher_->add(buf, nonce_len); - this->auth_hasher_->calculate(); + hasher->init(); + hasher->add(buf, nonce_len); + hasher->calculate(); // Prepare buffer: auth_type (1 byte) + nonce (hex_size bytes) - this->auth_buf_[0] = auth_type; - this->auth_hasher_->get_hex(buf); + this->auth_buf_[0] = this->auth_type_; + hasher->get_hex(buf); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE // Log nonce for debugging @@ -661,7 +660,19 @@ bool ESPHomeOTAComponent::handle_auth_send_() { } // Try to write auth_type + nonce - const size_t hex_size = this->auth_hasher_->get_size() * 2; + // Calculate hex_size based on auth_type + size_t hex_size; +#ifdef USE_OTA_SHA256 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + hex_size = 64; // SHA256 = 32 bytes * 2 + } else +#endif + { +#ifdef USE_OTA_MD5 + hex_size = 32; // MD5 = 16 bytes * 2 +#endif + } + const size_t to_write = 1 + hex_size; size_t remaining = to_write - this->auth_buf_pos_; @@ -685,21 +696,41 @@ bool ESPHomeOTAComponent::handle_auth_send_() { // All written, prepare for reading phase this->auth_buf_pos_ = 0; - // Start challenge hash: password + nonce - this->auth_hasher_->init(); - this->auth_hasher_->add(this->password_.c_str(), this->password_.length()); - this->auth_hasher_->add(reinterpret_cast(this->auth_buf_.get() + 1), hex_size); + // We'll start the challenge hash in handle_auth_read_ when we have the cnonce return true; } bool ESPHomeOTAComponent::handle_auth_read_() { - const size_t hex_size = this->auth_hasher_->get_size() * 2; + // Calculate hex_size based on auth_type + size_t hex_size; +#ifdef USE_OTA_SHA256 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + hex_size = 64; // SHA256 = 32 bytes * 2 + } else +#endif + { +#ifdef USE_OTA_MD5 + hex_size = 32; // MD5 = 16 bytes * 2 +#endif + } + const size_t to_read = hex_size * 2; // CNonce + Response - // Try to read remaining bytes + // Initialize buffer if not already done + if (!this->auth_buf_) { + // Note: we're reusing the buffer from handle_auth_send_ which should have the nonce + // But if we reach here without it, something went wrong + this->log_auth_warning_(LOG_STR("Auth buffer not initialized")); + this->cleanup_connection_(); + return false; + } + + // Try to read remaining bytes (CNonce + Response) + // We need to read into the buffer starting after the auth_type (1 byte) and nonce (hex_size bytes) + size_t offset = 1 + hex_size; size_t remaining = to_read - this->auth_buf_pos_; - ssize_t read = this->client_->read(this->auth_buf_.get() + this->auth_buf_pos_, remaining); + ssize_t read = this->client_->read(this->auth_buf_.get() + offset + this->auth_buf_pos_, remaining); if (read == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { @@ -724,33 +755,79 @@ bool ESPHomeOTAComponent::handle_auth_read_() { } // We have all the data, verify it - char *buf = reinterpret_cast(this->auth_buf_.get()); - const char *response = buf + hex_size; + // Create hasher on stack based on auth_type + HashBase *hasher = nullptr; - // Add CNonce to hash - this->auth_hasher_->add(buf, hex_size); - this->auth_hasher_->calculate(); +#ifdef USE_OTA_SHA256 + sha256::SHA256 sha256_hasher; + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + hasher = &sha256_hasher; + } +#endif +#ifdef USE_OTA_MD5 + md5::MD5Digest md5_hasher; + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_AUTH) { + hasher = &md5_hasher; + } +#endif + + if (!hasher) { + this->log_auth_warning_(LOG_STR("Invalid auth type")); + this->cleanup_connection_(); + return false; + } + + // Get pointers to the data + char *nonce = reinterpret_cast(this->auth_buf_.get() + 1); // Skip auth_type byte + char *cnonce = reinterpret_cast(this->auth_buf_.get() + offset); + const char *response = cnonce + hex_size; + + // Calculate expected hash: password + nonce + cnonce + hasher->init(); + hasher->add(this->password_.c_str(), this->password_.length()); + hasher->add(nonce, hex_size); + hasher->add(cnonce, hex_size); + hasher->calculate(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char log_buf[hex_size + 1]; - // Log CNonce - memcpy(log_buf, buf, hex_size); - log_buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: CNonce is %s", log_buf); +#ifdef USE_OTA_SHA256 + char log_buf_sha[65]; // 64 hex chars + null terminator for SHA256 +#endif +#ifdef USE_OTA_MD5 + char log_buf_md5[33]; // 32 hex chars + null terminator for MD5 +#endif + char *log_buf = nullptr; +#ifdef USE_OTA_SHA256 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + log_buf = log_buf_sha; + } +#endif +#ifdef USE_OTA_MD5 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_AUTH) { + log_buf = log_buf_md5; + } +#endif - // Log computed hash - this->auth_hasher_->get_hex(log_buf); - log_buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: Result is %s", log_buf); + if (log_buf) { + // Log CNonce + memcpy(log_buf, cnonce, hex_size); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: CNonce is %s", log_buf); - // Log received response - memcpy(log_buf, response, hex_size); - log_buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: Response is %s", log_buf); + // Log computed hash + hasher->get_hex(log_buf); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: Result is %s", log_buf); + + // Log received response + memcpy(log_buf, response, hex_size); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: Response is %s", log_buf); + } #endif // Compare response - bool matches = this->auth_hasher_->equals_hex(response); + bool matches = hasher->equals_hex(response); if (!matches) { this->log_auth_warning_(LOG_STR("Password mismatch")); @@ -765,10 +842,10 @@ bool ESPHomeOTAComponent::handle_auth_read_() { } void ESPHomeOTAComponent::cleanup_auth_() { - this->auth_hasher_ = nullptr; this->auth_buf_ = nullptr; this->auth_buf_size_ = 0; this->auth_buf_pos_ = 0; + this->auth_type_ = 0; } #endif // USE_OTA_PASSWORD diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 55ae34d3af..8b18ee8d46 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -79,11 +79,11 @@ class ESPHomeOTAComponent : public ota::OTAComponent { uint8_t handshake_buf_pos_{0}; uint8_t ota_features_{0}; #ifdef USE_OTA_PASSWORD - std::unique_ptr auth_hasher_; std::unique_ptr auth_buf_; size_t auth_buf_size_{0}; size_t auth_buf_pos_{0}; -#endif // USE_OTA_PASSWORD + uint8_t auth_type_{0}; // Store auth type to know which hasher to use +#endif // USE_OTA_PASSWORD }; } // namespace esphome From 4b003389b8282d7c12029ba5fa0ec3d683da795e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:14:28 -0500 Subject: [PATCH 1449/1626] stack it --- esphome/components/esphome/ota/ota_esphome.cpp | 9 --------- 1 file changed, 9 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e2425e0181..fccf4106db 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -717,15 +717,6 @@ bool ESPHomeOTAComponent::handle_auth_read_() { const size_t to_read = hex_size * 2; // CNonce + Response - // Initialize buffer if not already done - if (!this->auth_buf_) { - // Note: we're reusing the buffer from handle_auth_send_ which should have the nonce - // But if we reach here without it, something went wrong - this->log_auth_warning_(LOG_STR("Auth buffer not initialized")); - this->cleanup_connection_(); - return false; - } - // Try to read remaining bytes (CNonce + Response) // We need to read into the buffer starting after the auth_type (1 byte) and nonce (hex_size bytes) size_t offset = 1 + hex_size; From 3b92c6630dcf2aaea28e77718e4c2326cf51b313 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:26:16 -0500 Subject: [PATCH 1450/1626] stack it --- .../components/esphome/ota/ota_esphome.cpp | 167 ++++++++---------- esphome/components/esphome/ota/ota_esphome.h | 3 + 2 files changed, 78 insertions(+), 92 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index fccf4106db..b7dbed4e19 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -578,28 +578,17 @@ void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG bool ESPHomeOTAComponent::handle_auth_send_() { // Initialize auth buffer if not already done if (!this->auth_buf_) { - // Determine which auth type to use and create hasher on stack - HashBase *hasher = nullptr; - -#ifdef USE_OTA_SHA256 - sha256::SHA256 sha256_hasher; -#endif -#ifdef USE_OTA_MD5 - md5::MD5Digest md5_hasher; -#endif - + // Determine which auth type to use #ifdef USE_OTA_SHA256 bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; #ifdef ALLOW_OTA_DOWNGRADE_MD5 if (client_supports_sha256) { this->auth_type_ = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; - hasher = &sha256_hasher; } else { #ifdef USE_OTA_MD5 this->log_auth_warning_(LOG_STR("Using MD5 for compatibility (deprecated)")); this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; - hasher = &md5_hasher; #else this->log_auth_warning_(LOG_STR("Client doesn't support SHA256 and MD5 is disabled")); this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); @@ -613,12 +602,10 @@ bool ESPHomeOTAComponent::handle_auth_send_() { return false; } this->auth_type_ = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; - hasher = &sha256_hasher; #endif // ALLOW_OTA_DOWNGRADE_MD5 #else // !USE_OTA_SHA256 #ifdef USE_OTA_MD5 this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; - hasher = &md5_hasher; #else this->log_auth_warning_(LOG_STR("No auth methods available")); this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); @@ -626,53 +613,28 @@ bool ESPHomeOTAComponent::handle_auth_send_() { #endif // USE_OTA_MD5 #endif // USE_OTA_SHA256 - // Calculate required buffer size using the hasher - const size_t hex_size = hasher->get_size() * 2; - const size_t nonce_len = hasher->get_size() / 4; - // Buffer needs: 1 (auth_type) + hex_size (nonce) + hex_size*2 (cnonce+response) - this->auth_buf_size_ = 1 + hex_size + hex_size * 2; - this->auth_buf_ = std::make_unique(this->auth_buf_size_); - this->auth_buf_pos_ = 0; + // Generate nonce with appropriate hasher + bool success = false; +#ifdef USE_OTA_SHA256 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + sha256::SHA256 sha_hasher; + success = this->prepare_auth_nonce_(&sha_hasher); + } +#endif +#ifdef USE_OTA_MD5 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_AUTH) { + md5::MD5Digest md5_hasher; + success = this->prepare_auth_nonce_(&md5_hasher); + } +#endif - // Generate nonce - char *buf = reinterpret_cast(this->auth_buf_.get() + 1); - if (!random_bytes(reinterpret_cast(buf), nonce_len)) { - this->log_auth_warning_(LOG_STR("Random bytes generation failed")); - this->cleanup_connection_(); + if (!success) { return false; } - - hasher->init(); - hasher->add(buf, nonce_len); - hasher->calculate(); - - // Prepare buffer: auth_type (1 byte) + nonce (hex_size bytes) - this->auth_buf_[0] = this->auth_type_; - hasher->get_hex(buf); - -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - // Log nonce for debugging - char log_buf[hex_size + 1]; - memcpy(log_buf, buf, hex_size); - log_buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: Nonce is %s", log_buf); -#endif } // Try to write auth_type + nonce - // Calculate hex_size based on auth_type - size_t hex_size; -#ifdef USE_OTA_SHA256 - if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { - hex_size = 64; // SHA256 = 32 bytes * 2 - } else -#endif - { -#ifdef USE_OTA_MD5 - hex_size = 32; // MD5 = 16 bytes * 2 -#endif - } - + size_t hex_size = this->get_auth_hex_size_(); const size_t to_write = 1 + hex_size; size_t remaining = to_write - this->auth_buf_pos_; @@ -695,26 +657,11 @@ bool ESPHomeOTAComponent::handle_auth_send_() { // All written, prepare for reading phase this->auth_buf_pos_ = 0; - - // We'll start the challenge hash in handle_auth_read_ when we have the cnonce - return true; } bool ESPHomeOTAComponent::handle_auth_read_() { - // Calculate hex_size based on auth_type - size_t hex_size; -#ifdef USE_OTA_SHA256 - if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { - hex_size = 64; // SHA256 = 32 bytes * 2 - } else -#endif - { -#ifdef USE_OTA_MD5 - hex_size = 32; // MD5 = 16 bytes * 2 -#endif - } - + size_t hex_size = this->get_auth_hex_size_(); const size_t to_read = hex_size * 2; // CNonce + Response // Try to read remaining bytes (CNonce + Response) @@ -746,29 +693,72 @@ bool ESPHomeOTAComponent::handle_auth_read_() { } // We have all the data, verify it - // Create hasher on stack based on auth_type - HashBase *hasher = nullptr; + bool matches = false; #ifdef USE_OTA_SHA256 - sha256::SHA256 sha256_hasher; if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { - hasher = &sha256_hasher; + sha256::SHA256 sha_hasher; + matches = this->verify_hash_auth_(&sha_hasher, hex_size); } #endif #ifdef USE_OTA_MD5 - md5::MD5Digest md5_hasher; if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_AUTH) { - hasher = &md5_hasher; + md5::MD5Digest md5_hasher; + matches = this->verify_hash_auth_(&md5_hasher, hex_size); } #endif - if (!hasher) { - this->log_auth_warning_(LOG_STR("Invalid auth type")); + if (!matches) { + this->log_auth_warning_(LOG_STR("Password mismatch")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; + } + + // Authentication successful - clean up auth state + this->cleanup_auth_(); + + return true; +} + +bool ESPHomeOTAComponent::prepare_auth_nonce_(HashBase *hasher) { + // Calculate required buffer size using the hasher + const size_t hex_size = hasher->get_size() * 2; + const size_t nonce_len = hasher->get_size() / 4; + // Buffer needs: 1 (auth_type) + hex_size (nonce) + hex_size*2 (cnonce+response) + this->auth_buf_size_ = 1 + hex_size + hex_size * 2; + this->auth_buf_ = std::make_unique(this->auth_buf_size_); + this->auth_buf_pos_ = 0; + + // Generate nonce + char *buf = reinterpret_cast(this->auth_buf_.get() + 1); + if (!random_bytes(reinterpret_cast(buf), nonce_len)) { + this->log_auth_warning_(LOG_STR("Random bytes generation failed")); this->cleanup_connection_(); return false; } + hasher->init(); + hasher->add(buf, nonce_len); + hasher->calculate(); + + // Prepare buffer: auth_type (1 byte) + nonce (hex_size bytes) + this->auth_buf_[0] = this->auth_type_; + hasher->get_hex(buf); + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char log_buf[hex_size + 1]; + // Log nonce for debugging + memcpy(log_buf, buf, hex_size); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: Nonce is %s", log_buf); +#endif + + return true; +} + +bool ESPHomeOTAComponent::verify_hash_auth_(HashBase *hasher, size_t hex_size) { // Get pointers to the data + size_t offset = 1 + hex_size; // Skip auth_type byte and nonce char *nonce = reinterpret_cast(this->auth_buf_.get() + 1); // Skip auth_type byte char *cnonce = reinterpret_cast(this->auth_buf_.get() + offset); const char *response = cnonce + hex_size; @@ -789,12 +779,12 @@ bool ESPHomeOTAComponent::handle_auth_read_() { #endif char *log_buf = nullptr; #ifdef USE_OTA_SHA256 - if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + if (hex_size == 64) { log_buf = log_buf_sha; } #endif #ifdef USE_OTA_MD5 - if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_AUTH) { + if (hex_size == 32) { log_buf = log_buf_md5; } #endif @@ -818,18 +808,11 @@ bool ESPHomeOTAComponent::handle_auth_read_() { #endif // Compare response - bool matches = hasher->equals_hex(response); + return hasher->equals_hex(response); +} - if (!matches) { - this->log_auth_warning_(LOG_STR("Password mismatch")); - this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); - return false; - } - - // Authentication successful - clean up auth state - this->cleanup_auth_(); - - return true; +size_t ESPHomeOTAComponent::get_auth_hex_size_() const { + return this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH ? 64 : 32; } void ESPHomeOTAComponent::cleanup_auth_() { diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 8b18ee8d46..15d1aac914 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -46,6 +46,9 @@ class ESPHomeOTAComponent : public ota::OTAComponent { #ifdef USE_OTA_PASSWORD bool handle_auth_send_(); bool handle_auth_read_(); + bool prepare_auth_nonce_(HashBase *hasher); + bool verify_hash_auth_(HashBase *hasher, size_t hex_size); + size_t get_auth_hex_size_() const; void cleanup_auth_(); void log_auth_warning_(const LogString *msg); #endif // USE_OTA_PASSWORD From e2c637cf489e902bec6b084618fe7e847ab3848a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:26:54 -0500 Subject: [PATCH 1451/1626] stack it --- esphome/components/esphome/ota/ota_esphome.cpp | 4 ++-- esphome/components/esphome/ota/ota_esphome.h | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index b7dbed4e19..b39ab5222a 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -725,8 +725,8 @@ bool ESPHomeOTAComponent::prepare_auth_nonce_(HashBase *hasher) { const size_t hex_size = hasher->get_size() * 2; const size_t nonce_len = hasher->get_size() / 4; // Buffer needs: 1 (auth_type) + hex_size (nonce) + hex_size*2 (cnonce+response) - this->auth_buf_size_ = 1 + hex_size + hex_size * 2; - this->auth_buf_ = std::make_unique(this->auth_buf_size_); + const size_t auth_buf_size = 1 + hex_size + hex_size * 2; + this->auth_buf_ = std::make_unique(auth_buf_size); this->auth_buf_pos_ = 0; // Generate nonce diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 15d1aac914..ce19d52253 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -83,7 +83,6 @@ class ESPHomeOTAComponent : public ota::OTAComponent { uint8_t ota_features_{0}; #ifdef USE_OTA_PASSWORD std::unique_ptr auth_buf_; - size_t auth_buf_size_{0}; size_t auth_buf_pos_{0}; uint8_t auth_type_{0}; // Store auth type to know which hasher to use #endif // USE_OTA_PASSWORD From 0fb3d7550e15f76a77cd9185c3dae4a78d760bf7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:27:04 -0500 Subject: [PATCH 1452/1626] stack it --- esphome/components/esphome/ota/ota_esphome.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index b39ab5222a..e2ccbf0c94 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -817,7 +817,6 @@ size_t ESPHomeOTAComponent::get_auth_hex_size_() const { void ESPHomeOTAComponent::cleanup_auth_() { this->auth_buf_ = nullptr; - this->auth_buf_size_ = 0; this->auth_buf_pos_ = 0; this->auth_type_ = 0; } From d1d8efd5a2ce80fe4d7924ce34c0111329506379 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:28:03 -0500 Subject: [PATCH 1453/1626] stack it --- .../components/esphome/ota/ota_esphome.cpp | 45 ++++++------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e2ccbf0c94..90b08ad826 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -771,40 +771,21 @@ bool ESPHomeOTAComponent::verify_hash_auth_(HashBase *hasher, size_t hex_size) { hasher->calculate(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE -#ifdef USE_OTA_SHA256 - char log_buf_sha[65]; // 64 hex chars + null terminator for SHA256 -#endif -#ifdef USE_OTA_MD5 - char log_buf_md5[33]; // 32 hex chars + null terminator for MD5 -#endif - char *log_buf = nullptr; -#ifdef USE_OTA_SHA256 - if (hex_size == 64) { - log_buf = log_buf_sha; - } -#endif -#ifdef USE_OTA_MD5 - if (hex_size == 32) { - log_buf = log_buf_md5; - } -#endif + char log_buf[hex_size + 1]; + // Log CNonce + memcpy(log_buf, cnonce, hex_size); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: CNonce is %s", log_buf); - if (log_buf) { - // Log CNonce - memcpy(log_buf, cnonce, hex_size); - log_buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: CNonce is %s", log_buf); + // Log computed hash + hasher->get_hex(log_buf); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: Result is %s", log_buf); - // Log computed hash - hasher->get_hex(log_buf); - log_buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: Result is %s", log_buf); - - // Log received response - memcpy(log_buf, response, hex_size); - log_buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: Response is %s", log_buf); - } + // Log received response + memcpy(log_buf, response, hex_size); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: Response is %s", log_buf); #endif // Compare response From a2d3e81c4ec79eb2f73eab9e9b8bc5b4651e43ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:29:09 -0500 Subject: [PATCH 1454/1626] stack it --- esphome/components/esphome/ota/ota_esphome.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 90b08ad826..fbc14a0747 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -724,8 +724,11 @@ bool ESPHomeOTAComponent::prepare_auth_nonce_(HashBase *hasher) { // Calculate required buffer size using the hasher const size_t hex_size = hasher->get_size() * 2; const size_t nonce_len = hasher->get_size() / 4; - // Buffer needs: 1 (auth_type) + hex_size (nonce) + hex_size*2 (cnonce+response) - const size_t auth_buf_size = 1 + hex_size + hex_size * 2; + // Buffer needs to hold max of: + // - During send: auth_type (1) + nonce (hex_size) + // - During read: cnonce (hex_size) + response (hex_size) + // So max is hex_size * 2 + const size_t auth_buf_size = hex_size * 2; this->auth_buf_ = std::make_unique(auth_buf_size); this->auth_buf_pos_ = 0; From 9f421ca60c270619408e001f62786d6dafb54e89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:32:02 -0500 Subject: [PATCH 1455/1626] stack it --- esphome/components/esphome/ota/ota_esphome.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index fbc14a0747..b55a7ee191 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -724,11 +724,11 @@ bool ESPHomeOTAComponent::prepare_auth_nonce_(HashBase *hasher) { // Calculate required buffer size using the hasher const size_t hex_size = hasher->get_size() * 2; const size_t nonce_len = hasher->get_size() / 4; - // Buffer needs to hold max of: - // - During send: auth_type (1) + nonce (hex_size) - // - During read: cnonce (hex_size) + response (hex_size) - // So max is hex_size * 2 - const size_t auth_buf_size = hex_size * 2; + // Buffer layout: + // - auth_type (1 byte) + nonce (hex_size) - sent in AUTH_SEND + // - cnonce (hex_size) + response (hex_size) - read in AUTH_READ at offset 1+hex_size + // Total: 1 + hex_size + (hex_size * 2) + const size_t auth_buf_size = 1 + hex_size + hex_size * 2; this->auth_buf_ = std::make_unique(auth_buf_size); this->auth_buf_pos_ = 0; From 6430ae80cf51eebe713bc4be7e0d4c30d6e39b89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:38:13 -0500 Subject: [PATCH 1456/1626] stack it --- .../components/esphome/ota/ota_esphome.cpp | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index b55a7ee191..1e8c61778f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -665,10 +665,10 @@ bool ESPHomeOTAComponent::handle_auth_read_() { const size_t to_read = hex_size * 2; // CNonce + Response // Try to read remaining bytes (CNonce + Response) - // We need to read into the buffer starting after the auth_type (1 byte) and nonce (hex_size bytes) - size_t offset = 1 + hex_size; + // We read cnonce+response starting at offset 1+hex_size (after auth_type and our nonce) + size_t cnonce_offset = 1 + hex_size; // Offset where cnonce should be stored in buffer size_t remaining = to_read - this->auth_buf_pos_; - ssize_t read = this->client_->read(this->auth_buf_.get() + offset + this->auth_buf_pos_, remaining); + ssize_t read = this->client_->read(this->auth_buf_.get() + cnonce_offset + this->auth_buf_pos_, remaining); if (read == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { @@ -760,11 +760,17 @@ bool ESPHomeOTAComponent::prepare_auth_nonce_(HashBase *hasher) { } bool ESPHomeOTAComponent::verify_hash_auth_(HashBase *hasher, size_t hex_size) { - // Get pointers to the data - size_t offset = 1 + hex_size; // Skip auth_type byte and nonce + // Buffer layout after AUTH_READ completes: + // [0]: auth_type (1 byte) + // [1...hex_size]: nonce (hex_size bytes) - our random nonce sent in AUTH_SEND + // [1+hex_size...1+2*hex_size-1]: cnonce (hex_size bytes) - client's nonce + // [1+2*hex_size...1+3*hex_size-1]: response (hex_size bytes) - client's hash + + // Get pointers to the data in the buffer char *nonce = reinterpret_cast(this->auth_buf_.get() + 1); // Skip auth_type byte - char *cnonce = reinterpret_cast(this->auth_buf_.get() + offset); - const char *response = cnonce + hex_size; + size_t cnonce_offset = 1 + hex_size; // Offset where cnonce starts in buffer + char *cnonce = reinterpret_cast(this->auth_buf_.get() + cnonce_offset); + const char *response = cnonce + hex_size; // Response immediately follows cnonce // Calculate expected hash: password + nonce + cnonce hasher->init(); From e5868a79a2ae3d383fdc4ea59fb8796ef9e41948 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:40:50 -0500 Subject: [PATCH 1457/1626] stack it --- .../components/esphome/ota/ota_esphome.cpp | 79 +++++++++++-------- esphome/components/esphome/ota/ota_esphome.h | 1 + 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 1e8c61778f..52e996f660 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -575,43 +575,58 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { #ifdef USE_OTA_PASSWORD void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG, "Auth: %s", LOG_STR_ARG(msg)); } +bool ESPHomeOTAComponent::select_auth_type_() { +#ifdef USE_OTA_SHA256 + bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + +#ifdef ALLOW_OTA_DOWNGRADE_MD5 + // Allow fallback to MD5 if client doesn't support SHA256 + if (client_supports_sha256) { + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; + return true; + } +#ifdef USE_OTA_MD5 + this->log_auth_warning_(LOG_STR("Using MD5 for compatibility (deprecated)")); + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; + return true; +#else + this->log_auth_warning_(LOG_STR("Client doesn't support SHA256 and MD5 is disabled")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; +#endif // USE_OTA_MD5 + +#else // !ALLOW_OTA_DOWNGRADE_MD5 + // Require SHA256 + if (!client_supports_sha256) { + this->log_auth_warning_(LOG_STR("Client requires SHA256")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; + } + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; + return true; +#endif // ALLOW_OTA_DOWNGRADE_MD5 + +#else // !USE_OTA_SHA256 +#ifdef USE_OTA_MD5 + // Only MD5 available + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; + return true; +#else + // No auth methods available + this->log_auth_warning_(LOG_STR("No auth methods available")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; +#endif // USE_OTA_MD5 +#endif // USE_OTA_SHA256 +} + bool ESPHomeOTAComponent::handle_auth_send_() { // Initialize auth buffer if not already done if (!this->auth_buf_) { - // Determine which auth type to use -#ifdef USE_OTA_SHA256 - bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; - -#ifdef ALLOW_OTA_DOWNGRADE_MD5 - if (client_supports_sha256) { - this->auth_type_ = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; - } else { -#ifdef USE_OTA_MD5 - this->log_auth_warning_(LOG_STR("Using MD5 for compatibility (deprecated)")); - this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; -#else - this->log_auth_warning_(LOG_STR("Client doesn't support SHA256 and MD5 is disabled")); - this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); - return false; -#endif // USE_OTA_MD5 - } -#else // !ALLOW_OTA_DOWNGRADE_MD5 - if (!client_supports_sha256) { - this->log_auth_warning_(LOG_STR("Client requires SHA256")); - this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + // Select auth type based on client capabilities and configuration + if (!this->select_auth_type_()) { return false; } - this->auth_type_ = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; -#endif // ALLOW_OTA_DOWNGRADE_MD5 -#else // !USE_OTA_SHA256 -#ifdef USE_OTA_MD5 - this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; -#else - this->log_auth_warning_(LOG_STR("No auth methods available")); - this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); - return false; -#endif // USE_OTA_MD5 -#endif // USE_OTA_SHA256 // Generate nonce with appropriate hasher bool success = false; diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index ce19d52253..680c5788b9 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -46,6 +46,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { #ifdef USE_OTA_PASSWORD bool handle_auth_send_(); bool handle_auth_read_(); + bool select_auth_type_(); bool prepare_auth_nonce_(HashBase *hasher); bool verify_hash_auth_(HashBase *hasher, size_t hex_size); size_t get_auth_hex_size_() const; From 2d6669068fa95a5fd7de61796eeaaee0d662f3ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:41:40 -0500 Subject: [PATCH 1458/1626] stack it --- esphome/components/esphome/ota/ota_esphome.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 680c5788b9..cd4a5d7a40 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -84,7 +84,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { uint8_t ota_features_{0}; #ifdef USE_OTA_PASSWORD std::unique_ptr auth_buf_; - size_t auth_buf_pos_{0}; + uint8_t auth_buf_pos_{0}; uint8_t auth_type_{0}; // Store auth type to know which hasher to use #endif // USE_OTA_PASSWORD }; From 5fb99e901304092e1ef2c0836f5e0072ac22fe8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:42:58 -0500 Subject: [PATCH 1459/1626] stack it --- esphome/components/esphome/ota/ota_esphome.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 52e996f660..6016160159 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -510,11 +510,7 @@ bool ESPHomeOTAComponent::try_read_(size_t to_read, const LogString *error_desc, if (read <= 0) { // Error or connection closed - if (read == -1) { - this->log_socket_error_(error_desc); - } else { - this->log_remote_closed_(close_desc); - } + read == -1 ? this->log_socket_error_(error_desc) : this->log_remote_closed_(close_desc); this->cleanup_connection_(); return false; } From 7e8de7c92cea8ad9bde9c5c519c3328cd0aaeb0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:44:24 -0500 Subject: [PATCH 1460/1626] stack it --- esphome/components/esphome/ota/ota_esphome.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6016160159..7272f1d208 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -509,8 +509,7 @@ bool ESPHomeOTAComponent::try_read_(size_t to_read, const LogString *error_desc, } if (read <= 0) { - // Error or connection closed - read == -1 ? this->log_socket_error_(error_desc) : this->log_remote_closed_(close_desc); + read == 0 ? this->log_remote_closed_(close_desc) : this->log_socket_error_(error_desc); this->cleanup_connection_(); return false; } From 20cbc48ad4639734dd68f063a3e3a881a97ae2dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:45:56 -0500 Subject: [PATCH 1461/1626] stack it --- .../components/esphome/ota/ota_esphome.cpp | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 7272f1d208..872a8be8f4 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -734,11 +734,14 @@ bool ESPHomeOTAComponent::prepare_auth_nonce_(HashBase *hasher) { // Calculate required buffer size using the hasher const size_t hex_size = hasher->get_size() * 2; const size_t nonce_len = hasher->get_size() / 4; - // Buffer layout: - // - auth_type (1 byte) + nonce (hex_size) - sent in AUTH_SEND - // - cnonce (hex_size) + response (hex_size) - read in AUTH_READ at offset 1+hex_size - // Total: 1 + hex_size + (hex_size * 2) - const size_t auth_buf_size = 1 + hex_size + hex_size * 2; + + // Buffer layout after AUTH_READ completes: + // [0]: auth_type (1 byte) + // [1...hex_size]: nonce (hex_size bytes) - our random nonce sent in AUTH_SEND + // [1+hex_size...1+2*hex_size-1]: cnonce (hex_size bytes) - client's nonce + // [1+2*hex_size...1+3*hex_size-1]: response (hex_size bytes) - client's hash + // Total: 1 + 3*hex_size + const size_t auth_buf_size = 1 + 3 * hex_size; this->auth_buf_ = std::make_unique(auth_buf_size); this->auth_buf_pos_ = 0; @@ -770,13 +773,7 @@ bool ESPHomeOTAComponent::prepare_auth_nonce_(HashBase *hasher) { } bool ESPHomeOTAComponent::verify_hash_auth_(HashBase *hasher, size_t hex_size) { - // Buffer layout after AUTH_READ completes: - // [0]: auth_type (1 byte) - // [1...hex_size]: nonce (hex_size bytes) - our random nonce sent in AUTH_SEND - // [1+hex_size...1+2*hex_size-1]: cnonce (hex_size bytes) - client's nonce - // [1+2*hex_size...1+3*hex_size-1]: response (hex_size bytes) - client's hash - - // Get pointers to the data in the buffer + // Get pointers to the data in the buffer (see prepare_auth_nonce_ for buffer layout) char *nonce = reinterpret_cast(this->auth_buf_.get() + 1); // Skip auth_type byte size_t cnonce_offset = 1 + hex_size; // Offset where cnonce starts in buffer char *cnonce = reinterpret_cast(this->auth_buf_.get() + cnonce_offset); From dba680a748555ec22f7137edcb5ad2848ce72417 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:52:48 -0500 Subject: [PATCH 1462/1626] stack it --- .../components/esphome/ota/ota_esphome.cpp | 65 +++++++++---------- esphome/components/esphome/ota/ota_esphome.h | 4 ++ 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 872a8be8f4..5d2da9d93d 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -344,7 +344,7 @@ void ESPHomeOTAComponent::handle_data_() { size_t requested = std::min(sizeof(buf), ota_size - total); ssize_t read = this->client_->read(buf, requested); if (read == -1) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (this->would_block_(errno)) { this->yield_and_feed_watchdog_(); continue; } @@ -442,7 +442,7 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { ssize_t read = this->client_->read(buf + at, len - at); if (read == -1) { - if (errno != EAGAIN && errno != EWOULDBLOCK) { + if (!this->would_block_(errno)) { ESP_LOGW(TAG, "Error reading %d bytes, errno %d", len, errno); return false; } @@ -469,7 +469,7 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { ssize_t written = this->client_->write(buf + at, len - at); if (written == -1) { - if (errno != EAGAIN && errno != EWOULDBLOCK) { + if (!this->would_block_(errno)) { ESP_LOGW(TAG, "Error writing %d bytes, errno %d", len, errno); return false; } @@ -499,12 +499,8 @@ void ESPHomeOTAComponent::log_remote_closed_(const LogString *during) { ESP_LOGW(TAG, "Remote closed during %s", LOG_STR_ARG(during)); } -bool ESPHomeOTAComponent::try_read_(size_t to_read, const LogString *error_desc, const LogString *close_desc) { - // Read bytes into handshake buffer, starting at handshake_buf_pos_ - size_t bytes_to_read = to_read - this->handshake_buf_pos_; - ssize_t read = this->client_->read(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_read); - - if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { +bool ESPHomeOTAComponent::handle_read_error_(ssize_t read, const LogString *error_desc, const LogString *close_desc) { + if (read == -1 && this->would_block_(errno)) { return false; // No data yet, try again next loop } @@ -513,6 +509,29 @@ bool ESPHomeOTAComponent::try_read_(size_t to_read, const LogString *error_desc, this->cleanup_connection_(); return false; } + return true; +} + +bool ESPHomeOTAComponent::handle_write_error_(ssize_t written, const LogString *error_desc) { + if (written == -1) { + if (this->would_block_(errno)) { + return false; // Try again next loop + } + this->log_socket_error_(error_desc); + this->cleanup_connection_(); + return false; + } + return true; +} + +bool ESPHomeOTAComponent::try_read_(size_t to_read, const LogString *error_desc, const LogString *close_desc) { + // Read bytes into handshake buffer, starting at handshake_buf_pos_ + size_t bytes_to_read = to_read - this->handshake_buf_pos_; + ssize_t read = this->client_->read(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_read); + + if (!this->handle_read_error_(read, error_desc, close_desc)) { + return false; + } this->handshake_buf_pos_ += read; // Return true only if we have all the requested bytes @@ -524,12 +543,7 @@ bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *error_des size_t bytes_to_write = to_write - this->handshake_buf_pos_; ssize_t written = this->client_->write(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_write); - if (written == -1) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - return false; // Try again next loop - } - this->log_socket_error_(error_desc); - this->cleanup_connection_(); + if (!this->handle_write_error_(written, error_desc)) { return false; } @@ -649,12 +663,7 @@ bool ESPHomeOTAComponent::handle_auth_send_() { size_t remaining = to_write - this->auth_buf_pos_; ssize_t written = this->client_->write(this->auth_buf_.get() + this->auth_buf_pos_, remaining); - if (written == -1) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - return false; // Try again next loop - } - this->log_auth_warning_(LOG_STR("Writing auth type and nonce failed")); - this->cleanup_connection_(); + if (!this->handle_write_error_(written, LOG_STR("auth write"))) { return false; } @@ -680,18 +689,8 @@ bool ESPHomeOTAComponent::handle_auth_read_() { size_t remaining = to_read - this->auth_buf_pos_; ssize_t read = this->client_->read(this->auth_buf_.get() + cnonce_offset + this->auth_buf_pos_, remaining); - if (read == -1) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - return false; // Try again next loop - } - this->log_auth_warning_(LOG_STR("Reading cnonce response failed")); - this->cleanup_connection_(); - return false; - } - - if (read == 0) { - this->log_auth_warning_(LOG_STR("Remote closed during auth read")); - this->cleanup_connection_(); + auto *auth_read_desc = LOG_STR("auth read"); + if (!this->handle_read_error_(read, auth_read_desc, auth_read_desc)) { return false; } diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index cd4a5d7a40..6f7bef550a 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -58,6 +58,10 @@ class ESPHomeOTAComponent : public ota::OTAComponent { bool try_read_(size_t to_read, const LogString *error_desc, const LogString *close_desc); bool try_write_(size_t to_write, const LogString *error_desc); + + bool would_block_(int error_code) const { return error_code == EAGAIN || error_code == EWOULDBLOCK; } + bool handle_read_error_(ssize_t read, const LogString *error_desc, const LogString *close_desc); + bool handle_write_error_(ssize_t written, const LogString *error_desc); void transition_ota_state_(OTAState next_state); void log_socket_error_(const LogString *msg); From c789fbf9f340cb14a5b1963adaf77a30e86d9d5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:54:21 -0500 Subject: [PATCH 1463/1626] stack it --- esphome/components/esphome/ota/ota_esphome.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 5d2da9d93d..c63eb929d3 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -341,7 +341,9 @@ void ESPHomeOTAComponent::handle_data_() { while (total < ota_size) { // TODO: timeout check - size_t requested = std::min(sizeof(buf), ota_size - total); + size_t remaining = ota_size - total; + const size_t buf_size = sizeof(buf); + size_t requested = remaining < buf_size ? remaining : buf_size; ssize_t read = this->client_->read(buf, requested); if (read == -1) { if (this->would_block_(errno)) { From 9875e96b13602cc1b153b951fc5b224a6930daee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Sep 2025 23:56:11 -0500 Subject: [PATCH 1464/1626] stack it --- esphome/components/esphome/ota/ota_esphome.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index c63eb929d3..9c698822ba 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -28,6 +28,7 @@ namespace esphome { static const char *const TAG = "esphome.ota"; static constexpr uint16_t OTA_BLOCK_SIZE = 8192; +static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size for OTA data transfer static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer @@ -276,7 +277,8 @@ void ESPHomeOTAComponent::handle_data_() { bool update_started = false; size_t total = 0; uint32_t last_progress = 0; - uint8_t buf[1024]; + uint8_t buf[OTA_BUFFER_SIZE]; + const size_t buf_size = sizeof(buf); char *sbuf = reinterpret_cast(buf); size_t ota_size; #if USE_OTA_VERSION == 2 @@ -342,7 +344,6 @@ void ESPHomeOTAComponent::handle_data_() { while (total < ota_size) { // TODO: timeout check size_t remaining = ota_size - total; - const size_t buf_size = sizeof(buf); size_t requested = remaining < buf_size ? remaining : buf_size; ssize_t read = this->client_->read(buf, requested); if (read == -1) { From 93ca48d9aa9d0fd42c1c5157f33f41243c42402a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 00:00:00 -0500 Subject: [PATCH 1465/1626] stack it --- esphome/components/esphome/ota/ota_esphome.cpp | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 9c698822ba..d213f872e0 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -32,6 +32,15 @@ static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer +#ifdef USE_OTA_PASSWORD +#ifdef USE_OTA_MD5 +static constexpr size_t MD5_HEX_SIZE = 32; // MD5 hash as hex string (16 bytes * 2) +#endif +#ifdef USE_OTA_SHA256 +static constexpr size_t SHA256_HEX_SIZE = 64; // SHA256 hash as hex string (32 bytes * 2) +#endif +#endif // USE_OTA_PASSWORD + void ESPHomeOTAComponent::setup() { #ifdef USE_OTA_STATE_CALLBACK ota::register_ota_platform(this); @@ -811,7 +820,14 @@ bool ESPHomeOTAComponent::verify_hash_auth_(HashBase *hasher, size_t hex_size) { } size_t ESPHomeOTAComponent::get_auth_hex_size_() const { - return this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH ? 64 : 32; +#ifdef USE_OTA_SHA256 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + return SHA256_HEX_SIZE; + } +#endif +#ifdef USE_OTA_MD5 + return MD5_HEX_SIZE; +#endif } void ESPHomeOTAComponent::cleanup_auth_() { From 9cdd4bc555f4581409eb90ffb66d89e838f24793 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 00:15:57 -0500 Subject: [PATCH 1466/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index d213f872e0..ae6e7c576f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -607,11 +607,11 @@ bool ESPHomeOTAComponent::select_auth_type_() { return true; } #ifdef USE_OTA_MD5 - this->log_auth_warning_(LOG_STR("Using MD5 for compatibility (deprecated)")); + this->log_auth_warning_(LOG_STR("Using deprecated MD5")); this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; return true; #else - this->log_auth_warning_(LOG_STR("Client doesn't support SHA256 and MD5 is disabled")); + this->log_auth_warning_(LOG_STR("SHA256 required")); this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); return false; #endif // USE_OTA_MD5 @@ -619,7 +619,7 @@ bool ESPHomeOTAComponent::select_auth_type_() { #else // !ALLOW_OTA_DOWNGRADE_MD5 // Require SHA256 if (!client_supports_sha256) { - this->log_auth_warning_(LOG_STR("Client requires SHA256")); + this->log_auth_warning_(LOG_STR("SHA256 required")); this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); return false; } @@ -759,7 +759,7 @@ bool ESPHomeOTAComponent::prepare_auth_nonce_(HashBase *hasher) { // Generate nonce char *buf = reinterpret_cast(this->auth_buf_.get() + 1); if (!random_bytes(reinterpret_cast(buf), nonce_len)) { - this->log_auth_warning_(LOG_STR("Random bytes generation failed")); + this->log_auth_warning_(LOG_STR("Random failed")); this->cleanup_connection_(); return false; } From 5abde23432da07ea5a0c55278e16261cfda3f205 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 01:08:10 -0500 Subject: [PATCH 1467/1626] merge --- .../components/esphome/ota/ota_esphome.cpp | 107 ++++++------------ esphome/components/esphome/ota/ota_esphome.h | 21 ++-- 2 files changed, 50 insertions(+), 78 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ae6e7c576f..fe2625f15e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -124,11 +124,11 @@ static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; #define ALLOW_OTA_DOWNGRADE_MD5 void ESPHomeOTAComponent::handle_handshake_() { - /// Handle the initial OTA handshake. + /// Handle the OTA handshake and authentication. /// /// This method is non-blocking and will return immediately if no data is available. - /// It reads all 5 magic bytes (0x6C, 0x26, 0xF7, 0x5C, 0x45) non-blocking - /// before proceeding to handle_data_(). A 10-second timeout is enforced from initial connection. + /// It manages the state machine through connection, magic bytes validation, feature + /// negotiation, and authentication before entering the blocking data transfer phase. if (this->client_ == nullptr) { // We already checked server_->ready() in loop(), so we can accept directly @@ -168,7 +168,7 @@ void ESPHomeOTAComponent::handle_handshake_() { switch (this->ota_state_) { case OTAState::MAGIC_READ: { // Try to read remaining magic bytes (5 total) - if (!this->try_read_(5, LOG_STR("reading magic bytes"), LOG_STR("handshake"))) { + if (!this->try_read_(5, LOG_STR("read magic"))) { return; } @@ -183,21 +183,16 @@ void ESPHomeOTAComponent::handle_handshake_() { // Magic bytes valid, move to next state this->transition_ota_state_(OTAState::MAGIC_ACK); + this->handshake_buf_[0] = ota::OTA_RESPONSE_OK; + this->handshake_buf_[1] = USE_OTA_VERSION; [[fallthrough]]; } case OTAState::MAGIC_ACK: { // Send OK and version - 2 bytes - // Prepare response in handshake buffer if not already done - if (this->handshake_buf_pos_ == 0) { - this->handshake_buf_[0] = ota::OTA_RESPONSE_OK; - this->handshake_buf_[1] = USE_OTA_VERSION; - } - - if (!this->try_write_(2, LOG_STR("writing magic ack"))) { + if (!this->try_write_(2, LOG_STR("ack magic"))) { return; } - // All bytes sent, create backend and move to next state this->backend_ = ota::make_ota_backend(); this->transition_ota_state_(OTAState::FEATURE_READ); @@ -206,30 +201,24 @@ void ESPHomeOTAComponent::handle_handshake_() { case OTAState::FEATURE_READ: { // Read features - 1 byte - if (!this->try_read_(1, LOG_STR("reading features"), LOG_STR("feature read"))) { + if (!this->try_read_(1, LOG_STR("read feature"))) { return; } - this->ota_features_ = this->handshake_buf_[0]; ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); this->transition_ota_state_(OTAState::FEATURE_ACK); + this->handshake_buf_[0] = + ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) + ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION + : ota::OTA_RESPONSE_HEADER_OK; [[fallthrough]]; } case OTAState::FEATURE_ACK: { // Acknowledge header - 1 byte - // Prepare response in handshake buffer if not already done - if (this->handshake_buf_pos_ == 0) { - this->handshake_buf_[0] = - ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) - ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION - : ota::OTA_RESPONSE_HEADER_OK; - } - - if (!this->try_write_(1, LOG_STR("writing feature ack"))) { + if (!this->try_write_(1, LOG_STR("ack feature"))) { return; } - #ifdef USE_OTA_PASSWORD // If password is set, move to auth phase if (!this->password_.empty()) { @@ -266,11 +255,10 @@ void ESPHomeOTAComponent::handle_handshake_() { case OTAState::DATA: this->handle_data_(); - return; + [[fallthrough]]; - case OTAState::IDLE: - // This shouldn't happen - return; + default: + break; } } @@ -287,20 +275,12 @@ void ESPHomeOTAComponent::handle_data_() { size_t total = 0; uint32_t last_progress = 0; uint8_t buf[OTA_BUFFER_SIZE]; - const size_t buf_size = sizeof(buf); char *sbuf = reinterpret_cast(buf); size_t ota_size; #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif - // The handshake and auth have already been completed - // We already have: - // - this->backend_ created - // - this->ota_features_ set - // - Feature acknowledgment sent - // - Authentication completed (if password was set) - // Acknowledge auth OK - 1 byte buf[0] = ota::OTA_RESPONSE_AUTH_OK; this->writeall_(buf, 1); @@ -353,26 +333,23 @@ void ESPHomeOTAComponent::handle_data_() { while (total < ota_size) { // TODO: timeout check size_t remaining = ota_size - total; - size_t requested = remaining < buf_size ? remaining : buf_size; + size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; ssize_t read = this->client_->read(buf, requested); if (read == -1) { if (this->would_block_(errno)) { this->yield_and_feed_watchdog_(); continue; } - ESP_LOGW(TAG, "Read error, errno %d", errno); + ESP_LOGW(TAG, "Read err %d", errno); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } else if (read == 0) { - // $ man recv - // "When a stream socket peer has performed an orderly shutdown, the return value will - // be 0 (the traditional "end-of-file" return)." - ESP_LOGW(TAG, "Remote closed connection"); + ESP_LOGW(TAG, "Remote closed"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } error_code = this->backend_->write(buf, read); if (error_code != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Flash write error, code: %d", error_code); + ESP_LOGW(TAG, "Flash write err %d", error_code); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } total += read; @@ -403,7 +380,7 @@ void ESPHomeOTAComponent::handle_data_() { error_code = this->backend_->end(); if (error_code != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Error ending update! code: %d", error_code); + ESP_LOGW(TAG, "End update err %d", error_code); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -455,11 +432,11 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { ssize_t read = this->client_->read(buf + at, len - at); if (read == -1) { if (!this->would_block_(errno)) { - ESP_LOGW(TAG, "Error reading %d bytes, errno %d", len, errno); + ESP_LOGW(TAG, "Read err %d bytes, errno %d", len, errno); return false; } } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed connection"); + ESP_LOGW(TAG, "Remote closed"); return false; } else { at += read; @@ -482,7 +459,7 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { ssize_t written = this->client_->write(buf + at, len - at); if (written == -1) { if (!this->would_block_(errno)) { - ESP_LOGW(TAG, "Error writing %d bytes, errno %d", len, errno); + ESP_LOGW(TAG, "Write err %d bytes, errno %d", len, errno); return false; } } else { @@ -508,40 +485,40 @@ void ESPHomeOTAComponent::log_start_(const LogString *phase) { } void ESPHomeOTAComponent::log_remote_closed_(const LogString *during) { - ESP_LOGW(TAG, "Remote closed during %s", LOG_STR_ARG(during)); + ESP_LOGW(TAG, "Remote closed at %s", LOG_STR_ARG(during)); } -bool ESPHomeOTAComponent::handle_read_error_(ssize_t read, const LogString *error_desc, const LogString *close_desc) { +bool ESPHomeOTAComponent::handle_read_error_(ssize_t read, const LogString *desc) { if (read == -1 && this->would_block_(errno)) { return false; // No data yet, try again next loop } if (read <= 0) { - read == 0 ? this->log_remote_closed_(close_desc) : this->log_socket_error_(error_desc); + read == 0 ? this->log_remote_closed_(desc) : this->log_socket_error_(desc); this->cleanup_connection_(); return false; } return true; } -bool ESPHomeOTAComponent::handle_write_error_(ssize_t written, const LogString *error_desc) { +bool ESPHomeOTAComponent::handle_write_error_(ssize_t written, const LogString *desc) { if (written == -1) { if (this->would_block_(errno)) { return false; // Try again next loop } - this->log_socket_error_(error_desc); + this->log_socket_error_(desc); this->cleanup_connection_(); return false; } return true; } -bool ESPHomeOTAComponent::try_read_(size_t to_read, const LogString *error_desc, const LogString *close_desc) { +bool ESPHomeOTAComponent::try_read_(size_t to_read, const LogString *desc) { // Read bytes into handshake buffer, starting at handshake_buf_pos_ size_t bytes_to_read = to_read - this->handshake_buf_pos_; ssize_t read = this->client_->read(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_read); - if (!this->handle_read_error_(read, error_desc, close_desc)) { + if (!this->handle_read_error_(read, desc)) { return false; } @@ -550,12 +527,12 @@ bool ESPHomeOTAComponent::try_read_(size_t to_read, const LogString *error_desc, return this->handshake_buf_pos_ >= to_read; } -bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *error_desc) { +bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *desc) { // Write bytes from handshake buffer, starting at handshake_buf_pos_ size_t bytes_to_write = to_write - this->handshake_buf_pos_; ssize_t written = this->client_->write(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_write); - if (!this->handle_write_error_(written, error_desc)) { + if (!this->handle_write_error_(written, desc)) { return false; } @@ -564,11 +541,6 @@ bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *error_des return this->handshake_buf_pos_ >= to_write; } -void ESPHomeOTAComponent::transition_ota_state_(OTAState next_state) { - this->ota_state_ = next_state; - this->handshake_buf_pos_ = 0; // Reset buffer position for next state -} - void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; @@ -582,12 +554,6 @@ void ESPHomeOTAComponent::cleanup_connection_() { #endif } -void ESPHomeOTAComponent::send_error_and_cleanup_(ota::OTAResponseTypes error) { - uint8_t error_byte = static_cast(error); - this->client_->write(&error_byte, 1); // Best effort, non-blocking - this->cleanup_connection_(); -} - void ESPHomeOTAComponent::yield_and_feed_watchdog_() { App.feed_wdt(); delay(1); @@ -675,7 +641,7 @@ bool ESPHomeOTAComponent::handle_auth_send_() { size_t remaining = to_write - this->auth_buf_pos_; ssize_t written = this->client_->write(this->auth_buf_.get() + this->auth_buf_pos_, remaining); - if (!this->handle_write_error_(written, LOG_STR("auth write"))) { + if (!this->handle_write_error_(written, LOG_STR("ack auth"))) { return false; } @@ -701,8 +667,7 @@ bool ESPHomeOTAComponent::handle_auth_read_() { size_t remaining = to_read - this->auth_buf_pos_; ssize_t read = this->client_->read(this->auth_buf_.get() + cnonce_offset + this->auth_buf_pos_, remaining); - auto *auth_read_desc = LOG_STR("auth read"); - if (!this->handle_read_error_(read, auth_read_desc, auth_read_desc)) { + if (!this->handle_read_error_(read, LOG_STR("read auth"))) { return false; } @@ -760,7 +725,7 @@ bool ESPHomeOTAComponent::prepare_auth_nonce_(HashBase *hasher) { char *buf = reinterpret_cast(this->auth_buf_.get() + 1); if (!random_bytes(reinterpret_cast(buf), nonce_len)) { this->log_auth_warning_(LOG_STR("Random failed")); - this->cleanup_connection_(); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_UNKNOWN); return false; } diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 6f7bef550a..1e26494fd0 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -56,20 +56,27 @@ class ESPHomeOTAComponent : public ota::OTAComponent { bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); - bool try_read_(size_t to_read, const LogString *error_desc, const LogString *close_desc); - bool try_write_(size_t to_write, const LogString *error_desc); + bool try_read_(size_t to_read, const LogString *desc); + bool try_write_(size_t to_write, const LogString *desc); - bool would_block_(int error_code) const { return error_code == EAGAIN || error_code == EWOULDBLOCK; } - bool handle_read_error_(ssize_t read, const LogString *error_desc, const LogString *close_desc); - bool handle_write_error_(ssize_t written, const LogString *error_desc); - void transition_ota_state_(OTAState next_state); + inline bool would_block_(int error_code) const { return error_code == EAGAIN || error_code == EWOULDBLOCK; } + bool handle_read_error_(ssize_t read, const LogString *desc); + bool handle_write_error_(ssize_t written, const LogString *desc); + inline void transition_ota_state_(OTAState next_state) { + this->ota_state_ = next_state; + this->handshake_buf_pos_ = 0; // Reset buffer position for next state + } void log_socket_error_(const LogString *msg); void log_read_error_(const LogString *what); void log_start_(const LogString *phase); void log_remote_closed_(const LogString *during); void cleanup_connection_(); - void send_error_and_cleanup_(ota::OTAResponseTypes error); + inline void send_error_and_cleanup_(ota::OTAResponseTypes error) { + uint8_t error_byte = static_cast(error); + this->client_->write(&error_byte, 1); // Best effort, non-blocking + this->cleanup_connection_(); + } void yield_and_feed_watchdog_(); #ifdef USE_OTA_PASSWORD From 603bde05e7c97cba08b76cee82c2c5ba8dbf74e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 01:11:30 -0500 Subject: [PATCH 1468/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index fe2625f15e..007ff9af97 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -223,14 +223,13 @@ void ESPHomeOTAComponent::handle_handshake_() { // If password is set, move to auth phase if (!this->password_.empty()) { this->transition_ota_state_(OTAState::AUTH_SEND); - [[fallthrough]]; } else #endif { // No password, move directly to data phase this->transition_ota_state_(OTAState::DATA); - [[fallthrough]]; } + [[fallthrough]]; } #ifdef USE_OTA_PASSWORD From 91adbc2466517e5cab26a2f21c0fa01cc357e5e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 01:39:56 -0500 Subject: [PATCH 1469/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 007ff9af97..664c05f3b1 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -749,16 +749,14 @@ bool ESPHomeOTAComponent::prepare_auth_nonce_(HashBase *hasher) { bool ESPHomeOTAComponent::verify_hash_auth_(HashBase *hasher, size_t hex_size) { // Get pointers to the data in the buffer (see prepare_auth_nonce_ for buffer layout) - char *nonce = reinterpret_cast(this->auth_buf_.get() + 1); // Skip auth_type byte - size_t cnonce_offset = 1 + hex_size; // Offset where cnonce starts in buffer - char *cnonce = reinterpret_cast(this->auth_buf_.get() + cnonce_offset); - const char *response = cnonce + hex_size; // Response immediately follows cnonce + const char *nonce = reinterpret_cast(this->auth_buf_.get() + 1); // Skip auth_type byte + const char *cnonce = nonce + hex_size; // CNonce immediately follows nonce + const char *response = cnonce + hex_size; // Response immediately follows cnonce // Calculate expected hash: password + nonce + cnonce hasher->init(); hasher->add(this->password_.c_str(), this->password_.length()); - hasher->add(nonce, hex_size); - hasher->add(cnonce, hex_size); + hasher->add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer) hasher->calculate(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE From 8b98ed16e959b172a6364525cbd285786c5dff8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 01:52:17 -0500 Subject: [PATCH 1470/1626] error --- esphome/components/esphome/ota/ota_esphome.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 664c05f3b1..caa526ff91 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -789,6 +789,10 @@ size_t ESPHomeOTAComponent::get_auth_hex_size_() const { #endif #ifdef USE_OTA_MD5 return MD5_HEX_SIZE; +#else +#ifndef USE_OTA_SHA256 +#error "Either USE_OTA_MD5 or USE_OTA_SHA256 must be defined when USE_OTA_PASSWORD is enabled" +#endif #endif } From ceb1dcba408148b16b0ae47511b507cc635bca3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 02:04:18 -0500 Subject: [PATCH 1471/1626] fix --- esphome/components/esphome/ota/ota_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index caa526ff91..f73837f8fa 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -81,7 +81,7 @@ void ESPHomeOTAComponent::setup() { return; } - err = this->server_->listen(4); + err = this->server_->listen(1); // Only one client at a time if (err != 0) { this->log_socket_error_(LOG_STR("listen")); this->mark_failed(); From 61a11547cacd414f1d1ce4716c79ef9a88bf2a1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 09:24:17 -0500 Subject: [PATCH 1472/1626] [esp32_improv] Fix crashes from uninitialized pointers and missing null checks (#10902) --- .../esp32_improv/esp32_improv_component.cpp | 74 ++++++++++++++----- .../esp32_improv/esp32_improv_component.h | 15 ++-- 2 files changed, 66 insertions(+), 23 deletions(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index d47cc50a00..c5a0b89f99 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -15,6 +15,8 @@ using namespace bytebuffer; static const char *const TAG = "esp32_improv.component"; static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; +static constexpr uint16_t STOP_ADVERTISING_DELAY = + 10000; // Delay (ms) before stopping service to allow BLE clients to read the final state ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } @@ -193,6 +195,25 @@ void ESP32ImprovComponent::set_status_indicator_state_(bool state) { #endif } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG +const char *ESP32ImprovComponent::state_to_string_(improv::State state) { + switch (state) { + case improv::STATE_STOPPED: + return "STOPPED"; + case improv::STATE_AWAITING_AUTHORIZATION: + return "AWAITING_AUTHORIZATION"; + case improv::STATE_AUTHORIZED: + return "AUTHORIZED"; + case improv::STATE_PROVISIONING: + return "PROVISIONING"; + case improv::STATE_PROVISIONED: + return "PROVISIONED"; + default: + return "UNKNOWN"; + } +} +#endif + bool ESP32ImprovComponent::check_identify_() { uint32_t now = millis(); @@ -206,31 +227,42 @@ bool ESP32ImprovComponent::check_identify_() { } void ESP32ImprovComponent::set_state_(improv::State state) { - ESP_LOGV(TAG, "Setting state: %d", state); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + if (this->state_ != state) { + ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_, + this->state_to_string_(state), state); + } +#endif this->state_ = state; - if (this->status_->get_value().empty() || this->status_->get_value()[0] != state) { + if (this->status_ != nullptr && (this->status_->get_value().empty() || this->status_->get_value()[0] != state)) { this->status_->set_value(ByteBuffer::wrap(static_cast(state))); if (state != improv::STATE_STOPPED) this->status_->notify(); } - std::vector service_data(8, 0); - service_data[0] = 0x77; // PR - service_data[1] = 0x46; // IM - service_data[2] = static_cast(state); + // Only advertise valid Improv states (0x01-0x04). + // STATE_STOPPED (0x00) is internal only and not part of the Improv spec. + // Advertising 0x00 causes undefined behavior in some clients and makes them + // repeatedly connect trying to determine the actual state. + if (state != improv::STATE_STOPPED) { + std::vector service_data(8, 0); + service_data[0] = 0x77; // PR + service_data[1] = 0x46; // IM + service_data[2] = static_cast(state); - uint8_t capabilities = 0x00; + uint8_t capabilities = 0x00; #ifdef USE_OUTPUT - if (this->status_indicator_ != nullptr) - capabilities |= improv::CAPABILITY_IDENTIFY; + if (this->status_indicator_ != nullptr) + capabilities |= improv::CAPABILITY_IDENTIFY; #endif - service_data[3] = capabilities; - service_data[4] = 0x00; // Reserved - service_data[5] = 0x00; // Reserved - service_data[6] = 0x00; // Reserved - service_data[7] = 0x00; // Reserved + service_data[3] = capabilities; + service_data[4] = 0x00; // Reserved + service_data[5] = 0x00; // Reserved + service_data[6] = 0x00; // Reserved + service_data[7] = 0x00; // Reserved - esp32_ble::global_ble->advertising_set_service_data(service_data); + esp32_ble::global_ble->advertising_set_service_data(service_data); + } #ifdef USE_ESP32_IMPROV_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif @@ -240,7 +272,12 @@ void ESP32ImprovComponent::set_error_(improv::Error error) { if (error != improv::ERROR_NONE) { ESP_LOGE(TAG, "Error: %d", error); } - if (this->error_->get_value().empty() || this->error_->get_value()[0] != error) { + // The error_ characteristic is initialized in setup_characteristics() which is called + // from the loop, while the BLE disconnect callback is registered in setup(). + // error_ can be nullptr if: + // 1. A client connects/disconnects before setup_characteristics() is called + // 2. The device is already provisioned so the service never starts (should_start_ is false) + if (this->error_ != nullptr && (this->error_->get_value().empty() || this->error_->get_value()[0] != error)) { this->error_->set_value(ByteBuffer::wrap(static_cast(error))); if (this->state_ != improv::STATE_STOPPED) this->error_->notify(); @@ -264,7 +301,10 @@ void ESP32ImprovComponent::start() { void ESP32ImprovComponent::stop() { this->should_start_ = false; - this->set_timeout("end-service", 1000, [this] { + // Wait before stopping the service to ensure all BLE clients see the state change. + // This prevents clients from repeatedly reconnecting and wasting resources by allowing + // them to observe that the device is provisioned before the service disappears. + this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] { if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr) return; this->service_->stop(); diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 87cec23876..686da08111 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -79,12 +79,12 @@ class ESP32ImprovComponent : public Component { std::vector incoming_data_; wifi::WiFiAP connecting_sta_; - BLEService *service_ = nullptr; - BLECharacteristic *status_; - BLECharacteristic *error_; - BLECharacteristic *rpc_; - BLECharacteristic *rpc_response_; - BLECharacteristic *capabilities_; + BLEService *service_{nullptr}; + BLECharacteristic *status_{nullptr}; + BLECharacteristic *error_{nullptr}; + BLECharacteristic *rpc_{nullptr}; + BLECharacteristic *rpc_response_{nullptr}; + BLECharacteristic *capabilities_{nullptr}; #ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *authorizer_{nullptr}; @@ -108,6 +108,9 @@ class ESP32ImprovComponent : public Component { void process_incoming_data_(); void on_wifi_connect_timeout_(); bool check_identify_(); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + const char *state_to_string_(improv::State state); +#endif }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) From e4460bc8024523843bbdb48541facef837fbfc9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 09:25:32 -0500 Subject: [PATCH 1473/1626] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f73837f8fa..f1506f066c 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -254,7 +254,7 @@ void ESPHomeOTAComponent::handle_handshake_() { case OTAState::DATA: this->handle_data_(); - [[fallthrough]]; + return; default: break; From 7aa0815cd234812b31e141919421156fab253407 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 10:51:15 -0500 Subject: [PATCH 1474/1626] imporv_name --- esphome/components/esp32_ble/ble.cpp | 22 ++++ esphome/components/esp32_ble/ble.h | 2 + .../components/esp32_ble/ble_advertising.cpp | 8 +- .../components/esp32_ble/ble_advertising.h | 5 + .../esp32_improv/esp32_improv_component.cpp | 105 +++++++++++++----- .../esp32_improv/esp32_improv_component.h | 6 +- 6 files changed, 120 insertions(+), 28 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 6b6b19e079..db16e12ae2 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -73,6 +73,28 @@ void ESP32BLE::advertising_set_manufacturer_data(const std::vector &dat this->advertising_start(); } +void ESP32BLE::advertising_set_service_data_and_name(std::span data, bool include_name) { + // This method atomically updates both service data and device name inclusion in BLE advertising. + // When include_name is true, the device name is included in the advertising packet making it + // visible to passive BLE scanners. When false, the name is only visible in scan response + // (requires active scanning). This atomic operation ensures we only restart advertising once + // when changing both properties, avoiding the brief gap that would occur with separate calls. + + this->advertising_init_(); + bool needs_restart = false; + + this->advertising_->set_service_data(data); + + if (this->advertising_->get_include_name() != include_name) { + this->advertising_->set_include_name(include_name); + needs_restart = true; + } + + if (needs_restart || !data.empty()) { + this->advertising_start(); + } +} + void ESP32BLE::advertising_register_raw_advertisement_callback(std::function &&callback) { this->advertising_init_(); this->advertising_->register_raw_advertisement_callback(std::move(callback)); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 368ac644cf..1aa3bc86ef 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -9,6 +9,7 @@ #endif #include +#include #include "esphome/core/automation.h" #include "esphome/core/component.h" @@ -118,6 +119,7 @@ class ESP32BLE : public Component { void advertising_set_service_data(const std::vector &data); void advertising_set_manufacturer_data(const std::vector &data); void advertising_set_appearance(uint16_t appearance) { this->appearance_ = appearance; } + void advertising_set_service_data_and_name(std::span data, bool include_name); void advertising_add_service_uuid(ESPBTUUID uuid); void advertising_remove_service_uuid(ESPBTUUID uuid); void advertising_register_raw_advertisement_callback(std::function &&callback); diff --git a/esphome/components/esp32_ble/ble_advertising.cpp b/esphome/components/esp32_ble/ble_advertising.cpp index d8b9b1cc36..df70768c23 100644 --- a/esphome/components/esp32_ble/ble_advertising.cpp +++ b/esphome/components/esp32_ble/ble_advertising.cpp @@ -43,7 +43,7 @@ void BLEAdvertising::remove_service_uuid(ESPBTUUID uuid) { this->advertising_uuids_.end()); } -void BLEAdvertising::set_service_data(const std::vector &data) { +void BLEAdvertising::set_service_data(std::span data) { delete[] this->advertising_data_.p_service_data; this->advertising_data_.p_service_data = nullptr; this->advertising_data_.service_data_len = data.size(); @@ -54,6 +54,10 @@ void BLEAdvertising::set_service_data(const std::vector &data) { } } +void BLEAdvertising::set_service_data(const std::vector &data) { + this->set_service_data(std::span(data)); +} + void BLEAdvertising::set_manufacturer_data(const std::vector &data) { delete[] this->advertising_data_.p_manufacturer_data; this->advertising_data_.p_manufacturer_data = nullptr; @@ -84,7 +88,7 @@ esp_err_t BLEAdvertising::services_advertisement_() { esp_err_t err; this->advertising_data_.set_scan_rsp = false; - this->advertising_data_.include_name = !this->scan_response_; + this->advertising_data_.include_name = this->include_name_in_adv_ || !this->scan_response_; this->advertising_data_.include_txpower = !this->scan_response_; err = esp_ble_gap_config_adv_data(&this->advertising_data_); if (err != ESP_OK) { diff --git a/esphome/components/esp32_ble/ble_advertising.h b/esphome/components/esp32_ble/ble_advertising.h index e373554ea9..83db8fcd31 100644 --- a/esphome/components/esp32_ble/ble_advertising.h +++ b/esphome/components/esp32_ble/ble_advertising.h @@ -4,6 +4,7 @@ #include #include +#include #include #ifdef USE_ESP32 @@ -36,6 +37,9 @@ class BLEAdvertising { void set_manufacturer_data(const std::vector &data); void set_appearance(uint16_t appearance) { this->advertising_data_.appearance = appearance; } void set_service_data(const std::vector &data); + void set_service_data(std::span data); + void set_include_name(bool include_name) { this->include_name_in_adv_ = include_name; } + bool get_include_name() const { return this->include_name_in_adv_; } void register_raw_advertisement_callback(std::function &&callback); void start(); @@ -45,6 +49,7 @@ class BLEAdvertising { esp_err_t services_advertisement_(); bool scan_response_; + bool include_name_in_adv_{false}; esp_ble_adv_data_t advertising_data_; esp_ble_adv_data_t scan_response_data_; esp_ble_adv_params_t advertising_params_; diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index c5a0b89f99..1c3ea538f3 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -17,6 +17,8 @@ static const char *const TAG = "esp32_improv.component"; static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; static constexpr uint16_t STOP_ADVERTISING_DELAY = 10000; // Delay (ms) before stopping service to allow BLE clients to read the final state +static constexpr uint16_t NAME_ADVERTISING_INTERVAL = 60000; // Advertise name every 60 seconds +static constexpr uint16_t NAME_ADVERTISING_DURATION = 1000; // Advertise name for 1 second ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } @@ -99,6 +101,11 @@ void ESP32ImprovComponent::loop() { this->process_incoming_data_(); uint32_t now = App.get_loop_component_start_time(); + // Check if we need to update advertising type + if (this->state_ != improv::STATE_STOPPED && this->state_ != improv::STATE_PROVISIONED) { + this->update_advertising_type_(); + } + switch (this->state_) { case improv::STATE_STOPPED: this->set_status_indicator_state_(false); @@ -107,9 +114,22 @@ void ESP32ImprovComponent::loop() { if (this->service_->is_created()) { this->service_->start(); } else if (this->service_->is_running()) { + // Start by advertising the device name first BEFORE setting any state + ESP_LOGV(TAG, "Starting with device name advertising"); + this->advertising_device_name_ = true; + this->last_name_adv_time_ = App.get_loop_component_start_time(); + esp32_ble::global_ble->advertising_set_service_data_and_name(std::span{}, true); esp32_ble::global_ble->advertising_start(); - this->set_state_(improv::STATE_AWAITING_AUTHORIZATION); + // Set initial state based on whether we have an authorizer + // authorizer_ member only exists when USE_BINARY_SENSOR is defined +#ifdef USE_BINARY_SENSOR + this->set_state_( + this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION, false); +#else + // No binary_sensor support = no authorizer possible, start as authorized + this->set_state_(improv::STATE_AUTHORIZED, false); +#endif this->set_error_(improv::ERROR_NONE); ESP_LOGD(TAG, "Service started!"); } @@ -226,12 +246,15 @@ bool ESP32ImprovComponent::check_identify_() { return identify; } -void ESP32ImprovComponent::set_state_(improv::State state) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG - if (this->state_ != state) { - ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_, - this->state_to_string_(state), state); +void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertising) { + // Skip if state hasn't changed + if (this->state_ == state) { + return; } + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_, + this->state_to_string_(state), state); #endif this->state_ = state; if (this->status_ != nullptr && (this->status_->get_value().empty() || this->status_->get_value()[0] != state)) { @@ -243,25 +266,13 @@ void ESP32ImprovComponent::set_state_(improv::State state) { // STATE_STOPPED (0x00) is internal only and not part of the Improv spec. // Advertising 0x00 causes undefined behavior in some clients and makes them // repeatedly connect trying to determine the actual state. - if (state != improv::STATE_STOPPED) { - std::vector service_data(8, 0); - service_data[0] = 0x77; // PR - service_data[1] = 0x46; // IM - service_data[2] = static_cast(state); - - uint8_t capabilities = 0x00; -#ifdef USE_OUTPUT - if (this->status_indicator_ != nullptr) - capabilities |= improv::CAPABILITY_IDENTIFY; -#endif - - service_data[3] = capabilities; - service_data[4] = 0x00; // Reserved - service_data[5] = 0x00; // Reserved - service_data[6] = 0x00; // Reserved - service_data[7] = 0x00; // Reserved - - esp32_ble::global_ble->advertising_set_service_data(service_data); + if (state != improv::STATE_STOPPED && update_advertising) { + // State change always overrides name advertising and resets the timer + this->advertising_device_name_ = false; + // Reset the timer so we wait another 60 seconds before advertising name + this->last_name_adv_time_ = App.get_loop_component_start_time(); + // Advertise the new state via service data + this->advertise_service_data_(); } #ifdef USE_ESP32_IMPROV_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); @@ -388,6 +399,50 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() { wifi::global_wifi_component->clear_sta(); } +void ESP32ImprovComponent::advertise_service_data_() { + uint8_t service_data[8] = {}; + service_data[0] = 0x77; // PR + service_data[1] = 0x46; // IM + service_data[2] = static_cast(this->state_); + + uint8_t capabilities = 0x00; +#ifdef USE_OUTPUT + if (this->status_indicator_ != nullptr) + capabilities |= improv::CAPABILITY_IDENTIFY; +#endif + + service_data[3] = capabilities; + // service_data[4-7] are already 0 (Reserved) + + // Atomically set service data and disable name in advertising + esp32_ble::global_ble->advertising_set_service_data_and_name(std::span(service_data), false); +} + +void ESP32ImprovComponent::update_advertising_type_() { + uint32_t now = App.get_loop_component_start_time(); + + // If we're advertising the device name and it's been more than NAME_ADVERTISING_DURATION, switch back to service data + if (this->advertising_device_name_) { + if (now - this->last_name_adv_time_ >= NAME_ADVERTISING_DURATION) { + ESP_LOGV(TAG, "Switching back to service data advertising"); + this->advertising_device_name_ = false; + // Restore service data advertising + this->advertise_service_data_(); + } + return; + } + + // Check if it's time to advertise the device name (every NAME_ADVERTISING_INTERVAL) + if (now - this->last_name_adv_time_ >= NAME_ADVERTISING_INTERVAL) { + ESP_LOGV(TAG, "Switching to device name advertising"); + this->advertising_device_name_ = true; + this->last_name_adv_time_ = now; + + // Atomically clear service data and enable name in advertising data + esp32_ble::global_ble->advertising_set_service_data_and_name(std::span{}, true); + } +} + ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esp32_improv diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 686da08111..ea51f64d4b 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -100,14 +100,18 @@ class ESP32ImprovComponent : public Component { #endif bool status_indicator_state_{false}; + uint32_t last_name_adv_time_{0}; + bool advertising_device_name_{false}; void set_status_indicator_state_(bool state); + void update_advertising_type_(); - void set_state_(improv::State state); + void set_state_(improv::State state, bool update_advertising = true); void set_error_(improv::Error error); void send_response_(std::vector &response); void process_incoming_data_(); void on_wifi_connect_timeout_(); bool check_identify_(); + void advertise_service_data_(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG const char *state_to_string_(improv::State state); #endif From bb986cfb6e15a1045d4d4141c26d18abdd1fd8e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 10:53:50 -0500 Subject: [PATCH 1475/1626] [esp32_ble_tracker] Reduce gap_scan_result log verbosity to VV --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 908bb36c9e..a7d73a9709 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -295,7 +295,7 @@ void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_ga void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { // Note: This handler is called from the main loop context via esp32_ble's event queue. // We process advertisements immediately instead of buffering them. - ESP_LOGV(TAG, "gap_scan_result - event %d", scan_result.search_evt); + ESP_LOGVV(TAG, "gap_scan_result - event %d", scan_result.search_evt); if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { // Process the scan result immediately From e6ca3afd562e3945a645e41ab15123a0141fa211 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 11:09:10 -0500 Subject: [PATCH 1476/1626] preen --- esphome/components/esp32_ble/ble.cpp | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index db16e12ae2..2108ded472 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -81,18 +81,9 @@ void ESP32BLE::advertising_set_service_data_and_name(std::span da // when changing both properties, avoiding the brief gap that would occur with separate calls. this->advertising_init_(); - bool needs_restart = false; - this->advertising_->set_service_data(data); - - if (this->advertising_->get_include_name() != include_name) { - this->advertising_->set_include_name(include_name); - needs_restart = true; - } - - if (needs_restart || !data.empty()) { - this->advertising_start(); - } + this->advertising_->set_include_name(include_name); + this->advertising_start(); } void ESP32BLE::advertising_register_raw_advertisement_callback(std::function &&callback) { From a9a5cef281369e2f86adf5a011f614a50d66b6c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 11:09:59 -0500 Subject: [PATCH 1477/1626] preen --- esphome/components/esp32_ble/ble_advertising.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble_advertising.h b/esphome/components/esp32_ble/ble_advertising.h index 83db8fcd31..7a31d926f6 100644 --- a/esphome/components/esp32_ble/ble_advertising.h +++ b/esphome/components/esp32_ble/ble_advertising.h @@ -39,7 +39,6 @@ class BLEAdvertising { void set_service_data(const std::vector &data); void set_service_data(std::span data); void set_include_name(bool include_name) { this->include_name_in_adv_ = include_name; } - bool get_include_name() const { return this->include_name_in_adv_; } void register_raw_advertisement_callback(std::function &&callback); void start(); From aed6fa14f0fc7ab85ff8e484461ff7004cca5b50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 12:23:45 -0500 Subject: [PATCH 1478/1626] make_captive_portal_captive --- esphome/components/captive_portal/__init__.py | 10 + .../captive_portal/captive_portal.cpp | 49 ++-- .../captive_portal/captive_portal.h | 27 +- .../captive_portal/dns_server_esp32_idf.cpp | 232 ++++++++++++++++++ .../captive_portal/dns_server_esp32_idf.h | 29 +++ 5 files changed, 302 insertions(+), 45 deletions(-) create mode 100644 esphome/components/captive_portal/dns_server_esp32_idf.cpp create mode 100644 esphome/components/captive_portal/dns_server_esp32_idf.h diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 9f2af0a230..4e0c0d6093 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -9,6 +10,7 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_LN882X, PLATFORM_RTL87XX, + PlatformFramework, ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority @@ -58,3 +60,11 @@ async def to_code(config): cg.add_library("DNSServer", None) if CORE.is_libretiny: cg.add_library("DNSServer", None) + + +# Only compile the ESP-IDF DNS server when using ESP-IDF framework +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "dns_server_esp32_idf.cpp": {PlatformFramework.ESP32_IDF}, + } +) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 7eb0ffa99e..6873f8e93c 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -57,7 +57,7 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { void CaptivePortal::setup() { #ifndef USE_ARDUINO - // No DNS server needed for non-Arduino frameworks + // Disable loop for non-Arduino frameworks (DNS runs in its own task on ESP-IDF) this->disable_loop(); #endif } @@ -67,51 +67,46 @@ void CaptivePortal::start() { this->base_->add_handler(this); } + network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); + ESP_LOGI(TAG, "Starting captive portal on IP: %s", ip.str().c_str()); + +#ifdef USE_ESP_IDF + // Create DNS server instance for ESP-IDF + this->dns_server_ = make_unique(); + this->dns_server_->start(ip); +#endif #ifdef USE_ARDUINO this->dns_server_ = make_unique(); this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); - network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); this->dns_server_->start(53, F("*"), ip); // Re-enable loop() when DNS server is started this->enable_loop(); #endif - this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *req) { - if (!this->active_ || req->host().c_str() == wifi::global_wifi_component->wifi_soft_ap_ip().str()) { - req->send(404, F("text/html"), F("File not found")); - return; - } - -#ifdef USE_ESP8266 - String url = F("http://"); - url += wifi::global_wifi_component->wifi_soft_ap_ip().str().c_str(); -#else - auto url = "http://" + wifi::global_wifi_component->wifi_soft_ap_ip().str(); -#endif - req->redirect(url.c_str()); - }); - this->initialized_ = true; this->active_ = true; + ESP_LOGI(TAG, "Captive portal started"); } void CaptivePortal::handleRequest(AsyncWebServerRequest *req) { - if (req->url() == F("/")) { -#ifndef USE_ESP8266 - auto *response = req->beginResponse(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); -#else - auto *response = req->beginResponse_P(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); -#endif - response->addHeader(F("Content-Encoding"), F("gzip")); - req->send(response); - return; - } else if (req->url() == F("/config.json")) { + if (req->url() == F("/config.json")) { this->handle_config(req); return; } else if (req->url() == F("/wifisave")) { this->handle_wifisave(req); return; } + + // All other requests get the captive portal page + // This includes OS captive portal detection endpoints which will trigger + // the captive portal when they don't receive their expected responses +#ifndef USE_ESP8266 + auto *response = req->beginResponse(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); +#else + auto *response = req->beginResponse_P(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); +#endif + response->addHeader(F("Content-Encoding"), F("gzip")); + req->send(response); } CaptivePortal::CaptivePortal(web_server_base::WebServerBase *base) : base_(base) { global_captive_portal = this; } diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index 382afe92f0..705af8ab45 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -5,6 +5,9 @@ #ifdef USE_ARDUINO #include #endif +#ifdef USE_ESP_IDF +#include "dns_server_esp32_idf.h" +#endif #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" @@ -34,26 +37,14 @@ class CaptivePortal : public AsyncWebHandler, public Component { void end() { this->active_ = false; this->base_->deinit(); -#ifdef USE_ARDUINO - this->dns_server_->stop(); - this->dns_server_ = nullptr; -#endif + if (this->dns_server_ != nullptr) { + this->dns_server_->stop(); + this->dns_server_ = nullptr; + } } bool canHandle(AsyncWebServerRequest *request) const override { - if (!this->active_) - return false; - - if (request->method() == HTTP_GET) { - if (request->url() == F("/")) - return true; - if (request->url() == F("/config.json")) - return true; - if (request->url() == F("/wifisave")) - return true; - } - - return false; + return this->active_ && request->method() == HTTP_GET; } void handle_config(AsyncWebServerRequest *request); @@ -66,7 +57,7 @@ class CaptivePortal : public AsyncWebHandler, public Component { web_server_base::WebServerBase *base_; bool initialized_{false}; bool active_{false}; -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) || defined(USE_ESP_IDF) std::unique_ptr dns_server_{nullptr}; #endif }; diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/captive_portal/dns_server_esp32_idf.cpp new file mode 100644 index 0000000000..a32e268f20 --- /dev/null +++ b/esphome/components/captive_portal/dns_server_esp32_idf.cpp @@ -0,0 +1,232 @@ +#include "dns_server_esp32_idf.h" +#ifdef USE_ESP_IDF + +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include +#include +#include + +namespace esphome::captive_portal { + +static const char *const TAG = "captive_portal.dns"; + +// DNS constants +static constexpr uint16_t DNS_PORT = 53; +static constexpr uint16_t DNS_MAX_LEN = 256; +static constexpr uint16_t DNS_QR_FLAG = 1 << 15; +static constexpr uint16_t DNS_OPCODE_MASK = 0x7800; +static constexpr uint16_t DNS_QTYPE_A = 0x0001; +static constexpr uint16_t DNS_QCLASS_IN = 0x0001; +static constexpr uint16_t DNS_ANSWER_TTL = 300; +static constexpr size_t DNS_TASK_STACK_SIZE = 3072; + +// DNS Header structure +struct DNSHeader { + uint16_t id; + uint16_t flags; + uint16_t qd_count; + uint16_t an_count; + uint16_t ns_count; + uint16_t ar_count; +} __attribute__((packed)); + +// DNS Question structure +struct DNSQuestion { + uint16_t type; + uint16_t dns_class; +} __attribute__((packed)); + +// DNS Answer structure +struct DNSAnswer { + uint16_t ptr_offset; + uint16_t type; + uint16_t dns_class; + uint32_t ttl; + uint16_t addr_len; + uint32_t ip_addr; +} __attribute__((packed)); + +DNSServer::~DNSServer() { this->stop(); } + +void DNSServer::start(const network::IPAddress &ip) { + this->server_ip_ = ip; + ESP_LOGI(TAG, "Starting DNS server on %s", ip.str().c_str()); + + // Create socket + this->dns_socket_ = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (this->dns_socket_ < 0) { + ESP_LOGE(TAG, "Socket create failed: %d", errno); + return; + } + ESP_LOGD(TAG, "Socket created: %d", this->dns_socket_); + + // Set socket options + int enable = 1; + if (setsockopt(this->dns_socket_, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable)) < 0) { + ESP_LOGW(TAG, "SO_REUSEADDR failed: %d", errno); + } + + // Bind to port 53 + struct sockaddr_in server_addr = {}; + server_addr.sin_family = AF_INET; + server_addr.sin_addr.s_addr = htonl(INADDR_ANY); + server_addr.sin_port = htons(DNS_PORT); + + if (bind(this->dns_socket_, (struct sockaddr *) &server_addr, sizeof(server_addr)) < 0) { + ESP_LOGE(TAG, "Bind failed: %d", errno); + close(this->dns_socket_); + this->dns_socket_ = -1; + return; + } + ESP_LOGD(TAG, "Bound to port %d", DNS_PORT); + + // Create task + BaseType_t task_result = + xTaskCreate(&DNSServer::dns_server_task, "dns_server", DNS_TASK_STACK_SIZE, this, 1, &this->dns_task_handle_); + if (task_result != pdPASS) { + ESP_LOGE(TAG, "Task create failed"); + close(this->dns_socket_); + this->dns_socket_ = -1; + return; + } +} + +void DNSServer::stop() { + if (this->dns_task_handle_) { + vTaskDelete(this->dns_task_handle_); + this->dns_task_handle_ = nullptr; + } + + if (this->dns_socket_ >= 0) { + close(this->dns_socket_); + this->dns_socket_ = -1; + } + + ESP_LOGI(TAG, "Stopped"); +} + +void DNSServer::dns_server_task(void *pvParameters) { + DNSServer *server = static_cast(pvParameters); + ESP_LOGV(TAG, "Task started, socket: %d", server->dns_socket_); + + // Set socket timeout to prevent blocking forever + struct timeval timeout; + timeout.tv_sec = 1; + timeout.tv_usec = 0; + if (setsockopt(server->dns_socket_, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) < 0) { + ESP_LOGW(TAG, "SO_RCVTIMEO failed: %d", errno); + } + + while (true) { + server->process_dns_request(server->dns_socket_); + } +} + +void DNSServer::process_dns_request(int sock) { + struct sockaddr_in client_addr; + socklen_t client_addr_len = sizeof(client_addr); + uint8_t rx_buffer[DNS_MAX_LEN]; + uint8_t tx_buffer[DNS_MAX_LEN]; + + // Receive DNS request + int len = recvfrom(sock, rx_buffer, sizeof(rx_buffer), 0, (struct sockaddr *) &client_addr, &client_addr_len); + + if (len < 0) { + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + ESP_LOGE(TAG, "recvfrom failed: %d", errno); + } + return; + } + + ESP_LOGVV(TAG, "Received %d bytes from %s:%d", len, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); + + if (len < sizeof(DNSHeader) + 1) { + ESP_LOGW(TAG, "Request too short: %d", len); + return; + } + + // Parse DNS header + DNSHeader *header = (DNSHeader *) rx_buffer; + uint16_t flags = ntohs(header->flags); + uint16_t qd_count = ntohs(header->qd_count); + + // Check if it's a standard query + if ((flags & DNS_QR_FLAG) || (flags & DNS_OPCODE_MASK) || qd_count != 1) { + ESP_LOGV(TAG, "Not a standard query: flags=0x%04X, qd_count=%d", flags, qd_count); + return; // Not a standard query + } + + // Parse domain name (we don't actually care about it - redirect everything) + uint8_t *ptr = rx_buffer + sizeof(DNSHeader); + uint8_t *name_start = ptr; + while (*ptr != 0 && ptr < (rx_buffer + len)) { + if (*ptr > 63) { // Check for invalid label length + return; + } + ptr += *ptr + 1; + } + + if (*ptr != 0) { + return; // Name not terminated + } + ptr++; // Skip the null terminator + + // Check we have room for the question + if (ptr + sizeof(DNSQuestion) > rx_buffer + len) { + return; // Request truncated + } + + // Parse DNS question + DNSQuestion *question = (DNSQuestion *) ptr; + uint16_t qtype = ntohs(question->type); + uint16_t qclass = ntohs(question->dns_class); + + // We only handle A queries + if (qtype != DNS_QTYPE_A || qclass != DNS_QCLASS_IN) { + ESP_LOGV(TAG, "Not an A query: type=0x%04X, class=0x%04X", qtype, qclass); + return; // Not an A query + } + + // Build DNS response + memset(tx_buffer, 0, sizeof(tx_buffer)); + + // Copy request header and modify flags + memcpy(tx_buffer, rx_buffer, sizeof(DNSHeader)); + DNSHeader *response_header = (DNSHeader *) tx_buffer; + response_header->flags = htons(DNS_QR_FLAG | 0x8000); // Response + Authoritative + response_header->an_count = htons(1); // One answer + + // Copy the question section + size_t question_len = (ptr + sizeof(DNSQuestion)) - rx_buffer - sizeof(DNSHeader); + memcpy(tx_buffer + sizeof(DNSHeader), rx_buffer + sizeof(DNSHeader), question_len); + + // Add answer section + size_t answer_offset = sizeof(DNSHeader) + question_len; + DNSAnswer *answer = (DNSAnswer *) (tx_buffer + answer_offset); + + // Pointer to name in question (offset from start of packet) + answer->ptr_offset = htons(0xC000 | sizeof(DNSHeader)); + answer->type = htons(DNS_QTYPE_A); + answer->dns_class = htons(DNS_QCLASS_IN); + answer->ttl = htonl(DNS_ANSWER_TTL); + answer->addr_len = htons(4); + + // Get the raw IP address + ip4_addr_t addr = this->server_ip_; + answer->ip_addr = addr.addr; + + size_t response_len = answer_offset + sizeof(DNSAnswer); + + // Send response + int sent = sendto(sock, tx_buffer, response_len, 0, (struct sockaddr *) &client_addr, client_addr_len); + if (sent < 0) { + ESP_LOGV(TAG, "Send failed: %d", errno); + } else { + ESP_LOGV(TAG, "Sent %d bytes", sent); + } +} + +} // namespace esphome::captive_portal + +#endif // USE_ESP_IDF diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/captive_portal/dns_server_esp32_idf.h new file mode 100644 index 0000000000..7cbf4490ed --- /dev/null +++ b/esphome/components/captive_portal/dns_server_esp32_idf.h @@ -0,0 +1,29 @@ +#pragma once +#ifdef USE_ESP_IDF + +#include "esphome/core/helpers.h" +#include "esphome/components/network/ip_address.h" +#include +#include + +namespace esphome::captive_portal { + +class DNSServer { + public: + ~DNSServer(); + + void start(const network::IPAddress &ip); + void stop(); + + protected: + static void dns_server_task(void *pvParameters); + void process_dns_request(int sock); + + TaskHandle_t dns_task_handle_{nullptr}; + int dns_socket_{-1}; + network::IPAddress server_ip_; +}; + +} // namespace esphome::captive_portal + +#endif // USE_ESP_IDF From 6b72736d5e3d74cfe8d4b1c258c0c1ba6736c31f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 12:32:24 -0500 Subject: [PATCH 1479/1626] wip --- .../captive_portal/captive_portal.cpp | 2 +- .../captive_portal/captive_portal.h | 3 + .../captive_portal/dns_server_esp32_idf.cpp | 63 ++++++++++--------- .../captive_portal/dns_server_esp32_idf.h | 2 - 4 files changed, 37 insertions(+), 33 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 6873f8e93c..d5c820930a 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -85,7 +85,7 @@ void CaptivePortal::start() { this->initialized_ = true; this->active_ = true; - ESP_LOGI(TAG, "Captive portal started"); + ESP_LOGV(TAG, "Captive portal started"); } void CaptivePortal::handleRequest(AsyncWebServerRequest *req) { diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index 705af8ab45..f3d40ecae8 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -44,6 +44,9 @@ class CaptivePortal : public AsyncWebHandler, public Component { } bool canHandle(AsyncWebServerRequest *request) const override { + // Handle all GET requests when captive portal is active + // This allows us to respond with the portal page for any URL, + // triggering OS captive portal detection return this->active_ && request->method() == HTTP_GET; } diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/captive_portal/dns_server_esp32_idf.cpp index a32e268f20..503990637a 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/captive_portal/dns_server_esp32_idf.cpp @@ -47,8 +47,6 @@ struct DNSAnswer { uint32_t ip_addr; } __attribute__((packed)); -DNSServer::~DNSServer() { this->stop(); } - void DNSServer::start(const network::IPAddress &ip) { this->server_ip_ = ip; ESP_LOGI(TAG, "Starting DNS server on %s", ip.str().c_str()); @@ -103,7 +101,7 @@ void DNSServer::stop() { this->dns_socket_ = -1; } - ESP_LOGI(TAG, "Stopped"); + ESP_LOGV(TAG, "Stopped"); } void DNSServer::dns_server_task(void *pvParameters) { @@ -126,11 +124,10 @@ void DNSServer::dns_server_task(void *pvParameters) { void DNSServer::process_dns_request(int sock) { struct sockaddr_in client_addr; socklen_t client_addr_len = sizeof(client_addr); - uint8_t rx_buffer[DNS_MAX_LEN]; - uint8_t tx_buffer[DNS_MAX_LEN]; + uint8_t buffer[DNS_MAX_LEN]; // Receive DNS request - int len = recvfrom(sock, rx_buffer, sizeof(rx_buffer), 0, (struct sockaddr *) &client_addr, &client_addr_len); + int len = recvfrom(sock, buffer, sizeof(buffer), 0, (struct sockaddr *) &client_addr, &client_addr_len); if (len < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { @@ -147,7 +144,7 @@ void DNSServer::process_dns_request(int sock) { } // Parse DNS header - DNSHeader *header = (DNSHeader *) rx_buffer; + DNSHeader *header = (DNSHeader *) buffer; uint16_t flags = ntohs(header->flags); uint16_t qd_count = ntohs(header->qd_count); @@ -158,22 +155,29 @@ void DNSServer::process_dns_request(int sock) { } // Parse domain name (we don't actually care about it - redirect everything) - uint8_t *ptr = rx_buffer + sizeof(DNSHeader); - uint8_t *name_start = ptr; - while (*ptr != 0 && ptr < (rx_buffer + len)) { - if (*ptr > 63) { // Check for invalid label length + uint8_t *ptr = buffer + sizeof(DNSHeader); + uint8_t *end = buffer + len; + + while (ptr < end && *ptr != 0) { + uint8_t label_len = *ptr; + if (label_len > 63) { // Check for invalid label length return; } - ptr += *ptr + 1; + // Check if we have room for this label plus the length byte + if (ptr + label_len + 1 > end) { + return; // Would overflow + } + ptr += label_len + 1; } - if (*ptr != 0) { - return; // Name not terminated + // Check if we reached a proper null terminator + if (ptr >= end || *ptr != 0) { + return; // Name not terminated or truncated } ptr++; // Skip the null terminator // Check we have room for the question - if (ptr + sizeof(DNSQuestion) > rx_buffer + len) { + if (ptr + sizeof(DNSQuestion) > end) { return; // Request truncated } @@ -188,22 +192,21 @@ void DNSServer::process_dns_request(int sock) { return; // Not an A query } - // Build DNS response - memset(tx_buffer, 0, sizeof(tx_buffer)); + // Build DNS response by modifying the request in-place + header->flags = htons(DNS_QR_FLAG | 0x8000); // Response + Authoritative + header->an_count = htons(1); // One answer - // Copy request header and modify flags - memcpy(tx_buffer, rx_buffer, sizeof(DNSHeader)); - DNSHeader *response_header = (DNSHeader *) tx_buffer; - response_header->flags = htons(DNS_QR_FLAG | 0x8000); // Response + Authoritative - response_header->an_count = htons(1); // One answer - - // Copy the question section - size_t question_len = (ptr + sizeof(DNSQuestion)) - rx_buffer - sizeof(DNSHeader); - memcpy(tx_buffer + sizeof(DNSHeader), rx_buffer + sizeof(DNSHeader), question_len); - - // Add answer section + // Add answer section after the question + size_t question_len = (ptr + sizeof(DNSQuestion)) - buffer - sizeof(DNSHeader); size_t answer_offset = sizeof(DNSHeader) + question_len; - DNSAnswer *answer = (DNSAnswer *) (tx_buffer + answer_offset); + + // Check if we have room for the answer + if (answer_offset + sizeof(DNSAnswer) > sizeof(buffer)) { + ESP_LOGW(TAG, "Response too large"); + return; + } + + DNSAnswer *answer = (DNSAnswer *) (buffer + answer_offset); // Pointer to name in question (offset from start of packet) answer->ptr_offset = htons(0xC000 | sizeof(DNSHeader)); @@ -219,7 +222,7 @@ void DNSServer::process_dns_request(int sock) { size_t response_len = answer_offset + sizeof(DNSAnswer); // Send response - int sent = sendto(sock, tx_buffer, response_len, 0, (struct sockaddr *) &client_addr, client_addr_len); + int sent = sendto(sock, buffer, response_len, 0, (struct sockaddr *) &client_addr, client_addr_len); if (sent < 0) { ESP_LOGV(TAG, "Send failed: %d", errno); } else { diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/captive_portal/dns_server_esp32_idf.h index 7cbf4490ed..87b5c76e44 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.h +++ b/esphome/components/captive_portal/dns_server_esp32_idf.h @@ -10,8 +10,6 @@ namespace esphome::captive_portal { class DNSServer { public: - ~DNSServer(); - void start(const network::IPAddress &ip); void stop(); From 0356081961e53dbd4ab9455f165d6693305278ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 12:47:00 -0500 Subject: [PATCH 1480/1626] make it captive --- .../captive_portal/captive_portal.cpp | 10 +- .../captive_portal/captive_portal.h | 12 ++- .../captive_portal/dns_server_esp32_idf.cpp | 95 +++++++------------ .../captive_portal/dns_server_esp32_idf.h | 12 +-- 4 files changed, 54 insertions(+), 75 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index d5c820930a..daf66b6e12 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -56,10 +56,8 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { } void CaptivePortal::setup() { -#ifndef USE_ARDUINO - // Disable loop for non-Arduino frameworks (DNS runs in its own task on ESP-IDF) + // Disable loop by default - will be enabled when captive portal starts this->disable_loop(); -#endif } void CaptivePortal::start() { this->base_->init(); @@ -79,12 +77,14 @@ void CaptivePortal::start() { this->dns_server_ = make_unique(); this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); this->dns_server_->start(53, F("*"), ip); - // Re-enable loop() when DNS server is started - this->enable_loop(); #endif this->initialized_ = true; this->active_ = true; + + // Enable loop() now that captive portal is active + this->enable_loop(); + ESP_LOGV(TAG, "Captive portal started"); } diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index f3d40ecae8..f48c286f0c 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -22,20 +22,24 @@ class CaptivePortal : public AsyncWebHandler, public Component { CaptivePortal(web_server_base::WebServerBase *base); void setup() override; void dump_config() override; -#ifdef USE_ARDUINO void loop() override { +#ifdef USE_ARDUINO if (this->dns_server_ != nullptr) { this->dns_server_->processNextRequest(); - } else { - this->disable_loop(); } - } #endif +#ifdef USE_ESP_IDF + if (this->dns_server_ != nullptr) { + this->dns_server_->process_next_request(); + } +#endif + } float get_setup_priority() const override; void start(); bool is_active() const { return this->active_; } void end() { this->active_ = false; + this->disable_loop(); // Stop processing DNS requests this->base_->deinit(); if (this->dns_server_ != nullptr) { this->dns_server_->stop(); diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/captive_portal/dns_server_esp32_idf.cpp index 503990637a..e60cbc851e 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/captive_portal/dns_server_esp32_idf.cpp @@ -3,8 +3,8 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/components/socket/socket.h" #include -#include #include namespace esphome::captive_portal { @@ -51,83 +51,57 @@ void DNSServer::start(const network::IPAddress &ip) { this->server_ip_ = ip; ESP_LOGI(TAG, "Starting DNS server on %s", ip.str().c_str()); - // Create socket - this->dns_socket_ = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); - if (this->dns_socket_ < 0) { - ESP_LOGE(TAG, "Socket create failed: %d", errno); + // Create loop-monitored UDP socket + this->socket_ = socket::socket_ip_loop_monitored(SOCK_DGRAM, IPPROTO_UDP); + if (this->socket_ == nullptr) { + ESP_LOGE(TAG, "Socket create failed"); return; } - ESP_LOGD(TAG, "Socket created: %d", this->dns_socket_); // Set socket options int enable = 1; - if (setsockopt(this->dns_socket_, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable)) < 0) { - ESP_LOGW(TAG, "SO_REUSEADDR failed: %d", errno); - } + this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable)); // Bind to port 53 - struct sockaddr_in server_addr = {}; - server_addr.sin_family = AF_INET; - server_addr.sin_addr.s_addr = htonl(INADDR_ANY); - server_addr.sin_port = htons(DNS_PORT); + struct sockaddr_storage server_addr = {}; + socklen_t addr_len = socket::set_sockaddr_any((struct sockaddr *) &server_addr, sizeof(server_addr), DNS_PORT); - if (bind(this->dns_socket_, (struct sockaddr *) &server_addr, sizeof(server_addr)) < 0) { + int err = this->socket_->bind((struct sockaddr *) &server_addr, addr_len); + if (err != 0) { ESP_LOGE(TAG, "Bind failed: %d", errno); - close(this->dns_socket_); - this->dns_socket_ = -1; + this->socket_ = nullptr; return; } ESP_LOGD(TAG, "Bound to port %d", DNS_PORT); - - // Create task - BaseType_t task_result = - xTaskCreate(&DNSServer::dns_server_task, "dns_server", DNS_TASK_STACK_SIZE, this, 1, &this->dns_task_handle_); - if (task_result != pdPASS) { - ESP_LOGE(TAG, "Task create failed"); - close(this->dns_socket_); - this->dns_socket_ = -1; - return; - } } void DNSServer::stop() { - if (this->dns_task_handle_) { - vTaskDelete(this->dns_task_handle_); - this->dns_task_handle_ = nullptr; + if (this->socket_ != nullptr) { + this->socket_->close(); + this->socket_ = nullptr; } - - if (this->dns_socket_ >= 0) { - close(this->dns_socket_); - this->dns_socket_ = -1; - } - ESP_LOGV(TAG, "Stopped"); } -void DNSServer::dns_server_task(void *pvParameters) { - DNSServer *server = static_cast(pvParameters); - ESP_LOGV(TAG, "Task started, socket: %d", server->dns_socket_); - - // Set socket timeout to prevent blocking forever - struct timeval timeout; - timeout.tv_sec = 1; - timeout.tv_usec = 0; - if (setsockopt(server->dns_socket_, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) < 0) { - ESP_LOGW(TAG, "SO_RCVTIMEO failed: %d", errno); - } - - while (true) { - server->process_dns_request(server->dns_socket_); +void DNSServer::process_next_request() { + // Process one request if socket is valid and data is available + if (this->socket_ != nullptr && this->socket_->ready()) { + this->process_dns_request(); } } -void DNSServer::process_dns_request(int sock) { +void DNSServer::process_dns_request() { struct sockaddr_in client_addr; socklen_t client_addr_len = sizeof(client_addr); - uint8_t buffer[DNS_MAX_LEN]; - // Receive DNS request - int len = recvfrom(sock, buffer, sizeof(buffer), 0, (struct sockaddr *) &client_addr, &client_addr_len); + // Receive DNS request using raw fd for recvfrom + int fd = this->socket_->get_fd(); + if (fd < 0) { + return; + } + + ssize_t len = recvfrom(fd, this->buffer_, sizeof(this->buffer_), MSG_DONTWAIT, (struct sockaddr *) &client_addr, + &client_addr_len); if (len < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { @@ -144,7 +118,7 @@ void DNSServer::process_dns_request(int sock) { } // Parse DNS header - DNSHeader *header = (DNSHeader *) buffer; + DNSHeader *header = (DNSHeader *) this->buffer_; uint16_t flags = ntohs(header->flags); uint16_t qd_count = ntohs(header->qd_count); @@ -155,8 +129,8 @@ void DNSServer::process_dns_request(int sock) { } // Parse domain name (we don't actually care about it - redirect everything) - uint8_t *ptr = buffer + sizeof(DNSHeader); - uint8_t *end = buffer + len; + uint8_t *ptr = this->buffer_ + sizeof(DNSHeader); + uint8_t *end = this->buffer_ + len; while (ptr < end && *ptr != 0) { uint8_t label_len = *ptr; @@ -197,16 +171,16 @@ void DNSServer::process_dns_request(int sock) { header->an_count = htons(1); // One answer // Add answer section after the question - size_t question_len = (ptr + sizeof(DNSQuestion)) - buffer - sizeof(DNSHeader); + size_t question_len = (ptr + sizeof(DNSQuestion)) - this->buffer_ - sizeof(DNSHeader); size_t answer_offset = sizeof(DNSHeader) + question_len; // Check if we have room for the answer - if (answer_offset + sizeof(DNSAnswer) > sizeof(buffer)) { + if (answer_offset + sizeof(DNSAnswer) > sizeof(this->buffer_)) { ESP_LOGW(TAG, "Response too large"); return; } - DNSAnswer *answer = (DNSAnswer *) (buffer + answer_offset); + DNSAnswer *answer = (DNSAnswer *) (this->buffer_ + answer_offset); // Pointer to name in question (offset from start of packet) answer->ptr_offset = htons(0xC000 | sizeof(DNSHeader)); @@ -222,7 +196,8 @@ void DNSServer::process_dns_request(int sock) { size_t response_len = answer_offset + sizeof(DNSAnswer); // Send response - int sent = sendto(sock, buffer, response_len, 0, (struct sockaddr *) &client_addr, client_addr_len); + ssize_t sent = + this->socket_->sendto(this->buffer_, response_len, 0, (struct sockaddr *) &client_addr, client_addr_len); if (sent < 0) { ESP_LOGV(TAG, "Send failed: %d", errno); } else { diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/captive_portal/dns_server_esp32_idf.h index 87b5c76e44..cec039b332 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.h +++ b/esphome/components/captive_portal/dns_server_esp32_idf.h @@ -1,10 +1,10 @@ #pragma once #ifdef USE_ESP_IDF +#include #include "esphome/core/helpers.h" #include "esphome/components/network/ip_address.h" -#include -#include +#include "esphome/components/socket/socket.h" namespace esphome::captive_portal { @@ -12,14 +12,14 @@ class DNSServer { public: void start(const network::IPAddress &ip); void stop(); + void process_next_request(); protected: - static void dns_server_task(void *pvParameters); - void process_dns_request(int sock); + void process_dns_request(); - TaskHandle_t dns_task_handle_{nullptr}; - int dns_socket_{-1}; + std::unique_ptr socket_{nullptr}; network::IPAddress server_ip_; + uint8_t buffer_[256]; // DNS_MAX_LEN }; } // namespace esphome::captive_portal From 29943bfef195fa6a2d033c6f8bd674677430a3ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 12:48:09 -0500 Subject: [PATCH 1481/1626] preen --- esphome/components/captive_portal/dns_server_esp32_idf.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/captive_portal/dns_server_esp32_idf.h index cec039b332..9837eca1ae 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.h +++ b/esphome/components/captive_portal/dns_server_esp32_idf.h @@ -15,11 +15,13 @@ class DNSServer { void process_next_request(); protected: + static constexpr size_t DNS_BUFFER_SIZE = 256; + void process_dns_request(); std::unique_ptr socket_{nullptr}; network::IPAddress server_ip_; - uint8_t buffer_[256]; // DNS_MAX_LEN + uint8_t buffer_[DNS_BUFFER_SIZE]; }; } // namespace esphome::captive_portal From 72c1830b9b4382f31c4f7d38e794efafd00b26ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 12:49:08 -0500 Subject: [PATCH 1482/1626] preen --- esphome/components/captive_portal/dns_server_esp32_idf.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/captive_portal/dns_server_esp32_idf.cpp index e60cbc851e..1e93c0abd0 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/captive_portal/dns_server_esp32_idf.cpp @@ -13,13 +13,11 @@ static const char *const TAG = "captive_portal.dns"; // DNS constants static constexpr uint16_t DNS_PORT = 53; -static constexpr uint16_t DNS_MAX_LEN = 256; static constexpr uint16_t DNS_QR_FLAG = 1 << 15; static constexpr uint16_t DNS_OPCODE_MASK = 0x7800; static constexpr uint16_t DNS_QTYPE_A = 0x0001; static constexpr uint16_t DNS_QCLASS_IN = 0x0001; static constexpr uint16_t DNS_ANSWER_TTL = 300; -static constexpr size_t DNS_TASK_STACK_SIZE = 3072; // DNS Header structure struct DNSHeader { From cf650708d20f36d6319eed4b24ab58f6a3174d80 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:05:42 -0500 Subject: [PATCH 1483/1626] preen --- esphome/components/captive_portal/dns_server_esp32_idf.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/captive_portal/dns_server_esp32_idf.cpp index 1e93c0abd0..80131e4ad7 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/captive_portal/dns_server_esp32_idf.cpp @@ -47,7 +47,7 @@ struct DNSAnswer { void DNSServer::start(const network::IPAddress &ip) { this->server_ip_ = ip; - ESP_LOGI(TAG, "Starting DNS server on %s", ip.str().c_str()); + ESP_LOGV(TAG, "Starting DNS server on %s", ip.str().c_str()); // Create loop-monitored UDP socket this->socket_ = socket::socket_ip_loop_monitored(SOCK_DGRAM, IPPROTO_UDP); @@ -70,7 +70,7 @@ void DNSServer::start(const network::IPAddress &ip) { this->socket_ = nullptr; return; } - ESP_LOGD(TAG, "Bound to port %d", DNS_PORT); + ESP_LOGV(TAG, "Bound to port %d", DNS_PORT); } void DNSServer::stop() { @@ -111,7 +111,7 @@ void DNSServer::process_dns_request() { ESP_LOGVV(TAG, "Received %d bytes from %s:%d", len, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); if (len < sizeof(DNSHeader) + 1) { - ESP_LOGW(TAG, "Request too short: %d", len); + ESP_LOGV(TAG, "Request too short: %d", len); return; } From 3ab362214b20e865785030786a247fb4537c11c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:09:06 -0500 Subject: [PATCH 1484/1626] preen --- esphome/components/captive_portal/dns_server_esp32_idf.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/captive_portal/dns_server_esp32_idf.h index 9837eca1ae..1d8ca260a7 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.h +++ b/esphome/components/captive_portal/dns_server_esp32_idf.h @@ -15,7 +15,7 @@ class DNSServer { void process_next_request(); protected: - static constexpr size_t DNS_BUFFER_SIZE = 256; + static constexpr size_t DNS_BUFFER_SIZE = 128; void process_dns_request(); From cba69e6a36a7745bd27011afbe3fec78d11b3dc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:09:09 -0500 Subject: [PATCH 1485/1626] preen --- esphome/components/captive_portal/dns_server_esp32_idf.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/captive_portal/dns_server_esp32_idf.h index 1d8ca260a7..05efd359f4 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.h +++ b/esphome/components/captive_portal/dns_server_esp32_idf.h @@ -15,7 +15,7 @@ class DNSServer { void process_next_request(); protected: - static constexpr size_t DNS_BUFFER_SIZE = 128; + static constexpr size_t DNS_BUFFER_SIZE = 192; void process_dns_request(); From 89f41833d8b862c085c1c4f49a4a3d1507330b21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:11:50 -0500 Subject: [PATCH 1486/1626] remove debugging --- esphome/components/captive_portal/captive_portal.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index daf66b6e12..97ae83ed05 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -66,7 +66,6 @@ void CaptivePortal::start() { } network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); - ESP_LOGI(TAG, "Starting captive portal on IP: %s", ip.str().c_str()); #ifdef USE_ESP_IDF // Create DNS server instance for ESP-IDF From e5908389aa3d8d8784009303e60dad296f2cb0b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:19:23 -0500 Subject: [PATCH 1487/1626] tidy --- esphome/components/captive_portal/dns_server_esp32_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/captive_portal/dns_server_esp32_idf.cpp index 80131e4ad7..2327648284 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/captive_portal/dns_server_esp32_idf.cpp @@ -110,7 +110,7 @@ void DNSServer::process_dns_request() { ESP_LOGVV(TAG, "Received %d bytes from %s:%d", len, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); - if (len < sizeof(DNSHeader) + 1) { + if (len < static_cast(sizeof(DNSHeader) + 1)) { ESP_LOGV(TAG, "Request too short: %d", len); return; } From d66fd678c2092bca185da6369bf8a86139615b94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:20:58 -0500 Subject: [PATCH 1488/1626] simple --- esphome/components/captive_portal/dns_server_esp32_idf.cpp | 7 ++----- esphome/components/captive_portal/dns_server_esp32_idf.h | 2 -- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/captive_portal/dns_server_esp32_idf.cpp index 2327648284..740107400a 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/captive_portal/dns_server_esp32_idf.cpp @@ -83,12 +83,9 @@ void DNSServer::stop() { void DNSServer::process_next_request() { // Process one request if socket is valid and data is available - if (this->socket_ != nullptr && this->socket_->ready()) { - this->process_dns_request(); + if (this->socket_ == nullptr || !this->socket_->ready()) { + return; } -} - -void DNSServer::process_dns_request() { struct sockaddr_in client_addr; socklen_t client_addr_len = sizeof(client_addr); diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/captive_portal/dns_server_esp32_idf.h index 05efd359f4..13d9def8e3 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.h +++ b/esphome/components/captive_portal/dns_server_esp32_idf.h @@ -17,8 +17,6 @@ class DNSServer { protected: static constexpr size_t DNS_BUFFER_SIZE = 192; - void process_dns_request(); - std::unique_ptr socket_{nullptr}; network::IPAddress server_ip_; uint8_t buffer_[DNS_BUFFER_SIZE]; From edea7c18ba4110a1b66ee6f53a97dcfd51f2bfcf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:32:41 -0500 Subject: [PATCH 1489/1626] fix existing code tidy is comlpaining about --- esphome/components/captive_portal/captive_portal.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 97ae83ed05..17ee53a7a0 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -45,8 +45,8 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { request->send(stream); } void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { - std::string ssid = request->arg("ssid").c_str(); - std::string psk = request->arg("psk").c_str(); + std::string ssid = request->arg("ssid"); + std::string psk = request->arg("psk"); ESP_LOGI(TAG, "Requested WiFi Settings Change:"); ESP_LOGI(TAG, " SSID='%s'", ssid.c_str()); ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); From 76129446164508ec26efb25166b37266871534d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:35:49 -0500 Subject: [PATCH 1490/1626] tidy, i ts needed for arudino --- esphome/components/captive_portal/captive_portal.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 17ee53a7a0..20abc6506d 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -45,8 +45,8 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { request->send(stream); } void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { - std::string ssid = request->arg("ssid"); - std::string psk = request->arg("psk"); + std::string ssid = request->arg("ssid").c_str(); // NOLINT(readability-redundant-string-cstr) + std::string psk = request->arg("psk").c_str(); // NOLINT(readability-redundant-string-cstr) ESP_LOGI(TAG, "Requested WiFi Settings Change:"); ESP_LOGI(TAG, " SSID='%s'", ssid.c_str()); ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); From c17e38e58f70fc116ed6e2bd313ff8eada07cb54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:39:03 -0500 Subject: [PATCH 1491/1626] order matters --- esphome/components/esp32_ble/ble.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 2108ded472..64cef70de2 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -81,8 +81,17 @@ void ESP32BLE::advertising_set_service_data_and_name(std::span da // when changing both properties, avoiding the brief gap that would occur with separate calls. this->advertising_init_(); - this->advertising_->set_service_data(data); - this->advertising_->set_include_name(include_name); + + if (include_name) { + // When including name, clear service data first to avoid packet overflow + this->advertising_->set_service_data(std::span{}); + this->advertising_->set_include_name(true); + } else { + // When including service data, clear name first to avoid packet overflow + this->advertising_->set_include_name(false); + this->advertising_->set_service_data(data); + } + this->advertising_start(); } From 7e4cfe369d701ce3321b95735a9ed6d1ed27d8c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:45:12 -0500 Subject: [PATCH 1492/1626] make bot happy --- .../esp32_improv/esp32_improv_component.cpp | 39 +++++++++++-------- .../esp32_improv/esp32_improv_component.h | 1 + 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 1c3ea538f3..6f193c0c51 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -20,6 +20,11 @@ static constexpr uint16_t STOP_ADVERTISING_DELAY = static constexpr uint16_t NAME_ADVERTISING_INTERVAL = 60000; // Advertise name every 60 seconds static constexpr uint16_t NAME_ADVERTISING_DURATION = 1000; // Advertise name for 1 second +// Improv service data constants +static constexpr uint8_t IMPROV_SERVICE_DATA_SIZE = 8; +static constexpr uint8_t IMPROV_PROTOCOL_ID_1 = 0x77; // 'P' << 1 | 'R' >> 7 +static constexpr uint8_t IMPROV_PROTOCOL_ID_2 = 0x46; // 'I' << 1 | 'M' >> 7 + ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } void ESP32ImprovComponent::setup() { @@ -122,14 +127,7 @@ void ESP32ImprovComponent::loop() { esp32_ble::global_ble->advertising_start(); // Set initial state based on whether we have an authorizer - // authorizer_ member only exists when USE_BINARY_SENSOR is defined -#ifdef USE_BINARY_SENSOR - this->set_state_( - this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION, false); -#else - // No binary_sensor support = no authorizer possible, start as authorized - this->set_state_(improv::STATE_AUTHORIZED, false); -#endif + this->set_state_(this->get_initial_state_(), false); this->set_error_(improv::ERROR_NONE); ESP_LOGD(TAG, "Service started!"); } @@ -140,14 +138,13 @@ void ESP32ImprovComponent::loop() { if (this->authorizer_ == nullptr || (this->authorized_start_ != 0 && ((now - this->authorized_start_) < this->authorized_duration_))) { this->set_state_(improv::STATE_AUTHORIZED); - } else -#else - { this->set_state_(improv::STATE_AUTHORIZED); } -#endif - { + } else { if (!this->check_identify_()) this->set_status_indicator_state_(true); } +#else + this->set_state_(improv::STATE_AUTHORIZED); +#endif break; } case improv::STATE_AUTHORIZED: { @@ -400,9 +397,9 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() { } void ESP32ImprovComponent::advertise_service_data_() { - uint8_t service_data[8] = {}; - service_data[0] = 0x77; // PR - service_data[1] = 0x46; // IM + uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {}; + service_data[0] = IMPROV_PROTOCOL_ID_1; // PR + service_data[1] = IMPROV_PROTOCOL_ID_2; // IM service_data[2] = static_cast(this->state_); uint8_t capabilities = 0x00; @@ -443,6 +440,16 @@ void ESP32ImprovComponent::update_advertising_type_() { } } +improv::State ESP32ImprovComponent::get_initial_state_() const { +#ifdef USE_BINARY_SENSOR + // If we have an authorizer, start in awaiting authorization state + return this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION; +#else + // No binary_sensor support = no authorizer possible, start as authorized + return improv::STATE_AUTHORIZED; +#endif +} + ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esp32_improv diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index ea51f64d4b..eb07e09dce 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -107,6 +107,7 @@ class ESP32ImprovComponent : public Component { void set_state_(improv::State state, bool update_advertising = true); void set_error_(improv::Error error); + improv::State get_initial_state_() const; void send_response_(std::vector &response); void process_incoming_data_(); void on_wifi_connect_timeout_(); From f387e7690c949557baf3e511a53c030525567336 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:46:12 -0500 Subject: [PATCH 1493/1626] nesting --- .../components/esp32_improv/esp32_improv_component.cpp | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 6f193c0c51..ca08ff0cca 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -149,12 +149,10 @@ void ESP32ImprovComponent::loop() { } case improv::STATE_AUTHORIZED: { #ifdef USE_BINARY_SENSOR - if (this->authorizer_ != nullptr) { - if (now - this->authorized_start_ > this->authorized_duration_) { - ESP_LOGD(TAG, "Authorization timeout"); - this->set_state_(improv::STATE_AWAITING_AUTHORIZATION); - return; - } + if (this->authorizer_ != nullptr && now - this->authorized_start_ > this->authorized_duration_) { + ESP_LOGD(TAG, "Authorization timeout"); + this->set_state_(improv::STATE_AWAITING_AUTHORIZATION); + return; } #endif if (!this->check_identify_()) { From f6cc548d19b448a43bb6494abefc923b7b71e86e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:52:38 -0500 Subject: [PATCH 1494/1626] fix auto load --- esphome/components/captive_portal/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 4e0c0d6093..69db605ccc 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -15,7 +15,14 @@ from esphome.const import ( from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority -AUTO_LOAD = ["web_server_base", "ota.web_server"] + +def AUTO_LOAD(): + auto_load = ["web_server_base", "ota.web_server"] + if CORE.using_esp_idf: + auto_load.append("socket") + return auto_load + + DEPENDENCIES = ["wifi"] CODEOWNERS = ["@esphome/core"] From ddd004985b12245e516281d71552c6916a151afd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 13:52:49 -0500 Subject: [PATCH 1495/1626] fix auto load --- esphome/components/captive_portal/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 69db605ccc..99acb76bcf 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -16,7 +16,7 @@ from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority -def AUTO_LOAD(): +def AUTO_LOAD() -> list[str]: auto_load = ["web_server_base", "ota.web_server"] if CORE.using_esp_idf: auto_load.append("socket") From 2bf79a607f4695848a9c7cff795161f29b5bd384 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Sep 2025 15:55:14 -0400 Subject: [PATCH 1496/1626] [sx126x] Fix issues with variable length FSK packets (#10911) --- esphome/components/sx126x/sx126x.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index cae047d168..f5393c478a 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -217,7 +217,7 @@ void SX126x::configure() { this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 4); // set packet params and sync word - this->set_packet_params_(this->payload_length_); + this->set_packet_params_(this->get_max_packet_size()); if (this->sync_value_.size() == 2) { this->write_register_(REG_LORA_SYNCWORD, this->sync_value_.data(), this->sync_value_.size()); } @@ -236,7 +236,7 @@ void SX126x::configure() { this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 8); // set packet params and sync word - this->set_packet_params_(this->payload_length_); + this->set_packet_params_(this->get_max_packet_size()); if (!this->sync_value_.empty()) { this->write_register_(REG_GFSK_SYNCWORD, this->sync_value_.data(), this->sync_value_.size()); } @@ -274,7 +274,7 @@ void SX126x::set_packet_params_(uint8_t payload_length) { buf[2] = (this->preamble_detect_ > 0) ? ((this->preamble_detect_ - 1) | 0x04) : 0x00; buf[3] = this->sync_value_.size() * 8; buf[4] = 0x00; - buf[5] = 0x00; + buf[5] = (this->payload_length_ > 0) ? 0x00 : 0x01; buf[6] = payload_length; buf[7] = this->crc_enable_ ? 0x06 : 0x01; buf[8] = 0x00; @@ -314,6 +314,9 @@ SX126xError SX126x::transmit_packet(const std::vector &packet) { buf[0] = 0xFF; buf[1] = 0xFF; this->write_opcode_(RADIO_CLR_IRQSTATUS, buf, 2); + if (this->payload_length_ == 0) { + this->set_packet_params_(this->get_max_packet_size()); + } if (this->rx_start_) { this->set_mode_rx(); } else { From 9dd6be4061c8b6a47362d42debb66050bb35769c Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 27 Sep 2025 17:50:18 -0500 Subject: [PATCH 1497/1626] [zwave_proxy, api] Add notification message when Z-Wave HomeID changes (#10860) --- esphome/components/api/api.proto | 4 ++- esphome/components/api/api_pb2.cpp | 21 +++++++++++ esphome/components/api/api_pb2.h | 8 ++++- esphome/components/api/api_pb2_dump.cpp | 5 +++ esphome/components/api/api_server.cpp | 9 +++++ esphome/components/api/api_server.h | 3 ++ .../components/zwave_proxy/zwave_proxy.cpp | 36 +++++++++++++++---- esphome/components/zwave_proxy/zwave_proxy.h | 1 + 8 files changed, 79 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 9273cca2d3..0e385c4a17 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2310,11 +2310,13 @@ message ZWaveProxyFrame { enum ZWaveProxyRequestType { ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; + ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE = 2; } message ZWaveProxyRequest { option (id) = 129; - option (source) = SOURCE_CLIENT; + option (source) = SOURCE_BOTH; option (ifdef) = "USE_ZWAVE_PROXY"; ZWaveProxyRequestType type = 1; + bytes data = 2 [(pointer_to_buffer) = true]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 410ba2334e..0140c60e5b 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3112,6 +3112,27 @@ bool ZWaveProxyRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } return true; } +bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 2: { + // Use raw data directly to avoid allocation + this->data = value.data(); + this->data_len = value.size(); + break; + } + default: + return false; + } + return true; +} +void ZWaveProxyRequest::encode(ProtoWriteBuffer buffer) const { + buffer.encode_uint32(1, static_cast(this->type)); + buffer.encode_bytes(2, this->data, this->data_len); +} +void ZWaveProxyRequest::calculate_size(ProtoSize &size) const { + size.add_uint32(1, static_cast(this->type)); + size.add_length(2, this->data_len); +} #endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index ee8472b21c..d71ee9777d 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -280,6 +280,7 @@ enum UpdateCommand : uint32_t { enum ZWaveProxyRequestType : uint32_t { ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0, ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1, + ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE = 2, }; #endif @@ -2971,16 +2972,21 @@ class ZWaveProxyFrame final : public ProtoDecodableMessage { class ZWaveProxyRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 129; - static constexpr uint8_t ESTIMATED_SIZE = 2; + static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "z_wave_proxy_request"; } #endif enums::ZWaveProxyRequestType type{}; + const uint8_t *data{nullptr}; + uint16_t data_len{0}; + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override; }; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index a5494168f9..c5f1d99dd4 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -662,6 +662,8 @@ template<> const char *proto_enum_to_string(enums: return "ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE"; case enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE: return "ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE"; + case enums::ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE: + return "ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE"; default: return "UNKNOWN"; } @@ -2161,6 +2163,9 @@ void ZWaveProxyFrame::dump_to(std::string &out) const { void ZWaveProxyRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "ZWaveProxyRequest"); dump_field(out, "type", static_cast(this->type)); + out.append(" data: "); + out.append(format_hex_pretty(this->data, this->data_len)); + out.append("\n"); } #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ff94b92b5d..1d5c06092f 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -356,6 +356,15 @@ void APIServer::on_update(update::UpdateEntity *obj) { } #endif +#ifdef USE_ZWAVE_PROXY +void APIServer::on_zwave_proxy_request(const esphome::api::ProtoMessage &msg) { + // We could add code to manage a second subscription type, but, since this message type is + // very infrequent and small, we simply send it to all clients + for (auto &c : this->clients_) + c->send_message(msg, api::ZWaveProxyRequest::MESSAGE_TYPE); +} +#endif + #ifdef USE_ALARM_CONTROL_PANEL API_DISPATCH_UPDATE(alarm_control_panel::AlarmControlPanel, alarm_control_panel) #endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 354c764825..627870af1d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -126,6 +126,9 @@ class APIServer : public Component, public Controller { #ifdef USE_UPDATE void on_update(update::UpdateEntity *obj) override; #endif +#ifdef USE_ZWAVE_PROXY + void on_zwave_proxy_request(const esphome::api::ProtoMessage &msg); +#endif bool is_connected() const; diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index feaf6e2d42..70932da87c 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -1,4 +1,5 @@ #include "zwave_proxy.h" +#include "esphome/components/api/api_server.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -97,12 +98,19 @@ void ZWaveProxy::process_uart_() { // - buffer_[3]: Command ID (0x20 for GET_NETWORK_IDS) if (this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS && this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && this->buffer_[0] == ZWAVE_FRAME_TYPE_START) { - // Extract the 4-byte Home ID starting at offset 4 + // Store the 4-byte Home ID, which starts at offset 4, and notify connected clients if it changed // The frame parser has already validated the checksum and ensured all bytes are present - std::memcpy(this->home_id_.data(), this->buffer_.data() + 4, this->home_id_.size()); - this->home_id_ready_ = true; - ESP_LOGI(TAG, "Home ID: %s", - format_hex_pretty(this->home_id_.data(), this->home_id_.size(), ':', false).c_str()); + if (this->set_home_id(&this->buffer_[4])) { + api::ZWaveProxyRequest msg; + msg.type = api::enums::ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE; + msg.data = this->home_id_.data(); + msg.data_len = this->home_id_.size(); + if (api::global_api_server != nullptr) { + // We could add code to manage a second subscription type, but, since this message is + // very infrequent and small, we simply send it to all clients + api::global_api_server->on_zwave_proxy_request(msg); + } + } } ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); if (this->api_connection_ != nullptr) { @@ -120,7 +128,12 @@ void ZWaveProxy::process_uart_() { } } -void ZWaveProxy::dump_config() { ESP_LOGCONFIG(TAG, "Z-Wave Proxy"); } +void ZWaveProxy::dump_config() { + ESP_LOGCONFIG(TAG, + "Z-Wave Proxy:\n" + " Home ID: %s", + format_hex_pretty(this->home_id_.data(), this->home_id_.size(), ':', false).c_str()); +} void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) { switch (type) { @@ -145,6 +158,17 @@ void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::en } } +bool ZWaveProxy::set_home_id(const uint8_t *new_home_id) { + if (std::memcmp(this->home_id_.data(), new_home_id, this->home_id_.size()) == 0) { + ESP_LOGV(TAG, "Home ID unchanged"); + return false; // No change + } + std::memcpy(this->home_id_.data(), new_home_id, this->home_id_.size()); + ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty(this->home_id_.data(), this->home_id_.size(), ':', false).c_str()); + this->home_id_ready_ = true; + return true; // Home ID was changed +} + void ZWaveProxy::send_frame(const uint8_t *data, size_t length) { if (length == 1 && data[0] == this->last_response_) { ESP_LOGV(TAG, "Skipping sending duplicate response: 0x%02X", data[0]); diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index ea6837888b..a9123a81ca 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -56,6 +56,7 @@ class ZWaveProxy : public uart::UARTDevice, public Component { uint32_t get_home_id() { return encode_uint32(this->home_id_[0], this->home_id_[1], this->home_id_[2], this->home_id_[3]); } + bool set_home_id(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. void send_frame(const uint8_t *data, size_t length); From 3f03e8c42324e7a79ed146110676fd5c502d7efc Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Sun, 28 Sep 2025 09:15:18 +0200 Subject: [PATCH 1498/1626] [usb_host][usb_uart] Allow on ESP32-P4 (#10815) --- esphome/components/usb_host/__init__.py | 3 ++- esphome/components/usb_host/usb_host.h | 2 +- esphome/components/usb_host/usb_host_client.cpp | 2 +- esphome/components/usb_host/usb_host_component.cpp | 2 +- esphome/components/usb_uart/__init__.py | 1 - esphome/components/usb_uart/ch34x.cpp | 2 +- esphome/components/usb_uart/cp210x.cpp | 2 +- esphome/components/usb_uart/usb_uart.cpp | 7 ++++--- esphome/components/usb_uart/usb_uart.h | 2 +- 9 files changed, 12 insertions(+), 11 deletions(-) diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 0fe3310127..de734bf425 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components.esp32 import ( + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, add_idf_sdkconfig_option, @@ -47,7 +48,7 @@ CONFIG_SCHEMA = cv.All( } ), cv.only_with_esp_idf, - only_on_variant(supported=[VARIANT_ESP32S2, VARIANT_ESP32S3]), + only_on_variant(supported=[VARIANT_ESP32S2, VARIANT_ESP32S3, VARIANT_ESP32P4]), ) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index d2ff4da068..8cf313aa9b 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -1,7 +1,7 @@ #pragma once // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "esphome/core/component.h" #include #include "usb/usb_host.h" diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 5c9d56c7f9..a9d4d42a8c 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -1,5 +1,5 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "usb_host.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 682026a9c5..fb19239c73 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -1,5 +1,5 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "usb_host.h" #include #include "esphome/core/log.h" diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 6999b1b955..a852e1f78b 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -24,7 +24,6 @@ usb_uart_ns = cg.esphome_ns.namespace("usb_uart") USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component) USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent) - UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") UART_PARITY_OPTIONS = { "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 601bfe7366..7a0cc537fb 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 35834c7529..5fec0bed02 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 8603e28d62..a8a8bc231c 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -1,5 +1,5 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "usb_uart.h" #include "esphome/core/log.h" #include "esphome/components/uart/uart_debugger.h" @@ -297,7 +297,8 @@ static void fix_mps(const usb_ep_desc_t *ep) { if (ep != nullptr) { auto *ep_mutable = const_cast(ep); if (ep->wMaxPacketSize > 64) { - ESP_LOGW(TAG, "Corrected MPS of EP %u from %u to 64", ep->bEndpointAddress, ep->wMaxPacketSize); + ESP_LOGW(TAG, "Corrected MPS of EP 0x%02X from %u to 64", static_cast(ep->bEndpointAddress & 0xFF), + ep->wMaxPacketSize); ep_mutable->wMaxPacketSize = 64; } } @@ -314,7 +315,7 @@ void USBUartTypeCdcAcm::on_connected() { for (auto *channel : this->channels_) { if (i == cdc_devs.size()) { ESP_LOGE(TAG, "No configuration found for channel %d", channel->index_); - this->status_set_warning(LOG_STR("No configuration found for channel")); + this->status_set_warning("No configuration found for channel"); break; } channel->cdc_dev_ = cdc_devs[i++]; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index b41e0a52e9..a5e7905ac5 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/uart/uart_component.h" From 0d2d18c19806f2259ee73b1da7176a06afdbab82 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Sun, 28 Sep 2025 11:35:40 +0200 Subject: [PATCH 1499/1626] [usb_uart] Disable flow control on ch34x --- esphome/components/usb_uart/ch34x.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 7a0cc537fb..889366b579 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -72,6 +72,7 @@ void USBUartTypeCH34X::enable_channels() { if (channel->index_ >= 2) cmd += 0xE; this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback); + this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0, callback); } USBUartTypeCdcAcm::enable_channels(); } From a7042687c1502a334b163b7fdf751832161e83d6 Mon Sep 17 00:00:00 2001 From: Brandon Ibach Date: Sun, 28 Sep 2025 06:58:42 -0400 Subject: [PATCH 1500/1626] [spi] fix SPI interface selection on ESP32-S2 and -S3 (#10732) (#10766) --- esphome/components/spi/__init__.py | 3 --- tests/components/spi/test.esp32-s3-ard.yaml | 13 +++++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 tests/components/spi/test.esp32-s3-ard.yaml diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 894c6d1878..d803ee66dc 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -276,9 +276,6 @@ def get_spi_interface(index): return ["&SPI", "&SPI1"][index] if index == 0: return "&SPI" - # Following code can't apply to C2, H2 or 8266 since they have only one SPI - if get_target_variant() in (VARIANT_ESP32S3, VARIANT_ESP32S2): - return "new SPIClass(FSPI)" return "new SPIClass(HSPI)" diff --git a/tests/components/spi/test.esp32-s3-ard.yaml b/tests/components/spi/test.esp32-s3-ard.yaml new file mode 100644 index 0000000000..e4d4f20586 --- /dev/null +++ b/tests/components/spi/test.esp32-s3-ard.yaml @@ -0,0 +1,13 @@ +spi: + - id: three_spi + interface: spi3 + clk_pin: + number: 47 + mosi_pin: + number: 40 + - id: hw_spi + interface: hardware + clk_pin: + number: 0 + miso_pin: + number: 41 From a2f833d66537d88f59df9f95ef700919744ef13d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 16:07:26 -0500 Subject: [PATCH 1501/1626] [captive_portal] Add DNS server support for ESP-IDF framework (#10919) --- esphome/components/captive_portal/__init__.py | 19 +- .../captive_portal/captive_portal.cpp | 60 +++-- .../captive_portal/captive_portal.h | 42 ++-- .../captive_portal/dns_server_esp32_idf.cpp | 205 ++++++++++++++++++ .../captive_portal/dns_server_esp32_idf.h | 27 +++ 5 files changed, 297 insertions(+), 56 deletions(-) create mode 100644 esphome/components/captive_portal/dns_server_esp32_idf.cpp create mode 100644 esphome/components/captive_portal/dns_server_esp32_idf.h diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 9f2af0a230..99acb76bcf 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -9,11 +10,19 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_LN882X, PLATFORM_RTL87XX, + PlatformFramework, ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority -AUTO_LOAD = ["web_server_base", "ota.web_server"] + +def AUTO_LOAD() -> list[str]: + auto_load = ["web_server_base", "ota.web_server"] + if CORE.using_esp_idf: + auto_load.append("socket") + return auto_load + + DEPENDENCIES = ["wifi"] CODEOWNERS = ["@esphome/core"] @@ -58,3 +67,11 @@ async def to_code(config): cg.add_library("DNSServer", None) if CORE.is_libretiny: cg.add_library("DNSServer", None) + + +# Only compile the ESP-IDF DNS server when using ESP-IDF framework +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "dns_server_esp32_idf.cpp": {PlatformFramework.ESP32_IDF}, + } +) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 7eb0ffa99e..20abc6506d 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -45,8 +45,8 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { request->send(stream); } void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { - std::string ssid = request->arg("ssid").c_str(); - std::string psk = request->arg("psk").c_str(); + std::string ssid = request->arg("ssid").c_str(); // NOLINT(readability-redundant-string-cstr) + std::string psk = request->arg("psk").c_str(); // NOLINT(readability-redundant-string-cstr) ESP_LOGI(TAG, "Requested WiFi Settings Change:"); ESP_LOGI(TAG, " SSID='%s'", ssid.c_str()); ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); @@ -56,10 +56,8 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { } void CaptivePortal::setup() { -#ifndef USE_ARDUINO - // No DNS server needed for non-Arduino frameworks + // Disable loop by default - will be enabled when captive portal starts this->disable_loop(); -#endif } void CaptivePortal::start() { this->base_->init(); @@ -67,51 +65,47 @@ void CaptivePortal::start() { this->base_->add_handler(this); } + network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); + +#ifdef USE_ESP_IDF + // Create DNS server instance for ESP-IDF + this->dns_server_ = make_unique(); + this->dns_server_->start(ip); +#endif #ifdef USE_ARDUINO this->dns_server_ = make_unique(); this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); - network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); this->dns_server_->start(53, F("*"), ip); - // Re-enable loop() when DNS server is started - this->enable_loop(); #endif - this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *req) { - if (!this->active_ || req->host().c_str() == wifi::global_wifi_component->wifi_soft_ap_ip().str()) { - req->send(404, F("text/html"), F("File not found")); - return; - } - -#ifdef USE_ESP8266 - String url = F("http://"); - url += wifi::global_wifi_component->wifi_soft_ap_ip().str().c_str(); -#else - auto url = "http://" + wifi::global_wifi_component->wifi_soft_ap_ip().str(); -#endif - req->redirect(url.c_str()); - }); - this->initialized_ = true; this->active_ = true; + + // Enable loop() now that captive portal is active + this->enable_loop(); + + ESP_LOGV(TAG, "Captive portal started"); } void CaptivePortal::handleRequest(AsyncWebServerRequest *req) { - if (req->url() == F("/")) { -#ifndef USE_ESP8266 - auto *response = req->beginResponse(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); -#else - auto *response = req->beginResponse_P(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); -#endif - response->addHeader(F("Content-Encoding"), F("gzip")); - req->send(response); - return; - } else if (req->url() == F("/config.json")) { + if (req->url() == F("/config.json")) { this->handle_config(req); return; } else if (req->url() == F("/wifisave")) { this->handle_wifisave(req); return; } + + // All other requests get the captive portal page + // This includes OS captive portal detection endpoints which will trigger + // the captive portal when they don't receive their expected responses +#ifndef USE_ESP8266 + auto *response = req->beginResponse(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); +#else + auto *response = req->beginResponse_P(200, F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); +#endif + response->addHeader(F("Content-Encoding"), F("gzip")); + req->send(response); } CaptivePortal::CaptivePortal(web_server_base::WebServerBase *base) : base_(base) { global_captive_portal = this; } diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index 382afe92f0..f48c286f0c 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -5,6 +5,9 @@ #ifdef USE_ARDUINO #include #endif +#ifdef USE_ESP_IDF +#include "dns_server_esp32_idf.h" +#endif #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" @@ -19,41 +22,36 @@ class CaptivePortal : public AsyncWebHandler, public Component { CaptivePortal(web_server_base::WebServerBase *base); void setup() override; void dump_config() override; -#ifdef USE_ARDUINO void loop() override { +#ifdef USE_ARDUINO if (this->dns_server_ != nullptr) { this->dns_server_->processNextRequest(); - } else { - this->disable_loop(); } - } #endif +#ifdef USE_ESP_IDF + if (this->dns_server_ != nullptr) { + this->dns_server_->process_next_request(); + } +#endif + } float get_setup_priority() const override; void start(); bool is_active() const { return this->active_; } void end() { this->active_ = false; + this->disable_loop(); // Stop processing DNS requests this->base_->deinit(); -#ifdef USE_ARDUINO - this->dns_server_->stop(); - this->dns_server_ = nullptr; -#endif + if (this->dns_server_ != nullptr) { + this->dns_server_->stop(); + this->dns_server_ = nullptr; + } } bool canHandle(AsyncWebServerRequest *request) const override { - if (!this->active_) - return false; - - if (request->method() == HTTP_GET) { - if (request->url() == F("/")) - return true; - if (request->url() == F("/config.json")) - return true; - if (request->url() == F("/wifisave")) - return true; - } - - return false; + // Handle all GET requests when captive portal is active + // This allows us to respond with the portal page for any URL, + // triggering OS captive portal detection + return this->active_ && request->method() == HTTP_GET; } void handle_config(AsyncWebServerRequest *request); @@ -66,7 +64,7 @@ class CaptivePortal : public AsyncWebHandler, public Component { web_server_base::WebServerBase *base_; bool initialized_{false}; bool active_{false}; -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) || defined(USE_ESP_IDF) std::unique_ptr dns_server_{nullptr}; #endif }; diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/captive_portal/dns_server_esp32_idf.cpp new file mode 100644 index 0000000000..740107400a --- /dev/null +++ b/esphome/components/captive_portal/dns_server_esp32_idf.cpp @@ -0,0 +1,205 @@ +#include "dns_server_esp32_idf.h" +#ifdef USE_ESP_IDF + +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/components/socket/socket.h" +#include +#include + +namespace esphome::captive_portal { + +static const char *const TAG = "captive_portal.dns"; + +// DNS constants +static constexpr uint16_t DNS_PORT = 53; +static constexpr uint16_t DNS_QR_FLAG = 1 << 15; +static constexpr uint16_t DNS_OPCODE_MASK = 0x7800; +static constexpr uint16_t DNS_QTYPE_A = 0x0001; +static constexpr uint16_t DNS_QCLASS_IN = 0x0001; +static constexpr uint16_t DNS_ANSWER_TTL = 300; + +// DNS Header structure +struct DNSHeader { + uint16_t id; + uint16_t flags; + uint16_t qd_count; + uint16_t an_count; + uint16_t ns_count; + uint16_t ar_count; +} __attribute__((packed)); + +// DNS Question structure +struct DNSQuestion { + uint16_t type; + uint16_t dns_class; +} __attribute__((packed)); + +// DNS Answer structure +struct DNSAnswer { + uint16_t ptr_offset; + uint16_t type; + uint16_t dns_class; + uint32_t ttl; + uint16_t addr_len; + uint32_t ip_addr; +} __attribute__((packed)); + +void DNSServer::start(const network::IPAddress &ip) { + this->server_ip_ = ip; + ESP_LOGV(TAG, "Starting DNS server on %s", ip.str().c_str()); + + // Create loop-monitored UDP socket + this->socket_ = socket::socket_ip_loop_monitored(SOCK_DGRAM, IPPROTO_UDP); + if (this->socket_ == nullptr) { + ESP_LOGE(TAG, "Socket create failed"); + return; + } + + // Set socket options + int enable = 1; + this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable)); + + // Bind to port 53 + struct sockaddr_storage server_addr = {}; + socklen_t addr_len = socket::set_sockaddr_any((struct sockaddr *) &server_addr, sizeof(server_addr), DNS_PORT); + + int err = this->socket_->bind((struct sockaddr *) &server_addr, addr_len); + if (err != 0) { + ESP_LOGE(TAG, "Bind failed: %d", errno); + this->socket_ = nullptr; + return; + } + ESP_LOGV(TAG, "Bound to port %d", DNS_PORT); +} + +void DNSServer::stop() { + if (this->socket_ != nullptr) { + this->socket_->close(); + this->socket_ = nullptr; + } + ESP_LOGV(TAG, "Stopped"); +} + +void DNSServer::process_next_request() { + // Process one request if socket is valid and data is available + if (this->socket_ == nullptr || !this->socket_->ready()) { + return; + } + struct sockaddr_in client_addr; + socklen_t client_addr_len = sizeof(client_addr); + + // Receive DNS request using raw fd for recvfrom + int fd = this->socket_->get_fd(); + if (fd < 0) { + return; + } + + ssize_t len = recvfrom(fd, this->buffer_, sizeof(this->buffer_), MSG_DONTWAIT, (struct sockaddr *) &client_addr, + &client_addr_len); + + if (len < 0) { + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + ESP_LOGE(TAG, "recvfrom failed: %d", errno); + } + return; + } + + ESP_LOGVV(TAG, "Received %d bytes from %s:%d", len, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); + + if (len < static_cast(sizeof(DNSHeader) + 1)) { + ESP_LOGV(TAG, "Request too short: %d", len); + return; + } + + // Parse DNS header + DNSHeader *header = (DNSHeader *) this->buffer_; + uint16_t flags = ntohs(header->flags); + uint16_t qd_count = ntohs(header->qd_count); + + // Check if it's a standard query + if ((flags & DNS_QR_FLAG) || (flags & DNS_OPCODE_MASK) || qd_count != 1) { + ESP_LOGV(TAG, "Not a standard query: flags=0x%04X, qd_count=%d", flags, qd_count); + return; // Not a standard query + } + + // Parse domain name (we don't actually care about it - redirect everything) + uint8_t *ptr = this->buffer_ + sizeof(DNSHeader); + uint8_t *end = this->buffer_ + len; + + while (ptr < end && *ptr != 0) { + uint8_t label_len = *ptr; + if (label_len > 63) { // Check for invalid label length + return; + } + // Check if we have room for this label plus the length byte + if (ptr + label_len + 1 > end) { + return; // Would overflow + } + ptr += label_len + 1; + } + + // Check if we reached a proper null terminator + if (ptr >= end || *ptr != 0) { + return; // Name not terminated or truncated + } + ptr++; // Skip the null terminator + + // Check we have room for the question + if (ptr + sizeof(DNSQuestion) > end) { + return; // Request truncated + } + + // Parse DNS question + DNSQuestion *question = (DNSQuestion *) ptr; + uint16_t qtype = ntohs(question->type); + uint16_t qclass = ntohs(question->dns_class); + + // We only handle A queries + if (qtype != DNS_QTYPE_A || qclass != DNS_QCLASS_IN) { + ESP_LOGV(TAG, "Not an A query: type=0x%04X, class=0x%04X", qtype, qclass); + return; // Not an A query + } + + // Build DNS response by modifying the request in-place + header->flags = htons(DNS_QR_FLAG | 0x8000); // Response + Authoritative + header->an_count = htons(1); // One answer + + // Add answer section after the question + size_t question_len = (ptr + sizeof(DNSQuestion)) - this->buffer_ - sizeof(DNSHeader); + size_t answer_offset = sizeof(DNSHeader) + question_len; + + // Check if we have room for the answer + if (answer_offset + sizeof(DNSAnswer) > sizeof(this->buffer_)) { + ESP_LOGW(TAG, "Response too large"); + return; + } + + DNSAnswer *answer = (DNSAnswer *) (this->buffer_ + answer_offset); + + // Pointer to name in question (offset from start of packet) + answer->ptr_offset = htons(0xC000 | sizeof(DNSHeader)); + answer->type = htons(DNS_QTYPE_A); + answer->dns_class = htons(DNS_QCLASS_IN); + answer->ttl = htonl(DNS_ANSWER_TTL); + answer->addr_len = htons(4); + + // Get the raw IP address + ip4_addr_t addr = this->server_ip_; + answer->ip_addr = addr.addr; + + size_t response_len = answer_offset + sizeof(DNSAnswer); + + // Send response + ssize_t sent = + this->socket_->sendto(this->buffer_, response_len, 0, (struct sockaddr *) &client_addr, client_addr_len); + if (sent < 0) { + ESP_LOGV(TAG, "Send failed: %d", errno); + } else { + ESP_LOGV(TAG, "Sent %d bytes", sent); + } +} + +} // namespace esphome::captive_portal + +#endif // USE_ESP_IDF diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/captive_portal/dns_server_esp32_idf.h new file mode 100644 index 0000000000..13d9def8e3 --- /dev/null +++ b/esphome/components/captive_portal/dns_server_esp32_idf.h @@ -0,0 +1,27 @@ +#pragma once +#ifdef USE_ESP_IDF + +#include +#include "esphome/core/helpers.h" +#include "esphome/components/network/ip_address.h" +#include "esphome/components/socket/socket.h" + +namespace esphome::captive_portal { + +class DNSServer { + public: + void start(const network::IPAddress &ip); + void stop(); + void process_next_request(); + + protected: + static constexpr size_t DNS_BUFFER_SIZE = 192; + + std::unique_ptr socket_{nullptr}; + network::IPAddress server_ip_; + uint8_t buffer_[DNS_BUFFER_SIZE]; +}; + +} // namespace esphome::captive_portal + +#endif // USE_ESP_IDF From 34b4cb46f606d2e9cb0c817f984e9fa6cee1462f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 16:33:36 -0500 Subject: [PATCH 1502/1626] [esp32_improv] Make device name visible to passive BLE scanners (#10918) --- esphome/components/esp32_ble/ble.cpp | 22 +++ esphome/components/esp32_ble/ble.h | 2 + .../components/esp32_ble/ble_advertising.cpp | 8 +- .../components/esp32_ble/ble_advertising.h | 4 + .../esp32_improv/esp32_improv_component.cpp | 132 +++++++++++++----- .../esp32_improv/esp32_improv_component.h | 7 +- 6 files changed, 136 insertions(+), 39 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 6b6b19e079..64cef70de2 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -73,6 +73,28 @@ void ESP32BLE::advertising_set_manufacturer_data(const std::vector &dat this->advertising_start(); } +void ESP32BLE::advertising_set_service_data_and_name(std::span data, bool include_name) { + // This method atomically updates both service data and device name inclusion in BLE advertising. + // When include_name is true, the device name is included in the advertising packet making it + // visible to passive BLE scanners. When false, the name is only visible in scan response + // (requires active scanning). This atomic operation ensures we only restart advertising once + // when changing both properties, avoiding the brief gap that would occur with separate calls. + + this->advertising_init_(); + + if (include_name) { + // When including name, clear service data first to avoid packet overflow + this->advertising_->set_service_data(std::span{}); + this->advertising_->set_include_name(true); + } else { + // When including service data, clear name first to avoid packet overflow + this->advertising_->set_include_name(false); + this->advertising_->set_service_data(data); + } + + this->advertising_start(); +} + void ESP32BLE::advertising_register_raw_advertisement_callback(std::function &&callback) { this->advertising_init_(); this->advertising_->register_raw_advertisement_callback(std::move(callback)); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 368ac644cf..1aa3bc86ef 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -9,6 +9,7 @@ #endif #include +#include #include "esphome/core/automation.h" #include "esphome/core/component.h" @@ -118,6 +119,7 @@ class ESP32BLE : public Component { void advertising_set_service_data(const std::vector &data); void advertising_set_manufacturer_data(const std::vector &data); void advertising_set_appearance(uint16_t appearance) { this->appearance_ = appearance; } + void advertising_set_service_data_and_name(std::span data, bool include_name); void advertising_add_service_uuid(ESPBTUUID uuid); void advertising_remove_service_uuid(ESPBTUUID uuid); void advertising_register_raw_advertisement_callback(std::function &&callback); diff --git a/esphome/components/esp32_ble/ble_advertising.cpp b/esphome/components/esp32_ble/ble_advertising.cpp index d8b9b1cc36..df70768c23 100644 --- a/esphome/components/esp32_ble/ble_advertising.cpp +++ b/esphome/components/esp32_ble/ble_advertising.cpp @@ -43,7 +43,7 @@ void BLEAdvertising::remove_service_uuid(ESPBTUUID uuid) { this->advertising_uuids_.end()); } -void BLEAdvertising::set_service_data(const std::vector &data) { +void BLEAdvertising::set_service_data(std::span data) { delete[] this->advertising_data_.p_service_data; this->advertising_data_.p_service_data = nullptr; this->advertising_data_.service_data_len = data.size(); @@ -54,6 +54,10 @@ void BLEAdvertising::set_service_data(const std::vector &data) { } } +void BLEAdvertising::set_service_data(const std::vector &data) { + this->set_service_data(std::span(data)); +} + void BLEAdvertising::set_manufacturer_data(const std::vector &data) { delete[] this->advertising_data_.p_manufacturer_data; this->advertising_data_.p_manufacturer_data = nullptr; @@ -84,7 +88,7 @@ esp_err_t BLEAdvertising::services_advertisement_() { esp_err_t err; this->advertising_data_.set_scan_rsp = false; - this->advertising_data_.include_name = !this->scan_response_; + this->advertising_data_.include_name = this->include_name_in_adv_ || !this->scan_response_; this->advertising_data_.include_txpower = !this->scan_response_; err = esp_ble_gap_config_adv_data(&this->advertising_data_); if (err != ESP_OK) { diff --git a/esphome/components/esp32_ble/ble_advertising.h b/esphome/components/esp32_ble/ble_advertising.h index e373554ea9..7a31d926f6 100644 --- a/esphome/components/esp32_ble/ble_advertising.h +++ b/esphome/components/esp32_ble/ble_advertising.h @@ -4,6 +4,7 @@ #include #include +#include #include #ifdef USE_ESP32 @@ -36,6 +37,8 @@ class BLEAdvertising { void set_manufacturer_data(const std::vector &data); void set_appearance(uint16_t appearance) { this->advertising_data_.appearance = appearance; } void set_service_data(const std::vector &data); + void set_service_data(std::span data); + void set_include_name(bool include_name) { this->include_name_in_adv_ = include_name; } void register_raw_advertisement_callback(std::function &&callback); void start(); @@ -45,6 +48,7 @@ class BLEAdvertising { esp_err_t services_advertisement_(); bool scan_response_; + bool include_name_in_adv_{false}; esp_ble_adv_data_t advertising_data_; esp_ble_adv_data_t scan_response_data_; esp_ble_adv_params_t advertising_params_; diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index c5a0b89f99..ca08ff0cca 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -17,6 +17,13 @@ static const char *const TAG = "esp32_improv.component"; static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; static constexpr uint16_t STOP_ADVERTISING_DELAY = 10000; // Delay (ms) before stopping service to allow BLE clients to read the final state +static constexpr uint16_t NAME_ADVERTISING_INTERVAL = 60000; // Advertise name every 60 seconds +static constexpr uint16_t NAME_ADVERTISING_DURATION = 1000; // Advertise name for 1 second + +// Improv service data constants +static constexpr uint8_t IMPROV_SERVICE_DATA_SIZE = 8; +static constexpr uint8_t IMPROV_PROTOCOL_ID_1 = 0x77; // 'P' << 1 | 'R' >> 7 +static constexpr uint8_t IMPROV_PROTOCOL_ID_2 = 0x46; // 'I' << 1 | 'M' >> 7 ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } @@ -99,6 +106,11 @@ void ESP32ImprovComponent::loop() { this->process_incoming_data_(); uint32_t now = App.get_loop_component_start_time(); + // Check if we need to update advertising type + if (this->state_ != improv::STATE_STOPPED && this->state_ != improv::STATE_PROVISIONED) { + this->update_advertising_type_(); + } + switch (this->state_) { case improv::STATE_STOPPED: this->set_status_indicator_state_(false); @@ -107,9 +119,15 @@ void ESP32ImprovComponent::loop() { if (this->service_->is_created()) { this->service_->start(); } else if (this->service_->is_running()) { + // Start by advertising the device name first BEFORE setting any state + ESP_LOGV(TAG, "Starting with device name advertising"); + this->advertising_device_name_ = true; + this->last_name_adv_time_ = App.get_loop_component_start_time(); + esp32_ble::global_ble->advertising_set_service_data_and_name(std::span{}, true); esp32_ble::global_ble->advertising_start(); - this->set_state_(improv::STATE_AWAITING_AUTHORIZATION); + // Set initial state based on whether we have an authorizer + this->set_state_(this->get_initial_state_(), false); this->set_error_(improv::ERROR_NONE); ESP_LOGD(TAG, "Service started!"); } @@ -120,24 +138,21 @@ void ESP32ImprovComponent::loop() { if (this->authorizer_ == nullptr || (this->authorized_start_ != 0 && ((now - this->authorized_start_) < this->authorized_duration_))) { this->set_state_(improv::STATE_AUTHORIZED); - } else -#else - { this->set_state_(improv::STATE_AUTHORIZED); } -#endif - { + } else { if (!this->check_identify_()) this->set_status_indicator_state_(true); } +#else + this->set_state_(improv::STATE_AUTHORIZED); +#endif break; } case improv::STATE_AUTHORIZED: { #ifdef USE_BINARY_SENSOR - if (this->authorizer_ != nullptr) { - if (now - this->authorized_start_ > this->authorized_duration_) { - ESP_LOGD(TAG, "Authorization timeout"); - this->set_state_(improv::STATE_AWAITING_AUTHORIZATION); - return; - } + if (this->authorizer_ != nullptr && now - this->authorized_start_ > this->authorized_duration_) { + ESP_LOGD(TAG, "Authorization timeout"); + this->set_state_(improv::STATE_AWAITING_AUTHORIZATION); + return; } #endif if (!this->check_identify_()) { @@ -226,12 +241,15 @@ bool ESP32ImprovComponent::check_identify_() { return identify; } -void ESP32ImprovComponent::set_state_(improv::State state) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG - if (this->state_ != state) { - ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_, - this->state_to_string_(state), state); +void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertising) { + // Skip if state hasn't changed + if (this->state_ == state) { + return; } + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_, + this->state_to_string_(state), state); #endif this->state_ = state; if (this->status_ != nullptr && (this->status_->get_value().empty() || this->status_->get_value()[0] != state)) { @@ -243,25 +261,13 @@ void ESP32ImprovComponent::set_state_(improv::State state) { // STATE_STOPPED (0x00) is internal only and not part of the Improv spec. // Advertising 0x00 causes undefined behavior in some clients and makes them // repeatedly connect trying to determine the actual state. - if (state != improv::STATE_STOPPED) { - std::vector service_data(8, 0); - service_data[0] = 0x77; // PR - service_data[1] = 0x46; // IM - service_data[2] = static_cast(state); - - uint8_t capabilities = 0x00; -#ifdef USE_OUTPUT - if (this->status_indicator_ != nullptr) - capabilities |= improv::CAPABILITY_IDENTIFY; -#endif - - service_data[3] = capabilities; - service_data[4] = 0x00; // Reserved - service_data[5] = 0x00; // Reserved - service_data[6] = 0x00; // Reserved - service_data[7] = 0x00; // Reserved - - esp32_ble::global_ble->advertising_set_service_data(service_data); + if (state != improv::STATE_STOPPED && update_advertising) { + // State change always overrides name advertising and resets the timer + this->advertising_device_name_ = false; + // Reset the timer so we wait another 60 seconds before advertising name + this->last_name_adv_time_ = App.get_loop_component_start_time(); + // Advertise the new state via service data + this->advertise_service_data_(); } #ifdef USE_ESP32_IMPROV_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); @@ -388,6 +394,60 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() { wifi::global_wifi_component->clear_sta(); } +void ESP32ImprovComponent::advertise_service_data_() { + uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {}; + service_data[0] = IMPROV_PROTOCOL_ID_1; // PR + service_data[1] = IMPROV_PROTOCOL_ID_2; // IM + service_data[2] = static_cast(this->state_); + + uint8_t capabilities = 0x00; +#ifdef USE_OUTPUT + if (this->status_indicator_ != nullptr) + capabilities |= improv::CAPABILITY_IDENTIFY; +#endif + + service_data[3] = capabilities; + // service_data[4-7] are already 0 (Reserved) + + // Atomically set service data and disable name in advertising + esp32_ble::global_ble->advertising_set_service_data_and_name(std::span(service_data), false); +} + +void ESP32ImprovComponent::update_advertising_type_() { + uint32_t now = App.get_loop_component_start_time(); + + // If we're advertising the device name and it's been more than NAME_ADVERTISING_DURATION, switch back to service data + if (this->advertising_device_name_) { + if (now - this->last_name_adv_time_ >= NAME_ADVERTISING_DURATION) { + ESP_LOGV(TAG, "Switching back to service data advertising"); + this->advertising_device_name_ = false; + // Restore service data advertising + this->advertise_service_data_(); + } + return; + } + + // Check if it's time to advertise the device name (every NAME_ADVERTISING_INTERVAL) + if (now - this->last_name_adv_time_ >= NAME_ADVERTISING_INTERVAL) { + ESP_LOGV(TAG, "Switching to device name advertising"); + this->advertising_device_name_ = true; + this->last_name_adv_time_ = now; + + // Atomically clear service data and enable name in advertising data + esp32_ble::global_ble->advertising_set_service_data_and_name(std::span{}, true); + } +} + +improv::State ESP32ImprovComponent::get_initial_state_() const { +#ifdef USE_BINARY_SENSOR + // If we have an authorizer, start in awaiting authorization state + return this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION; +#else + // No binary_sensor support = no authorizer possible, start as authorized + return improv::STATE_AUTHORIZED; +#endif +} + ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esp32_improv diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 686da08111..eb07e09dce 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -100,14 +100,19 @@ class ESP32ImprovComponent : public Component { #endif bool status_indicator_state_{false}; + uint32_t last_name_adv_time_{0}; + bool advertising_device_name_{false}; void set_status_indicator_state_(bool state); + void update_advertising_type_(); - void set_state_(improv::State state); + void set_state_(improv::State state, bool update_advertising = true); void set_error_(improv::Error error); + improv::State get_initial_state_() const; void send_response_(std::vector &response); void process_incoming_data_(); void on_wifi_connect_timeout_(); bool check_identify_(); + void advertise_service_data_(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG const char *state_to_string_(improv::State state); #endif From 0111f725ffdf02afecb0ea02c7a5d6d910cd1da4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 16:34:32 -0500 Subject: [PATCH 1503/1626] [esp32_ble_tracker] Reduce gap_scan_result log verbosity to VV (#10917) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 908bb36c9e..a7d73a9709 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -295,7 +295,7 @@ void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_ga void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) { // Note: This handler is called from the main loop context via esp32_ble's event queue. // We process advertisements immediately instead of buffering them. - ESP_LOGV(TAG, "gap_scan_result - event %d", scan_result.search_evt); + ESP_LOGVV(TAG, "gap_scan_result - event %d", scan_result.search_evt); if (scan_result.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { // Process the scan result immediately From ef73ae21164748d5253be838a786420b5136e97f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 16:36:40 -0500 Subject: [PATCH 1504/1626] [esp32_ble_server] Replace HashMap with vector for services - saves 1KB flash, 26x faster (#10894) --- .../esp32_ble_server/ble_server.cpp | 44 +++++++++---------- .../components/esp32_ble_server/ble_server.h | 9 +++- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 5339bf8aed..89299bb417 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -70,11 +70,11 @@ void BLEServer::loop() { // it is at the top of the GATT table this->device_information_service_->do_create(this); // Create all services previously created - for (auto &pair : this->services_) { - if (pair.second == this->device_information_service_) { + for (auto &entry : this->services_) { + if (entry.service == this->device_information_service_) { continue; } - pair.second->do_create(this); + entry.service->do_create(this); } this->state_ = STARTING_SERVICE; } @@ -118,7 +118,7 @@ BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t n } BLEService *service = // NOLINT(cppcoreguidelines-owning-memory) new BLEService(uuid, num_handles, inst_id, advertise); - this->services_.emplace(BLEServer::get_service_key(uuid, inst_id), service); + this->services_.push_back({uuid, inst_id, service}); if (this->parent_->is_active() && this->registered_) { service->do_create(this); } @@ -127,26 +127,24 @@ BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t n void BLEServer::remove_service(ESPBTUUID uuid, uint8_t inst_id) { ESP_LOGV(TAG, "Removing BLE service - %s %d", uuid.to_string().c_str(), inst_id); - BLEService *service = this->get_service(uuid, inst_id); - if (service == nullptr) { - ESP_LOGW(TAG, "BLE service %s %d does not exist", uuid.to_string().c_str(), inst_id); - return; + for (auto it = this->services_.begin(); it != this->services_.end(); ++it) { + if (it->uuid == uuid && it->inst_id == inst_id) { + it->service->do_delete(); + delete it->service; // NOLINT(cppcoreguidelines-owning-memory) + this->services_.erase(it); + return; + } } - service->do_delete(); - delete service; // NOLINT(cppcoreguidelines-owning-memory) - this->services_.erase(BLEServer::get_service_key(uuid, inst_id)); + ESP_LOGW(TAG, "BLE service %s %d does not exist", uuid.to_string().c_str(), inst_id); } BLEService *BLEServer::get_service(ESPBTUUID uuid, uint8_t inst_id) { - BLEService *service = nullptr; - if (this->services_.count(BLEServer::get_service_key(uuid, inst_id)) > 0) { - service = this->services_.at(BLEServer::get_service_key(uuid, inst_id)); + for (auto &entry : this->services_) { + if (entry.uuid == uuid && entry.inst_id == inst_id) { + return entry.service; + } } - return service; -} - -std::string BLEServer::get_service_key(ESPBTUUID uuid, uint8_t inst_id) { - return uuid.to_string() + std::to_string(inst_id); + return nullptr; } void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, @@ -174,8 +172,8 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga break; } - for (const auto &pair : this->services_) { - pair.second->gatts_event_handler(event, gatts_if, param); + for (auto &entry : this->services_) { + entry.service->gatts_event_handler(event, gatts_if, param); } } @@ -183,8 +181,8 @@ void BLEServer::ble_before_disabled_event_handler() { // Delete all clients this->clients_.clear(); // Delete all services - for (auto &pair : this->services_) { - pair.second->do_delete(); + for (auto &entry : this->services_) { + entry.service->do_delete(); } this->registered_ = false; this->state_ = INIT; diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 531b52d6b9..b5973ed099 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -66,7 +66,12 @@ class BLEServer : public Component, void ble_before_disabled_event_handler() override; protected: - static std::string get_service_key(ESPBTUUID uuid, uint8_t inst_id); + struct ServiceEntry { + ESPBTUUID uuid; + uint8_t inst_id; + BLEService *service; + }; + void restart_advertising_(); void add_client_(uint16_t conn_id) { this->clients_.insert(conn_id); } @@ -77,7 +82,7 @@ class BLEServer : public Component, bool registered_{false}; std::unordered_set clients_; - std::unordered_map services_{}; + std::vector services_{}; std::vector services_to_start_{}; BLEService *device_information_service_{}; From ab79e596b53e47b938df6d3c4de75eff99d262c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 17:32:16 -0500 Subject: [PATCH 1505/1626] [esp32_ble_server] Optimize notification and action managers for typical use cases (#10897) --- .../esp32_ble_server/ble_characteristic.cpp | 37 +++++++++++++---- .../esp32_ble_server/ble_characteristic.h | 11 ++++- .../ble_server_automations.cpp | 41 ++++++++++++++----- .../esp32_ble_server/ble_server_automations.h | 21 ++++++++-- 4 files changed, 88 insertions(+), 22 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 373d57436e..fabcc75321 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -51,11 +51,11 @@ void BLECharacteristic::notify() { for (auto &client : this->service_->get_server()->get_clients()) { size_t length = this->value_.size(); - // If the client is not in the list of clients to notify, skip it - if (this->clients_to_notify_.count(client) == 0) + // Find the client in the list of clients to notify + auto *entry = this->find_client_in_notify_list_(client); + if (entry == nullptr) continue; - // If the client is in the list of clients to notify, check if it requires an ack (i.e. INDICATE) - bool require_ack = this->clients_to_notify_[client]; + bool require_ack = entry->indicate; // TODO: Remove this block when INDICATE acknowledgment is supported if (require_ack) { ESP_LOGW(TAG, "INDICATE acknowledgment is not yet supported (i.e. it works as a NOTIFY)"); @@ -79,10 +79,11 @@ void BLECharacteristic::add_descriptor(BLEDescriptor *descriptor) { uint16_t cccd = encode_uint16(value[1], value[0]); bool notify = (cccd & 1) != 0; bool indicate = (cccd & 2) != 0; + // Remove existing entry if present + this->remove_client_from_notify_list_(conn_id); + // Add new entry if needed if (notify || indicate) { - this->clients_to_notify_[conn_id] = indicate; - } else { - this->clients_to_notify_.erase(conn_id); + this->clients_to_notify_.push_back({conn_id, indicate}); } }); } @@ -307,6 +308,28 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt } } +void BLECharacteristic::remove_client_from_notify_list_(uint16_t conn_id) { + // Since we typically have very few clients (often just 1), we can optimize + // for the common case by swapping with the last element and popping + for (size_t i = 0; i < this->clients_to_notify_.size(); i++) { + if (this->clients_to_notify_[i].conn_id == conn_id) { + // Swap with last element and pop (safe even when i is the last element) + this->clients_to_notify_[i] = this->clients_to_notify_.back(); + this->clients_to_notify_.pop_back(); + return; + } + } +} + +BLECharacteristic::ClientNotificationEntry *BLECharacteristic::find_client_in_notify_list_(uint16_t conn_id) { + for (auto &entry : this->clients_to_notify_) { + if (entry.conn_id == conn_id) { + return &entry; + } + } + return nullptr; +} + } // namespace esp32_ble_server } // namespace esphome diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 3698b8c4aa..97b3af2a21 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -6,7 +6,6 @@ #include "esphome/components/bytebuffer/bytebuffer.h" #include -#include #ifdef USE_ESP32 @@ -89,7 +88,15 @@ class BLECharacteristic : public EventEmitter descriptors_; - std::unordered_map clients_to_notify_; + + struct ClientNotificationEntry { + uint16_t conn_id; + bool indicate; // true = indicate, false = notify + }; + std::vector clients_to_notify_; + + void remove_client_from_notify_list_(uint16_t conn_id); + ClientNotificationEntry *find_client_in_notify_list_(uint16_t conn_id); esp_gatt_perm_t permissions_ = ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.cpp b/esphome/components/esp32_ble_server/ble_server_automations.cpp index 41ef2b8bfe..b140e08b46 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.cpp +++ b/esphome/components/esp32_ble_server/ble_server_automations.cpp @@ -45,17 +45,16 @@ Trigger *BLETriggers::create_server_on_disconnect_trigger(BLEServer *s void BLECharacteristicSetValueActionManager::set_listener(BLECharacteristic *characteristic, EventEmitterListenerID listener_id, const std::function &pre_notify_listener) { - // Check if there is already a listener for this characteristic - if (this->listeners_.count(characteristic) > 0) { - // Unpack the pair listener_id, pre_notify_listener_id - auto listener_pairs = this->listeners_[characteristic]; - EventEmitterListenerID old_listener_id = listener_pairs.first; - EventEmitterListenerID old_pre_notify_listener_id = listener_pairs.second; + // Find and remove existing listener for this characteristic + auto *existing = this->find_listener_(characteristic); + if (existing != nullptr) { // Remove the previous listener characteristic->EventEmitter::off(BLECharacteristicEvt::EmptyEvt::ON_READ, - old_listener_id); + existing->listener_id); // Remove the pre-notify listener - this->off(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, old_pre_notify_listener_id); + this->off(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, existing->pre_notify_listener_id); + // Remove from vector + this->remove_listener_(characteristic); } // Create a new listener for the pre-notify event EventEmitterListenerID pre_notify_listener_id = @@ -66,8 +65,30 @@ void BLECharacteristicSetValueActionManager::set_listener(BLECharacteristic *cha pre_notify_listener(); } }); - // Save the pair listener_id, pre_notify_listener_id to the map - this->listeners_[characteristic] = std::make_pair(listener_id, pre_notify_listener_id); + // Save the entry to the vector + this->listeners_.push_back({characteristic, listener_id, pre_notify_listener_id}); +} + +BLECharacteristicSetValueActionManager::ListenerEntry *BLECharacteristicSetValueActionManager::find_listener_( + BLECharacteristic *characteristic) { + for (auto &entry : this->listeners_) { + if (entry.characteristic == characteristic) { + return &entry; + } + } + return nullptr; +} + +void BLECharacteristicSetValueActionManager::remove_listener_(BLECharacteristic *characteristic) { + // Since we typically have very few listeners, optimize by swapping with back and popping + for (size_t i = 0; i < this->listeners_.size(); i++) { + if (this->listeners_[i].characteristic == characteristic) { + // Swap with last element and pop (safe even when i is the last element) + this->listeners_[i] = this->listeners_.back(); + this->listeners_.pop_back(); + return; + } + } } } // namespace esp32_ble_server_automations diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index eab6b05f05..910335826c 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -8,7 +8,6 @@ #include "esphome/core/automation.h" #include -#include #include #ifdef USE_ESP32 @@ -21,6 +20,9 @@ namespace esp32_ble_server_automations { using namespace esp32_ble; using namespace event_emitter; +// Invalid listener ID constant - 0 is used as sentinel value in EventEmitter +static constexpr EventEmitterListenerID INVALID_LISTENER_ID = 0; + class BLETriggers { public: static Trigger, uint16_t> *create_characteristic_on_write_trigger( @@ -46,14 +48,27 @@ class BLECharacteristicSetValueActionManager void set_listener(BLECharacteristic *characteristic, EventEmitterListenerID listener_id, const std::function &pre_notify_listener); EventEmitterListenerID get_listener(BLECharacteristic *characteristic) { - return this->listeners_[characteristic].first; + for (const auto &entry : this->listeners_) { + if (entry.characteristic == characteristic) { + return entry.listener_id; + } + } + return INVALID_LISTENER_ID; } void emit_pre_notify(BLECharacteristic *characteristic) { this->emit_(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, characteristic); } private: - std::unordered_map> listeners_; + struct ListenerEntry { + BLECharacteristic *characteristic; + EventEmitterListenerID listener_id; + EventEmitterListenerID pre_notify_listener_id; + }; + std::vector listeners_; + + ListenerEntry *find_listener_(BLECharacteristic *characteristic); + void remove_listener_(BLECharacteristic *characteristic); }; template class BLECharacteristicSetValueAction : public Action { From 78655968dfdda32e241ec22267954b1ea2513b10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 18:07:13 -0500 Subject: [PATCH 1506/1626] [event_emitter] Replace unordered_map with vector - saves 2.6KB flash, 2.3x faster (#10900) --- .../event_emitter/event_emitter.cpp | 14 --- .../components/event_emitter/event_emitter.h | 106 +++++++++++++----- 2 files changed, 80 insertions(+), 40 deletions(-) delete mode 100644 esphome/components/event_emitter/event_emitter.cpp diff --git a/esphome/components/event_emitter/event_emitter.cpp b/esphome/components/event_emitter/event_emitter.cpp deleted file mode 100644 index 8487e19c2f..0000000000 --- a/esphome/components/event_emitter/event_emitter.cpp +++ /dev/null @@ -1,14 +0,0 @@ -#include "event_emitter.h" - -namespace esphome { -namespace event_emitter { - -static const char *const TAG = "event_emitter"; - -void raise_event_emitter_full_error() { - ESP_LOGE(TAG, "EventEmitter has reached the maximum number of listeners for event"); - ESP_LOGW(TAG, "Removing listener to make space for new listener"); -} - -} // namespace event_emitter -} // namespace esphome diff --git a/esphome/components/event_emitter/event_emitter.h b/esphome/components/event_emitter/event_emitter.h index 3876a2cc14..74afde03c0 100644 --- a/esphome/components/event_emitter/event_emitter.h +++ b/esphome/components/event_emitter/event_emitter.h @@ -1,5 +1,4 @@ #pragma once -#include #include #include #include @@ -10,52 +9,107 @@ namespace esphome { namespace event_emitter { using EventEmitterListenerID = uint32_t; -void raise_event_emitter_full_error(); +static constexpr EventEmitterListenerID INVALID_LISTENER_ID = 0; // EventEmitter class that can emit events with a specific name (it is highly recommended to use an enum class for this) // and a list of arguments. Supports multiple listeners for each event. template class EventEmitter { public: EventEmitterListenerID on(EvtType event, std::function listener) { - EventEmitterListenerID listener_id = get_next_id_(event); - listeners_[event][listener_id] = listener; + EventEmitterListenerID listener_id = this->get_next_id_(); + + // Find or create event entry + EventEntry *entry = this->find_or_create_event_(event); + entry->listeners.push_back({listener_id, listener}); + return listener_id; } void off(EvtType event, EventEmitterListenerID id) { - if (listeners_.count(event) == 0) + EventEntry *entry = this->find_event_(event); + if (entry == nullptr) return; - listeners_[event].erase(id); + + // Remove listener with given id + for (auto it = entry->listeners.begin(); it != entry->listeners.end(); ++it) { + if (it->id == id) { + // Swap with last and pop for efficient removal + *it = entry->listeners.back(); + entry->listeners.pop_back(); + + // Remove event entry if no more listeners + if (entry->listeners.empty()) { + this->remove_event_(event); + } + return; + } + } } protected: void emit_(EvtType event, Args... args) { - if (listeners_.count(event) == 0) + EventEntry *entry = this->find_event_(event); + if (entry == nullptr) return; - for (const auto &listener : listeners_[event]) { - listener.second(args...); - } - } - EventEmitterListenerID get_next_id_(EvtType event) { - // Check if the map is full - if (listeners_[event].size() == std::numeric_limits::max()) { - // Raise an error if the map is full - raise_event_emitter_full_error(); - off(event, 0); - return 0; + // Call all listeners for this event + for (const auto &listener : entry->listeners) { + listener.callback(args...); } - // Get the next ID for the given event. - EventEmitterListenerID next_id = (current_id_ + 1) % std::numeric_limits::max(); - while (listeners_[event].count(next_id) > 0) { - next_id = (next_id + 1) % std::numeric_limits::max(); - } - current_id_ = next_id; - return current_id_; } private: - std::unordered_map>> listeners_; + struct Listener { + EventEmitterListenerID id; + std::function callback; + }; + + struct EventEntry { + EvtType event; + std::vector listeners; + }; + + EventEmitterListenerID get_next_id_() { + // Simple incrementing ID, wrapping around at max + EventEmitterListenerID next_id = (this->current_id_ + 1); + if (next_id == INVALID_LISTENER_ID) { + next_id = 1; + } + this->current_id_ = next_id; + return this->current_id_; + } + + EventEntry *find_event_(EvtType event) { + for (auto &entry : this->events_) { + if (entry.event == event) { + return &entry; + } + } + return nullptr; + } + + EventEntry *find_or_create_event_(EvtType event) { + EventEntry *entry = this->find_event_(event); + if (entry != nullptr) + return entry; + + // Create new event entry + this->events_.push_back({event, {}}); + return &this->events_.back(); + } + + void remove_event_(EvtType event) { + for (auto it = this->events_.begin(); it != this->events_.end(); ++it) { + if (it->event == event) { + // Swap with last and pop + *it = this->events_.back(); + this->events_.pop_back(); + return; + } + } + } + + std::vector events_; EventEmitterListenerID current_id_ = 0; }; From 4b86f31b664deab7a66cf7a43c319a2b8d6301c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 18:09:08 -0500 Subject: [PATCH 1507/1626] [core] Fix platform component normalization happening too late in validation pipeline (#10908) --- esphome/config.py | 12 +- tests/unit_tests/fixtures/ota_empty_dict.yaml | 17 +++ .../unit_tests/fixtures/ota_no_platform.yaml | 17 +++ .../fixtures/ota_with_platform_list.yaml | 19 +++ tests/unit_tests/test_config_normalization.py | 122 ++++++++++++++++++ 5 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 tests/unit_tests/fixtures/ota_empty_dict.yaml create mode 100644 tests/unit_tests/fixtures/ota_no_platform.yaml create mode 100644 tests/unit_tests/fixtures/ota_with_platform_list.yaml create mode 100644 tests/unit_tests/test_config_normalization.py diff --git a/esphome/config.py b/esphome/config.py index a7e47f646b..a5297a53cb 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -382,6 +382,12 @@ class LoadValidationStep(ConfigValidationStep): result.add_str_error(f"Component not found: {self.domain}", path) return CORE.loaded_integrations.add(self.domain) + # For platform components, normalize conf before creating MetadataValidationStep + if component.is_platform_component: + if not self.conf: + result[self.domain] = self.conf = [] + elif not isinstance(self.conf, list): + result[self.domain] = self.conf = [self.conf] # Process AUTO_LOAD for load in component.auto_load: @@ -399,12 +405,6 @@ class LoadValidationStep(ConfigValidationStep): # Remove this is as an output path result.remove_output_path([self.domain], self.domain) - # Ensure conf is a list - if not self.conf: - result[self.domain] = self.conf = [] - elif not isinstance(self.conf, list): - result[self.domain] = self.conf = [self.conf] - for i, p_config in enumerate(self.conf): path = [self.domain, i] # Construct temporary unknown output path diff --git a/tests/unit_tests/fixtures/ota_empty_dict.yaml b/tests/unit_tests/fixtures/ota_empty_dict.yaml new file mode 100644 index 0000000000..cf9b166afa --- /dev/null +++ b/tests/unit_tests/fixtures/ota_empty_dict.yaml @@ -0,0 +1,17 @@ +esphome: + name: test-device2 + +esp32: + board: esp32dev + framework: + type: esp-idf + +# OTA with empty dict - should be normalized +ota: {} + +wifi: + ssid: "test" + password: "test" + +# Captive portal auto-loads ota.web_server which triggers the issue +captive_portal: diff --git a/tests/unit_tests/fixtures/ota_no_platform.yaml b/tests/unit_tests/fixtures/ota_no_platform.yaml new file mode 100644 index 0000000000..0b09c836fb --- /dev/null +++ b/tests/unit_tests/fixtures/ota_no_platform.yaml @@ -0,0 +1,17 @@ +esphome: + name: test-device + +esp32: + board: esp32dev + framework: + type: esp-idf + +# OTA with no value - this should be normalized to empty list +ota: + +wifi: + ssid: "test" + password: "test" + +# Captive portal auto-loads ota.web_server which triggers the issue +captive_portal: diff --git a/tests/unit_tests/fixtures/ota_with_platform_list.yaml b/tests/unit_tests/fixtures/ota_with_platform_list.yaml new file mode 100644 index 0000000000..b1b03743ae --- /dev/null +++ b/tests/unit_tests/fixtures/ota_with_platform_list.yaml @@ -0,0 +1,19 @@ +esphome: + name: test-device3 + +esp32: + board: esp32dev + framework: + type: esp-idf + +# OTA with proper list format +ota: + - platform: esphome + password: "test123" + +wifi: + ssid: "test" + password: "test" + +# Captive portal auto-loads ota.web_server +captive_portal: diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py new file mode 100644 index 0000000000..4b79ddd426 --- /dev/null +++ b/tests/unit_tests/test_config_normalization.py @@ -0,0 +1,122 @@ +"""Unit tests for esphome.config module.""" + +from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from esphome import config, yaml_util +from esphome.core import CORE + + +@pytest.fixture +def mock_get_component() -> Generator[Mock, None, None]: + """Fixture for mocking get_component.""" + with patch("esphome.config.get_component") as mock_get_component: + yield mock_get_component + + +@pytest.fixture +def mock_get_platform() -> Generator[Mock, None, None]: + """Fixture for mocking get_platform.""" + with patch("esphome.config.get_platform") as mock_get_platform: + # Default mock platform + mock_get_platform.return_value = MagicMock() + yield mock_get_platform + + +@pytest.fixture +def fixtures_dir() -> Path: + """Get the fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +def test_ota_component_configs_with_proper_platform_list( + mock_get_component: Mock, + mock_get_platform: Mock, +) -> None: + """Test iter_component_configs handles OTA properly configured as a list.""" + test_config = { + "ota": [ + {"platform": "esphome", "password": "test123", "id": "my_ota"}, + ], + } + + mock_get_component.return_value = MagicMock( + is_platform_component=True, multi_conf=False + ) + + configs = list(config.iter_component_configs(test_config)) + assert len(configs) == 2 + + assert configs[0][0] == "ota" + assert configs[0][2] == test_config["ota"] # The list itself + + assert configs[1][0] == "ota.esphome" + assert configs[1][2]["platform"] == "esphome" + assert configs[1][2]["password"] == "test123" + + +def test_iter_component_configs_with_multi_conf(mock_get_component: Mock) -> None: + """Test that iter_component_configs handles multi_conf components correctly.""" + test_config = { + "switch": [ + {"name": "Switch 1"}, + {"name": "Switch 2"}, + ], + } + + mock_get_component.return_value = MagicMock( + is_platform_component=False, multi_conf=True + ) + + configs = list(config.iter_component_configs(test_config)) + assert len(configs) == 2 + + for domain, component, conf in configs: + assert domain == "switch" + assert "name" in conf + + +def test_ota_no_platform_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with no platform (ota:) gets normalized when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" + + config_file = fixtures_dir / "ota_no_platform.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def test_ota_empty_dict_with_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with empty dict ({}) gets normalized when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" + + config_file = fixtures_dir / "ota_empty_dict.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: + """Test OTA with proper platform list remains valid when captive_portal auto-loads.""" + CORE.config_path = fixtures_dir / "dummy.yaml" + + config_file = fixtures_dir / "ota_with_platform_list.yaml" + raw_config = yaml_util.load_yaml(config_file) + result = config.validate_config(raw_config, {}) + + assert "ota" in result + assert isinstance(result["ota"], list), f"Expected list, got {type(result['ota'])}" + platforms = {p.get("platform") for p in result["ota"]} + assert "esphome" in platforms, f"Expected esphome platform in {platforms}" + assert "web_server" in platforms, f"Expected web_server platform in {platforms}" From 77dff521835f1b33c0c696012a5458a7e1e10e79 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 29 Sep 2025 09:12:06 +1000 Subject: [PATCH 1508/1626] [mipi_spi] Fix t-display-amoled (#10922) --- esphome/components/mipi/__init__.py | 10 +++++++++- esphome/components/mipi_spi/models/amoled.py | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 8b1ca899df..f670a5913d 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -401,6 +401,12 @@ class DriverChip: sequence.append((MADCTL, madctl)) return madctl + def skip_command(self, command: str): + """ + Allow suppressing a standard command in the init sequence. + """ + return self.get_default(f"no_{command.lower()}", False) + def get_sequence(self, config) -> tuple[tuple[int, ...], int]: """ Create the init sequence for the display. @@ -432,7 +438,9 @@ class DriverChip: sequence.append((INVOFF,)) if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)): sequence.append((BRIGHTNESS, brightness)) - sequence.append((SLPOUT,)) + # Add a SLPOUT command if required. + if not self.skip_command("SLPOUT"): + sequence.append((SLPOUT,)) sequence.append((DISPON,)) # Flatten the sequence into a list of bytes, with the length of each command diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index bc95fc7f71..4d6c8da4b0 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -27,7 +27,8 @@ DriverChip( bus_mode=TYPE_QUAD, brightness=0xD0, color_order=MODE_RGB, - initsequence=(SLPOUT,), # Requires early SLPOUT + no_slpout=True, # SLPOUT is in the init sequence, early + initsequence=(SLPOUT,), ) DriverChip( @@ -95,6 +96,7 @@ CO5300 = DriverChip( brightness=0xD0, color_order=MODE_RGB, bus_mode=TYPE_QUAD, + no_slpout=True, initsequence=( (SLPOUT,), # Requires early SLPOUT (PAGESEL, 0x00), From e07af13bef28dcc37e1ffbd464b519c4d28fbd6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 18:25:29 -0500 Subject: [PATCH 1509/1626] [usb_host] Fix double-free crash with lock-free atomic pool allocation --- esphome/components/usb_host/usb_host.h | 43 +++++++--- .../components/usb_host/usb_host_client.cpp | 80 ++++++++++++++++--- esphome/components/usb_uart/usb_uart.cpp | 13 ++- 3 files changed, 109 insertions(+), 27 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 8cf313aa9b..e1e96cfce3 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -9,11 +9,31 @@ #include #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" -#include +#include namespace esphome { namespace usb_host { +// THREADING MODEL: +// This component uses a dedicated USB task for event processing to prevent data loss. +// - USB Task (high priority): Handles USB events, executes transfer callbacks +// - Main Loop Task: Initiates transfers, processes completion events +// +// Thread-safe communication: +// - Lock-free queues for USB task -> main loop events (SPSC pattern) +// - Lock-free TransferRequest pool using atomic bitmask (MCSP pattern) +// +// TransferRequest pool access pattern: +// - get_trq_() [allocate]: Called from BOTH USB task and main loop threads +// * USB task: via USB UART input callbacks that restart transfers immediately +// * Main loop: for output transfers and flow-controlled input restarts +// - release_trq() [deallocate]: Called from main loop thread only +// +// The multi-threaded allocation is intentional for performance: +// - USB task can immediately restart input transfers without context switching +// - Main loop controls backpressure by deciding when to restart after consuming data +// The atomic bitmask ensures thread-safe allocation without mutex blocking. + static const char *const TAG = "usb_host"; // Forward declarations @@ -98,13 +118,7 @@ class USBClient : public Component { friend class USBHost; public: - USBClient(uint16_t vid, uint16_t pid) : vid_(vid), pid_(pid) { init_pool(); } - - void init_pool() { - this->trq_pool_.clear(); - for (size_t i = 0; i != MAX_REQUESTS; i++) - this->trq_pool_.push_back(&this->requests_[i]); - } + USBClient(uint16_t vid, uint16_t pid) : vid_(vid), pid_(pid), trq_in_use_(0) {} void setup() override; void loop() override; // setup must happen after the host bus has been setup @@ -126,10 +140,13 @@ class USBClient : public Component { protected: bool register_(); - TransferRequest *get_trq_(); + TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe) virtual void disconnect(); virtual void on_connected() {} - virtual void on_disconnected() { this->init_pool(); } + virtual void on_disconnected() { + // Reset all requests to available (all bits to 0) + this->trq_in_use_.store(0); + } // USB task management static void usb_task_fn(void *arg); @@ -143,7 +160,11 @@ class USBClient : public Component { int state_{USB_CLIENT_INIT}; uint16_t vid_{}; uint16_t pid_{}; - std::list trq_pool_{}; + // Lock-free pool management using atomic bitmask (no dynamic allocation) + // Bit i = 1: requests_[i] is in use, Bit i = 0: requests_[i] is available + // Supports multiple concurrent consumers (both threads can allocate) + // Single producer for deallocation (main loop only) + std::atomic trq_in_use_; TransferRequest requests_[MAX_REQUESTS]{}; }; class USBHost : public Component { diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index a9d4d42a8c..1cfe2f5f13 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace esphome { namespace usb_host { @@ -185,9 +186,11 @@ void USBClient::setup() { this->mark_failed(); return; } - for (auto *trq : this->trq_pool_) { - usb_host_transfer_alloc(64, 0, &trq->transfer); - trq->client = this; + // Pre-allocate USB transfer buffers for all slots at startup + // This avoids any dynamic allocation during runtime + for (size_t i = 0; i < MAX_REQUESTS; i++) { + usb_host_transfer_alloc(64, 0, &this->requests_[i].transfer); + this->requests_[i].client = this; // Set once, never changes } // Create and start USB task @@ -347,17 +350,39 @@ static void control_callback(const usb_transfer_t *xfer) { queue_transfer_cleanup(trq, EVENT_CONTROL_COMPLETE); } +// THREAD CONTEXT: Called from both USB task and main loop threads (multi-consumer) +// - USB task: USB UART input callbacks restart transfers for immediate data reception +// - Main loop: Output transfers and flow-controlled input restarts after consuming data +// +// THREAD SAFETY: Lock-free using atomic compare-and-swap on bitmask +// This multi-threaded access is intentional for performance - USB task can +// immediately restart transfers without waiting for main loop scheduling. TransferRequest *USBClient::get_trq_() { - if (this->trq_pool_.empty()) { - ESP_LOGE(TAG, "Too many requests queued"); - return nullptr; + uint16_t mask = this->trq_in_use_.load(std::memory_order_relaxed); + + // Find first available slot (bit = 0) and try to claim it atomically + for (size_t i = 0; i < MAX_REQUESTS; i++) { + if (!(mask & (1U << i))) { + // Slot i appears available, try to claim it atomically + uint16_t expected = mask; + uint16_t desired = mask | (1U << i); // Set bit i to mark as in-use + + if (this->trq_in_use_.compare_exchange_weak(expected, desired, std::memory_order_acquire, + std::memory_order_relaxed)) { + // Successfully claimed slot i - prepare the TransferRequest + auto *trq = &this->requests_[i]; + trq->transfer->context = trq; + trq->transfer->device_handle = this->device_handle_; + return trq; + } + // Another thread claimed this slot, retry with updated mask + mask = expected; + i--; // Retry the same index with new mask value + } } - auto *trq = this->trq_pool_.front(); - this->trq_pool_.pop_front(); - trq->client = this; - trq->transfer->context = trq; - trq->transfer->device_handle = this->device_handle_; - return trq; + + ESP_LOGE(TAG, "Too many requests queued (all %d slots in use)", MAX_REQUESTS); + return nullptr; } void USBClient::disconnect() { this->on_disconnected(); @@ -370,6 +395,8 @@ void USBClient::disconnect() { this->device_addr_ = -1; } +// THREAD CONTEXT: Called from main loop thread only +// - Used for device configuration and control operations bool USBClient::control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback, const std::vector &data) { auto *trq = this->get_trq_(); @@ -425,6 +452,9 @@ static void transfer_callback(usb_transfer_t *xfer) { } /** * Performs a transfer input operation. + * THREAD CONTEXT: Called from both USB task and main loop threads! + * - USB task: USB UART input callbacks call start_input() which calls this + * - Main loop: Initial setup and other components * * @param ep_address The endpoint address. * @param callback The callback function to be called when the transfer is complete. @@ -451,6 +481,9 @@ void USBClient::transfer_in(uint8_t ep_address, const transfer_cb_t &callback, u /** * Performs an output transfer operation. + * THREAD CONTEXT: Called from main loop thread only + * - USB UART output uses defer() to ensure main loop context + * - Modbus and other components call from loop() * * @param ep_address The endpoint address. * @param callback The callback function to be called when the transfer is complete. @@ -483,7 +516,28 @@ void USBClient::dump_config() { " Product id %04X", this->vid_, this->pid_); } -void USBClient::release_trq(TransferRequest *trq) { this->trq_pool_.push_back(trq); } +// THREAD CONTEXT: Only called from main loop thread (single producer for deallocation) +// - Via event processing when handling EVENT_TRANSFER_COMPLETE/EVENT_CONTROL_COMPLETE +// - Directly when transfer submission fails +// +// THREAD SAFETY: Lock-free using atomic AND to clear bit +// Single-producer pattern makes this simpler than allocation +void USBClient::release_trq(TransferRequest *trq) { + if (trq == nullptr) + return; + + // Calculate index from pointer arithmetic + size_t index = trq - this->requests_; + if (index >= MAX_REQUESTS) { + ESP_LOGE(TAG, "Invalid TransferRequest pointer"); + return; + } + + // Atomically clear bit i to mark slot as available + // fetch_and with inverted bitmask clears the bit atomically + uint16_t bit = 1U << index; + this->trq_in_use_.fetch_and(~bit, std::memory_order_release); +} } // namespace usb_host } // namespace esphome diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index a8a8bc231c..29003e071e 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -216,9 +216,16 @@ void USBUartComponent::dump_config() { void USBUartComponent::start_input(USBUartChannel *channel) { if (!channel->initialised_.load() || channel->input_started_.load()) return; - // Note: This function is called from both USB task and main loop, so we cannot - // directly check ring buffer space here. Backpressure is handled by the chunk pool: - // when exhausted, USB input stops until chunks are freed by the main loop + // THREAD CONTEXT: Called from both USB task and main loop threads + // - USB task: Immediate restart after successful transfer for continuous data flow + // - Main loop: Controlled restart after consuming data (backpressure mechanism) + // + // This dual-thread access is intentional for performance: + // - USB task restarts avoid context switch delays for high-speed data + // - Main loop restarts provide flow control when buffers are full + // + // The underlying transfer_in() uses lock-free atomic allocation from the + // TransferRequest pool, making this multi-threaded access safe const auto *ep = channel->cdc_dev_.in_ep; // CALLBACK CONTEXT: This lambda is executed in USB task via transfer_callback auto callback = [this, channel](const usb_host::TransferStatus &status) { From d65b1fad674e1e05791c6fcfca704c62cf5566e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 18:41:20 -0500 Subject: [PATCH 1510/1626] fix underflow --- .../components/usb_host/usb_host_client.cpp | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 1cfe2f5f13..f37bf3ec64 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -361,24 +361,30 @@ TransferRequest *USBClient::get_trq_() { uint16_t mask = this->trq_in_use_.load(std::memory_order_relaxed); // Find first available slot (bit = 0) and try to claim it atomically - for (size_t i = 0; i < MAX_REQUESTS; i++) { - if (!(mask & (1U << i))) { - // Slot i appears available, try to claim it atomically - uint16_t expected = mask; - uint16_t desired = mask | (1U << i); // Set bit i to mark as in-use - - if (this->trq_in_use_.compare_exchange_weak(expected, desired, std::memory_order_acquire, - std::memory_order_relaxed)) { - // Successfully claimed slot i - prepare the TransferRequest - auto *trq = &this->requests_[i]; - trq->transfer->context = trq; - trq->transfer->device_handle = this->device_handle_; - return trq; - } - // Another thread claimed this slot, retry with updated mask - mask = expected; - i--; // Retry the same index with new mask value + // We use a while loop to allow retrying the same slot after CAS failure + size_t i = 0; + while (i < MAX_REQUESTS) { + if (mask & (1U << i)) { + // Slot is in use, move to next slot + i++; + continue; } + + // Slot i appears available, try to claim it atomically + uint16_t expected = mask; + uint16_t desired = mask | (1U << i); // Set bit i to mark as in-use + + if (this->trq_in_use_.compare_exchange_weak(expected, desired, std::memory_order_acquire, + std::memory_order_relaxed)) { + // Successfully claimed slot i - prepare the TransferRequest + auto *trq = &this->requests_[i]; + trq->transfer->context = trq; + trq->transfer->device_handle = this->device_handle_; + return trq; + } + // Another thread claimed this slot, retry with updated mask + // Don't increment i - retry the same slot with the updated mask + mask = expected; } ESP_LOGE(TAG, "Too many requests queued (all %d slots in use)", MAX_REQUESTS); From 73ce3d4ef649f14747879f5a78137bc5a57e1e8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 18:44:08 -0500 Subject: [PATCH 1511/1626] reduce flash usag --- esphome/components/usb_host/usb_host_client.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index f37bf3ec64..a5dd1457ba 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -387,7 +387,7 @@ TransferRequest *USBClient::get_trq_() { mask = expected; } - ESP_LOGE(TAG, "Too many requests queued (all %d slots in use)", MAX_REQUESTS); + ESP_LOGE(TAG, "All %d transfer slots in use", MAX_REQUESTS); return nullptr; } void USBClient::disconnect() { From a37cd67bc3414a6b8e9a021ab9a12ee6fe93c315 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 18:49:53 -0500 Subject: [PATCH 1512/1626] add static assert to ensure we do not break it in the future --- esphome/components/usb_host/usb_host.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index e1e96cfce3..4f8d2ec9a8 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -52,7 +52,8 @@ static const uint8_t USB_DIR_IN = 1 << 7; static const uint8_t USB_DIR_OUT = 0; static const size_t SETUP_PACKET_SIZE = 8; -static const size_t MAX_REQUESTS = 16; // maximum number of outstanding requests possible. +static const size_t MAX_REQUESTS = 16; // maximum number of outstanding requests possible. +static_assert(MAX_REQUESTS <= 16, "MAX_REQUESTS must be <= 16 to fit in uint16_t bitmask"); static constexpr size_t USB_EVENT_QUEUE_SIZE = 32; // Size of event queue between USB task and main loop static constexpr size_t USB_TASK_STACK_SIZE = 4096; // Stack size for USB task (same as ESP-IDF USB examples) static constexpr UBaseType_t USB_TASK_PRIORITY = 5; // Higher priority than main loop (tskIDLE_PRIORITY + 5) @@ -164,6 +165,7 @@ class USBClient : public Component { // Bit i = 1: requests_[i] is in use, Bit i = 0: requests_[i] is available // Supports multiple concurrent consumers (both threads can allocate) // Single producer for deallocation (main loop only) + // Limited to 16 slots by uint16_t size (enforced by static_assert) std::atomic trq_in_use_; TransferRequest requests_[MAX_REQUESTS]{}; }; From f2ee0195defa3a98c86d891b03939c611605c3d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 18:53:31 -0500 Subject: [PATCH 1513/1626] fix retry --- esphome/components/usb_host/usb_host_client.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index a5dd1457ba..a83d7d83d2 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -382,9 +382,10 @@ TransferRequest *USBClient::get_trq_() { trq->transfer->device_handle = this->device_handle_; return trq; } - // Another thread claimed this slot, retry with updated mask - // Don't increment i - retry the same slot with the updated mask - mask = expected; + // CAS failed - another thread modified the bitmask + // Restart search from the beginning with the updated mask + mask = this->trq_in_use_.load(std::memory_order_relaxed); + i = 0; } ESP_LOGE(TAG, "All %d transfer slots in use", MAX_REQUESTS); From 5334ddd9f05869e9d7c20380dd069bb2287a3685 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 18:54:14 -0500 Subject: [PATCH 1514/1626] Update esphome/components/usb_host/usb_host_client.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/usb_host/usb_host_client.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index a83d7d83d2..d18b90b790 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -543,7 +543,7 @@ void USBClient::release_trq(TransferRequest *trq) { // Atomically clear bit i to mark slot as available // fetch_and with inverted bitmask clears the bit atomically uint16_t bit = 1U << index; - this->trq_in_use_.fetch_and(~bit, std::memory_order_release); + this->trq_in_use_.fetch_and(static_cast(~bit), std::memory_order_release); } } // namespace usb_host From f6253d52b4fa6da11d57113da702ec4d3b2b6397 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 20:28:03 -0500 Subject: [PATCH 1515/1626] [esp32_ble_server] Conditionally compile BLE automation features to save memory (#10910) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/esp32_ble_server/__init__.py | 37 +++++++++++++------ .../ble_server_automations.cpp | 10 +++++ .../esp32_ble_server/ble_server_automations.h | 16 ++++++++ esphome/core/defines.h | 7 ++++ 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index a8bb99b745..9eab9647b3 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -488,6 +488,7 @@ async def to_code_descriptor(descriptor_conf, char_var): cg.add(desc_var.set_value(value)) if CONF_ON_WRITE in descriptor_conf: on_write_conf = descriptor_conf[CONF_ON_WRITE] + cg.add_define("USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE") await automation.build_automation( BLETriggers_ns.create_descriptor_on_write_trigger(desc_var), [(cg.std_vector.template(cg.uint8), "x"), (cg.uint16, "id")], @@ -505,23 +506,32 @@ async def to_code_characteristic(service_var, char_conf): ) if CONF_ON_WRITE in char_conf: on_write_conf = char_conf[CONF_ON_WRITE] + cg.add_define("USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE") await automation.build_automation( BLETriggers_ns.create_characteristic_on_write_trigger(char_var), [(cg.std_vector.template(cg.uint8), "x"), (cg.uint16, "id")], on_write_conf, ) if CONF_VALUE in char_conf: - action_conf = { - CONF_ID: char_conf[CONF_ID], - CONF_VALUE: char_conf[CONF_VALUE], - } - value_action = await ble_server_characteristic_set_value( - action_conf, - char_conf[CONF_CHAR_VALUE_ACTION_ID_], - cg.TemplateArguments(), - {}, - ) - cg.add(value_action.play()) + # Check if the value is templated (Lambda) + value_data = char_conf[CONF_VALUE][CONF_DATA] + if isinstance(value_data, cv.Lambda): + # Templated value - need the full action infrastructure + action_conf = { + CONF_ID: char_conf[CONF_ID], + CONF_VALUE: char_conf[CONF_VALUE], + } + value_action = await ble_server_characteristic_set_value( + action_conf, + char_conf[CONF_CHAR_VALUE_ACTION_ID_], + cg.TemplateArguments(), + {}, + ) + cg.add(value_action.play()) + else: + # Static value - just set it directly without action infrastructure + value = await parse_value(char_conf[CONF_VALUE], {}) + cg.add(char_var.set_value(value)) for descriptor_conf in char_conf[CONF_DESCRIPTORS]: await to_code_descriptor(descriptor_conf, char_var) @@ -560,12 +570,14 @@ async def to_code(config): else: cg.add(var.enqueue_start_service(service_var)) if CONF_ON_CONNECT in config: + cg.add_define("USE_ESP32_BLE_SERVER_ON_CONNECT") await automation.build_automation( BLETriggers_ns.create_server_on_connect_trigger(var), [(cg.uint16, "id")], config[CONF_ON_CONNECT], ) if CONF_ON_DISCONNECT in config: + cg.add_define("USE_ESP32_BLE_SERVER_ON_DISCONNECT") await automation.build_automation( BLETriggers_ns.create_server_on_disconnect_trigger(var), [(cg.uint16, "id")], @@ -594,6 +606,7 @@ async def ble_server_characteristic_set_value(config, action_id, template_arg, a var = cg.new_Pvariable(action_id, template_arg, paren) value = await parse_value(config[CONF_VALUE], args) cg.add(var.set_buffer(value)) + cg.add_define("USE_ESP32_BLE_SERVER_SET_VALUE_ACTION") return var @@ -612,6 +625,7 @@ async def ble_server_descriptor_set_value(config, action_id, template_arg, args) var = cg.new_Pvariable(action_id, template_arg, paren) value = await parse_value(config[CONF_VALUE], args) cg.add(var.set_buffer(value)) + cg.add_define("USE_ESP32_BLE_SERVER_DESCRIPTOR_SET_VALUE_ACTION") return var @@ -629,4 +643,5 @@ async def ble_server_descriptor_set_value(config, action_id, template_arg, args) ) async def ble_server_characteristic_notify(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) + cg.add_define("USE_ESP32_BLE_SERVER_NOTIFY_ACTION") return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.cpp b/esphome/components/esp32_ble_server/ble_server_automations.cpp index b140e08b46..67e00a9bfe 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.cpp +++ b/esphome/components/esp32_ble_server/ble_server_automations.cpp @@ -9,6 +9,7 @@ namespace esp32_ble_server_automations { using namespace esp32_ble; +#ifdef USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE Trigger, uint16_t> *BLETriggers::create_characteristic_on_write_trigger( BLECharacteristic *characteristic) { Trigger, uint16_t> *on_write_trigger = // NOLINT(cppcoreguidelines-owning-memory) @@ -18,7 +19,9 @@ Trigger, uint16_t> *BLETriggers::create_characteristic_on_w [on_write_trigger](const std::vector &data, uint16_t id) { on_write_trigger->trigger(data, id); }); return on_write_trigger; } +#endif +#ifdef USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE Trigger, uint16_t> *BLETriggers::create_descriptor_on_write_trigger(BLEDescriptor *descriptor) { Trigger, uint16_t> *on_write_trigger = // NOLINT(cppcoreguidelines-owning-memory) new Trigger, uint16_t>(); @@ -27,21 +30,27 @@ Trigger, uint16_t> *BLETriggers::create_descriptor_on_write [on_write_trigger](const std::vector &data, uint16_t id) { on_write_trigger->trigger(data, id); }); return on_write_trigger; } +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_CONNECT Trigger *BLETriggers::create_server_on_connect_trigger(BLEServer *server) { Trigger *on_connect_trigger = new Trigger(); // NOLINT(cppcoreguidelines-owning-memory) server->on(BLEServerEvt::EmptyEvt::ON_CONNECT, [on_connect_trigger](uint16_t conn_id) { on_connect_trigger->trigger(conn_id); }); return on_connect_trigger; } +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_DISCONNECT Trigger *BLETriggers::create_server_on_disconnect_trigger(BLEServer *server) { Trigger *on_disconnect_trigger = new Trigger(); // NOLINT(cppcoreguidelines-owning-memory) server->on(BLEServerEvt::EmptyEvt::ON_DISCONNECT, [on_disconnect_trigger](uint16_t conn_id) { on_disconnect_trigger->trigger(conn_id); }); return on_disconnect_trigger; } +#endif +#ifdef USE_ESP32_BLE_SERVER_SET_VALUE_ACTION void BLECharacteristicSetValueActionManager::set_listener(BLECharacteristic *characteristic, EventEmitterListenerID listener_id, const std::function &pre_notify_listener) { @@ -90,6 +99,7 @@ void BLECharacteristicSetValueActionManager::remove_listener_(BLECharacteristic } } } +#endif } // namespace esp32_ble_server_automations } // namespace esp32_ble_server diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index 910335826c..8fcb5842c3 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -25,13 +25,22 @@ static constexpr EventEmitterListenerID INVALID_LISTENER_ID = 0; class BLETriggers { public: +#ifdef USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE static Trigger, uint16_t> *create_characteristic_on_write_trigger( BLECharacteristic *characteristic); +#endif +#ifdef USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE static Trigger, uint16_t> *create_descriptor_on_write_trigger(BLEDescriptor *descriptor); +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_CONNECT static Trigger *create_server_on_connect_trigger(BLEServer *server); +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_DISCONNECT static Trigger *create_server_on_disconnect_trigger(BLEServer *server); +#endif }; +#ifdef USE_ESP32_BLE_SERVER_SET_VALUE_ACTION enum BLECharacteristicSetValueActionEvt { PRE_NOTIFY, }; @@ -97,13 +106,17 @@ template class BLECharacteristicSetValueAction : public Action class BLECharacteristicNotifyAction : public Action { public: BLECharacteristicNotifyAction(BLECharacteristic *characteristic) : parent_(characteristic) {} void play(Ts... x) override { +#ifdef USE_ESP32_BLE_SERVER_SET_VALUE_ACTION // Call the pre-notify event BLECharacteristicSetValueActionManager::get_instance()->emit_pre_notify(this->parent_); +#endif // Notify the characteristic this->parent_->notify(); } @@ -111,7 +124,9 @@ template class BLECharacteristicNotifyAction : public Action class BLEDescriptorSetValueAction : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} @@ -122,6 +137,7 @@ template class BLEDescriptorSetValueAction : public Action Date: Sun, 28 Sep 2025 21:29:38 -0500 Subject: [PATCH 1516/1626] no need to copy --- esphome/components/usb_host/usb_host_client.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index d18b90b790..082575e364 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -371,11 +371,9 @@ TransferRequest *USBClient::get_trq_() { } // Slot i appears available, try to claim it atomically - uint16_t expected = mask; uint16_t desired = mask | (1U << i); // Set bit i to mark as in-use - if (this->trq_in_use_.compare_exchange_weak(expected, desired, std::memory_order_acquire, - std::memory_order_relaxed)) { + if (this->trq_in_use_.compare_exchange_weak(mask, desired, std::memory_order_acquire, std::memory_order_relaxed)) { // Successfully claimed slot i - prepare the TransferRequest auto *trq = &this->requests_[i]; trq->transfer->context = trq; @@ -383,8 +381,8 @@ TransferRequest *USBClient::get_trq_() { return trq; } // CAS failed - another thread modified the bitmask - // Restart search from the beginning with the updated mask - mask = this->trq_in_use_.load(std::memory_order_relaxed); + // mask was already updated by compare_exchange_weak with the current value + // No need to reload - the CAS already did that for us i = 0; } From b8bbe91e675d333985650f1ff162ecd40d7936f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 21:33:35 -0500 Subject: [PATCH 1517/1626] switch to != per discord review comemnts --- esphome/components/usb_host/usb_host_client.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 082575e364..b26385a8ef 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -363,7 +363,7 @@ TransferRequest *USBClient::get_trq_() { // Find first available slot (bit = 0) and try to claim it atomically // We use a while loop to allow retrying the same slot after CAS failure size_t i = 0; - while (i < MAX_REQUESTS) { + while (i != MAX_REQUESTS) { if (mask & (1U << i)) { // Slot is in use, move to next slot i++; From 7975f12d60ddb85cdd0bfd7ea6bc61a51a686b0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 22:31:27 -0500 Subject: [PATCH 1518/1626] [esp32] deep sleep fixes to align with variant support --- esphome/components/deep_sleep/__init__.py | 11 ++- .../deep_sleep/deep_sleep_component.h | 24 +++--- .../deep_sleep/deep_sleep_esp32.cpp | 78 ++++++++++++------- .../deep_sleep/common-esp32-all.yaml | 14 ++++ .../deep_sleep/common-esp32-ext1.yaml | 12 +++ .../deep_sleep/test.esp32-c2-idf.yaml | 10 +++ .../deep_sleep/test.esp32-c6-idf.yaml | 2 +- .../deep_sleep/test.esp32-h2-idf.yaml | 16 ++++ .../components/deep_sleep/test.esp32-idf.yaml | 2 +- .../deep_sleep/test.esp32-s2-idf.yaml | 2 +- .../deep_sleep/test.esp32-s3-idf.yaml | 2 +- .../build_components_base.esp32-c2-idf.yaml | 17 ++++ .../build_components_base.esp32-h2-idf.yaml | 17 ++++ 13 files changed, 163 insertions(+), 44 deletions(-) create mode 100644 tests/components/deep_sleep/common-esp32-all.yaml create mode 100644 tests/components/deep_sleep/common-esp32-ext1.yaml create mode 100644 tests/components/deep_sleep/test.esp32-c2-idf.yaml create mode 100644 tests/components/deep_sleep/test.esp32-h2-idf.yaml create mode 100644 tests/test_build_components/build_components_base.esp32-c2-idf.yaml create mode 100644 tests/test_build_components/build_components_base.esp32-h2-idf.yaml diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 05ae60239d..19fb726016 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -197,7 +197,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ESP32_EXT1_WAKEUP): cv.All( cv.only_on_esp32, esp32.only_on_variant( - unsupported=[VARIANT_ESP32C3], msg_prefix="Wakeup from ext1" + unsupported=[VARIANT_ESP32C2, VARIANT_ESP32C3], + msg_prefix="Wakeup from ext1", ), cv.Schema( { @@ -214,7 +215,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_TOUCH_WAKEUP): cv.All( cv.only_on_esp32, esp32.only_on_variant( - unsupported=[VARIANT_ESP32C3], msg_prefix="Wakeup from touch" + unsupported=[ + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + ], + msg_prefix="Wakeup from touch", ), cv.boolean, ), diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 7a640b9ea5..38744163c7 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -34,7 +34,7 @@ enum WakeupPinMode { WAKEUP_PIN_MODE_INVERT_WAKEUP, }; -#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32C3) +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) struct Ext1Wakeup { uint64_t mask; esp_sleep_ext1_wakeup_mode_t wakeup_mode; @@ -50,7 +50,7 @@ struct WakeupCauseToRunDuration { uint32_t gpio_cause; }; -#endif +#endif // USE_ESP32 template class EnterDeepSleepAction; @@ -73,20 +73,22 @@ class DeepSleepComponent : public Component { void set_wakeup_pin(InternalGPIOPin *pin) { this->wakeup_pin_ = pin; } void set_wakeup_pin_mode(WakeupPinMode wakeup_pin_mode); -#endif +#endif // USE_ESP32 #if defined(USE_ESP32) -#if !defined(USE_ESP32_VARIANT_ESP32C3) - +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) void set_ext1_wakeup(Ext1Wakeup ext1_wakeup); - - void set_touch_wakeup(bool touch_wakeup); - #endif + +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + void set_touch_wakeup(bool touch_wakeup); +#endif + // Set the duration in ms for how long the code should run before entering // deep sleep mode, according to the cause the ESP32 has woken. void set_run_duration(WakeupCauseToRunDuration wakeup_cause_to_run_duration); -#endif +#endif // USE_ESP32 /// Set a duration in ms for how long the code should run before entering deep sleep mode. void set_run_duration(uint32_t time_ms); @@ -117,13 +119,13 @@ class DeepSleepComponent : public Component { InternalGPIOPin *wakeup_pin_; WakeupPinMode wakeup_pin_mode_{WAKEUP_PIN_MODE_IGNORE}; -#if !defined(USE_ESP32_VARIANT_ESP32C3) +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) optional ext1_wakeup_; #endif optional touch_wakeup_; optional wakeup_cause_to_run_duration_; -#endif +#endif // USE_ESP32 optional run_duration_; bool next_enter_deep_sleep_{false}; bool prevent_{false}; diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index e9d0a4981f..b93d9ce601 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -7,6 +7,26 @@ namespace esphome { namespace deep_sleep { +// Deep Sleep feature support matrix for ESP32 variants: +// +// | Variant | ext0 | ext1 | Touch | GPIO wakeup | +// |-----------|------|------|-------|-------------| +// | ESP32 | ✓ | ✓ | ✓ | | +// | ESP32-S2 | ✓ | ✓ | ✓ | | +// | ESP32-S3 | ✓ | ✓ | ✓ | | +// | ESP32-C2 | | | | ✓ | +// | ESP32-C3 | | | | ✓ | +// | ESP32-C5 | | (✓) | | (✓) | +// | ESP32-C6 | | ✓ | | ✓ | +// | ESP32-H2 | | ✓ | | | +// +// Notes: +// - (✓) = Supported by hardware but not yet implemented in ESPHome +// - ext0: Single pin wakeup using RTC GPIO (esp_sleep_enable_ext0_wakeup) +// - ext1: Multiple pin wakeup (esp_sleep_enable_ext1_wakeup) +// - Touch: Touch pad wakeup (esp_sleep_enable_touchpad_wakeup) +// - GPIO wakeup: GPIO wakeup for non-RTC pins (esp_deep_sleep_enable_gpio_wakeup) + static const char *const TAG = "deep_sleep"; optional DeepSleepComponent::get_run_duration_() const { @@ -30,13 +50,13 @@ void DeepSleepComponent::set_wakeup_pin_mode(WakeupPinMode wakeup_pin_mode) { this->wakeup_pin_mode_ = wakeup_pin_mode; } -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wakeup_ = ext1_wakeup; } - -#if !defined(USE_ESP32_VARIANT_ESP32H2) -void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) +void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif void DeepSleepComponent::set_run_duration(WakeupCauseToRunDuration wakeup_cause_to_run_duration) { @@ -72,9 +92,13 @@ bool DeepSleepComponent::prepare_to_sleep_() { } void DeepSleepComponent::deep_sleep_() { -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + // Timer wakeup - all variants support this if (this->sleep_duration_.has_value()) esp_sleep_enable_timer_wakeup(*this->sleep_duration_); + + // Single pin wakeup (ext0) - ESP32, S2, S3 only +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -95,32 +119,15 @@ void DeepSleepComponent::deep_sleep_() { } esp_sleep_enable_ext0_wakeup(gpio_pin, level); } - if (this->ext1_wakeup_.has_value()) { - esp_sleep_enable_ext1_wakeup(this->ext1_wakeup_->mask, this->ext1_wakeup_->wakeup_mode); - } - - if (this->touch_wakeup_.has_value() && *(this->touch_wakeup_)) { - esp_sleep_enable_touchpad_wakeup(); - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - } #endif -#if defined(USE_ESP32_VARIANT_ESP32H2) - if (this->sleep_duration_.has_value()) - esp_sleep_enable_timer_wakeup(*this->sleep_duration_); - if (this->ext1_wakeup_.has_value()) { - esp_sleep_enable_ext1_wakeup(this->ext1_wakeup_->mask, this->ext1_wakeup_->wakeup_mode); - } -#endif - -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) - if (this->sleep_duration_.has_value()) - esp_sleep_enable_timer_wakeup(*this->sleep_duration_); + // GPIO wakeup - C2, C3, C6 only +#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); - if (this->wakeup_pin_->get_flags() && gpio::FLAG_PULLUP) { + if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLUP) { gpio_sleep_set_pull_mode(gpio_pin, GPIO_PULLUP_ONLY); - } else if (this->wakeup_pin_->get_flags() && gpio::FLAG_PULLDOWN) { + } else if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLDOWN) { gpio_sleep_set_pull_mode(gpio_pin, GPIO_PULLDOWN_ONLY); } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); @@ -138,9 +145,26 @@ void DeepSleepComponent::deep_sleep_() { static_cast(level)); } #endif + + // Multiple pin wakeup (ext1) - All except C2, C3 +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) + if (this->ext1_wakeup_.has_value()) { + esp_sleep_enable_ext1_wakeup(this->ext1_wakeup_->mask, this->ext1_wakeup_->wakeup_mode); + } +#endif + + // Touch wakeup - ESP32, S2, S3 only +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + if (this->touch_wakeup_.has_value() && *(this->touch_wakeup_)) { + esp_sleep_enable_touchpad_wakeup(); + esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + } +#endif + esp_deep_sleep_start(); } } // namespace deep_sleep } // namespace esphome -#endif +#endif // USE_ESP32 diff --git a/tests/components/deep_sleep/common-esp32-all.yaml b/tests/components/deep_sleep/common-esp32-all.yaml new file mode 100644 index 0000000000..b97eec76b9 --- /dev/null +++ b/tests/components/deep_sleep/common-esp32-all.yaml @@ -0,0 +1,14 @@ +deep_sleep: + run_duration: + default: 10s + gpio_wakeup_reason: 30s + touch_wakeup_reason: 15s + sleep_duration: 50s + wakeup_pin: ${wakeup_pin} + wakeup_pin_mode: INVERT_WAKEUP + esp32_ext1_wakeup: + pins: + - number: GPIO2 + - number: GPIO13 + mode: ANY_HIGH + touch_wakeup: true diff --git a/tests/components/deep_sleep/common-esp32-ext1.yaml b/tests/components/deep_sleep/common-esp32-ext1.yaml new file mode 100644 index 0000000000..9ed4279a33 --- /dev/null +++ b/tests/components/deep_sleep/common-esp32-ext1.yaml @@ -0,0 +1,12 @@ +deep_sleep: + run_duration: + default: 10s + gpio_wakeup_reason: 30s + sleep_duration: 50s + wakeup_pin: ${wakeup_pin} + wakeup_pin_mode: INVERT_WAKEUP + esp32_ext1_wakeup: + pins: + - number: GPIO2 + - number: GPIO5 + mode: ANY_HIGH diff --git a/tests/components/deep_sleep/test.esp32-c2-idf.yaml b/tests/components/deep_sleep/test.esp32-c2-idf.yaml new file mode 100644 index 0000000000..7023bf67d6 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c2-idf.yaml @@ -0,0 +1,10 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml + +deep_sleep: + run_duration: 10s + sleep_duration: 50s + wakeup_pin: ${wakeup_pin} + wakeup_pin_mode: INVERT_WAKEUP diff --git a/tests/components/deep_sleep/test.esp32-c6-idf.yaml b/tests/components/deep_sleep/test.esp32-c6-idf.yaml index 10c17af0f5..11abe70711 100644 --- a/tests/components/deep_sleep/test.esp32-c6-idf.yaml +++ b/tests/components/deep_sleep/test.esp32-c6-idf.yaml @@ -2,4 +2,4 @@ substitutions: wakeup_pin: GPIO4 <<: !include common.yaml -<<: !include common-esp32.yaml +<<: !include common-esp32-ext1.yaml diff --git a/tests/components/deep_sleep/test.esp32-h2-idf.yaml b/tests/components/deep_sleep/test.esp32-h2-idf.yaml new file mode 100644 index 0000000000..e3c46c5176 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-h2-idf.yaml @@ -0,0 +1,16 @@ +substitutions: + wakeup_pin: GPIO9 + +<<: !include common.yaml + +deep_sleep: + run_duration: + default: 10s + gpio_wakeup_reason: 30s + sleep_duration: 50s + esp32_ext1_wakeup: + pins: + - number: GPIO7 + - number: GPIO8 + - number: GPIO9 + mode: ANY_HIGH diff --git a/tests/components/deep_sleep/test.esp32-idf.yaml b/tests/components/deep_sleep/test.esp32-idf.yaml index 10c17af0f5..e45eb08349 100644 --- a/tests/components/deep_sleep/test.esp32-idf.yaml +++ b/tests/components/deep_sleep/test.esp32-idf.yaml @@ -2,4 +2,4 @@ substitutions: wakeup_pin: GPIO4 <<: !include common.yaml -<<: !include common-esp32.yaml +<<: !include common-esp32-all.yaml diff --git a/tests/components/deep_sleep/test.esp32-s2-idf.yaml b/tests/components/deep_sleep/test.esp32-s2-idf.yaml index 10c17af0f5..e45eb08349 100644 --- a/tests/components/deep_sleep/test.esp32-s2-idf.yaml +++ b/tests/components/deep_sleep/test.esp32-s2-idf.yaml @@ -2,4 +2,4 @@ substitutions: wakeup_pin: GPIO4 <<: !include common.yaml -<<: !include common-esp32.yaml +<<: !include common-esp32-all.yaml diff --git a/tests/components/deep_sleep/test.esp32-s3-idf.yaml b/tests/components/deep_sleep/test.esp32-s3-idf.yaml index 10c17af0f5..e45eb08349 100644 --- a/tests/components/deep_sleep/test.esp32-s3-idf.yaml +++ b/tests/components/deep_sleep/test.esp32-s3-idf.yaml @@ -2,4 +2,4 @@ substitutions: wakeup_pin: GPIO4 <<: !include common.yaml -<<: !include common-esp32.yaml +<<: !include common-esp32-all.yaml diff --git a/tests/test_build_components/build_components_base.esp32-c2-idf.yaml b/tests/test_build_components/build_components_base.esp32-c2-idf.yaml new file mode 100644 index 0000000000..a0ae9e3a46 --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-c2-idf.yaml @@ -0,0 +1,17 @@ +esphome: + name: componenttestesp32c2idf + friendly_name: $component_name + +esp32: + board: esp32-c2-devkitm-1 + framework: + type: esp-idf + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/test_build_components/build_components_base.esp32-h2-idf.yaml b/tests/test_build_components/build_components_base.esp32-h2-idf.yaml new file mode 100644 index 0000000000..5e668a9a97 --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-h2-idf.yaml @@ -0,0 +1,17 @@ +esphome: + name: componenttestesp32h2idf + friendly_name: $component_name + +esp32: + board: esp32-h2-devkitm-1 + framework: + type: esp-idf + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file From a56d044d981647ed454693f5649a75bfcf9260c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 22:42:58 -0500 Subject: [PATCH 1519/1626] [api] Prevent API from overriding noise encryption keys set in YAML (#10927) --- esphome/components/api/__init__.py | 1 + esphome/components/api/api_server.cpp | 11 +++- .../noise_encryption_key_protection.yaml | 10 ++++ .../test_noise_encryption_key_protection.py | 51 +++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/noise_encryption_key_protection.yaml create mode 100644 tests/integration/test_noise_encryption_key_protection.py diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 394d3ead43..6a0e092008 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -224,6 +224,7 @@ async def to_code(config): if key := encryption_config.get(CONF_KEY): decoded = base64.b64decode(key) cg.add(var.set_noise_psk(list(decoded))) + cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired # This will allow a plaintext client to provide a noise key, diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1d5c06092f..dd6eb950a6 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -37,12 +37,14 @@ void APIServer::setup() { this->noise_pref_ = global_preferences->make_preference(hash, true); +#ifndef USE_API_NOISE_PSK_FROM_YAML + // Only load saved PSK if not set from YAML SavedNoisePsk noise_pref_saved{}; if (this->noise_pref_.load(&noise_pref_saved)) { ESP_LOGD(TAG, "Loaded saved Noise PSK"); - this->set_noise_psk(noise_pref_saved.psk); } +#endif #endif // Schedule reboot if no clients connect within timeout @@ -419,6 +421,12 @@ void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeo #ifdef USE_API_NOISE bool APIServer::save_noise_psk(psk_t psk, bool make_active) { +#ifdef USE_API_NOISE_PSK_FROM_YAML + // When PSK is set from YAML, this function should never be called + // but if it is, reject the change + ESP_LOGW(TAG, "Key set in YAML"); + return false; +#else auto &old_psk = this->noise_ctx_->get_psk(); if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) { ESP_LOGW(TAG, "New PSK matches old"); @@ -447,6 +455,7 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { }); } return true; +#endif } #endif diff --git a/tests/integration/fixtures/noise_encryption_key_protection.yaml b/tests/integration/fixtures/noise_encryption_key_protection.yaml new file mode 100644 index 0000000000..3ce84cd373 --- /dev/null +++ b/tests/integration/fixtures/noise_encryption_key_protection.yaml @@ -0,0 +1,10 @@ +esphome: + name: noise-key-test + +host: + +api: + encryption: + key: "zX9/JHxMKwpP0jUGsF0iESCm1wRvNgR6NkKVOhn7kSs=" + +logger: diff --git a/tests/integration/test_noise_encryption_key_protection.py b/tests/integration/test_noise_encryption_key_protection.py new file mode 100644 index 0000000000..03c43ca8d3 --- /dev/null +++ b/tests/integration/test_noise_encryption_key_protection.py @@ -0,0 +1,51 @@ +"""Integration test for noise encryption key protection from YAML.""" + +from __future__ import annotations + +import base64 + +from aioesphomeapi import InvalidEncryptionKeyAPIError +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_noise_encryption_key_protection( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that noise encryption key set in YAML cannot be changed via API.""" + # The key that's set in the YAML fixture + noise_psk = "zX9/JHxMKwpP0jUGsF0iESCm1wRvNgR6NkKVOhn7kSs=" + + # Keep ESPHome process running throughout all tests + async with run_compiled(yaml_config): + # First connection - test key change attempt + async with api_client_connected(noise_psk=noise_psk) as client: + # Verify connection is established + device_info = await client.device_info() + assert device_info is not None + + # Try to set a new encryption key via API + new_key = base64.b64encode( + b"x" * 32 + ) # Valid 32-byte key in base64 as bytes + + # This should fail since key was set in YAML + success = await client.noise_encryption_set_key(new_key) + assert success is False + + # Reconnect with the original key to verify it still works + async with api_client_connected(noise_psk=noise_psk) as client: + # Verify connection is still successful with original key + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "noise-key-test" + + # Verify that connecting with a wrong key fails + wrong_key = base64.b64encode(b"y" * 32).decode() # Different key + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=wrong_key) as client: + await client.device_info() From 0246a8eb1de7f3bd820d29df75c2ab4dc723193c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 23:48:51 -0500 Subject: [PATCH 1520/1626] [usb_host] Fix double-free crash with lock-free atomic pool allocation (#10926) --- esphome/components/usb_host/usb_host.h | 47 +++++++--- .../components/usb_host/usb_host_client.cpp | 85 ++++++++++++++++--- esphome/components/usb_uart/usb_uart.cpp | 13 ++- 3 files changed, 117 insertions(+), 28 deletions(-) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 8cf313aa9b..4f8d2ec9a8 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -9,11 +9,31 @@ #include #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" -#include +#include namespace esphome { namespace usb_host { +// THREADING MODEL: +// This component uses a dedicated USB task for event processing to prevent data loss. +// - USB Task (high priority): Handles USB events, executes transfer callbacks +// - Main Loop Task: Initiates transfers, processes completion events +// +// Thread-safe communication: +// - Lock-free queues for USB task -> main loop events (SPSC pattern) +// - Lock-free TransferRequest pool using atomic bitmask (MCSP pattern) +// +// TransferRequest pool access pattern: +// - get_trq_() [allocate]: Called from BOTH USB task and main loop threads +// * USB task: via USB UART input callbacks that restart transfers immediately +// * Main loop: for output transfers and flow-controlled input restarts +// - release_trq() [deallocate]: Called from main loop thread only +// +// The multi-threaded allocation is intentional for performance: +// - USB task can immediately restart input transfers without context switching +// - Main loop controls backpressure by deciding when to restart after consuming data +// The atomic bitmask ensures thread-safe allocation without mutex blocking. + static const char *const TAG = "usb_host"; // Forward declarations @@ -32,7 +52,8 @@ static const uint8_t USB_DIR_IN = 1 << 7; static const uint8_t USB_DIR_OUT = 0; static const size_t SETUP_PACKET_SIZE = 8; -static const size_t MAX_REQUESTS = 16; // maximum number of outstanding requests possible. +static const size_t MAX_REQUESTS = 16; // maximum number of outstanding requests possible. +static_assert(MAX_REQUESTS <= 16, "MAX_REQUESTS must be <= 16 to fit in uint16_t bitmask"); static constexpr size_t USB_EVENT_QUEUE_SIZE = 32; // Size of event queue between USB task and main loop static constexpr size_t USB_TASK_STACK_SIZE = 4096; // Stack size for USB task (same as ESP-IDF USB examples) static constexpr UBaseType_t USB_TASK_PRIORITY = 5; // Higher priority than main loop (tskIDLE_PRIORITY + 5) @@ -98,13 +119,7 @@ class USBClient : public Component { friend class USBHost; public: - USBClient(uint16_t vid, uint16_t pid) : vid_(vid), pid_(pid) { init_pool(); } - - void init_pool() { - this->trq_pool_.clear(); - for (size_t i = 0; i != MAX_REQUESTS; i++) - this->trq_pool_.push_back(&this->requests_[i]); - } + USBClient(uint16_t vid, uint16_t pid) : vid_(vid), pid_(pid), trq_in_use_(0) {} void setup() override; void loop() override; // setup must happen after the host bus has been setup @@ -126,10 +141,13 @@ class USBClient : public Component { protected: bool register_(); - TransferRequest *get_trq_(); + TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe) virtual void disconnect(); virtual void on_connected() {} - virtual void on_disconnected() { this->init_pool(); } + virtual void on_disconnected() { + // Reset all requests to available (all bits to 0) + this->trq_in_use_.store(0); + } // USB task management static void usb_task_fn(void *arg); @@ -143,7 +161,12 @@ class USBClient : public Component { int state_{USB_CLIENT_INIT}; uint16_t vid_{}; uint16_t pid_{}; - std::list trq_pool_{}; + // Lock-free pool management using atomic bitmask (no dynamic allocation) + // Bit i = 1: requests_[i] is in use, Bit i = 0: requests_[i] is available + // Supports multiple concurrent consumers (both threads can allocate) + // Single producer for deallocation (main loop only) + // Limited to 16 slots by uint16_t size (enforced by static_assert) + std::atomic trq_in_use_; TransferRequest requests_[MAX_REQUESTS]{}; }; class USBHost : public Component { diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index a9d4d42a8c..b26385a8ef 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace esphome { namespace usb_host { @@ -185,9 +186,11 @@ void USBClient::setup() { this->mark_failed(); return; } - for (auto *trq : this->trq_pool_) { - usb_host_transfer_alloc(64, 0, &trq->transfer); - trq->client = this; + // Pre-allocate USB transfer buffers for all slots at startup + // This avoids any dynamic allocation during runtime + for (size_t i = 0; i < MAX_REQUESTS; i++) { + usb_host_transfer_alloc(64, 0, &this->requests_[i].transfer); + this->requests_[i].client = this; // Set once, never changes } // Create and start USB task @@ -347,17 +350,44 @@ static void control_callback(const usb_transfer_t *xfer) { queue_transfer_cleanup(trq, EVENT_CONTROL_COMPLETE); } +// THREAD CONTEXT: Called from both USB task and main loop threads (multi-consumer) +// - USB task: USB UART input callbacks restart transfers for immediate data reception +// - Main loop: Output transfers and flow-controlled input restarts after consuming data +// +// THREAD SAFETY: Lock-free using atomic compare-and-swap on bitmask +// This multi-threaded access is intentional for performance - USB task can +// immediately restart transfers without waiting for main loop scheduling. TransferRequest *USBClient::get_trq_() { - if (this->trq_pool_.empty()) { - ESP_LOGE(TAG, "Too many requests queued"); - return nullptr; + uint16_t mask = this->trq_in_use_.load(std::memory_order_relaxed); + + // Find first available slot (bit = 0) and try to claim it atomically + // We use a while loop to allow retrying the same slot after CAS failure + size_t i = 0; + while (i != MAX_REQUESTS) { + if (mask & (1U << i)) { + // Slot is in use, move to next slot + i++; + continue; + } + + // Slot i appears available, try to claim it atomically + uint16_t desired = mask | (1U << i); // Set bit i to mark as in-use + + if (this->trq_in_use_.compare_exchange_weak(mask, desired, std::memory_order_acquire, std::memory_order_relaxed)) { + // Successfully claimed slot i - prepare the TransferRequest + auto *trq = &this->requests_[i]; + trq->transfer->context = trq; + trq->transfer->device_handle = this->device_handle_; + return trq; + } + // CAS failed - another thread modified the bitmask + // mask was already updated by compare_exchange_weak with the current value + // No need to reload - the CAS already did that for us + i = 0; } - auto *trq = this->trq_pool_.front(); - this->trq_pool_.pop_front(); - trq->client = this; - trq->transfer->context = trq; - trq->transfer->device_handle = this->device_handle_; - return trq; + + ESP_LOGE(TAG, "All %d transfer slots in use", MAX_REQUESTS); + return nullptr; } void USBClient::disconnect() { this->on_disconnected(); @@ -370,6 +400,8 @@ void USBClient::disconnect() { this->device_addr_ = -1; } +// THREAD CONTEXT: Called from main loop thread only +// - Used for device configuration and control operations bool USBClient::control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback, const std::vector &data) { auto *trq = this->get_trq_(); @@ -425,6 +457,9 @@ static void transfer_callback(usb_transfer_t *xfer) { } /** * Performs a transfer input operation. + * THREAD CONTEXT: Called from both USB task and main loop threads! + * - USB task: USB UART input callbacks call start_input() which calls this + * - Main loop: Initial setup and other components * * @param ep_address The endpoint address. * @param callback The callback function to be called when the transfer is complete. @@ -451,6 +486,9 @@ void USBClient::transfer_in(uint8_t ep_address, const transfer_cb_t &callback, u /** * Performs an output transfer operation. + * THREAD CONTEXT: Called from main loop thread only + * - USB UART output uses defer() to ensure main loop context + * - Modbus and other components call from loop() * * @param ep_address The endpoint address. * @param callback The callback function to be called when the transfer is complete. @@ -483,7 +521,28 @@ void USBClient::dump_config() { " Product id %04X", this->vid_, this->pid_); } -void USBClient::release_trq(TransferRequest *trq) { this->trq_pool_.push_back(trq); } +// THREAD CONTEXT: Only called from main loop thread (single producer for deallocation) +// - Via event processing when handling EVENT_TRANSFER_COMPLETE/EVENT_CONTROL_COMPLETE +// - Directly when transfer submission fails +// +// THREAD SAFETY: Lock-free using atomic AND to clear bit +// Single-producer pattern makes this simpler than allocation +void USBClient::release_trq(TransferRequest *trq) { + if (trq == nullptr) + return; + + // Calculate index from pointer arithmetic + size_t index = trq - this->requests_; + if (index >= MAX_REQUESTS) { + ESP_LOGE(TAG, "Invalid TransferRequest pointer"); + return; + } + + // Atomically clear bit i to mark slot as available + // fetch_and with inverted bitmask clears the bit atomically + uint16_t bit = 1U << index; + this->trq_in_use_.fetch_and(static_cast(~bit), std::memory_order_release); +} } // namespace usb_host } // namespace esphome diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index a8a8bc231c..29003e071e 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -216,9 +216,16 @@ void USBUartComponent::dump_config() { void USBUartComponent::start_input(USBUartChannel *channel) { if (!channel->initialised_.load() || channel->input_started_.load()) return; - // Note: This function is called from both USB task and main loop, so we cannot - // directly check ring buffer space here. Backpressure is handled by the chunk pool: - // when exhausted, USB input stops until chunks are freed by the main loop + // THREAD CONTEXT: Called from both USB task and main loop threads + // - USB task: Immediate restart after successful transfer for continuous data flow + // - Main loop: Controlled restart after consuming data (backpressure mechanism) + // + // This dual-thread access is intentional for performance: + // - USB task restarts avoid context switch delays for high-speed data + // - Main loop restarts provide flow control when buffers are full + // + // The underlying transfer_in() uses lock-free atomic allocation from the + // TransferRequest pool, making this multi-threaded access safe const auto *ep = channel->cdc_dev_.in_ep; // CALLBACK CONTEXT: This lambda is executed in USB task via transfer_callback auto callback = [this, channel](const usb_host::TransferStatus &status) { From b9361b0868500ce4eec9b7ae0b71308185d02819 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:08:55 -0600 Subject: [PATCH 1521/1626] [esp32_improv] Disable loop by default until provisioning needed (#10764) --- esphome/components/esp32_improv/esp32_improv_component.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index d41094fda1..d47cc50a00 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -31,6 +31,9 @@ void ESP32ImprovComponent::setup() { #endif global_ble_server->on(BLEServerEvt::EmptyEvt::ON_DISCONNECT, [this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); }); + + // Start with loop disabled - will be enabled by start() when needed + this->disable_loop(); } void ESP32ImprovComponent::setup_characteristics() { From 6d9fc672d5734908b3ce4ac37bf528096ef64bf2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:21:19 -0400 Subject: [PATCH 1522/1626] [libretiny] Fix lib_ignore handling and ignore incompatible libraries (#10846) --- esphome/components/heatpumpir/climate.py | 2 +- esphome/components/web_server_base/__init__.py | 2 ++ esphome/core/config.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 4f83bf2435..ec6eac670f 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -128,4 +128,4 @@ async def to_code(config): cg.add_library("tonia/HeatpumpIR", "1.0.37") if CORE.is_libretiny or CORE.is_esp32: - CORE.add_platformio_option("lib_ignore", "IRremoteESP8266") + CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 2aff405036..a82ec462d9 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -40,5 +40,7 @@ async def to_code(config): cg.add_library("Update", None) if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) + if CORE.is_libretiny: + CORE.add_platformio_option("lib_ignore", ["ESPAsyncTCP", "RPAsyncTCP"]) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json cg.add_library("ESP32Async/ESPAsyncWebServer", "3.7.10") diff --git a/esphome/core/config.py b/esphome/core/config.py index 96b9e23861..87e529143d 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -396,7 +396,7 @@ async def add_includes(includes): async def _add_platformio_options(pio_options): # Add includes at the very end, so that they override everything for key, val in pio_options.items(): - if key == "build_flags" and not isinstance(val, list): + if key in ["build_flags", "lib_ignore"] and not isinstance(val, list): val = [val] cg.add_platformio_option(key, val) From 1ecd26adb5b87515d0eae1b74b9286f1636f17c0 Mon Sep 17 00:00:00 2001 From: Stuart Parmenter Date: Wed, 24 Sep 2025 06:59:16 -0700 Subject: [PATCH 1523/1626] Set color_order to RGB for the Waveshare ESP32-S3-TOUCH-LCD-4.3 and ESP32-S3-TOUCH-LCD-7-800X480 (#10835) --- esphome/components/mipi_rgb/models/waveshare.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/mipi_rgb/models/waveshare.py b/esphome/components/mipi_rgb/models/waveshare.py index 49a75da232..a38493e816 100644 --- a/esphome/components/mipi_rgb/models/waveshare.py +++ b/esphome/components/mipi_rgb/models/waveshare.py @@ -7,6 +7,7 @@ wave_4_3 = DriverChip( "ESP32-S3-TOUCH-LCD-4.3", swap_xy=UNDEFINED, initsequence=(), + color_order="RGB", width=800, height=480, pclk_frequency="16MHz", From f2a9e9265ee7cf32215507aa6fdcf0c5b94121a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 09:24:17 -0500 Subject: [PATCH 1524/1626] [esp32_improv] Fix crashes from uninitialized pointers and missing null checks (#10902) --- .../esp32_improv/esp32_improv_component.cpp | 74 ++++++++++++++----- .../esp32_improv/esp32_improv_component.h | 15 ++-- 2 files changed, 66 insertions(+), 23 deletions(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index d47cc50a00..c5a0b89f99 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -15,6 +15,8 @@ using namespace bytebuffer; static const char *const TAG = "esp32_improv.component"; static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; +static constexpr uint16_t STOP_ADVERTISING_DELAY = + 10000; // Delay (ms) before stopping service to allow BLE clients to read the final state ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } @@ -193,6 +195,25 @@ void ESP32ImprovComponent::set_status_indicator_state_(bool state) { #endif } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG +const char *ESP32ImprovComponent::state_to_string_(improv::State state) { + switch (state) { + case improv::STATE_STOPPED: + return "STOPPED"; + case improv::STATE_AWAITING_AUTHORIZATION: + return "AWAITING_AUTHORIZATION"; + case improv::STATE_AUTHORIZED: + return "AUTHORIZED"; + case improv::STATE_PROVISIONING: + return "PROVISIONING"; + case improv::STATE_PROVISIONED: + return "PROVISIONED"; + default: + return "UNKNOWN"; + } +} +#endif + bool ESP32ImprovComponent::check_identify_() { uint32_t now = millis(); @@ -206,31 +227,42 @@ bool ESP32ImprovComponent::check_identify_() { } void ESP32ImprovComponent::set_state_(improv::State state) { - ESP_LOGV(TAG, "Setting state: %d", state); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + if (this->state_ != state) { + ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_, + this->state_to_string_(state), state); + } +#endif this->state_ = state; - if (this->status_->get_value().empty() || this->status_->get_value()[0] != state) { + if (this->status_ != nullptr && (this->status_->get_value().empty() || this->status_->get_value()[0] != state)) { this->status_->set_value(ByteBuffer::wrap(static_cast(state))); if (state != improv::STATE_STOPPED) this->status_->notify(); } - std::vector service_data(8, 0); - service_data[0] = 0x77; // PR - service_data[1] = 0x46; // IM - service_data[2] = static_cast(state); + // Only advertise valid Improv states (0x01-0x04). + // STATE_STOPPED (0x00) is internal only and not part of the Improv spec. + // Advertising 0x00 causes undefined behavior in some clients and makes them + // repeatedly connect trying to determine the actual state. + if (state != improv::STATE_STOPPED) { + std::vector service_data(8, 0); + service_data[0] = 0x77; // PR + service_data[1] = 0x46; // IM + service_data[2] = static_cast(state); - uint8_t capabilities = 0x00; + uint8_t capabilities = 0x00; #ifdef USE_OUTPUT - if (this->status_indicator_ != nullptr) - capabilities |= improv::CAPABILITY_IDENTIFY; + if (this->status_indicator_ != nullptr) + capabilities |= improv::CAPABILITY_IDENTIFY; #endif - service_data[3] = capabilities; - service_data[4] = 0x00; // Reserved - service_data[5] = 0x00; // Reserved - service_data[6] = 0x00; // Reserved - service_data[7] = 0x00; // Reserved + service_data[3] = capabilities; + service_data[4] = 0x00; // Reserved + service_data[5] = 0x00; // Reserved + service_data[6] = 0x00; // Reserved + service_data[7] = 0x00; // Reserved - esp32_ble::global_ble->advertising_set_service_data(service_data); + esp32_ble::global_ble->advertising_set_service_data(service_data); + } #ifdef USE_ESP32_IMPROV_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif @@ -240,7 +272,12 @@ void ESP32ImprovComponent::set_error_(improv::Error error) { if (error != improv::ERROR_NONE) { ESP_LOGE(TAG, "Error: %d", error); } - if (this->error_->get_value().empty() || this->error_->get_value()[0] != error) { + // The error_ characteristic is initialized in setup_characteristics() which is called + // from the loop, while the BLE disconnect callback is registered in setup(). + // error_ can be nullptr if: + // 1. A client connects/disconnects before setup_characteristics() is called + // 2. The device is already provisioned so the service never starts (should_start_ is false) + if (this->error_ != nullptr && (this->error_->get_value().empty() || this->error_->get_value()[0] != error)) { this->error_->set_value(ByteBuffer::wrap(static_cast(error))); if (this->state_ != improv::STATE_STOPPED) this->error_->notify(); @@ -264,7 +301,10 @@ void ESP32ImprovComponent::start() { void ESP32ImprovComponent::stop() { this->should_start_ = false; - this->set_timeout("end-service", 1000, [this] { + // Wait before stopping the service to ensure all BLE clients see the state change. + // This prevents clients from repeatedly reconnecting and wasting resources by allowing + // them to observe that the device is provisioned before the service disappears. + this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] { if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr) return; this->service_->stop(); diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 87cec23876..686da08111 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -79,12 +79,12 @@ class ESP32ImprovComponent : public Component { std::vector incoming_data_; wifi::WiFiAP connecting_sta_; - BLEService *service_ = nullptr; - BLECharacteristic *status_; - BLECharacteristic *error_; - BLECharacteristic *rpc_; - BLECharacteristic *rpc_response_; - BLECharacteristic *capabilities_; + BLEService *service_{nullptr}; + BLECharacteristic *status_{nullptr}; + BLECharacteristic *error_{nullptr}; + BLECharacteristic *rpc_{nullptr}; + BLECharacteristic *rpc_response_{nullptr}; + BLECharacteristic *capabilities_{nullptr}; #ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *authorizer_{nullptr}; @@ -108,6 +108,9 @@ class ESP32ImprovComponent : public Component { void process_incoming_data_(); void on_wifi_connect_timeout_(); bool check_identify_(); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + const char *state_to_string_(improv::State state); +#endif }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) From 57f7a709cfdc8ac11909f92b247145a5e40fb34a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Sep 2025 15:55:14 -0400 Subject: [PATCH 1525/1626] [sx126x] Fix issues with variable length FSK packets (#10911) --- esphome/components/sx126x/sx126x.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index cae047d168..f5393c478a 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -217,7 +217,7 @@ void SX126x::configure() { this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 4); // set packet params and sync word - this->set_packet_params_(this->payload_length_); + this->set_packet_params_(this->get_max_packet_size()); if (this->sync_value_.size() == 2) { this->write_register_(REG_LORA_SYNCWORD, this->sync_value_.data(), this->sync_value_.size()); } @@ -236,7 +236,7 @@ void SX126x::configure() { this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 8); // set packet params and sync word - this->set_packet_params_(this->payload_length_); + this->set_packet_params_(this->get_max_packet_size()); if (!this->sync_value_.empty()) { this->write_register_(REG_GFSK_SYNCWORD, this->sync_value_.data(), this->sync_value_.size()); } @@ -274,7 +274,7 @@ void SX126x::set_packet_params_(uint8_t payload_length) { buf[2] = (this->preamble_detect_ > 0) ? ((this->preamble_detect_ - 1) | 0x04) : 0x00; buf[3] = this->sync_value_.size() * 8; buf[4] = 0x00; - buf[5] = 0x00; + buf[5] = (this->payload_length_ > 0) ? 0x00 : 0x01; buf[6] = payload_length; buf[7] = this->crc_enable_ ? 0x06 : 0x01; buf[8] = 0x00; @@ -314,6 +314,9 @@ SX126xError SX126x::transmit_packet(const std::vector &packet) { buf[0] = 0xFF; buf[1] = 0xFF; this->write_opcode_(RADIO_CLR_IRQSTATUS, buf, 2); + if (this->payload_length_ == 0) { + this->set_packet_params_(this->get_max_packet_size()); + } if (this->rx_start_) { this->set_mode_rx(); } else { From 127058e70024e4675dff9b3cf174f2eebd558c47 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Sun, 28 Sep 2025 11:35:40 +0200 Subject: [PATCH 1526/1626] [usb_uart] Disable flow control on ch34x --- esphome/components/usb_uart/ch34x.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 74e7933824..37cd33f841 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -72,6 +72,7 @@ void USBUartTypeCH34X::enable_channels() { if (channel->index_ >= 2) cmd += 0xE; this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback); + this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0, callback); } USBUartTypeCdcAcm::enable_channels(); } From 345fc0b6caaa4c84d3cd79f2343608a858d3d783 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 29 Sep 2025 09:12:06 +1000 Subject: [PATCH 1527/1626] [mipi_spi] Fix t-display-amoled (#10922) --- esphome/components/mipi/__init__.py | 10 +++++++++- esphome/components/mipi_spi/models/amoled.py | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 8b1ca899df..f670a5913d 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -401,6 +401,12 @@ class DriverChip: sequence.append((MADCTL, madctl)) return madctl + def skip_command(self, command: str): + """ + Allow suppressing a standard command in the init sequence. + """ + return self.get_default(f"no_{command.lower()}", False) + def get_sequence(self, config) -> tuple[tuple[int, ...], int]: """ Create the init sequence for the display. @@ -432,7 +438,9 @@ class DriverChip: sequence.append((INVOFF,)) if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)): sequence.append((BRIGHTNESS, brightness)) - sequence.append((SLPOUT,)) + # Add a SLPOUT command if required. + if not self.skip_command("SLPOUT"): + sequence.append((SLPOUT,)) sequence.append((DISPON,)) # Flatten the sequence into a list of bytes, with the length of each command diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index bc95fc7f71..4d6c8da4b0 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -27,7 +27,8 @@ DriverChip( bus_mode=TYPE_QUAD, brightness=0xD0, color_order=MODE_RGB, - initsequence=(SLPOUT,), # Requires early SLPOUT + no_slpout=True, # SLPOUT is in the init sequence, early + initsequence=(SLPOUT,), ) DriverChip( @@ -95,6 +96,7 @@ CO5300 = DriverChip( brightness=0xD0, color_order=MODE_RGB, bus_mode=TYPE_QUAD, + no_slpout=True, initsequence=( (SLPOUT,), # Requires early SLPOUT (PAGESEL, 0x00), From 58166b3e71cb56eecaa67755a5aa14f5b4d16451 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 29 Sep 2025 21:39:17 +1300 Subject: [PATCH 1528/1626] Bump version to 2025.9.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 6ef1d86c74..b0b92dfd63 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.9.1 +PROJECT_NUMBER = 2025.9.2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 0b36e26ec0..dafd49c066 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.9.1" +__version__ = "2025.9.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 29db576f7942f429171fa74d071a4c6462364cb8 Mon Sep 17 00:00:00 2001 From: Vladimir Makeev Date: Mon, 29 Sep 2025 17:08:51 +0400 Subject: [PATCH 1529/1626] [sim800l] Fixed ignoring incoming calls. (#10865) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/sim800l/sim800l.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index d97b0ae364..55cadcf182 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -288,11 +288,15 @@ void Sim800LComponent::parse_cmd_(std::string message) { if (item == 3) { // stat uint8_t current_call_state = parse_number(message.substr(start, end - start)).value_or(6); if (current_call_state != this->call_state_) { - ESP_LOGD(TAG, "Call state is now: %d", current_call_state); - if (current_call_state == 0) - this->call_connected_callback_.call(); + if (current_call_state == 4) { + ESP_LOGV(TAG, "Premature call state '4'. Ignoring, waiting for RING"); + } else { + this->call_state_ = current_call_state; + ESP_LOGD(TAG, "Call state is now: %d", current_call_state); + if (current_call_state == 0) + this->call_connected_callback_.call(); + } } - this->call_state_ = current_call_state; break; } // item 4 = "" From 6b83e5508852f1dcb14965849e5861c687085cc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 09:58:36 -0500 Subject: [PATCH 1530/1626] [api] Add message size limits to prevent memory exhaustion --- esphome/components/api/api_frame_helper.h | 10 ++++++ .../components/api/api_frame_helper_noise.cpp | 7 ++++ .../api/api_frame_helper_plaintext.cpp | 4 +-- tests/integration/test_oversized_payloads.py | 32 ++++++++++--------- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index c11d701ffe..fb0147a70b 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -17,6 +17,16 @@ namespace esphome::api { // uncomment to log raw packets //#define HELPER_LOG_PACKETS +// Maximum message size limits to prevent OOM on constrained devices +// Voice Assistant is our largest user at 1024 bytes per audio chunk +// Using 2048 + 256 bytes overhead = 2304 bytes total to support voice and future needs +// ESP8266 has very limited RAM and cannot support voice assistant +#ifdef USE_ESP8266 +static constexpr uint16_t MAX_MESSAGE_SIZE = 512; // Keep small for memory constrained ESP8266 +#else +static constexpr uint16_t MAX_MESSAGE_SIZE = 2304; // Support voice (1024) + headroom for larger messages +#endif + // Forward declaration struct ClientInfo; diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 0e49f93db5..b77af43cc2 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -184,6 +184,13 @@ APIError APINoiseFrameHelper::try_read_frame_(std::vector *frame) { return APIError::BAD_HANDSHAKE_PACKET_LEN; } + // Check against maximum message size to prevent OOM + if (msg_size > MAX_MESSAGE_SIZE) { + state_ = State::FAILED; + HELPER_LOG("Bad packet: message size %u exceeds maximum %u", msg_size, MAX_MESSAGE_SIZE); + return APIError::BAD_DATA_PACKET; + } + // reserve space for body if (rx_buf_.size() != msg_size) { rx_buf_.resize(msg_size); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 859bb26630..ef723274be 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -122,10 +122,10 @@ APIError APIPlaintextFrameHelper::try_read_frame_(std::vector *frame) { continue; } - if (msg_size_varint->as_uint32() > std::numeric_limits::max()) { + if (msg_size_varint->as_uint32() > MAX_MESSAGE_SIZE) { state_ = State::FAILED; HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", msg_size_varint->as_uint32(), - std::numeric_limits::max()); + MAX_MESSAGE_SIZE); return APIError::BAD_DATA_PACKET; } rx_header_parsed_len_ = msg_size_varint->as_uint16(); diff --git a/tests/integration/test_oversized_payloads.py b/tests/integration/test_oversized_payloads.py index f3e422620c..22167118af 100644 --- a/tests/integration/test_oversized_payloads.py +++ b/tests/integration/test_oversized_payloads.py @@ -15,7 +15,7 @@ async def test_oversized_payload_plaintext( run_compiled: RunCompiledFunction, api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, ) -> None: - """Test that oversized payloads (>100KiB) from client cause disconnection without crashing.""" + """Test that oversized payloads (>2304 bytes) from client cause disconnection without crashing.""" process_exited = False helper_log_found = False @@ -39,8 +39,8 @@ async def test_oversized_payload_plaintext( assert device_info is not None assert device_info.name == "oversized-plaintext" - # Create an oversized payload (>100KiB) - oversized_data = b"X" * (100 * 1024 + 1) # 100KiB + 1 byte + # Create an oversized payload (>2304 bytes which is our new limit) + oversized_data = b"X" * 3000 # ~3KiB, exceeds the 2304 byte limit # Access the internal connection to send raw data frame_helper = client._connection._frame_helper @@ -132,22 +132,24 @@ async def test_oversized_payload_noise( run_compiled: RunCompiledFunction, api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, ) -> None: - """Test that oversized payloads (>100KiB) from client cause disconnection without crashing with noise encryption.""" + """Test that oversized payloads from client cause disconnection without crashing with noise encryption.""" noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" process_exited = False - cipherstate_failed = False + helper_log_found = False def check_logs(line: str) -> None: - nonlocal process_exited, cipherstate_failed + nonlocal process_exited, helper_log_found # Check for signs that the process exited/crashed if "Segmentation fault" in line or "core dumped" in line: process_exited = True - # Check for the expected warning about decryption failure + # Check for HELPER_LOG message about message size exceeding maximum + # With our new protection, oversized messages are rejected at frame level if ( - "[W][api.connection" in line - and "Reading failed CIPHERSTATE_DECRYPT_FAILED" in line + "[VV]" in line + and "Bad packet: message size" in line + and "exceeds maximum" in line ): - cipherstate_failed = True + helper_log_found = True async with run_compiled(yaml_config, line_callback=check_logs): async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( @@ -159,8 +161,8 @@ async def test_oversized_payload_noise( assert device_info is not None assert device_info.name == "oversized-noise" - # Create an oversized payload (>100KiB) - oversized_data = b"Y" * (100 * 1024 + 1) # 100KiB + 1 byte + # Create an oversized payload (>2304 bytes which is our new limit) + oversized_data = b"Y" * 3000 # ~3KiB, exceeds the 2304 byte limit # Access the internal connection to send raw data frame_helper = client._connection._frame_helper @@ -175,9 +177,9 @@ async def test_oversized_payload_noise( # After disconnection, verify process didn't crash assert not process_exited, "ESPHome process should not crash" - # Verify we saw the expected warning message - assert cipherstate_failed, ( - "Expected to see warning about CIPHERSTATE_DECRYPT_FAILED" + # Verify we saw the expected HELPER_LOG message + assert helper_log_found, ( + "Expected to see HELPER_LOG about message size exceeding maximum" ) # Try to reconnect to verify the process is still running From bb82496c12dba043b637562534cb39e8a3957b30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 12:04:27 -0500 Subject: [PATCH 1531/1626] limtis --- esphome/components/api/__init__.py | 10 ++++++++++ esphome/components/api/api_server.cpp | 18 +++++++++++++++--- esphome/components/api/api_server.h | 11 ++++++++++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6a0e092008..9336d31491 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -59,6 +59,8 @@ CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" +CONF_LISTEN_BACKLOG = "listen_backlog" +CONF_MAX_CONNECTIONS = "max_connections" def validate_encryption_key(value): @@ -158,6 +160,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_CLIENT_DISCONNECTED): automation.validate_automation( single=True ), + cv.SplitDefault(CONF_LISTEN_BACKLOG, esp8266=1, default=4): cv.int_range( + min=1, max=10 + ), + cv.SplitDefault(CONF_MAX_CONNECTIONS, esp8266=4, default=8): cv.int_range( + min=1, max=20 + ), } ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), @@ -176,6 +184,8 @@ async def to_code(config): cg.add(var.set_password(config[CONF_PASSWORD])) cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) + cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) + cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) # Set USE_API_SERVICES if any services are enabled if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index dd6eb950a6..7fbe0e27f3 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -87,7 +87,7 @@ void APIServer::setup() { return; } - err = this->socket_->listen(4); + err = this->socket_->listen(this->listen_backlog_); if (err != 0) { ESP_LOGW(TAG, "Socket unable to listen: errno %d", errno); this->mark_failed(); @@ -140,9 +140,19 @@ void APIServer::loop() { while (true) { struct sockaddr_storage source_addr; socklen_t addr_len = sizeof(source_addr); + auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); if (!sock) break; + + // Check if we're at the connection limit + if (this->clients_.size() >= this->max_connections_) { + ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, sock->getpeername().c_str()); + // Immediately close - socket destructor will handle cleanup + sock.reset(); + continue; + } + ESP_LOGD(TAG, "Accept %s", sock->getpeername().c_str()); auto *conn = new APIConnection(std::move(sock), this); @@ -206,8 +216,10 @@ void APIServer::loop() { void APIServer::dump_config() { ESP_LOGCONFIG(TAG, "Server:\n" - " Address: %s:%u", - network::get_use_address().c_str(), this->port_); + " Address: %s:%u\n" + " Listen backlog: %u\n" + " Max connections: %u", + network::get_use_address().c_str(), this->port_, this->listen_backlog_, this->max_connections_); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_->has_psk())); if (!this->noise_ctx_->has_psk()) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 627870af1d..f0c51af460 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -44,6 +44,8 @@ class APIServer : public Component, public Controller { void set_reboot_timeout(uint32_t reboot_timeout); void set_batch_delay(uint16_t batch_delay); uint16_t get_batch_delay() const { return batch_delay_; } + void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } + void set_max_connections(uint8_t max_connections) { this->max_connections_ = max_connections; } // Get reference to shared buffer for API connections std::vector &get_shared_buffer_ref() { return shared_write_buffer_; } @@ -189,8 +191,15 @@ class APIServer : public Component, public Controller { // Group smaller types together uint16_t port_{6053}; uint16_t batch_delay_{100}; +#ifdef USE_ESP8266 + uint8_t listen_backlog_{1}; + uint8_t max_connections_{4}; +#else + uint8_t listen_backlog_{4}; + uint8_t max_connections_{8}; +#endif bool shutting_down_ = false; - // 5 bytes used, 3 bytes padding + // 7 bytes used, 1 byte padding #ifdef USE_API_NOISE std::shared_ptr noise_ctx_ = std::make_shared(); From 8b76b59a456164bfa406a6da1a0e91c1f5e74736 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 12:05:01 -0500 Subject: [PATCH 1532/1626] [socket] Reduce memory overhead for LWIP TCP accept queue on ESP8266/RP2040 --- .../components/socket/lwip_raw_tcp_impl.cpp | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 2d64a275df..3377682474 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -9,7 +9,7 @@ #include "lwip/tcp.h" #include #include -#include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -50,12 +50,18 @@ class LWIPRawImpl : public Socket { errno = EBADF; return nullptr; } - if (accepted_sockets_.empty()) { + if (this->accepted_socket_count_ == 0) { errno = EWOULDBLOCK; return nullptr; } - std::unique_ptr sock = std::move(accepted_sockets_.front()); - accepted_sockets_.pop(); + // Take from front for FIFO ordering + std::unique_ptr sock = std::move(this->accepted_sockets_[0]); + // Shift remaining sockets forward + for (uint8_t i = 1; i < this->accepted_socket_count_; i++) { + this->accepted_sockets_[i - 1] = std::move(this->accepted_sockets_[i]); + } + this->accepted_socket_count_--; + LWIP_LOG("Connection accepted by application, queue size: %d", this->accepted_socket_count_); if (addr != nullptr) { sock->getpeername(addr, addrlen); } @@ -494,9 +500,18 @@ class LWIPRawImpl : public Socket { // nothing to do here, we just don't push it to the queue return ERR_OK; } + // Check if we've reached the maximum accept queue size + if (this->accepted_socket_count_ >= MAX_ACCEPTED_SOCKETS) { + LWIP_LOG("Rejecting connection, queue full (%d)", this->accepted_socket_count_); + // Abort the connection when queue is full + tcp_abort(newpcb); + // Must return ERR_ABRT since we called tcp_abort() + return ERR_ABRT; + } auto sock = make_unique(family_, newpcb); sock->init(); - accepted_sockets_.push(std::move(sock)); + this->accepted_sockets_[this->accepted_socket_count_++] = std::move(sock); + LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); return ERR_OK; } void err_fn(err_t err) { @@ -587,7 +602,20 @@ class LWIPRawImpl : public Socket { } struct tcp_pcb *pcb_; - std::queue> accepted_sockets_; + // Accept queue - holds incoming connections briefly until the event loop calls accept() + // This is NOT a connection pool - just a temporary queue between LWIP callbacks and the main loop + // 3 slots is plenty since connections are pulled out quickly by the event loop + // + // Memory analysis: std::array<3> vs original std::queue implementation: + // - std::queue uses std::deque internally which on 32-bit systems needs: + // 24 bytes (deque object) + 32+ bytes (map array) + heap allocations + // Total: ~56+ bytes minimum, plus heap fragmentation + // - std::array<3>: 12 bytes fixed (3 pointers × 4 bytes) + // Saves ~44+ bytes RAM per listening socket + avoids ALL heap allocations + // Used on ESP8266 and RP2040 (platforms using LWIP_TCP implementation) + static constexpr size_t MAX_ACCEPTED_SOCKETS = 3; + std::array, MAX_ACCEPTED_SOCKETS> accepted_sockets_; + uint8_t accepted_socket_count_ = 0; // Number of sockets currently in queue bool rx_closed_ = false; pbuf *rx_buf_ = nullptr; size_t rx_buf_offset_ = 0; From 3560d6ca9698aa4f29eeff4e77c169bf1e284764 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 12:45:24 -0500 Subject: [PATCH 1533/1626] sane --- esphome/components/api/__init__.py | 35 ++++++++++++++++++++++------- esphome/components/api/api_server.h | 7 ++---- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 9336d31491..c91051ba20 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -160,12 +160,29 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_CLIENT_DISCONNECTED): automation.validate_automation( single=True ), - cv.SplitDefault(CONF_LISTEN_BACKLOG, esp8266=1, default=4): cv.int_range( - min=1, max=10 - ), - cv.SplitDefault(CONF_MAX_CONNECTIONS, esp8266=4, default=8): cv.int_range( - min=1, max=20 - ), + # Connection limits to prevent memory exhaustion on resource-constrained devices + # Each connection uses ~500-1000 bytes of RAM plus system resources + # Platform defaults based on available RAM and network stack implementation: + cv.SplitDefault( + CONF_LISTEN_BACKLOG, + esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets + esp32=4, # More RAM (520KB), BSD sockets + rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266 + bk72xx=4, # Moderate RAM, BSD-style sockets + rtl87xx=4, # Moderate RAM, BSD-style sockets + host=4, # Abundant resources + ln882x=4, # Moderate RAM + ): cv.int_range(min=1, max=10), + cv.SplitDefault( + CONF_MAX_CONNECTIONS, + esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes + esp32=8, # 520KB RAM available + rp2040=4, # 264KB RAM but LWIP constraints + bk72xx=8, # Moderate RAM + rtl87xx=8, # Moderate RAM + host=8, # Abundant resources + ln882x=8, # Moderate RAM + ): cv.int_range(min=1, max=20), } ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), @@ -184,8 +201,10 @@ async def to_code(config): cg.add(var.set_password(config[CONF_PASSWORD])) cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) - cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) - cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) + if CONF_LISTEN_BACKLOG in config: + cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) + if CONF_MAX_CONNECTIONS in config: + cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) # Set USE_API_SERVICES if any services are enabled if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index f0c51af460..b9049c1700 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -191,13 +191,10 @@ class APIServer : public Component, public Controller { // Group smaller types together uint16_t port_{6053}; uint16_t batch_delay_{100}; -#ifdef USE_ESP8266 - uint8_t listen_backlog_{1}; - uint8_t max_connections_{4}; -#else + // Connection limits - these defaults will be overridden by config values + // from cv.SplitDefault in __init__.py which sets platform-specific defaults uint8_t listen_backlog_{4}; uint8_t max_connections_{8}; -#endif bool shutting_down_ = false; // 7 bytes used, 1 byte padding From e3e98e2568fa461e63649d37d33d29b0b209e226 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 29 Sep 2025 23:09:41 +0500 Subject: [PATCH 1534/1626] [nrf52] add more tests (#10695) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- tests/components/analog_threshold/test.nrf52-adafruit.yaml | 1 + tests/components/analog_threshold/test.nrf52-mcumgr.yaml | 1 + tests/components/bang_bang/test.nrf52-adafruit.yaml | 1 + tests/components/bang_bang/test.nrf52-mcumgr.yaml | 1 + 4 files changed, 4 insertions(+) create mode 100644 tests/components/analog_threshold/test.nrf52-adafruit.yaml create mode 100644 tests/components/analog_threshold/test.nrf52-mcumgr.yaml create mode 100644 tests/components/bang_bang/test.nrf52-adafruit.yaml create mode 100644 tests/components/bang_bang/test.nrf52-mcumgr.yaml diff --git a/tests/components/analog_threshold/test.nrf52-adafruit.yaml b/tests/components/analog_threshold/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/analog_threshold/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/analog_threshold/test.nrf52-mcumgr.yaml b/tests/components/analog_threshold/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/analog_threshold/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/bang_bang/test.nrf52-adafruit.yaml b/tests/components/bang_bang/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/bang_bang/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/bang_bang/test.nrf52-mcumgr.yaml b/tests/components/bang_bang/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/bang_bang/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 054b215d8d265b0a1e139454878803d36fc5c0e4 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 29 Sep 2025 23:11:57 +0500 Subject: [PATCH 1535/1626] [nrf52] add more tests (#10694) --- esphome/components/cover/cover.cpp | 1 + esphome/components/valve/valve.cpp | 1 + tests/components/restart/test.nrf52-adafruit.yaml | 1 + tests/components/restart/test.nrf52-mcumgr.yaml | 1 + tests/components/script/test.nrf52-adafruit.yaml | 1 + tests/components/script/test.nrf52-mcumgr.yaml | 1 + tests/components/sprinkler/test.nrf52-adafruit.yaml | 1 + tests/components/sprinkler/test.nrf52-mcumgr.yaml | 1 + tests/components/template/common.yaml | 1 + tests/components/template/test.nrf52-adafruit.yaml | 6 ++++++ tests/components/template/test.nrf52-mcumgr.yaml | 6 ++++++ tests/components/thermostat/test.nrf52-adafruit.yaml | 1 + tests/components/thermostat/test.nrf52-mcumgr.yaml | 1 + 13 files changed, 23 insertions(+) create mode 100644 tests/components/restart/test.nrf52-adafruit.yaml create mode 100644 tests/components/restart/test.nrf52-mcumgr.yaml create mode 100644 tests/components/script/test.nrf52-adafruit.yaml create mode 100644 tests/components/script/test.nrf52-mcumgr.yaml create mode 100644 tests/components/sprinkler/test.nrf52-adafruit.yaml create mode 100644 tests/components/sprinkler/test.nrf52-mcumgr.yaml create mode 100644 tests/components/template/test.nrf52-adafruit.yaml create mode 100644 tests/components/template/test.nrf52-mcumgr.yaml create mode 100644 tests/components/thermostat/test.nrf52-adafruit.yaml create mode 100644 tests/components/thermostat/test.nrf52-mcumgr.yaml diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index 700bceec01..3378279371 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -1,5 +1,6 @@ #include "cover.h" #include "esphome/core/log.h" +#include namespace esphome { namespace cover { diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index 0ee710fc02..b041fe8449 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -1,5 +1,6 @@ #include "valve.h" #include "esphome/core/log.h" +#include namespace esphome { namespace valve { diff --git a/tests/components/restart/test.nrf52-adafruit.yaml b/tests/components/restart/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/restart/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/restart/test.nrf52-mcumgr.yaml b/tests/components/restart/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/restart/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/script/test.nrf52-adafruit.yaml b/tests/components/script/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/script/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/script/test.nrf52-mcumgr.yaml b/tests/components/script/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/script/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sprinkler/test.nrf52-adafruit.yaml b/tests/components/sprinkler/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sprinkler/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sprinkler/test.nrf52-mcumgr.yaml b/tests/components/sprinkler/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sprinkler/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index ae7dc98e57..efbb83ee06 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -341,6 +341,7 @@ datetime: time: - platform: sntp # Required for datetime + id: sntp_time wifi: # Required for sntp time ap: diff --git a/tests/components/template/test.nrf52-adafruit.yaml b/tests/components/template/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..6a8c01560a --- /dev/null +++ b/tests/components/template/test.nrf52-adafruit.yaml @@ -0,0 +1,6 @@ +packages: !include common.yaml + +time: + - id: !remove sntp_time + +wifi: !remove diff --git a/tests/components/template/test.nrf52-mcumgr.yaml b/tests/components/template/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..6a8c01560a --- /dev/null +++ b/tests/components/template/test.nrf52-mcumgr.yaml @@ -0,0 +1,6 @@ +packages: !include common.yaml + +time: + - id: !remove sntp_time + +wifi: !remove diff --git a/tests/components/thermostat/test.nrf52-adafruit.yaml b/tests/components/thermostat/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/thermostat/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/thermostat/test.nrf52-mcumgr.yaml b/tests/components/thermostat/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/thermostat/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 8ca9e2d0152cc1f2dc5aa7ad6c73e775391077d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 13:59:39 -0500 Subject: [PATCH 1536/1626] [script] Reduce RAM usage by storing names in flash --- esphome/components/script/__init__.py | 2 +- esphome/components/script/script.h | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index ee1f6a4ad0..e8a8aa5671 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -124,7 +124,7 @@ async def to_code(config): template, func_args = parameters_to_template(conf[CONF_PARAMETERS]) trigger = cg.new_Pvariable(conf[CONF_ID], template) # Add a human-readable name to the script - cg.add(trigger.set_name(conf[CONF_ID].id)) + cg.add(trigger.set_name(cg.LogStringLiteral(conf[CONF_ID].id))) if CONF_MAX_RUNS in conf: cg.add(trigger.set_max_runs(conf[CONF_MAX_RUNS])) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index b16bb53acc..b87402f52e 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -48,14 +48,14 @@ template class Script : public ScriptLogger, public Trigger void execute_tuple_(const std::tuple &tuple, seq /*unused*/) { this->execute(std::get(tuple)...); } - std::string name_; + const LogString *name_{nullptr}; }; /** A script type for which only a single instance at a time is allowed. @@ -68,7 +68,7 @@ template class SingleScript : public Script { void execute(Ts... x) override { if (this->is_action_running()) { this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' is already running! (mode: single)"), - this->name_.c_str()); + LOG_STR_ARG(this->name_)); return; } @@ -85,7 +85,7 @@ template class RestartScript : public Script { public: void execute(Ts... x) override { if (this->is_action_running()) { - this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), this->name_.c_str()); + this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), LOG_STR_ARG(this->name_)); this->stop_action(); } @@ -105,12 +105,12 @@ template class QueueingScript : public Script, public Com // num_runs_ + 1 if (this->max_runs_ != 0 && this->num_runs_ + 1 >= this->max_runs_) { this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of queued runs exceeded!"), - this->name_.c_str()); + LOG_STR_ARG(this->name_)); return; } this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' queueing new instance (mode: queued)"), - this->name_.c_str()); + LOG_STR_ARG(this->name_)); this->num_runs_++; this->var_queue_.push(std::make_tuple(x...)); return; @@ -157,7 +157,7 @@ template class ParallelScript : public Script { void execute(Ts... x) override { if (this->max_runs_ != 0 && this->automation_parent_->num_running() >= this->max_runs_) { this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of parallel runs exceeded!"), - this->name_.c_str()); + LOG_STR_ARG(this->name_)); return; } this->trigger(x...); From c0ff48de17ec5bdef8d060fefc09123df54345b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 14:05:27 -0500 Subject: [PATCH 1537/1626] fix --- esphome/codegen.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/codegen.py b/esphome/codegen.py index 8e02ec1164..6decd77c62 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -12,6 +12,7 @@ from esphome.cpp_generator import ( # noqa: F401 ArrayInitializer, Expression, LineComment, + LogStringLiteral, MockObj, MockObjClass, Pvariable, From b176d1f890559cb939c3793346d8a85ce154d724 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:24:42 -0400 Subject: [PATCH 1538/1626] [core] Don't remove storage in clean-all (#10921) --- esphome/__main__.py | 4 +- esphome/writer.py | 13 +++- tests/unit_tests/test_writer.py | 132 ++++++++++++++++++++++++++++++-- 3 files changed, 137 insertions(+), 12 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 42880e6cfc..38edcb070f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1155,7 +1155,9 @@ def parse_args(argv): "configuration", help="Your YAML configuration file(s).", nargs="+" ) - parser_clean_all = subparsers.add_parser("clean-all", help="Clean all files.") + parser_clean_all = subparsers.add_parser( + "clean-all", help="Clean all build and platform files." + ) parser_clean_all.add_argument( "configuration", help="Your YAML configuration directory.", nargs="*" ) diff --git a/esphome/writer.py b/esphome/writer.py index 403cd8165d..b5cfd9b667 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -340,10 +340,15 @@ def clean_all(configuration: list[str]): # Clean entire build dir for dir in configuration: - buid_dir = Path(dir) / ".esphome" - if buid_dir.is_dir(): - _LOGGER.info("Deleting %s", buid_dir) - shutil.rmtree(buid_dir) + build_dir = Path(dir) / ".esphome" + if build_dir.is_dir(): + _LOGGER.info("Cleaning %s", build_dir) + # Don't remove storage as it will cause the dashboard to regenerate all configs + for item in build_dir.iterdir(): + if item.is_file(): + item.unlink() + elif item.name != "storage" and item.is_dir(): + shutil.rmtree(item) # Clean PlatformIO project files try: diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 66e0b6cb67..bffd2b3881 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -790,16 +790,21 @@ def test_clean_all( with caplog.at_level("INFO"): clean_all([str(config1_dir), str(config2_dir)]) - # Verify deletions - assert not build_dir1.exists() - assert not build_dir2.exists() + # Verify deletions - .esphome directories remain but contents are cleaned + # The .esphome directory itself is not removed because it may contain storage + assert build_dir1.exists() + assert build_dir2.exists() + + # Verify that files in .esphome were removed + assert not (build_dir1 / "dummy.txt").exists() + assert not (build_dir2 / "dummy.txt").exists() assert not pio_cache.exists() assert not pio_packages.exists() assert not pio_platforms.exists() assert not pio_core.exists() # Verify logging mentions each - assert "Deleting" in caplog.text + assert "Cleaning" in caplog.text assert str(build_dir1) in caplog.text assert str(build_dir2) in caplog.text assert "PlatformIO cache" in caplog.text @@ -808,6 +813,55 @@ def test_clean_all( assert "PlatformIO core" in caplog.text +@patch("esphome.writer.CORE") +def test_clean_all_preserves_storage( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all preserves storage directory.""" + # Create build directory with storage subdirectory + config_dir = tmp_path / "config" + config_dir.mkdir() + + build_dir = config_dir / ".esphome" + build_dir.mkdir() + (build_dir / "dummy.txt").write_text("x") + (build_dir / "other_file.txt").write_text("y") + + # Create storage directory with content + storage_dir = build_dir / "storage" + storage_dir.mkdir() + (storage_dir / "storage.json").write_text('{"test": "data"}') + (storage_dir / "other_storage.txt").write_text("storage content") + + # Call clean_all + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + # Verify .esphome directory still exists + assert build_dir.exists() + + # Verify storage directory still exists with its contents + assert storage_dir.exists() + assert (storage_dir / "storage.json").exists() + assert (storage_dir / "other_storage.txt").exists() + + # Verify storage contents are intact + assert (storage_dir / "storage.json").read_text() == '{"test": "data"}' + assert (storage_dir / "other_storage.txt").read_text() == "storage content" + + # Verify other files were removed + assert not (build_dir / "dummy.txt").exists() + assert not (build_dir / "other_file.txt").exists() + + # Verify logging mentions deletion + assert "Cleaning" in caplog.text + assert str(build_dir) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all_platformio_not_available( mock_core: MagicMock, @@ -833,8 +887,8 @@ def test_clean_all_platformio_not_available( ): clean_all([str(config_dir)]) - # Build dir removed, PlatformIO dirs remain - assert not build_dir.exists() + # Build dir contents cleaned, PlatformIO dirs remain + assert build_dir.exists() assert pio_cache.exists() # No PlatformIO-specific logs @@ -866,4 +920,68 @@ def test_clean_all_partial_exists( clean_all([str(config_dir)]) - assert not build_dir.exists() + assert build_dir.exists() + + +@patch("esphome.writer.CORE") +def test_clean_all_removes_non_storage_directories( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all removes directories other than storage.""" + # Create build directory with various subdirectories + config_dir = tmp_path / "config" + config_dir.mkdir() + + build_dir = config_dir / ".esphome" + build_dir.mkdir() + + # Create files + (build_dir / "file1.txt").write_text("content1") + (build_dir / "file2.txt").write_text("content2") + + # Create storage directory (should be preserved) + storage_dir = build_dir / "storage" + storage_dir.mkdir() + (storage_dir / "storage.json").write_text('{"test": "data"}') + + # Create other directories (should be removed) + cache_dir = build_dir / "cache" + cache_dir.mkdir() + (cache_dir / "cache_file.txt").write_text("cache content") + + logs_dir = build_dir / "logs" + logs_dir.mkdir() + (logs_dir / "log1.txt").write_text("log content") + + temp_dir = build_dir / "temp" + temp_dir.mkdir() + (temp_dir / "temp_file.txt").write_text("temp content") + + # Call clean_all + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + # Verify .esphome directory still exists + assert build_dir.exists() + + # Verify storage directory and its contents are preserved + assert storage_dir.exists() + assert (storage_dir / "storage.json").exists() + assert (storage_dir / "storage.json").read_text() == '{"test": "data"}' + + # Verify files were removed + assert not (build_dir / "file1.txt").exists() + assert not (build_dir / "file2.txt").exists() + + # Verify non-storage directories were removed + assert not cache_dir.exists() + assert not logs_dir.exists() + assert not temp_dir.exists() + + # Verify logging mentions cleaning + assert "Cleaning" in caplog.text + assert str(build_dir) in caplog.text From b11a52fd1ed824b287ef7dce9fdb99d8b59835ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 14:38:54 -0500 Subject: [PATCH 1539/1626] Remove C2 and H2 from component test matrix to avoid CI disk space issues --- .../build_components_base.esp32-c2-idf.yaml | 17 ----------------- .../build_components_base.esp32-h2-idf.yaml | 17 ----------------- 2 files changed, 34 deletions(-) delete mode 100644 tests/test_build_components/build_components_base.esp32-c2-idf.yaml delete mode 100644 tests/test_build_components/build_components_base.esp32-h2-idf.yaml diff --git a/tests/test_build_components/build_components_base.esp32-c2-idf.yaml b/tests/test_build_components/build_components_base.esp32-c2-idf.yaml deleted file mode 100644 index a0ae9e3a46..0000000000 --- a/tests/test_build_components/build_components_base.esp32-c2-idf.yaml +++ /dev/null @@ -1,17 +0,0 @@ -esphome: - name: componenttestesp32c2idf - friendly_name: $component_name - -esp32: - board: esp32-c2-devkitm-1 - framework: - type: esp-idf - -logger: - level: VERY_VERBOSE - -packages: - component_under_test: !include - file: $component_test_file - vars: - component_test_file: $component_test_file diff --git a/tests/test_build_components/build_components_base.esp32-h2-idf.yaml b/tests/test_build_components/build_components_base.esp32-h2-idf.yaml deleted file mode 100644 index 5e668a9a97..0000000000 --- a/tests/test_build_components/build_components_base.esp32-h2-idf.yaml +++ /dev/null @@ -1,17 +0,0 @@ -esphome: - name: componenttestesp32h2idf - friendly_name: $component_name - -esp32: - board: esp32-h2-devkitm-1 - framework: - type: esp-idf - -logger: - level: VERY_VERBOSE - -packages: - component_under_test: !include - file: $component_test_file - vars: - component_test_file: $component_test_file From c2d9d66bb014f3902ded0b2ffe1685b4a7c2eb20 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 14:39:56 -0500 Subject: [PATCH 1540/1626] Remove C2 and H2 from component test matrix to avoid CI disk space issues --- .../components/deep_sleep/test.esp32-c2-idf.yaml | 10 ---------- .../components/deep_sleep/test.esp32-h2-idf.yaml | 16 ---------------- 2 files changed, 26 deletions(-) delete mode 100644 tests/components/deep_sleep/test.esp32-c2-idf.yaml delete mode 100644 tests/components/deep_sleep/test.esp32-h2-idf.yaml diff --git a/tests/components/deep_sleep/test.esp32-c2-idf.yaml b/tests/components/deep_sleep/test.esp32-c2-idf.yaml deleted file mode 100644 index 7023bf67d6..0000000000 --- a/tests/components/deep_sleep/test.esp32-c2-idf.yaml +++ /dev/null @@ -1,10 +0,0 @@ -substitutions: - wakeup_pin: GPIO4 - -<<: !include common.yaml - -deep_sleep: - run_duration: 10s - sleep_duration: 50s - wakeup_pin: ${wakeup_pin} - wakeup_pin_mode: INVERT_WAKEUP diff --git a/tests/components/deep_sleep/test.esp32-h2-idf.yaml b/tests/components/deep_sleep/test.esp32-h2-idf.yaml deleted file mode 100644 index e3c46c5176..0000000000 --- a/tests/components/deep_sleep/test.esp32-h2-idf.yaml +++ /dev/null @@ -1,16 +0,0 @@ -substitutions: - wakeup_pin: GPIO9 - -<<: !include common.yaml - -deep_sleep: - run_duration: - default: 10s - gpio_wakeup_reason: 30s - sleep_duration: 50s - esp32_ext1_wakeup: - pins: - - number: GPIO7 - - number: GPIO8 - - number: GPIO9 - mode: ANY_HIGH From 3b73738d9f590183813450244bd850f31afd4c1e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 16:35:53 -0500 Subject: [PATCH 1541/1626] [script] Reduce RAM usage by storing names in flash (#10941) --- esphome/codegen.py | 1 + esphome/components/script/__init__.py | 2 +- esphome/components/script/script.h | 14 +++++++------- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/codegen.py b/esphome/codegen.py index 8e02ec1164..6decd77c62 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -12,6 +12,7 @@ from esphome.cpp_generator import ( # noqa: F401 ArrayInitializer, Expression, LineComment, + LogStringLiteral, MockObj, MockObjClass, Pvariable, diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index ee1f6a4ad0..e8a8aa5671 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -124,7 +124,7 @@ async def to_code(config): template, func_args = parameters_to_template(conf[CONF_PARAMETERS]) trigger = cg.new_Pvariable(conf[CONF_ID], template) # Add a human-readable name to the script - cg.add(trigger.set_name(conf[CONF_ID].id)) + cg.add(trigger.set_name(cg.LogStringLiteral(conf[CONF_ID].id))) if CONF_MAX_RUNS in conf: cg.add(trigger.set_max_runs(conf[CONF_MAX_RUNS])) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index b16bb53acc..b87402f52e 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -48,14 +48,14 @@ template class Script : public ScriptLogger, public Trigger void execute_tuple_(const std::tuple &tuple, seq /*unused*/) { this->execute(std::get(tuple)...); } - std::string name_; + const LogString *name_{nullptr}; }; /** A script type for which only a single instance at a time is allowed. @@ -68,7 +68,7 @@ template class SingleScript : public Script { void execute(Ts... x) override { if (this->is_action_running()) { this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' is already running! (mode: single)"), - this->name_.c_str()); + LOG_STR_ARG(this->name_)); return; } @@ -85,7 +85,7 @@ template class RestartScript : public Script { public: void execute(Ts... x) override { if (this->is_action_running()) { - this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), this->name_.c_str()); + this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), LOG_STR_ARG(this->name_)); this->stop_action(); } @@ -105,12 +105,12 @@ template class QueueingScript : public Script, public Com // num_runs_ + 1 if (this->max_runs_ != 0 && this->num_runs_ + 1 >= this->max_runs_) { this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of queued runs exceeded!"), - this->name_.c_str()); + LOG_STR_ARG(this->name_)); return; } this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' queueing new instance (mode: queued)"), - this->name_.c_str()); + LOG_STR_ARG(this->name_)); this->num_runs_++; this->var_queue_.push(std::make_tuple(x...)); return; @@ -157,7 +157,7 @@ template class ParallelScript : public Script { void execute(Ts... x) override { if (this->max_runs_ != 0 && this->automation_parent_->num_running() >= this->max_runs_) { this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of parallel runs exceeded!"), - this->name_.c_str()); + LOG_STR_ARG(this->name_)); return; } this->trigger(x...); From 300f1de11c93c3df34cc4dd943c337b84d3fe7ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 17:00:23 -0500 Subject: [PATCH 1542/1626] Bump aioesphomeapi from 41.10.0 to 41.11.0 (#10942) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 42db2cc56f..0b6820e7b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==41.10.0 +aioesphomeapi==41.11.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 9efe9f1c198b2d6039b4d5319afab53cad44d6be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 17:49:03 -0500 Subject: [PATCH 1543/1626] wip --- .../components/esp32_ble_server/__init__.py | 87 ++++++++++++++++ .../esp32_ble_server/ble_characteristic.cpp | 9 +- .../esp32_ble_server/ble_characteristic.h | 18 +++- .../esp32_ble_server/ble_descriptor.cpp | 4 +- .../esp32_ble_server/ble_descriptor.h | 14 ++- .../esp32_ble_server/ble_server.cpp | 4 +- .../components/esp32_ble_server/ble_server.h | 17 +++- .../ble_server_automations.cpp | 23 +++-- .../esp32_ble_server/ble_server_automations.h | 15 +-- .../esp32_ble_server/ble_service.cpp | 4 + .../components/esp32_ble_server/ble_service.h | 3 + .../esp32_improv/esp32_improv_component.cpp | 14 ++- .../components/event_emitter/event_emitter.h | 99 ++++++------------- 13 files changed, 197 insertions(+), 114 deletions(-) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 9eab9647b3..7bd3a0e585 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -1,4 +1,5 @@ import encodings +from typing import TypeAlias from esphome import automation import esphome.codegen as cg @@ -31,6 +32,92 @@ CODEOWNERS = ["@jesserockz", "@clydebarrow", "@Rapsssito"] DEPENDENCIES = ["esp32"] DOMAIN = "esp32_ble_server" +# Type aliases +_ListenerAllocation: TypeAlias = tuple[str, int | str, int] # (component, uuid, count) +_ServerListenerAllocation: TypeAlias = tuple[str, int] # (component, count) + +# Event listener allocation tracking - used by components to reserve slots +_LISTENER_ALLOCATIONS: dict[ + str, list[_ListenerAllocation | _ServerListenerAllocation] +] = { + "characteristic_write": [], + "characteristic_read": [], + "descriptor_write": [], + "server_connect": [], + "server_disconnect": [], +} + + +def allocate_characteristic_event_listener( + uuid: int | str, event_type: str, component: str, count: int = 1 +) -> None: + """ + Allocate event listener slots for a characteristic. + + Args: + uuid: The characteristic UUID (int or string) + event_type: "WRITE" or "READ" + component: Name of the component requesting allocation + count: Number of listeners needed (default 1) + """ + if event_type not in ("WRITE", "READ"): + raise ValueError(f"Unknown event_type: {event_type}") + + key = f"characteristic_{event_type.lower()}" + _LISTENER_ALLOCATIONS[key].append((component, uuid, count)) + + +def allocate_descriptor_event_listener( + uuid: int | str, event_type: str, component: str, count: int = 1 +) -> None: + """Allocate event listener slots for a descriptor.""" + if event_type != "WRITE": + raise ValueError(f"Unknown event_type: {event_type}") + + _LISTENER_ALLOCATIONS["descriptor_write"].append((component, uuid, count)) + + +def allocate_server_event_listener( + event_type: str, component: str, count: int = 1 +) -> None: + """Allocate event listener slots for server events.""" + if event_type not in ("CONNECT", "DISCONNECT"): + raise ValueError(f"Unknown event_type: {event_type}") + + key = f"server_{event_type.lower()}" + _LISTENER_ALLOCATIONS[key].append((component, count)) + + +def _sum_allocations_for_uuid(allocation_key: str, uuid: int | str) -> int: + """Helper to sum allocations for a specific UUID.""" + return sum( + count + for comp, alloc_uuid, count in _LISTENER_ALLOCATIONS[allocation_key] + if alloc_uuid == uuid + ) + + +def _get_allocations_for_uuid(uuid: int | str, event_type: str) -> int: + """Get total allocated listeners for a specific UUID and event type.""" + if event_type not in ("WRITE", "READ"): + return 0 + key = f"characteristic_{event_type.lower()}" + return _sum_allocations_for_uuid(key, uuid) + + +def _get_descriptor_allocations_for_uuid(uuid: int | str) -> int: + """Get total allocated listeners for a descriptor UUID.""" + return _sum_allocations_for_uuid("descriptor_write", uuid) + + +def sanitize_uuid_for_identifier(uuid: int | str) -> str: + """Convert UUID to valid C++ identifier.""" + if isinstance(uuid, int): + return f"0x{uuid:04X}" + # For string UUIDs, replace dashes and colons with underscores + return str(uuid).replace("-", "_").replace(":", "_").lower() + + CONF_ADVERTISE = "advertise" CONF_APPEARANCE = "appearance" CONF_BROADCAST = "broadcast" diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index fabcc75321..efa9c80b30 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -208,8 +208,7 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt if (!param->read.need_rsp) break; // For some reason you can request a read but not want a response - this->EventEmitter::emit_(BLECharacteristicEvt::EmptyEvt::ON_READ, - param->read.conn_id); + this->emit_on_read_(param->read.conn_id); uint16_t max_offset = 22; @@ -277,8 +276,7 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt } if (!param->write.is_prep) { - this->EventEmitter, uint16_t>::emit_( - BLECharacteristicEvt::VectorEvt::ON_WRITE, this->value_, param->write.conn_id); + this->emit_on_write_(this->value_, param->write.conn_id); } break; @@ -289,8 +287,7 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt break; this->write_event_ = false; if (param->exec_write.exec_write_flag == ESP_GATT_PREP_WRITE_EXEC) { - this->EventEmitter, uint16_t>::emit_( - BLECharacteristicEvt::VectorEvt::ON_WRITE, this->value_, param->exec_write.conn_id); + this->emit_on_write_(this->value_, param->exec_write.conn_id); } esp_err_t err = esp_ble_gatts_send_response(gatts_if, param->write.conn_id, param->write.trans_id, ESP_GATT_OK, nullptr); diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 97b3af2a21..6b322bc7ff 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -6,6 +6,7 @@ #include "esphome/components/bytebuffer/bytebuffer.h" #include +#include #ifdef USE_ESP32 @@ -36,8 +37,9 @@ enum EmptyEvt { }; } // namespace BLECharacteristicEvt -class BLECharacteristic : public EventEmitter, uint16_t>, - public EventEmitter { +// Base class for BLE characteristics +// Specialized classes with EventEmitter support are generated per-UUID in the build process +class BLECharacteristic { public: BLECharacteristic(ESPBTUUID uuid, uint32_t properties); ~BLECharacteristic(); @@ -76,7 +78,19 @@ class BLECharacteristic : public EventEmitter, uint16_t)> &&listener) { + return INVALID_LISTENER_ID; + } + virtual EventEmitterListenerID on_read(std::function &&listener) { return INVALID_LISTENER_ID; } + virtual void off_write(EventEmitterListenerID id) {} + virtual void off_read(EventEmitterListenerID id) {} + protected: + // Virtual methods for emitting events - overridden by generated specialized classes + virtual void emit_on_write_(std::span value, uint16_t conn_id) {} + virtual void emit_on_read_(uint16_t conn_id) {} + bool write_event_{false}; BLEService *service_{}; ESPBTUUID uuid_; diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index afbe579513..dbfa8bc632 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -74,9 +74,7 @@ void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_ break; this->value_.attr_len = param->write.len; memcpy(this->value_.attr_value, param->write.value, param->write.len); - this->emit_(BLEDescriptorEvt::VectorEvt::ON_WRITE, - std::vector(param->write.value, param->write.value + param->write.len), - param->write.conn_id); + this->emit_on_write_(std::span(param->write.value, param->write.len), param->write.conn_id); break; } default: diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index 8d3c22c5a1..1b787f7a35 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -8,6 +8,7 @@ #include #include +#include namespace esphome { namespace esp32_ble_server { @@ -24,7 +25,9 @@ enum VectorEvt { }; } // namespace BLEDescriptorEvt -class BLEDescriptor : public EventEmitter, uint16_t> { +// Base class for BLE descriptors +// Specialized classes with EventEmitter support are generated per-UUID in the build process +class BLEDescriptor { public: BLEDescriptor(ESPBTUUID uuid, uint16_t max_len = 100, bool read = true, bool write = true); virtual ~BLEDescriptor(); @@ -39,7 +42,16 @@ class BLEDescriptor : public EventEmitterstate_ == CREATED; } bool is_failed() { return this->state_ == FAILED; } + // Event listener registration - overridden by generated specialized classes if needed + virtual EventEmitterListenerID on_write(std::function, uint16_t)> &&listener) { + return INVALID_LISTENER_ID; + } + virtual void off_write(EventEmitterListenerID id) {} + protected: + // Virtual method for emitting events - overridden by generated specialized classes + virtual void emit_on_write_(std::span value, uint16_t conn_id) {} + BLECharacteristic *characteristic_{nullptr}; ESPBTUUID uuid_; uint16_t handle_{0xFFFF}; diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 89299bb417..d73c343b4d 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -153,14 +153,14 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga case ESP_GATTS_CONNECT_EVT: { ESP_LOGD(TAG, "BLE Client connected"); this->add_client_(param->connect.conn_id); - this->emit_(BLEServerEvt::EmptyEvt::ON_CONNECT, param->connect.conn_id); + this->emit_on_connect_(param->connect.conn_id); break; } case ESP_GATTS_DISCONNECT_EVT: { ESP_LOGD(TAG, "BLE Client disconnected"); this->remove_client_(param->disconnect.conn_id); this->parent_->advertising_start(); - this->emit_(BLEServerEvt::EmptyEvt::ON_DISCONNECT, param->disconnect.conn_id); + this->emit_on_disconnect_(param->disconnect.conn_id); break; } case ESP_GATTS_REG_EVT: { diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index b5973ed099..e3181a945d 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -31,11 +31,9 @@ enum EmptyEvt { }; } // namespace BLEServerEvt -class BLEServer : public Component, - public GATTsEventHandler, - public BLEStatusEventHandler, - public Parented, - public EventEmitter { +// Base class for BLE server +// Note: Only one BLEServer instance exists per build, so we can use fixed defines +class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEventHandler, public Parented { public: void setup() override; void loop() override; @@ -65,7 +63,16 @@ class BLEServer : public Component, void ble_before_disabled_event_handler() override; + // Event listener registration - overridden by generated specialized classes if needed + virtual EventEmitterListenerID on_connect(std::function &&listener) { return INVALID_LISTENER_ID; } + virtual EventEmitterListenerID on_disconnect(std::function &&listener) { return INVALID_LISTENER_ID; } + virtual void off_connect(EventEmitterListenerID id) {} + virtual void off_disconnect(EventEmitterListenerID id) {} + protected: + // Virtual methods for emitting events + virtual void emit_on_connect_(uint16_t conn_id) {} + virtual void emit_on_disconnect_(uint16_t conn_id) {} struct ServiceEntry { ESPBTUUID uuid; uint8_t inst_id; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.cpp b/esphome/components/esp32_ble_server/ble_server_automations.cpp index 67e00a9bfe..afa958a4a0 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.cpp +++ b/esphome/components/esp32_ble_server/ble_server_automations.cpp @@ -14,9 +14,10 @@ Trigger, uint16_t> *BLETriggers::create_characteristic_on_w BLECharacteristic *characteristic) { Trigger, uint16_t> *on_write_trigger = // NOLINT(cppcoreguidelines-owning-memory) new Trigger, uint16_t>(); - characteristic->EventEmitter, uint16_t>::on( - BLECharacteristicEvt::VectorEvt::ON_WRITE, - [on_write_trigger](const std::vector &data, uint16_t id) { on_write_trigger->trigger(data, id); }); + characteristic->on_write([on_write_trigger](std::span data, uint16_t id) { + // Convert span to vector for trigger + on_write_trigger->trigger(std::vector(data.begin(), data.end()), id); + }); return on_write_trigger; } #endif @@ -25,9 +26,10 @@ Trigger, uint16_t> *BLETriggers::create_characteristic_on_w Trigger, uint16_t> *BLETriggers::create_descriptor_on_write_trigger(BLEDescriptor *descriptor) { Trigger, uint16_t> *on_write_trigger = // NOLINT(cppcoreguidelines-owning-memory) new Trigger, uint16_t>(); - descriptor->on( - BLEDescriptorEvt::VectorEvt::ON_WRITE, - [on_write_trigger](const std::vector &data, uint16_t id) { on_write_trigger->trigger(data, id); }); + descriptor->on_write([on_write_trigger](std::span data, uint16_t id) { + // Convert span to vector for trigger + on_write_trigger->trigger(std::vector(data.begin(), data.end()), id); + }); return on_write_trigger; } #endif @@ -35,8 +37,7 @@ Trigger, uint16_t> *BLETriggers::create_descriptor_on_write #ifdef USE_ESP32_BLE_SERVER_ON_CONNECT Trigger *BLETriggers::create_server_on_connect_trigger(BLEServer *server) { Trigger *on_connect_trigger = new Trigger(); // NOLINT(cppcoreguidelines-owning-memory) - server->on(BLEServerEvt::EmptyEvt::ON_CONNECT, - [on_connect_trigger](uint16_t conn_id) { on_connect_trigger->trigger(conn_id); }); + server->on_connect([on_connect_trigger](uint16_t conn_id) { on_connect_trigger->trigger(conn_id); }); return on_connect_trigger; } #endif @@ -44,8 +45,7 @@ Trigger *BLETriggers::create_server_on_connect_trigger(BLEServer *serv #ifdef USE_ESP32_BLE_SERVER_ON_DISCONNECT Trigger *BLETriggers::create_server_on_disconnect_trigger(BLEServer *server) { Trigger *on_disconnect_trigger = new Trigger(); // NOLINT(cppcoreguidelines-owning-memory) - server->on(BLEServerEvt::EmptyEvt::ON_DISCONNECT, - [on_disconnect_trigger](uint16_t conn_id) { on_disconnect_trigger->trigger(conn_id); }); + server->on_disconnect([on_disconnect_trigger](uint16_t conn_id) { on_disconnect_trigger->trigger(conn_id); }); return on_disconnect_trigger; } #endif @@ -58,8 +58,7 @@ void BLECharacteristicSetValueActionManager::set_listener(BLECharacteristic *cha auto *existing = this->find_listener_(characteristic); if (existing != nullptr) { // Remove the previous listener - characteristic->EventEmitter::off(BLECharacteristicEvt::EmptyEvt::ON_READ, - existing->listener_id); + characteristic->off_read(existing->listener_id); // Remove the pre-notify listener this->off(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, existing->pre_notify_listener_id); // Remove from vector diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index 8fcb5842c3..08a3322367 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -46,8 +46,12 @@ enum BLECharacteristicSetValueActionEvt { }; // Class to make sure only one BLECharacteristicSetValueAction is active at a time for each characteristic +#ifndef BLE_SET_VALUE_ACTION_MAX_LISTENERS +#define BLE_SET_VALUE_ACTION_MAX_LISTENERS 1 +#endif + class BLECharacteristicSetValueActionManager - : public EventEmitter { + : public EventEmitter { public: // Singleton pattern static BLECharacteristicSetValueActionManager *get_instance() { @@ -92,11 +96,10 @@ template class BLECharacteristicSetValueAction : public Actionparent_->set_value(this->buffer_.value(x...)); // Set the listener for read events - this->listener_id_ = this->parent_->EventEmitter::on( - BLECharacteristicEvt::EmptyEvt::ON_READ, [this, x...](uint16_t id) { - // Set the value of the characteristic every time it is read - this->parent_->set_value(this->buffer_.value(x...)); - }); + this->listener_id_ = this->parent_->on_read([this, x...](uint16_t id) { + // Set the value of the characteristic every time it is read + this->parent_->set_value(this->buffer_.value(x...)); + }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( this->parent_, this->listener_id_, [this, x...]() { this->parent_->set_value(this->buffer_.value(x...)); }); diff --git a/esphome/components/esp32_ble_server/ble_service.cpp b/esphome/components/esp32_ble_server/ble_service.cpp index 96fedf2346..3c5b40ba9e 100644 --- a/esphome/components/esp32_ble_server/ble_service.cpp +++ b/esphome/components/esp32_ble_server/ble_service.cpp @@ -41,6 +41,10 @@ BLECharacteristic *BLEService::create_characteristic(ESPBTUUID uuid, esp_gatt_ch return characteristic; } +void BLEService::add_characteristic(BLECharacteristic *characteristic) { + this->characteristics_.push_back(characteristic); +} + void BLEService::do_create(BLEServer *server) { this->server_ = server; diff --git a/esphome/components/esp32_ble_server/ble_service.h b/esphome/components/esp32_ble_server/ble_service.h index dcfad5f501..311c937c28 100644 --- a/esphome/components/esp32_ble_server/ble_service.h +++ b/esphome/components/esp32_ble_server/ble_service.h @@ -31,6 +31,9 @@ class BLEService { BLECharacteristic *create_characteristic(uint16_t uuid, esp_gatt_char_prop_t properties); BLECharacteristic *create_characteristic(ESPBTUUID uuid, esp_gatt_char_prop_t properties); + // Add pre-constructed characteristic (used by generated code) + void add_characteristic(BLECharacteristic *characteristic); + ESPBTUUID get_uuid() { return this->uuid_; } uint8_t get_inst_id() { return this->inst_id_; } BLECharacteristic *get_last_created_characteristic() { return this->last_created_characteristic_; } diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index ca08ff0cca..f773083890 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -38,8 +38,7 @@ void ESP32ImprovComponent::setup() { }); } #endif - global_ble_server->on(BLEServerEvt::EmptyEvt::ON_DISCONNECT, - [this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); }); + global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); }); // Start with loop disabled - will be enabled by start() when needed this->disable_loop(); @@ -57,12 +56,11 @@ void ESP32ImprovComponent::setup_characteristics() { this->error_->add_descriptor(error_descriptor); this->rpc_ = this->service_->create_characteristic(improv::RPC_COMMAND_UUID, BLECharacteristic::PROPERTY_WRITE); - this->rpc_->EventEmitter, uint16_t>::on( - BLECharacteristicEvt::VectorEvt::ON_WRITE, [this](const std::vector &data, uint16_t id) { - if (!data.empty()) { - this->incoming_data_.insert(this->incoming_data_.end(), data.begin(), data.end()); - } - }); + this->rpc_->on_write([this](std::span data, uint16_t id) { + if (!data.empty()) { + this->incoming_data_.insert(this->incoming_data_.end(), data.begin(), data.end()); + } + }); BLEDescriptor *rpc_descriptor = new BLE2902(); this->rpc_->add_descriptor(rpc_descriptor); diff --git a/esphome/components/event_emitter/event_emitter.h b/esphome/components/event_emitter/event_emitter.h index 74afde03c0..e44cac7cc7 100644 --- a/esphome/components/event_emitter/event_emitter.h +++ b/esphome/components/event_emitter/event_emitter.h @@ -1,5 +1,5 @@ #pragma once -#include +#include #include #include @@ -13,34 +13,31 @@ static constexpr EventEmitterListenerID INVALID_LISTENER_ID = 0; // EventEmitter class that can emit events with a specific name (it is highly recommended to use an enum class for this) // and a list of arguments. Supports multiple listeners for each event. -template class EventEmitter { +// MaxListeners is the compile-time maximum number of listeners per event type. +template class EventEmitter { public: EventEmitterListenerID on(EvtType event, std::function listener) { - EventEmitterListenerID listener_id = this->get_next_id_(); - - // Find or create event entry - EventEntry *entry = this->find_or_create_event_(event); - entry->listeners.push_back({listener_id, listener}); - - return listener_id; + // Find a free slot in the listeners array + for (auto &entry : this->listeners_) { + if (entry.id == INVALID_LISTENER_ID) { + // Found empty slot + EventEmitterListenerID listener_id = this->get_next_id_(); + entry.id = listener_id; + entry.event = event; + entry.callback = std::move(listener); + return listener_id; + } + } + // No free slots - array is full + return INVALID_LISTENER_ID; } void off(EvtType event, EventEmitterListenerID id) { - EventEntry *entry = this->find_event_(event); - if (entry == nullptr) - return; - - // Remove listener with given id - for (auto it = entry->listeners.begin(); it != entry->listeners.end(); ++it) { - if (it->id == id) { - // Swap with last and pop for efficient removal - *it = entry->listeners.back(); - entry->listeners.pop_back(); - - // Remove event entry if no more listeners - if (entry->listeners.empty()) { - this->remove_event_(event); - } + // Find and remove listener with given id + for (auto &entry : this->listeners_) { + if (entry.id == id && entry.event == event) { + entry.id = INVALID_LISTENER_ID; + entry.callback = nullptr; return; } } @@ -48,25 +45,19 @@ template class EventEmitter { protected: void emit_(EvtType event, Args... args) { - EventEntry *entry = this->find_event_(event); - if (entry == nullptr) - return; - // Call all listeners for this event - for (const auto &listener : entry->listeners) { - listener.callback(args...); + for (const auto &entry : this->listeners_) { + if (entry.id != INVALID_LISTENER_ID && entry.event == event) { + entry.callback(args...); + } } } private: - struct Listener { - EventEmitterListenerID id; - std::function callback; - }; - - struct EventEntry { + struct ListenerEntry { EvtType event; - std::vector listeners; + EventEmitterListenerID id{INVALID_LISTENER_ID}; + std::function callback{nullptr}; }; EventEmitterListenerID get_next_id_() { @@ -79,38 +70,8 @@ template class EventEmitter { return this->current_id_; } - EventEntry *find_event_(EvtType event) { - for (auto &entry : this->events_) { - if (entry.event == event) { - return &entry; - } - } - return nullptr; - } - - EventEntry *find_or_create_event_(EvtType event) { - EventEntry *entry = this->find_event_(event); - if (entry != nullptr) - return entry; - - // Create new event entry - this->events_.push_back({event, {}}); - return &this->events_.back(); - } - - void remove_event_(EvtType event) { - for (auto it = this->events_.begin(); it != this->events_.end(); ++it) { - if (it->event == event) { - // Swap with last and pop - *it = this->events_.back(); - this->events_.pop_back(); - return; - } - } - } - - std::vector events_; - EventEmitterListenerID current_id_ = 0; + std::array listeners_{}; + EventEmitterListenerID current_id_{0}; }; } // namespace event_emitter From e7750250e068175389f9f0df880281c1e16a5ee4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 17:55:26 -0500 Subject: [PATCH 1544/1626] wip --- .../components/esp32_ble_server/__init__.py | 194 ++++++++++++++++++ .../esp32_ble_server/ble_characteristic.cpp | 2 +- .../esp32_ble_server/ble_characteristic.h | 2 +- .../esp32_ble_server/ble_descriptor.h | 2 +- esphome/components/esp32_improv/__init__.py | 11 +- 5 files changed, 207 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 7bd3a0e585..f8f913b8e9 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -591,6 +591,12 @@ async def to_code_characteristic(service_var, char_conf): parse_properties(char_conf), ), ) + + # If this characteristic has notify or indicate, it will get a CCCD descriptor (0x2902) + # and ble_characteristic.cpp will register a listener for it + if char_conf.get(CONF_NOTIFY, False) or char_conf.get(CONF_INDICATE, False): + allocate_descriptor_event_listener(0x2902, "WRITE", "esp32_ble_server", 1) + if CONF_ON_WRITE in char_conf: on_write_conf = char_conf[CONF_ON_WRITE] cg.add_define("USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE") @@ -670,6 +676,194 @@ async def to_code(config): [(cg.uint16, "id")], config[CONF_ON_DISCONNECT], ) + + # Generate defines for BLECharacteristicSetValueActionManager + set_value_action_count = sum( + count for comp, count in _LISTENER_ALLOCATIONS["server_connect"] + ) + if set_value_action_count > 0: + cg.add_define("BLE_SET_VALUE_ACTION_MAX_LISTENERS", str(set_value_action_count)) + + # Generate defines and specialized classes for server events + server_connect_count = sum( + count for comp, count in _LISTENER_ALLOCATIONS["server_connect"] + ) + server_disconnect_count = sum( + count for comp, count in _LISTENER_ALLOCATIONS["server_disconnect"] + ) + + if server_connect_count > 0 or server_disconnect_count > 0: + # Generate the specialized BLEServer class with EventEmitter support + cg.add_define( + "BLE_SERVER_CONNECT_MAX_LISTENERS", str(max(server_connect_count, 1)) + ) + cg.add_define( + "BLE_SERVER_DISCONNECT_MAX_LISTENERS", + str(max(server_disconnect_count, 1)), + ) + # TODO: Generate specialized BLEServer class with EventEmitter mixins + + # Generate defines and specialized classes for characteristics + # Group allocations by UUID + char_write_by_uuid: dict[int | str, int] = {} + for comp, uuid, count in _LISTENER_ALLOCATIONS["characteristic_write"]: + char_write_by_uuid[uuid] = char_write_by_uuid.get(uuid, 0) + count + + char_read_by_uuid: dict[int | str, int] = {} + for comp, uuid, count in _LISTENER_ALLOCATIONS["characteristic_read"]: + char_read_by_uuid[uuid] = char_read_by_uuid.get(uuid, 0) + count + + # Generate defines for each UUID + for uuid, count in char_write_by_uuid.items(): + uuid_id = sanitize_uuid_for_identifier(uuid) + cg.add_define(f"BLE_CHAR_{uuid_id}_WRITE_MAX_LISTENERS", str(count)) + + for uuid, count in char_read_by_uuid.items(): + uuid_id = sanitize_uuid_for_identifier(uuid) + cg.add_define(f"BLE_CHAR_{uuid_id}_READ_MAX_LISTENERS", str(count)) + + # Generate defines and specialized classes for descriptors + descriptor_write_by_uuid: dict[int | str, int] = {} + for comp, uuid, count in _LISTENER_ALLOCATIONS["descriptor_write"]: + descriptor_write_by_uuid[uuid] = descriptor_write_by_uuid.get(uuid, 0) + count + + for uuid, count in descriptor_write_by_uuid.items(): + uuid_id = sanitize_uuid_for_identifier(uuid) + cg.add_define(f"BLE_DESC_{uuid_id}_WRITE_MAX_LISTENERS", str(count)) + + # Generate specialized characteristic classes with EventEmitter support + for uuid in set(list(char_write_by_uuid.keys()) + list(char_read_by_uuid.keys())): + uuid_id = sanitize_uuid_for_identifier(uuid) + write_count = char_write_by_uuid.get(uuid, 0) + read_count = char_read_by_uuid.get(uuid, 0) + + # Generate specialized class that inherits from BLECharacteristic and adds EventEmitter support + class_name = f"BLECharacteristic_{uuid_id}" + + # Build the class header with appropriate EventEmitter base classes + base_classes = ["BLECharacteristic"] + template_params = [] + + if write_count > 0: + base_classes.append( + f"EventEmitter, uint16_t>" + ) + template_params.append("write") + if read_count > 0: + base_classes.append( + f"EventEmitter" + ) + template_params.append("read") + + cg.add_global( + cg.RawExpression(f""" +class {class_name} : public {", public ".join(base_classes)} {{ + public: + {class_name}(ESPBTUUID uuid, uint32_t properties, uint16_t max_len = 100) + : BLECharacteristic(uuid, properties, max_len) {{}} + + // Override virtual methods to provide EventEmitter functionality + {"EventEmitterListenerID on_write(std::function, uint16_t)> &&listener) override {" if write_count > 0 else ""} + {" return this->EventEmitter, uint16_t>::on(BLECharacteristicEvt::SpanEvt::ON_WRITE, std::move(listener));" if write_count > 0 else ""} + {"}" if write_count > 0 else ""} + + {"void off_write(EventEmitterListenerID id) override {" if write_count > 0 else ""} + {" this->EventEmitter, uint16_t>::off(BLECharacteristicEvt::SpanEvt::ON_WRITE, id);" if write_count > 0 else ""} + {"}" if write_count > 0 else ""} + + {"EventEmitterListenerID on_read(std::function &&listener) override {" if read_count > 0 else ""} + {" return this->EventEmitter::on(BLECharacteristicEvt::EmptyEvt::ON_READ, std::move(listener));" if read_count > 0 else ""} + {"}" if read_count > 0 else ""} + + {"void off_read(EventEmitterListenerID id) override {" if read_count > 0 else ""} + {" this->EventEmitter::off(BLECharacteristicEvt::EmptyEvt::ON_READ, id);" if read_count > 0 else ""} + {"}" if read_count > 0 else ""} + + protected: + {"void emit_on_write_(std::span value, uint16_t conn_id) override {" if write_count > 0 else ""} + {" this->EventEmitter, uint16_t>::emit_(BLECharacteristicEvt::SpanEvt::ON_WRITE, value, conn_id);" if write_count > 0 else ""} + {"}" if write_count > 0 else ""} + + {"void emit_on_read_(uint16_t conn_id) override {" if read_count > 0 else ""} + {" this->EventEmitter::emit_(BLECharacteristicEvt::EmptyEvt::ON_READ, conn_id);" if read_count > 0 else ""} + {"}" if read_count > 0 else ""} +}}; +""") + ) + + # Generate specialized descriptor classes with EventEmitter support + for uuid, count in descriptor_write_by_uuid.items(): + uuid_id = sanitize_uuid_for_identifier(uuid) + class_name = f"BLEDescriptor_{uuid_id}" + + cg.add_global( + cg.RawExpression(f""" +class {class_name} : public BLEDescriptor, + public EventEmitter, uint16_t> {{ + public: + {class_name}(ESPBTUUID uuid, uint16_t max_len = 100, bool read = true, bool write = true) + : BLEDescriptor(uuid, max_len, read, write) {{}} + + EventEmitterListenerID on_write(std::function, uint16_t)> &&listener) override {{ + return this->EventEmitter, uint16_t>::on(BLEDescriptorEvt::SpanEvt::ON_WRITE, std::move(listener)); + }} + + void off_write(EventEmitterListenerID id) override {{ + this->EventEmitter, uint16_t>::off(BLEDescriptorEvt::SpanEvt::ON_WRITE, id); + }} + + protected: + void emit_on_write_(std::span value, uint16_t conn_id) override {{ + this->EventEmitter, uint16_t>::emit_(BLEDescriptorEvt::SpanEvt::ON_WRITE, value, conn_id); + }} +}}; +""") + ) + + # Generate specialized BLEServer class if needed + if server_connect_count > 0 or server_disconnect_count > 0: + base_classes = ["BLEServer"] + if server_connect_count > 0: + base_classes.append( + "EventEmitter" + ) + if server_disconnect_count > 0: + base_classes.append( + "EventEmitter" + ) + + cg.add_global( + cg.RawExpression(f""" +class BLEServerWithEvents : public {", public ".join(base_classes)} {{ + public: + {"EventEmitterListenerID on_connect(std::function &&listener) override {" if server_connect_count > 0 else ""} + {" return this->EventEmitter::on(BLEServerEvt::EmptyEvt::ON_CONNECT, std::move(listener));" if server_connect_count > 0 else ""} + {"}" if server_connect_count > 0 else ""} + + {"void off_connect(EventEmitterListenerID id) override {" if server_connect_count > 0 else ""} + {" this->EventEmitter::off(BLEServerEvt::EmptyEvt::ON_CONNECT, id);" if server_connect_count > 0 else ""} + {"}" if server_connect_count > 0 else ""} + + {"EventEmitterListenerID on_disconnect(std::function &&listener) override {" if server_disconnect_count > 0 else ""} + {" return this->EventEmitter::on(BLEServerEvt::EmptyEvt::ON_DISCONNECT, std::move(listener));" if server_disconnect_count > 0 else ""} + {"}" if server_disconnect_count > 0 else ""} + + {"void off_disconnect(EventEmitterListenerID id) override {" if server_disconnect_count > 0 else ""} + {" this->EventEmitter::off(BLEServerEvt::EmptyEvt::ON_DISCONNECT, id);" if server_disconnect_count > 0 else ""} + {"}" if server_disconnect_count > 0 else ""} + + protected: + {"void emit_on_connect_(uint16_t conn_id) override {" if server_connect_count > 0 else ""} + {" this->EventEmitter::emit_(BLEServerEvt::EmptyEvt::ON_CONNECT, conn_id);" if server_connect_count > 0 else ""} + {"}" if server_connect_count > 0 else ""} + + {"void emit_on_disconnect_(uint16_t conn_id) override {" if server_disconnect_count > 0 else ""} + {" this->EventEmitter::emit_(BLEServerEvt::EmptyEvt::ON_DISCONNECT, conn_id);" if server_disconnect_count > 0 else ""} + {"}" if server_disconnect_count > 0 else ""} +}}; +""") + ) + cg.add_define("USE_ESP32_BLE_SERVER") cg.add_define("USE_ESP32_BLE_ADVERTISING") add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index efa9c80b30..d3fde4ec25 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -73,7 +73,7 @@ void BLECharacteristic::notify() { void BLECharacteristic::add_descriptor(BLEDescriptor *descriptor) { // If the descriptor is the CCCD descriptor, listen to its write event to know if the client wants to be notified if (descriptor->get_uuid() == ESPBTUUID::from_uint16(ESP_GATT_UUID_CHAR_CLIENT_CONFIG)) { - descriptor->on(BLEDescriptorEvt::VectorEvt::ON_WRITE, [this](const std::vector &value, uint16_t conn_id) { + descriptor->on_write([this](std::span value, uint16_t conn_id) { if (value.size() != 2) return; uint16_t cccd = encode_uint16(value[1], value[0]); diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 6b322bc7ff..a64610d73f 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -28,7 +28,7 @@ using namespace event_emitter; class BLEService; namespace BLECharacteristicEvt { -enum VectorEvt { +enum SpanEvt { ON_WRITE, }; diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index 1b787f7a35..af1cbfd18d 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -20,7 +20,7 @@ using namespace event_emitter; class BLECharacteristic; namespace BLEDescriptorEvt { -enum VectorEvt { +enum SpanEvt { ON_WRITE, }; } // namespace BLEDescriptorEvt diff --git a/esphome/components/esp32_improv/__init__.py b/esphome/components/esp32_improv/__init__.py index fa33bd947a..43377326bd 100644 --- a/esphome/components/esp32_improv/__init__.py +++ b/esphome/components/esp32_improv/__init__.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble, output +from esphome.components import binary_sensor, esp32_ble, esp32_ble_server, output from esphome.components.esp32_ble import BTLoggers import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TRIGGER_ID @@ -98,6 +98,15 @@ async def to_code(config): # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) + # Allocate event listeners for esp32_improv + # Need 1 listener for server disconnect event + esp32_ble_server.allocate_server_event_listener("DISCONNECT", "esp32_improv", 1) + # The RPC characteristic UUID comes from the Improv library (0x00467768-6228-2272-4663-277478268000 + 0x01) + # We need 1 listener for the RPC write event + esp32_ble_server.allocate_characteristic_event_listener( + "00467768-6228-2272-4663-277478268001", "WRITE", "esp32_improv", 1 + ) + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) From d802d70311000acc69b9ff2b38057c20b34487d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 18:07:46 -0500 Subject: [PATCH 1545/1626] wip --- .../components/esp32_ble_server/__init__.py | 280 +----------------- .../esp32_ble_server/ble_characteristic.cpp | 12 +- .../esp32_ble_server/ble_characteristic.h | 32 +- .../esp32_ble_server/ble_descriptor.cpp | 4 +- .../esp32_ble_server/ble_descriptor.h | 19 +- .../esp32_ble_server/ble_server.cpp | 8 +- .../components/esp32_ble_server/ble_server.h | 22 +- .../ble_server_automations.cpp | 16 +- .../esp32_ble_server/ble_server_automations.h | 39 +-- esphome/components/esp32_improv/__init__.py | 11 +- esphome/components/event_emitter/__init__.py | 5 - .../components/event_emitter/event_emitter.h | 78 ----- 12 files changed, 50 insertions(+), 476 deletions(-) delete mode 100644 esphome/components/event_emitter/__init__.py delete mode 100644 esphome/components/event_emitter/event_emitter.h diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index f8f913b8e9..bcb4044f94 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -1,5 +1,4 @@ import encodings -from typing import TypeAlias from esphome import automation import esphome.codegen as cg @@ -27,96 +26,11 @@ from esphome.const import ( from esphome.core import CORE from esphome.schema_extractors import SCHEMA_EXTRACT -AUTO_LOAD = ["esp32_ble", "bytebuffer", "event_emitter"] +AUTO_LOAD = ["esp32_ble", "bytebuffer"] CODEOWNERS = ["@jesserockz", "@clydebarrow", "@Rapsssito"] DEPENDENCIES = ["esp32"] DOMAIN = "esp32_ble_server" -# Type aliases -_ListenerAllocation: TypeAlias = tuple[str, int | str, int] # (component, uuid, count) -_ServerListenerAllocation: TypeAlias = tuple[str, int] # (component, count) - -# Event listener allocation tracking - used by components to reserve slots -_LISTENER_ALLOCATIONS: dict[ - str, list[_ListenerAllocation | _ServerListenerAllocation] -] = { - "characteristic_write": [], - "characteristic_read": [], - "descriptor_write": [], - "server_connect": [], - "server_disconnect": [], -} - - -def allocate_characteristic_event_listener( - uuid: int | str, event_type: str, component: str, count: int = 1 -) -> None: - """ - Allocate event listener slots for a characteristic. - - Args: - uuid: The characteristic UUID (int or string) - event_type: "WRITE" or "READ" - component: Name of the component requesting allocation - count: Number of listeners needed (default 1) - """ - if event_type not in ("WRITE", "READ"): - raise ValueError(f"Unknown event_type: {event_type}") - - key = f"characteristic_{event_type.lower()}" - _LISTENER_ALLOCATIONS[key].append((component, uuid, count)) - - -def allocate_descriptor_event_listener( - uuid: int | str, event_type: str, component: str, count: int = 1 -) -> None: - """Allocate event listener slots for a descriptor.""" - if event_type != "WRITE": - raise ValueError(f"Unknown event_type: {event_type}") - - _LISTENER_ALLOCATIONS["descriptor_write"].append((component, uuid, count)) - - -def allocate_server_event_listener( - event_type: str, component: str, count: int = 1 -) -> None: - """Allocate event listener slots for server events.""" - if event_type not in ("CONNECT", "DISCONNECT"): - raise ValueError(f"Unknown event_type: {event_type}") - - key = f"server_{event_type.lower()}" - _LISTENER_ALLOCATIONS[key].append((component, count)) - - -def _sum_allocations_for_uuid(allocation_key: str, uuid: int | str) -> int: - """Helper to sum allocations for a specific UUID.""" - return sum( - count - for comp, alloc_uuid, count in _LISTENER_ALLOCATIONS[allocation_key] - if alloc_uuid == uuid - ) - - -def _get_allocations_for_uuid(uuid: int | str, event_type: str) -> int: - """Get total allocated listeners for a specific UUID and event type.""" - if event_type not in ("WRITE", "READ"): - return 0 - key = f"characteristic_{event_type.lower()}" - return _sum_allocations_for_uuid(key, uuid) - - -def _get_descriptor_allocations_for_uuid(uuid: int | str) -> int: - """Get total allocated listeners for a descriptor UUID.""" - return _sum_allocations_for_uuid("descriptor_write", uuid) - - -def sanitize_uuid_for_identifier(uuid: int | str) -> str: - """Convert UUID to valid C++ identifier.""" - if isinstance(uuid, int): - return f"0x{uuid:04X}" - # For string UUIDs, replace dashes and colons with underscores - return str(uuid).replace("-", "_").replace(":", "_").lower() - CONF_ADVERTISE = "advertise" CONF_APPEARANCE = "appearance" @@ -592,11 +506,6 @@ async def to_code_characteristic(service_var, char_conf): ), ) - # If this characteristic has notify or indicate, it will get a CCCD descriptor (0x2902) - # and ble_characteristic.cpp will register a listener for it - if char_conf.get(CONF_NOTIFY, False) or char_conf.get(CONF_INDICATE, False): - allocate_descriptor_event_listener(0x2902, "WRITE", "esp32_ble_server", 1) - if CONF_ON_WRITE in char_conf: on_write_conf = char_conf[CONF_ON_WRITE] cg.add_define("USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE") @@ -677,193 +586,6 @@ async def to_code(config): config[CONF_ON_DISCONNECT], ) - # Generate defines for BLECharacteristicSetValueActionManager - set_value_action_count = sum( - count for comp, count in _LISTENER_ALLOCATIONS["server_connect"] - ) - if set_value_action_count > 0: - cg.add_define("BLE_SET_VALUE_ACTION_MAX_LISTENERS", str(set_value_action_count)) - - # Generate defines and specialized classes for server events - server_connect_count = sum( - count for comp, count in _LISTENER_ALLOCATIONS["server_connect"] - ) - server_disconnect_count = sum( - count for comp, count in _LISTENER_ALLOCATIONS["server_disconnect"] - ) - - if server_connect_count > 0 or server_disconnect_count > 0: - # Generate the specialized BLEServer class with EventEmitter support - cg.add_define( - "BLE_SERVER_CONNECT_MAX_LISTENERS", str(max(server_connect_count, 1)) - ) - cg.add_define( - "BLE_SERVER_DISCONNECT_MAX_LISTENERS", - str(max(server_disconnect_count, 1)), - ) - # TODO: Generate specialized BLEServer class with EventEmitter mixins - - # Generate defines and specialized classes for characteristics - # Group allocations by UUID - char_write_by_uuid: dict[int | str, int] = {} - for comp, uuid, count in _LISTENER_ALLOCATIONS["characteristic_write"]: - char_write_by_uuid[uuid] = char_write_by_uuid.get(uuid, 0) + count - - char_read_by_uuid: dict[int | str, int] = {} - for comp, uuid, count in _LISTENER_ALLOCATIONS["characteristic_read"]: - char_read_by_uuid[uuid] = char_read_by_uuid.get(uuid, 0) + count - - # Generate defines for each UUID - for uuid, count in char_write_by_uuid.items(): - uuid_id = sanitize_uuid_for_identifier(uuid) - cg.add_define(f"BLE_CHAR_{uuid_id}_WRITE_MAX_LISTENERS", str(count)) - - for uuid, count in char_read_by_uuid.items(): - uuid_id = sanitize_uuid_for_identifier(uuid) - cg.add_define(f"BLE_CHAR_{uuid_id}_READ_MAX_LISTENERS", str(count)) - - # Generate defines and specialized classes for descriptors - descriptor_write_by_uuid: dict[int | str, int] = {} - for comp, uuid, count in _LISTENER_ALLOCATIONS["descriptor_write"]: - descriptor_write_by_uuid[uuid] = descriptor_write_by_uuid.get(uuid, 0) + count - - for uuid, count in descriptor_write_by_uuid.items(): - uuid_id = sanitize_uuid_for_identifier(uuid) - cg.add_define(f"BLE_DESC_{uuid_id}_WRITE_MAX_LISTENERS", str(count)) - - # Generate specialized characteristic classes with EventEmitter support - for uuid in set(list(char_write_by_uuid.keys()) + list(char_read_by_uuid.keys())): - uuid_id = sanitize_uuid_for_identifier(uuid) - write_count = char_write_by_uuid.get(uuid, 0) - read_count = char_read_by_uuid.get(uuid, 0) - - # Generate specialized class that inherits from BLECharacteristic and adds EventEmitter support - class_name = f"BLECharacteristic_{uuid_id}" - - # Build the class header with appropriate EventEmitter base classes - base_classes = ["BLECharacteristic"] - template_params = [] - - if write_count > 0: - base_classes.append( - f"EventEmitter, uint16_t>" - ) - template_params.append("write") - if read_count > 0: - base_classes.append( - f"EventEmitter" - ) - template_params.append("read") - - cg.add_global( - cg.RawExpression(f""" -class {class_name} : public {", public ".join(base_classes)} {{ - public: - {class_name}(ESPBTUUID uuid, uint32_t properties, uint16_t max_len = 100) - : BLECharacteristic(uuid, properties, max_len) {{}} - - // Override virtual methods to provide EventEmitter functionality - {"EventEmitterListenerID on_write(std::function, uint16_t)> &&listener) override {" if write_count > 0 else ""} - {" return this->EventEmitter, uint16_t>::on(BLECharacteristicEvt::SpanEvt::ON_WRITE, std::move(listener));" if write_count > 0 else ""} - {"}" if write_count > 0 else ""} - - {"void off_write(EventEmitterListenerID id) override {" if write_count > 0 else ""} - {" this->EventEmitter, uint16_t>::off(BLECharacteristicEvt::SpanEvt::ON_WRITE, id);" if write_count > 0 else ""} - {"}" if write_count > 0 else ""} - - {"EventEmitterListenerID on_read(std::function &&listener) override {" if read_count > 0 else ""} - {" return this->EventEmitter::on(BLECharacteristicEvt::EmptyEvt::ON_READ, std::move(listener));" if read_count > 0 else ""} - {"}" if read_count > 0 else ""} - - {"void off_read(EventEmitterListenerID id) override {" if read_count > 0 else ""} - {" this->EventEmitter::off(BLECharacteristicEvt::EmptyEvt::ON_READ, id);" if read_count > 0 else ""} - {"}" if read_count > 0 else ""} - - protected: - {"void emit_on_write_(std::span value, uint16_t conn_id) override {" if write_count > 0 else ""} - {" this->EventEmitter, uint16_t>::emit_(BLECharacteristicEvt::SpanEvt::ON_WRITE, value, conn_id);" if write_count > 0 else ""} - {"}" if write_count > 0 else ""} - - {"void emit_on_read_(uint16_t conn_id) override {" if read_count > 0 else ""} - {" this->EventEmitter::emit_(BLECharacteristicEvt::EmptyEvt::ON_READ, conn_id);" if read_count > 0 else ""} - {"}" if read_count > 0 else ""} -}}; -""") - ) - - # Generate specialized descriptor classes with EventEmitter support - for uuid, count in descriptor_write_by_uuid.items(): - uuid_id = sanitize_uuid_for_identifier(uuid) - class_name = f"BLEDescriptor_{uuid_id}" - - cg.add_global( - cg.RawExpression(f""" -class {class_name} : public BLEDescriptor, - public EventEmitter, uint16_t> {{ - public: - {class_name}(ESPBTUUID uuid, uint16_t max_len = 100, bool read = true, bool write = true) - : BLEDescriptor(uuid, max_len, read, write) {{}} - - EventEmitterListenerID on_write(std::function, uint16_t)> &&listener) override {{ - return this->EventEmitter, uint16_t>::on(BLEDescriptorEvt::SpanEvt::ON_WRITE, std::move(listener)); - }} - - void off_write(EventEmitterListenerID id) override {{ - this->EventEmitter, uint16_t>::off(BLEDescriptorEvt::SpanEvt::ON_WRITE, id); - }} - - protected: - void emit_on_write_(std::span value, uint16_t conn_id) override {{ - this->EventEmitter, uint16_t>::emit_(BLEDescriptorEvt::SpanEvt::ON_WRITE, value, conn_id); - }} -}}; -""") - ) - - # Generate specialized BLEServer class if needed - if server_connect_count > 0 or server_disconnect_count > 0: - base_classes = ["BLEServer"] - if server_connect_count > 0: - base_classes.append( - "EventEmitter" - ) - if server_disconnect_count > 0: - base_classes.append( - "EventEmitter" - ) - - cg.add_global( - cg.RawExpression(f""" -class BLEServerWithEvents : public {", public ".join(base_classes)} {{ - public: - {"EventEmitterListenerID on_connect(std::function &&listener) override {" if server_connect_count > 0 else ""} - {" return this->EventEmitter::on(BLEServerEvt::EmptyEvt::ON_CONNECT, std::move(listener));" if server_connect_count > 0 else ""} - {"}" if server_connect_count > 0 else ""} - - {"void off_connect(EventEmitterListenerID id) override {" if server_connect_count > 0 else ""} - {" this->EventEmitter::off(BLEServerEvt::EmptyEvt::ON_CONNECT, id);" if server_connect_count > 0 else ""} - {"}" if server_connect_count > 0 else ""} - - {"EventEmitterListenerID on_disconnect(std::function &&listener) override {" if server_disconnect_count > 0 else ""} - {" return this->EventEmitter::on(BLEServerEvt::EmptyEvt::ON_DISCONNECT, std::move(listener));" if server_disconnect_count > 0 else ""} - {"}" if server_disconnect_count > 0 else ""} - - {"void off_disconnect(EventEmitterListenerID id) override {" if server_disconnect_count > 0 else ""} - {" this->EventEmitter::off(BLEServerEvt::EmptyEvt::ON_DISCONNECT, id);" if server_disconnect_count > 0 else ""} - {"}" if server_disconnect_count > 0 else ""} - - protected: - {"void emit_on_connect_(uint16_t conn_id) override {" if server_connect_count > 0 else ""} - {" this->EventEmitter::emit_(BLEServerEvt::EmptyEvt::ON_CONNECT, conn_id);" if server_connect_count > 0 else ""} - {"}" if server_connect_count > 0 else ""} - - {"void emit_on_disconnect_(uint16_t conn_id) override {" if server_disconnect_count > 0 else ""} - {" this->EventEmitter::emit_(BLEServerEvt::EmptyEvt::ON_DISCONNECT, conn_id);" if server_disconnect_count > 0 else ""} - {"}" if server_disconnect_count > 0 else ""} -}}; -""") - ) - cg.add_define("USE_ESP32_BLE_SERVER") cg.add_define("USE_ESP32_BLE_ADVERTISING") add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index d3fde4ec25..12530b26ec 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -208,7 +208,9 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt if (!param->read.need_rsp) break; // For some reason you can request a read but not want a response - this->emit_on_read_(param->read.conn_id); + if (this->on_read_callback_) { + this->on_read_callback_(param->read.conn_id); + } uint16_t max_offset = 22; @@ -276,7 +278,9 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt } if (!param->write.is_prep) { - this->emit_on_write_(this->value_, param->write.conn_id); + if (this->on_write_callback_) { + this->on_write_callback_(this->value_, param->write.conn_id); + } } break; @@ -287,7 +291,9 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt break; this->write_event_ = false; if (param->exec_write.exec_write_flag == ESP_GATT_PREP_WRITE_EXEC) { - this->emit_on_write_(this->value_, param->exec_write.conn_id); + if (this->on_write_callback_) { + this->on_write_callback_(this->value_, param->exec_write.conn_id); + } } esp_err_t err = esp_ble_gatts_send_response(gatts_if, param->write.conn_id, param->write.trans_id, ESP_GATT_OK, nullptr); diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index a64610d73f..3404cca55b 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -2,11 +2,11 @@ #include "ble_descriptor.h" #include "esphome/components/esp32_ble/ble_uuid.h" -#include "esphome/components/event_emitter/event_emitter.h" #include "esphome/components/bytebuffer/bytebuffer.h" #include #include +#include #ifdef USE_ESP32 @@ -23,22 +23,9 @@ namespace esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; -using namespace event_emitter; class BLEService; -namespace BLECharacteristicEvt { -enum SpanEvt { - ON_WRITE, -}; - -enum EmptyEvt { - ON_READ, -}; -} // namespace BLECharacteristicEvt - -// Base class for BLE characteristics -// Specialized classes with EventEmitter support are generated per-UUID in the build process class BLECharacteristic { public: BLECharacteristic(ESPBTUUID uuid, uint32_t properties); @@ -78,19 +65,13 @@ class BLECharacteristic { bool is_created(); bool is_failed(); - // Event listener registration - overridden by generated specialized classes - virtual EventEmitterListenerID on_write(std::function, uint16_t)> &&listener) { - return INVALID_LISTENER_ID; + // Direct callback registration + void on_write(std::function, uint16_t)> &&callback) { + this->on_write_callback_ = std::move(callback); } - virtual EventEmitterListenerID on_read(std::function &&listener) { return INVALID_LISTENER_ID; } - virtual void off_write(EventEmitterListenerID id) {} - virtual void off_read(EventEmitterListenerID id) {} + void on_read(std::function &&callback) { this->on_read_callback_ = std::move(callback); } protected: - // Virtual methods for emitting events - overridden by generated specialized classes - virtual void emit_on_write_(std::span value, uint16_t conn_id) {} - virtual void emit_on_read_(uint16_t conn_id) {} - bool write_event_{false}; BLEService *service_{}; ESPBTUUID uuid_; @@ -112,6 +93,9 @@ class BLECharacteristic { void remove_client_from_notify_list_(uint16_t conn_id); ClientNotificationEntry *find_client_in_notify_list_(uint16_t conn_id); + std::function, uint16_t)> on_write_callback_{nullptr}; + std::function on_read_callback_{nullptr}; + esp_gatt_perm_t permissions_ = ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE; enum State : uint8_t { diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index dbfa8bc632..1182a83b82 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -74,7 +74,9 @@ void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_ break; this->value_.attr_len = param->write.len; memcpy(this->value_.attr_value, param->write.value, param->write.len); - this->emit_on_write_(std::span(param->write.value, param->write.len), param->write.conn_id); + if (this->on_write_callback_) { + this->on_write_callback_(std::span(param->write.value, param->write.len), param->write.conn_id); + } break; } default: diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index af1cbfd18d..00138d306d 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -19,14 +19,7 @@ using namespace event_emitter; class BLECharacteristic; -namespace BLEDescriptorEvt { -enum SpanEvt { - ON_WRITE, -}; -} // namespace BLEDescriptorEvt - // Base class for BLE descriptors -// Specialized classes with EventEmitter support are generated per-UUID in the build process class BLEDescriptor { public: BLEDescriptor(ESPBTUUID uuid, uint16_t max_len = 100, bool read = true, bool write = true); @@ -42,22 +35,20 @@ class BLEDescriptor { bool is_created() { return this->state_ == CREATED; } bool is_failed() { return this->state_ == FAILED; } - // Event listener registration - overridden by generated specialized classes if needed - virtual EventEmitterListenerID on_write(std::function, uint16_t)> &&listener) { - return INVALID_LISTENER_ID; + // Direct callback registration + void on_write(std::function, uint16_t)> &&callback) { + this->on_write_callback_ = std::move(callback); } - virtual void off_write(EventEmitterListenerID id) {} protected: - // Virtual method for emitting events - overridden by generated specialized classes - virtual void emit_on_write_(std::span value, uint16_t conn_id) {} - BLECharacteristic *characteristic_{nullptr}; ESPBTUUID uuid_; uint16_t handle_{0xFFFF}; esp_attr_value_t value_{}; + std::function, uint16_t)> on_write_callback_{nullptr}; + esp_gatt_perm_t permissions_{}; enum State : uint8_t { diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index d73c343b4d..0f993cad02 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -153,14 +153,18 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga case ESP_GATTS_CONNECT_EVT: { ESP_LOGD(TAG, "BLE Client connected"); this->add_client_(param->connect.conn_id); - this->emit_on_connect_(param->connect.conn_id); + if (this->on_connect_callback_) { + this->on_connect_callback_(param->connect.conn_id); + } break; } case ESP_GATTS_DISCONNECT_EVT: { ESP_LOGD(TAG, "BLE Client disconnected"); this->remove_client_(param->disconnect.conn_id); this->parent_->advertising_start(); - this->emit_on_disconnect_(param->disconnect.conn_id); + if (this->on_disconnect_callback_) { + this->on_disconnect_callback_(param->disconnect.conn_id); + } break; } case ESP_GATTS_REG_EVT: { diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index e3181a945d..8bb9429856 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -24,15 +24,7 @@ namespace esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; -namespace BLEServerEvt { -enum EmptyEvt { - ON_CONNECT, - ON_DISCONNECT, -}; -} // namespace BLEServerEvt - // Base class for BLE server -// Note: Only one BLEServer instance exists per build, so we can use fixed defines class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEventHandler, public Parented { public: void setup() override; @@ -63,16 +55,11 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv void ble_before_disabled_event_handler() override; - // Event listener registration - overridden by generated specialized classes if needed - virtual EventEmitterListenerID on_connect(std::function &&listener) { return INVALID_LISTENER_ID; } - virtual EventEmitterListenerID on_disconnect(std::function &&listener) { return INVALID_LISTENER_ID; } - virtual void off_connect(EventEmitterListenerID id) {} - virtual void off_disconnect(EventEmitterListenerID id) {} + // Direct callback registration + void on_connect(std::function &&callback) { this->on_connect_callback_ = std::move(callback); } + void on_disconnect(std::function &&callback) { this->on_disconnect_callback_ = std::move(callback); } protected: - // Virtual methods for emitting events - virtual void emit_on_connect_(uint16_t conn_id) {} - virtual void emit_on_disconnect_(uint16_t conn_id) {} struct ServiceEntry { ESPBTUUID uuid; uint8_t inst_id; @@ -84,6 +71,9 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv void add_client_(uint16_t conn_id) { this->clients_.insert(conn_id); } void remove_client_(uint16_t conn_id) { this->clients_.erase(conn_id); } + std::function on_connect_callback_{nullptr}; + std::function on_disconnect_callback_{nullptr}; + std::vector manufacturer_data_{}; esp_gatt_if_t gatts_if_{0}; bool registered_{false}; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.cpp b/esphome/components/esp32_ble_server/ble_server_automations.cpp index afa958a4a0..0761de994a 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.cpp +++ b/esphome/components/esp32_ble_server/ble_server_automations.cpp @@ -52,29 +52,15 @@ Trigger *BLETriggers::create_server_on_disconnect_trigger(BLEServer *s #ifdef USE_ESP32_BLE_SERVER_SET_VALUE_ACTION void BLECharacteristicSetValueActionManager::set_listener(BLECharacteristic *characteristic, - EventEmitterListenerID listener_id, const std::function &pre_notify_listener) { // Find and remove existing listener for this characteristic auto *existing = this->find_listener_(characteristic); if (existing != nullptr) { - // Remove the previous listener - characteristic->off_read(existing->listener_id); - // Remove the pre-notify listener - this->off(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, existing->pre_notify_listener_id); // Remove from vector this->remove_listener_(characteristic); } - // Create a new listener for the pre-notify event - EventEmitterListenerID pre_notify_listener_id = - this->on(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, - [pre_notify_listener, characteristic](const BLECharacteristic *evt_characteristic) { - // Only call the pre-notify listener if the characteristic is the one we are interested in - if (characteristic == evt_characteristic) { - pre_notify_listener(); - } - }); // Save the entry to the vector - this->listeners_.push_back({characteristic, listener_id, pre_notify_listener_id}); + this->listeners_.push_back({characteristic, pre_notify_listener}); } BLECharacteristicSetValueActionManager::ListenerEntry *BLECharacteristicSetValueActionManager::find_listener_( diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index 08a3322367..543b1153fc 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -4,7 +4,6 @@ #include "ble_characteristic.h" #include "ble_descriptor.h" -#include "esphome/components/event_emitter/event_emitter.h" #include "esphome/core/automation.h" #include @@ -18,10 +17,6 @@ namespace esp32_ble_server { namespace esp32_ble_server_automations { using namespace esp32_ble; -using namespace event_emitter; - -// Invalid listener ID constant - 0 is used as sentinel value in EventEmitter -static constexpr EventEmitterListenerID INVALID_LISTENER_ID = 0; class BLETriggers { public: @@ -41,42 +36,29 @@ class BLETriggers { }; #ifdef USE_ESP32_BLE_SERVER_SET_VALUE_ACTION -enum BLECharacteristicSetValueActionEvt { - PRE_NOTIFY, -}; - // Class to make sure only one BLECharacteristicSetValueAction is active at a time for each characteristic -#ifndef BLE_SET_VALUE_ACTION_MAX_LISTENERS -#define BLE_SET_VALUE_ACTION_MAX_LISTENERS 1 -#endif - -class BLECharacteristicSetValueActionManager - : public EventEmitter { +class BLECharacteristicSetValueActionManager { public: // Singleton pattern static BLECharacteristicSetValueActionManager *get_instance() { static BLECharacteristicSetValueActionManager instance; return &instance; } - void set_listener(BLECharacteristic *characteristic, EventEmitterListenerID listener_id, - const std::function &pre_notify_listener); - EventEmitterListenerID get_listener(BLECharacteristic *characteristic) { + void set_listener(BLECharacteristic *characteristic, const std::function &pre_notify_listener); + bool has_listener(BLECharacteristic *characteristic) { return this->find_listener_(characteristic) != nullptr; } + void emit_pre_notify(BLECharacteristic *characteristic) { for (const auto &entry : this->listeners_) { if (entry.characteristic == characteristic) { - return entry.listener_id; + entry.pre_notify_listener(); + break; } } - return INVALID_LISTENER_ID; - } - void emit_pre_notify(BLECharacteristic *characteristic) { - this->emit_(BLECharacteristicSetValueActionEvt::PRE_NOTIFY, characteristic); } private: struct ListenerEntry { BLECharacteristic *characteristic; - EventEmitterListenerID listener_id; - EventEmitterListenerID pre_notify_listener_id; + std::function pre_notify_listener; }; std::vector listeners_; @@ -91,23 +73,22 @@ template class BLECharacteristicSetValueAction : public Actionset_buffer(buffer.get_data()); } void play(Ts... x) override { // If the listener is already set, do nothing - if (BLECharacteristicSetValueActionManager::get_instance()->get_listener(this->parent_) == this->listener_id_) + if (BLECharacteristicSetValueActionManager::get_instance()->has_listener(this->parent_)) return; // Set initial value this->parent_->set_value(this->buffer_.value(x...)); // Set the listener for read events - this->listener_id_ = this->parent_->on_read([this, x...](uint16_t id) { + this->parent_->on_read([this, x...](uint16_t id) { // Set the value of the characteristic every time it is read this->parent_->set_value(this->buffer_.value(x...)); }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( - this->parent_, this->listener_id_, [this, x...]() { this->parent_->set_value(this->buffer_.value(x...)); }); + this->parent_, [this, x...]() { this->parent_->set_value(this->buffer_.value(x...)); }); } protected: BLECharacteristic *parent_; - EventEmitterListenerID listener_id_; }; #endif // USE_ESP32_BLE_SERVER_SET_VALUE_ACTION diff --git a/esphome/components/esp32_improv/__init__.py b/esphome/components/esp32_improv/__init__.py index 43377326bd..fa33bd947a 100644 --- a/esphome/components/esp32_improv/__init__.py +++ b/esphome/components/esp32_improv/__init__.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble, esp32_ble_server, output +from esphome.components import binary_sensor, esp32_ble, output from esphome.components.esp32_ble import BTLoggers import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TRIGGER_ID @@ -98,15 +98,6 @@ async def to_code(config): # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) - # Allocate event listeners for esp32_improv - # Need 1 listener for server disconnect event - esp32_ble_server.allocate_server_event_listener("DISCONNECT", "esp32_improv", 1) - # The RPC characteristic UUID comes from the Improv library (0x00467768-6228-2272-4663-277478268000 + 0x01) - # We need 1 listener for the RPC write event - esp32_ble_server.allocate_characteristic_event_listener( - "00467768-6228-2272-4663-277478268001", "WRITE", "esp32_improv", 1 - ) - var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/event_emitter/__init__.py b/esphome/components/event_emitter/__init__.py deleted file mode 100644 index fcbbf26f02..0000000000 --- a/esphome/components/event_emitter/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -CODEOWNERS = ["@Rapsssito"] - -# Allows event_emitter to be configured in yaml, to allow use of the C++ api. - -CONFIG_SCHEMA = {} diff --git a/esphome/components/event_emitter/event_emitter.h b/esphome/components/event_emitter/event_emitter.h deleted file mode 100644 index e44cac7cc7..0000000000 --- a/esphome/components/event_emitter/event_emitter.h +++ /dev/null @@ -1,78 +0,0 @@ -#pragma once -#include -#include -#include - -#include "esphome/core/log.h" - -namespace esphome { -namespace event_emitter { - -using EventEmitterListenerID = uint32_t; -static constexpr EventEmitterListenerID INVALID_LISTENER_ID = 0; - -// EventEmitter class that can emit events with a specific name (it is highly recommended to use an enum class for this) -// and a list of arguments. Supports multiple listeners for each event. -// MaxListeners is the compile-time maximum number of listeners per event type. -template class EventEmitter { - public: - EventEmitterListenerID on(EvtType event, std::function listener) { - // Find a free slot in the listeners array - for (auto &entry : this->listeners_) { - if (entry.id == INVALID_LISTENER_ID) { - // Found empty slot - EventEmitterListenerID listener_id = this->get_next_id_(); - entry.id = listener_id; - entry.event = event; - entry.callback = std::move(listener); - return listener_id; - } - } - // No free slots - array is full - return INVALID_LISTENER_ID; - } - - void off(EvtType event, EventEmitterListenerID id) { - // Find and remove listener with given id - for (auto &entry : this->listeners_) { - if (entry.id == id && entry.event == event) { - entry.id = INVALID_LISTENER_ID; - entry.callback = nullptr; - return; - } - } - } - - protected: - void emit_(EvtType event, Args... args) { - // Call all listeners for this event - for (const auto &entry : this->listeners_) { - if (entry.id != INVALID_LISTENER_ID && entry.event == event) { - entry.callback(args...); - } - } - } - - private: - struct ListenerEntry { - EvtType event; - EventEmitterListenerID id{INVALID_LISTENER_ID}; - std::function callback{nullptr}; - }; - - EventEmitterListenerID get_next_id_() { - // Simple incrementing ID, wrapping around at max - EventEmitterListenerID next_id = (this->current_id_ + 1); - if (next_id == INVALID_LISTENER_ID) { - next_id = 1; - } - this->current_id_ = next_id; - return this->current_id_; - } - - std::array listeners_{}; - EventEmitterListenerID current_id_{0}; -}; - -} // namespace event_emitter -} // namespace esphome From 9ff838bf35d75319adc36cf0fe8352d6bdfde9e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 18:13:46 -0500 Subject: [PATCH 1546/1626] wip --- .../esp32_ble_server/ble_characteristic.h | 14 +++++++++----- .../components/esp32_ble_server/ble_descriptor.h | 1 - 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 3404cca55b..4a29683f41 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -7,6 +7,7 @@ #include #include #include +#include #ifdef USE_ESP32 @@ -65,11 +66,14 @@ class BLECharacteristic { bool is_created(); bool is_failed(); - // Direct callback registration + // Direct callback registration - only allocates when callback is set void on_write(std::function, uint16_t)> &&callback) { - this->on_write_callback_ = std::move(callback); + this->on_write_callback_ = + std::make_unique, uint16_t)>>(std::move(callback)); + } + void on_read(std::function &&callback) { + this->on_read_callback_ = std::make_unique>(std::move(callback)); } - void on_read(std::function &&callback) { this->on_read_callback_ = std::move(callback); } protected: bool write_event_{false}; @@ -93,8 +97,8 @@ class BLECharacteristic { void remove_client_from_notify_list_(uint16_t conn_id); ClientNotificationEntry *find_client_in_notify_list_(uint16_t conn_id); - std::function, uint16_t)> on_write_callback_{nullptr}; - std::function on_read_callback_{nullptr}; + std::unique_ptr, uint16_t)>> on_write_callback_; + std::unique_ptr> on_read_callback_; esp_gatt_perm_t permissions_ = ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE; diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index 00138d306d..a43d9a84a0 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/components/esp32_ble/ble_uuid.h" -#include "esphome/components/event_emitter/event_emitter.h" #include "esphome/components/bytebuffer/bytebuffer.h" #ifdef USE_ESP32 From 43d8e213f631f82a78791fcfb2ef4abc48f1e4ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 18:15:25 -0500 Subject: [PATCH 1547/1626] wip --- CODEOWNERS | 1 - .../esp32_ble_server/ble_characteristic.cpp | 6 +++--- .../esp32_ble_server/ble_descriptor.cpp | 3 ++- .../components/esp32_ble_server/ble_descriptor.h | 10 ++++++---- .../components/esp32_ble_server/ble_server.cpp | 4 ++-- esphome/components/esp32_ble_server/ble_server.h | 15 ++++++++++----- 6 files changed, 23 insertions(+), 16 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3747acd2b5..0b9935faf7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -160,7 +160,6 @@ esphome/components/esp_ldo/* @clydebarrow esphome/components/espnow/* @jesserockz esphome/components/ethernet_info/* @gtjadsonsantos esphome/components/event/* @nohat -esphome/components/event_emitter/* @Rapsssito esphome/components/exposure_notifications/* @OttoWinter esphome/components/ezo/* @ssieb esphome/components/ezo_pmp/* @carlos-sarmiento diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 12530b26ec..d485d9fe2d 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -209,7 +209,7 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt break; // For some reason you can request a read but not want a response if (this->on_read_callback_) { - this->on_read_callback_(param->read.conn_id); + (*this->on_read_callback_)(param->read.conn_id); } uint16_t max_offset = 22; @@ -279,7 +279,7 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt if (!param->write.is_prep) { if (this->on_write_callback_) { - this->on_write_callback_(this->value_, param->write.conn_id); + (*this->on_write_callback_)(this->value_, param->write.conn_id); } } @@ -292,7 +292,7 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt this->write_event_ = false; if (param->exec_write.exec_write_flag == ESP_GATT_PREP_WRITE_EXEC) { if (this->on_write_callback_) { - this->on_write_callback_(this->value_, param->exec_write.conn_id); + (*this->on_write_callback_)(this->value_, param->exec_write.conn_id); } } esp_err_t err = diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 1182a83b82..16941cca0f 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -75,7 +75,8 @@ void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_ this->value_.attr_len = param->write.len; memcpy(this->value_.attr_value, param->write.value, param->write.len); if (this->on_write_callback_) { - this->on_write_callback_(std::span(param->write.value, param->write.len), param->write.conn_id); + (*this->on_write_callback_)(std::span(param->write.value, param->write.len), + param->write.conn_id); } break; } diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index a43d9a84a0..425462a316 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -8,13 +8,14 @@ #include #include #include +#include +#include namespace esphome { namespace esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; -using namespace event_emitter; class BLECharacteristic; @@ -34,9 +35,10 @@ class BLEDescriptor { bool is_created() { return this->state_ == CREATED; } bool is_failed() { return this->state_ == FAILED; } - // Direct callback registration + // Direct callback registration - only allocates when callback is set void on_write(std::function, uint16_t)> &&callback) { - this->on_write_callback_ = std::move(callback); + this->on_write_callback_ = + std::make_unique, uint16_t)>>(std::move(callback)); } protected: @@ -46,7 +48,7 @@ class BLEDescriptor { esp_attr_value_t value_{}; - std::function, uint16_t)> on_write_callback_{nullptr}; + std::unique_ptr, uint16_t)>> on_write_callback_; esp_gatt_perm_t permissions_{}; diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 0f993cad02..8f7931d40e 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -154,7 +154,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga ESP_LOGD(TAG, "BLE Client connected"); this->add_client_(param->connect.conn_id); if (this->on_connect_callback_) { - this->on_connect_callback_(param->connect.conn_id); + (*this->on_connect_callback_)(param->connect.conn_id); } break; } @@ -163,7 +163,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga this->remove_client_(param->disconnect.conn_id); this->parent_->advertising_start(); if (this->on_disconnect_callback_) { - this->on_disconnect_callback_(param->disconnect.conn_id); + (*this->on_disconnect_callback_)(param->disconnect.conn_id); } break; } diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 8bb9429856..7b3ee9bae0 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -13,6 +13,7 @@ #include #include #include +#include #ifdef USE_ESP32 @@ -55,9 +56,13 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv void ble_before_disabled_event_handler() override; - // Direct callback registration - void on_connect(std::function &&callback) { this->on_connect_callback_ = std::move(callback); } - void on_disconnect(std::function &&callback) { this->on_disconnect_callback_ = std::move(callback); } + // Direct callback registration - only allocates when callback is set + void on_connect(std::function &&callback) { + this->on_connect_callback_ = std::make_unique>(std::move(callback)); + } + void on_disconnect(std::function &&callback) { + this->on_disconnect_callback_ = std::make_unique>(std::move(callback)); + } protected: struct ServiceEntry { @@ -71,8 +76,8 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv void add_client_(uint16_t conn_id) { this->clients_.insert(conn_id); } void remove_client_(uint16_t conn_id) { this->clients_.erase(conn_id); } - std::function on_connect_callback_{nullptr}; - std::function on_disconnect_callback_{nullptr}; + std::unique_ptr> on_connect_callback_; + std::unique_ptr> on_disconnect_callback_; std::vector manufacturer_data_{}; esp_gatt_if_t gatts_if_{0}; From 87b54daee04a5c0ff7021413567e7aaa33877fd1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 18:17:25 -0500 Subject: [PATCH 1548/1626] wip --- esphome/components/esp32_ble_server/ble_service.cpp | 4 ---- esphome/components/esp32_ble_server/ble_service.h | 3 --- 2 files changed, 7 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_service.cpp b/esphome/components/esp32_ble_server/ble_service.cpp index 3c5b40ba9e..96fedf2346 100644 --- a/esphome/components/esp32_ble_server/ble_service.cpp +++ b/esphome/components/esp32_ble_server/ble_service.cpp @@ -41,10 +41,6 @@ BLECharacteristic *BLEService::create_characteristic(ESPBTUUID uuid, esp_gatt_ch return characteristic; } -void BLEService::add_characteristic(BLECharacteristic *characteristic) { - this->characteristics_.push_back(characteristic); -} - void BLEService::do_create(BLEServer *server) { this->server_ = server; diff --git a/esphome/components/esp32_ble_server/ble_service.h b/esphome/components/esp32_ble_server/ble_service.h index 311c937c28..dcfad5f501 100644 --- a/esphome/components/esp32_ble_server/ble_service.h +++ b/esphome/components/esp32_ble_server/ble_service.h @@ -31,9 +31,6 @@ class BLEService { BLECharacteristic *create_characteristic(uint16_t uuid, esp_gatt_char_prop_t properties); BLECharacteristic *create_characteristic(ESPBTUUID uuid, esp_gatt_char_prop_t properties); - // Add pre-constructed characteristic (used by generated code) - void add_characteristic(BLECharacteristic *characteristic); - ESPBTUUID get_uuid() { return this->uuid_; } uint8_t get_inst_id() { return this->inst_id_; } BLECharacteristic *get_last_created_characteristic() { return this->last_created_characteristic_; } From e9299e8671eb77e27999a2b3298b4e7ce8a3d76e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 18:54:30 -0500 Subject: [PATCH 1549/1626] Apply suggestions from code review --- esphome/components/esp32_ble_server/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index bcb4044f94..10fa09fcc3 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -31,7 +31,6 @@ CODEOWNERS = ["@jesserockz", "@clydebarrow", "@Rapsssito"] DEPENDENCIES = ["esp32"] DOMAIN = "esp32_ble_server" - CONF_ADVERTISE = "advertise" CONF_APPEARANCE = "appearance" CONF_BROADCAST = "broadcast" @@ -505,7 +504,6 @@ async def to_code_characteristic(service_var, char_conf): parse_properties(char_conf), ), ) - if CONF_ON_WRITE in char_conf: on_write_conf = char_conf[CONF_ON_WRITE] cg.add_define("USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE") @@ -585,7 +583,6 @@ async def to_code(config): [(cg.uint16, "id")], config[CONF_ON_DISCONNECT], ) - cg.add_define("USE_ESP32_BLE_SERVER") cg.add_define("USE_ESP32_BLE_ADVERTISING") add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) From a260c31a6329e3a9dd9bf89b7270799c905f95e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 18:57:05 -0500 Subject: [PATCH 1550/1626] preen --- esphome/components/esp32_ble_server/ble_server.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 7b3ee9bae0..51e9bc0814 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -25,7 +25,6 @@ namespace esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; -// Base class for BLE server class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEventHandler, public Parented { public: void setup() override; From 47a10e4be1777213b26ac451eaa33ef97f55f319 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 19:07:17 -0500 Subject: [PATCH 1551/1626] sever needs multi --- esphome/components/esp32_ble_server/ble_server.cpp | 8 ++++---- esphome/components/esp32_ble_server/ble_server.h | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 8f7931d40e..6b7d1af5ea 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -153,8 +153,8 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga case ESP_GATTS_CONNECT_EVT: { ESP_LOGD(TAG, "BLE Client connected"); this->add_client_(param->connect.conn_id); - if (this->on_connect_callback_) { - (*this->on_connect_callback_)(param->connect.conn_id); + for (auto &callback : this->on_connect_callbacks_) { + callback(param->connect.conn_id); } break; } @@ -162,8 +162,8 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga ESP_LOGD(TAG, "BLE Client disconnected"); this->remove_client_(param->disconnect.conn_id); this->parent_->advertising_start(); - if (this->on_disconnect_callback_) { - (*this->on_disconnect_callback_)(param->disconnect.conn_id); + for (auto &callback : this->on_disconnect_callbacks_) { + callback(param->disconnect.conn_id); } break; } diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 51e9bc0814..53579614d2 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -55,12 +55,12 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv void ble_before_disabled_event_handler() override; - // Direct callback registration - only allocates when callback is set + // Direct callback registration - supports multiple callbacks void on_connect(std::function &&callback) { - this->on_connect_callback_ = std::make_unique>(std::move(callback)); + this->on_connect_callbacks_.push_back(std::move(callback)); } void on_disconnect(std::function &&callback) { - this->on_disconnect_callback_ = std::make_unique>(std::move(callback)); + this->on_disconnect_callbacks_.push_back(std::move(callback)); } protected: @@ -75,8 +75,8 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv void add_client_(uint16_t conn_id) { this->clients_.insert(conn_id); } void remove_client_(uint16_t conn_id) { this->clients_.erase(conn_id); } - std::unique_ptr> on_connect_callback_; - std::unique_ptr> on_disconnect_callback_; + std::vector> on_connect_callbacks_; + std::vector> on_disconnect_callbacks_; std::vector manufacturer_data_{}; esp_gatt_if_t gatts_if_{0}; From 0cdfcad54d4dc3b718b51bbb9e76ed30686049e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 19:46:34 -0500 Subject: [PATCH 1552/1626] cleanup --- .../components/esp32_ble_server/ble_server.cpp | 12 ++++++++---- .../components/esp32_ble_server/ble_server.h | 17 +++++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 6b7d1af5ea..a9f8fd13a5 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -153,8 +153,10 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga case ESP_GATTS_CONNECT_EVT: { ESP_LOGD(TAG, "BLE Client connected"); this->add_client_(param->connect.conn_id); - for (auto &callback : this->on_connect_callbacks_) { - callback(param->connect.conn_id); + for (auto &entry : this->callbacks_) { + if (entry.type == CallbackType::ON_CONNECT) { + entry.callback(param->connect.conn_id); + } } break; } @@ -162,8 +164,10 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga ESP_LOGD(TAG, "BLE Client disconnected"); this->remove_client_(param->disconnect.conn_id); this->parent_->advertising_start(); - for (auto &callback : this->on_disconnect_callbacks_) { - callback(param->disconnect.conn_id); + for (auto &entry : this->callbacks_) { + if (entry.type == CallbackType::ON_DISCONNECT) { + entry.callback(param->disconnect.conn_id); + } } break; } diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 53579614d2..40a6465d9b 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -57,13 +57,23 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv // Direct callback registration - supports multiple callbacks void on_connect(std::function &&callback) { - this->on_connect_callbacks_.push_back(std::move(callback)); + this->callbacks_.push_back({CallbackType::ON_CONNECT, std::move(callback)}); } void on_disconnect(std::function &&callback) { - this->on_disconnect_callbacks_.push_back(std::move(callback)); + this->callbacks_.push_back({CallbackType::ON_DISCONNECT, std::move(callback)}); } protected: + enum class CallbackType : uint8_t { + ON_CONNECT, + ON_DISCONNECT, + }; + + struct CallbackEntry { + CallbackType type; + std::function callback; + }; + struct ServiceEntry { ESPBTUUID uuid; uint8_t inst_id; @@ -75,8 +85,7 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv void add_client_(uint16_t conn_id) { this->clients_.insert(conn_id); } void remove_client_(uint16_t conn_id) { this->clients_.erase(conn_id); } - std::vector> on_connect_callbacks_; - std::vector> on_disconnect_callbacks_; + std::vector callbacks_; std::vector manufacturer_data_{}; esp_gatt_if_t gatts_if_{0}; From 0dfb18a307ea82b336c5a138ac0ee918d9988f7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 19:59:05 -0500 Subject: [PATCH 1553/1626] cleanup --- .../esp32_ble_server/ble_server.cpp | 20 +++++++++---------- .../components/esp32_ble_server/ble_server.h | 1 + 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index a9f8fd13a5..942be7e597 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -147,28 +147,28 @@ BLEService *BLEServer::get_service(ESPBTUUID uuid, uint8_t inst_id) { return nullptr; } +void BLEServer::dispatch_callbacks_(CallbackType type, uint16_t conn_id) { + for (auto &entry : this->callbacks_) { + if (entry.type == type) { + entry.callback(conn_id); + } + } +} + void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) { switch (event) { case ESP_GATTS_CONNECT_EVT: { ESP_LOGD(TAG, "BLE Client connected"); this->add_client_(param->connect.conn_id); - for (auto &entry : this->callbacks_) { - if (entry.type == CallbackType::ON_CONNECT) { - entry.callback(param->connect.conn_id); - } - } + this->dispatch_callbacks_(CallbackType::ON_CONNECT, param->connect.conn_id); break; } case ESP_GATTS_DISCONNECT_EVT: { ESP_LOGD(TAG, "BLE Client disconnected"); this->remove_client_(param->disconnect.conn_id); this->parent_->advertising_start(); - for (auto &entry : this->callbacks_) { - if (entry.type == CallbackType::ON_DISCONNECT) { - entry.callback(param->disconnect.conn_id); - } - } + this->dispatch_callbacks_(CallbackType::ON_DISCONNECT, param->disconnect.conn_id); break; } case ESP_GATTS_REG_EVT: { diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 40a6465d9b..48005b1346 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -84,6 +84,7 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv void add_client_(uint16_t conn_id) { this->clients_.insert(conn_id); } void remove_client_(uint16_t conn_id) { this->clients_.erase(conn_id); } + void dispatch_callbacks_(CallbackType type, uint16_t conn_id); std::vector callbacks_; From 7703cabb7b666379e259dd94e850db401a50ae16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 30 Sep 2025 03:29:16 +0100 Subject: [PATCH 1554/1626] [voice_assistant] Fix wakeword string being reset while referenced (#10945) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/voice_assistant/voice_assistant.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 743c90e700..a0cf1a155b 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -242,7 +242,6 @@ void VoiceAssistant::loop() { msg.flags = flags; msg.audio_settings = audio_settings; msg.set_wake_word_phrase(StringRef(this->wake_word_)); - this->wake_word_ = ""; // Reset media player state tracking #ifdef USE_MEDIA_PLAYER From 83d86c8c59b0ab1a47cf083de8e2f66e33a7a772 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 22:46:47 -0500 Subject: [PATCH 1555/1626] [ota] Complete non-blocking authentication implementation (#10912) --- .../components/esphome/ota/ota_esphome.cpp | 627 ++++++++++++------ esphome/components/esphome/ota/ota_esphome.h | 53 +- esphome/core/defines.h | 1 + 3 files changed, 462 insertions(+), 219 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6ffeeedb1a..f1506f066c 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,11 +1,13 @@ #include "ota_esphome.h" #ifdef USE_OTA +#ifdef USE_OTA_PASSWORD #ifdef USE_OTA_MD5 #include "esphome/components/md5/md5.h" #endif #ifdef USE_OTA_SHA256 #include "esphome/components/sha256/sha256.h" #endif +#endif #include "esphome/components/network/util.h" #include "esphome/components/ota/ota_backend.h" #include "esphome/components/ota/ota_backend_arduino_esp32.h" @@ -26,9 +28,19 @@ namespace esphome { static const char *const TAG = "esphome.ota"; static constexpr uint16_t OTA_BLOCK_SIZE = 8192; +static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size for OTA data transfer static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer +#ifdef USE_OTA_PASSWORD +#ifdef USE_OTA_MD5 +static constexpr size_t MD5_HEX_SIZE = 32; // MD5 hash as hex string (16 bytes * 2) +#endif +#ifdef USE_OTA_SHA256 +static constexpr size_t SHA256_HEX_SIZE = 64; // SHA256 hash as hex string (32 bytes * 2) +#endif +#endif // USE_OTA_PASSWORD + void ESPHomeOTAComponent::setup() { #ifdef USE_OTA_STATE_CALLBACK ota::register_ota_platform(this); @@ -69,7 +81,7 @@ void ESPHomeOTAComponent::setup() { return; } - err = this->server_->listen(4); + err = this->server_->listen(1); // Only one client at a time if (err != 0) { this->log_socket_error_(LOG_STR("listen")); this->mark_failed(); @@ -112,11 +124,11 @@ static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; #define ALLOW_OTA_DOWNGRADE_MD5 void ESPHomeOTAComponent::handle_handshake_() { - /// Handle the initial OTA handshake. + /// Handle the OTA handshake and authentication. /// /// This method is non-blocking and will return immediately if no data is available. - /// It reads all 5 magic bytes (0x6C, 0x26, 0xF7, 0x5C, 0x45) non-blocking - /// before proceeding to handle_data_(). A 10-second timeout is enforced from initial connection. + /// It manages the state machine through connection, magic bytes validation, feature + /// negotiation, and authentication before entering the blocking data transfer phase. if (this->client_ == nullptr) { // We already checked server_->ready() in loop(), so we can accept directly @@ -141,7 +153,8 @@ void ESPHomeOTAComponent::handle_handshake_() { } this->log_start_(LOG_STR("handshake")); this->client_connect_time_ = App.get_loop_component_start_time(); - this->magic_buf_pos_ = 0; // Reset magic buffer position + this->handshake_buf_pos_ = 0; // Reset handshake buffer position + this->ota_state_ = OTAState::MAGIC_READ; } // Check for handshake timeout @@ -152,46 +165,99 @@ void ESPHomeOTAComponent::handle_handshake_() { return; } - // Try to read remaining magic bytes - if (this->magic_buf_pos_ < 5) { - // Read as many bytes as available - uint8_t bytes_to_read = 5 - this->magic_buf_pos_; - ssize_t read = this->client_->read(this->magic_buf_ + this->magic_buf_pos_, bytes_to_read); - - if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { - return; // No data yet, try again next loop - } - - if (read <= 0) { - // Error or connection closed - if (read == -1) { - this->log_socket_error_(LOG_STR("reading magic bytes")); - } else { - ESP_LOGW(TAG, "Remote closed during handshake"); + switch (this->ota_state_) { + case OTAState::MAGIC_READ: { + // Try to read remaining magic bytes (5 total) + if (!this->try_read_(5, LOG_STR("read magic"))) { + return; } - this->cleanup_connection_(); - return; + + // Validate magic bytes + static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; + if (memcmp(this->handshake_buf_, MAGIC_BYTES, 5) != 0) { + ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->handshake_buf_[0], + this->handshake_buf_[1], this->handshake_buf_[2], this->handshake_buf_[3], this->handshake_buf_[4]); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_MAGIC); + return; + } + + // Magic bytes valid, move to next state + this->transition_ota_state_(OTAState::MAGIC_ACK); + this->handshake_buf_[0] = ota::OTA_RESPONSE_OK; + this->handshake_buf_[1] = USE_OTA_VERSION; + [[fallthrough]]; } - this->magic_buf_pos_ += read; - } - - // Check if we have all 5 magic bytes - if (this->magic_buf_pos_ == 5) { - // Validate magic bytes - static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; - if (memcmp(this->magic_buf_, MAGIC_BYTES, 5) != 0) { - ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->magic_buf_[0], - this->magic_buf_[1], this->magic_buf_[2], this->magic_buf_[3], this->magic_buf_[4]); - // Send error response (non-blocking, best effort) - uint8_t error = static_cast(ota::OTA_RESPONSE_ERROR_MAGIC); - this->client_->write(&error, 1); - this->cleanup_connection_(); - return; + case OTAState::MAGIC_ACK: { + // Send OK and version - 2 bytes + if (!this->try_write_(2, LOG_STR("ack magic"))) { + return; + } + // All bytes sent, create backend and move to next state + this->backend_ = ota::make_ota_backend(); + this->transition_ota_state_(OTAState::FEATURE_READ); + [[fallthrough]]; } - // All 5 magic bytes are valid, continue with data handling - this->handle_data_(); + case OTAState::FEATURE_READ: { + // Read features - 1 byte + if (!this->try_read_(1, LOG_STR("read feature"))) { + return; + } + this->ota_features_ = this->handshake_buf_[0]; + ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); + this->transition_ota_state_(OTAState::FEATURE_ACK); + this->handshake_buf_[0] = + ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) + ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION + : ota::OTA_RESPONSE_HEADER_OK; + [[fallthrough]]; + } + + case OTAState::FEATURE_ACK: { + // Acknowledge header - 1 byte + if (!this->try_write_(1, LOG_STR("ack feature"))) { + return; + } +#ifdef USE_OTA_PASSWORD + // If password is set, move to auth phase + if (!this->password_.empty()) { + this->transition_ota_state_(OTAState::AUTH_SEND); + } else +#endif + { + // No password, move directly to data phase + this->transition_ota_state_(OTAState::DATA); + } + [[fallthrough]]; + } + +#ifdef USE_OTA_PASSWORD + case OTAState::AUTH_SEND: { + // Non-blocking authentication send + if (!this->handle_auth_send_()) { + return; + } + this->transition_ota_state_(OTAState::AUTH_READ); + [[fallthrough]]; + } + + case OTAState::AUTH_READ: { + // Non-blocking authentication read & verify + if (!this->handle_auth_read_()) { + return; + } + this->transition_ota_state_(OTAState::DATA); + [[fallthrough]]; + } +#endif + + case OTAState::DATA: + this->handle_data_(); + return; + + default: + break; } } @@ -199,114 +265,21 @@ void ESPHomeOTAComponent::handle_data_() { /// Handle the OTA data transfer and update process. /// /// This method is blocking and will not return until the OTA update completes, - /// fails, or times out. It handles authentication, receives the firmware data, - /// writes it to flash, and reboots on success. + /// fails, or times out. It receives the firmware data, writes it to flash, + /// and reboots on success. + /// + /// Authentication has already been handled in the non-blocking states AUTH_SEND/AUTH_READ. ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; bool update_started = false; size_t total = 0; uint32_t last_progress = 0; - uint8_t buf[1024]; + uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; - uint8_t ota_features; - std::unique_ptr backend; - (void) ota_features; #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif - // Send OK and version - 2 bytes - buf[0] = ota::OTA_RESPONSE_OK; - buf[1] = USE_OTA_VERSION; - this->writeall_(buf, 2); - - backend = ota::make_ota_backend(); - - // Read features - 1 byte - if (!this->readall_(buf, 1)) { - this->log_read_error_(LOG_STR("features")); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - ota_features = buf[0]; // NOLINT - ESP_LOGV(TAG, "Features: 0x%02X", ota_features); - - // Acknowledge header - 1 byte - buf[0] = ota::OTA_RESPONSE_HEADER_OK; - if ((ota_features & FEATURE_SUPPORTS_COMPRESSION) != 0 && backend->supports_compression()) { - buf[0] = ota::OTA_RESPONSE_SUPPORTS_COMPRESSION; - } - - this->writeall_(buf, 1); - -#ifdef USE_OTA_PASSWORD - if (!this->password_.empty()) { - bool auth_success = false; - -#ifdef USE_OTA_SHA256 - // SECURITY HARDENING: Prefer SHA256 authentication on platforms that support it. - // - // This is a hardening measure to prevent future downgrade attacks where an attacker - // could force the use of MD5 authentication by manipulating the feature flags. - // - // While MD5 is currently still acceptable for our OTA authentication use case - // (where the password is a shared secret and we're only authenticating, not - // encrypting), at some point in the future MD5 will likely become so weak that - // it could be practically attacked. - // - // We enforce SHA256 now on capable platforms because: - // 1. We can't retroactively update device firmware in the field - // 2. Clients (like esphome CLI) can always be updated to support SHA256 - // 3. This prevents any possibility of downgrade attacks in the future - // - // Devices that don't support SHA256 (due to platform limitations) will - // continue to use MD5 as their only option (see #else branch below). - - bool client_supports_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; - -#ifdef ALLOW_OTA_DOWNGRADE_MD5 - // Temporary compatibility mode: Allow MD5 for ~3 versions to enable OTA downgrades - // This prevents users from being locked out if they need to downgrade after updating - // TODO: Remove this entire ifdef block in 2026.1.0 - if (client_supports_sha256) { - sha256::SHA256 sha_hasher; - auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, - LOG_STR("SHA256"), sbuf); - } else { -#ifdef USE_OTA_MD5 - ESP_LOGW(TAG, "Using MD5 auth for compatibility (deprecated)"); - md5::MD5Digest md5_hasher; - auth_success = - this->perform_hash_auth_(&md5_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); -#endif // USE_OTA_MD5 - } -#else - // Strict mode: SHA256 required on capable platforms (future default) - if (!client_supports_sha256) { - ESP_LOGW(TAG, "Client requires SHA256"); - error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - sha256::SHA256 sha_hasher; - auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, - LOG_STR("SHA256"), sbuf); -#endif // ALLOW_OTA_DOWNGRADE_MD5 -#else - // Platform only supports MD5 - use it as the only available option - // This is not a security downgrade as the platform cannot support SHA256 -#ifdef USE_OTA_MD5 - md5::MD5Digest md5_hasher; - auth_success = - this->perform_hash_auth_(&md5_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); -#endif // USE_OTA_MD5 -#endif // USE_OTA_SHA256 - - if (!auth_success) { - error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - } -#endif // USE_OTA_PASSWORD - // Acknowledge auth OK - 1 byte buf[0] = ota::OTA_RESPONSE_AUTH_OK; this->writeall_(buf, 1); @@ -334,7 +307,7 @@ void ESPHomeOTAComponent::handle_data_() { #endif // This will block for a few seconds as it locks flash - error_code = backend->begin(ota_size); + error_code = this->backend_->begin(ota_size); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) update_started = true; @@ -350,7 +323,7 @@ void ESPHomeOTAComponent::handle_data_() { } sbuf[32] = '\0'; ESP_LOGV(TAG, "Update: Binary MD5 is %s", sbuf); - backend->set_update_md5(sbuf); + this->backend_->set_update_md5(sbuf); // Acknowledge MD5 OK - 1 byte buf[0] = ota::OTA_RESPONSE_BIN_MD5_OK; @@ -358,26 +331,24 @@ void ESPHomeOTAComponent::handle_data_() { while (total < ota_size) { // TODO: timeout check - size_t requested = std::min(sizeof(buf), ota_size - total); + size_t remaining = ota_size - total; + size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; ssize_t read = this->client_->read(buf, requested); if (read == -1) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (this->would_block_(errno)) { this->yield_and_feed_watchdog_(); continue; } - ESP_LOGW(TAG, "Read error, errno %d", errno); + ESP_LOGW(TAG, "Read err %d", errno); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } else if (read == 0) { - // $ man recv - // "When a stream socket peer has performed an orderly shutdown, the return value will - // be 0 (the traditional "end-of-file" return)." - ESP_LOGW(TAG, "Remote closed connection"); + ESP_LOGW(TAG, "Remote closed"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - error_code = backend->write(buf, read); + error_code = this->backend_->write(buf, read); if (error_code != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Flash write error, code: %d", error_code); + ESP_LOGW(TAG, "Flash write err %d", error_code); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } total += read; @@ -406,9 +377,9 @@ void ESPHomeOTAComponent::handle_data_() { buf[0] = ota::OTA_RESPONSE_RECEIVE_OK; this->writeall_(buf, 1); - error_code = backend->end(); + error_code = this->backend_->end(); if (error_code != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Error ending update! code: %d", error_code); + ESP_LOGW(TAG, "End update err %d", error_code); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -437,8 +408,8 @@ error: this->writeall_(buf, 1); this->cleanup_connection_(); - if (backend != nullptr && update_started) { - backend->abort(); + if (this->backend_ != nullptr && update_started) { + this->backend_->abort(); } this->status_momentary_error("onerror", 5000); @@ -459,12 +430,12 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { ssize_t read = this->client_->read(buf + at, len - at); if (read == -1) { - if (errno != EAGAIN && errno != EWOULDBLOCK) { - ESP_LOGW(TAG, "Error reading %d bytes, errno %d", len, errno); + if (!this->would_block_(errno)) { + ESP_LOGW(TAG, "Read err %d bytes, errno %d", len, errno); return false; } } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed connection"); + ESP_LOGW(TAG, "Remote closed"); return false; } else { at += read; @@ -486,8 +457,8 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { ssize_t written = this->client_->write(buf + at, len - at); if (written == -1) { - if (errno != EAGAIN && errno != EWOULDBLOCK) { - ESP_LOGW(TAG, "Error writing %d bytes, errno %d", len, errno); + if (!this->would_block_(errno)) { + ESP_LOGW(TAG, "Write err %d bytes, errno %d", len, errno); return false; } } else { @@ -512,11 +483,74 @@ void ESPHomeOTAComponent::log_start_(const LogString *phase) { ESP_LOGD(TAG, "Starting %s from %s", LOG_STR_ARG(phase), this->client_->getpeername().c_str()); } +void ESPHomeOTAComponent::log_remote_closed_(const LogString *during) { + ESP_LOGW(TAG, "Remote closed at %s", LOG_STR_ARG(during)); +} + +bool ESPHomeOTAComponent::handle_read_error_(ssize_t read, const LogString *desc) { + if (read == -1 && this->would_block_(errno)) { + return false; // No data yet, try again next loop + } + + if (read <= 0) { + read == 0 ? this->log_remote_closed_(desc) : this->log_socket_error_(desc); + this->cleanup_connection_(); + return false; + } + return true; +} + +bool ESPHomeOTAComponent::handle_write_error_(ssize_t written, const LogString *desc) { + if (written == -1) { + if (this->would_block_(errno)) { + return false; // Try again next loop + } + this->log_socket_error_(desc); + this->cleanup_connection_(); + return false; + } + return true; +} + +bool ESPHomeOTAComponent::try_read_(size_t to_read, const LogString *desc) { + // Read bytes into handshake buffer, starting at handshake_buf_pos_ + size_t bytes_to_read = to_read - this->handshake_buf_pos_; + ssize_t read = this->client_->read(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_read); + + if (!this->handle_read_error_(read, desc)) { + return false; + } + + this->handshake_buf_pos_ += read; + // Return true only if we have all the requested bytes + return this->handshake_buf_pos_ >= to_read; +} + +bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *desc) { + // Write bytes from handshake buffer, starting at handshake_buf_pos_ + size_t bytes_to_write = to_write - this->handshake_buf_pos_; + ssize_t written = this->client_->write(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_write); + + if (!this->handle_write_error_(written, desc)) { + return false; + } + + this->handshake_buf_pos_ += written; + // Return true only if we have written all the requested bytes + return this->handshake_buf_pos_ >= to_write; +} + void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; this->client_connect_time_ = 0; - this->magic_buf_pos_ = 0; + this->handshake_buf_pos_ = 0; + this->ota_state_ = OTAState::IDLE; + this->ota_features_ = 0; + this->backend_ = nullptr; +#ifdef USE_OTA_PASSWORD + this->cleanup_auth_(); +#endif } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { @@ -525,82 +559,247 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { } #ifdef USE_OTA_PASSWORD -void ESPHomeOTAComponent::log_auth_warning_(const LogString *action, const LogString *hash_name) { - ESP_LOGW(TAG, "Auth: %s %s failed", LOG_STR_ARG(action), LOG_STR_ARG(hash_name)); +void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG, "Auth: %s", LOG_STR_ARG(msg)); } + +bool ESPHomeOTAComponent::select_auth_type_() { +#ifdef USE_OTA_SHA256 + bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + +#ifdef ALLOW_OTA_DOWNGRADE_MD5 + // Allow fallback to MD5 if client doesn't support SHA256 + if (client_supports_sha256) { + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; + return true; + } +#ifdef USE_OTA_MD5 + this->log_auth_warning_(LOG_STR("Using deprecated MD5")); + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; + return true; +#else + this->log_auth_warning_(LOG_STR("SHA256 required")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; +#endif // USE_OTA_MD5 + +#else // !ALLOW_OTA_DOWNGRADE_MD5 + // Require SHA256 + if (!client_supports_sha256) { + this->log_auth_warning_(LOG_STR("SHA256 required")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; + } + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; + return true; +#endif // ALLOW_OTA_DOWNGRADE_MD5 + +#else // !USE_OTA_SHA256 +#ifdef USE_OTA_MD5 + // Only MD5 available + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; + return true; +#else + // No auth methods available + this->log_auth_warning_(LOG_STR("No auth methods available")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; +#endif // USE_OTA_MD5 +#endif // USE_OTA_SHA256 } -// Non-template function definition to reduce binary size -bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, - const LogString *name, char *buf) { - // Get sizes from the hasher - const size_t hex_size = hasher->get_size() * 2; // Hex is twice the byte size - const size_t nonce_len = hasher->get_size() / 4; // Nonce is 1/4 of hash size in bytes +bool ESPHomeOTAComponent::handle_auth_send_() { + // Initialize auth buffer if not already done + if (!this->auth_buf_) { + // Select auth type based on client capabilities and configuration + if (!this->select_auth_type_()) { + return false; + } - // Use the provided buffer for all hex operations + // Generate nonce with appropriate hasher + bool success = false; +#ifdef USE_OTA_SHA256 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + sha256::SHA256 sha_hasher; + success = this->prepare_auth_nonce_(&sha_hasher); + } +#endif +#ifdef USE_OTA_MD5 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_AUTH) { + md5::MD5Digest md5_hasher; + success = this->prepare_auth_nonce_(&md5_hasher); + } +#endif - // Small stack buffer for nonce seed bytes - uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) - - // Send auth request type - this->writeall_(&auth_request, 1); - - hasher->init(); - - // Generate nonce seed bytes using random_bytes - if (!random_bytes(nonce_bytes, nonce_len)) { - this->log_auth_warning_(LOG_STR("Random bytes generation failed"), name); - return false; + if (!success) { + return false; + } } - hasher->add(nonce_bytes, nonce_len); - hasher->calculate(); - // Generate and send nonce - hasher->get_hex(buf); - buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Nonce is %s", LOG_STR_ARG(name), buf); + // Try to write auth_type + nonce + size_t hex_size = this->get_auth_hex_size_(); + const size_t to_write = 1 + hex_size; + size_t remaining = to_write - this->auth_buf_pos_; - if (!this->writeall_(reinterpret_cast(buf), hex_size)) { - this->log_auth_warning_(LOG_STR("Writing nonce"), name); + ssize_t written = this->client_->write(this->auth_buf_.get() + this->auth_buf_pos_, remaining); + if (!this->handle_write_error_(written, LOG_STR("ack auth"))) { return false; } - // Start challenge: password + nonce - hasher->init(); - hasher->add(password.c_str(), password.length()); - hasher->add(buf, hex_size); + this->auth_buf_pos_ += written; - // Read cnonce and add to hash - if (!this->readall_(reinterpret_cast(buf), hex_size)) { - this->log_auth_warning_(LOG_STR("Reading cnonce"), name); + // Check if we still have more to write + if (this->auth_buf_pos_ < to_write) { + return false; // More to write, try again next loop + } + + // All written, prepare for reading phase + this->auth_buf_pos_ = 0; + return true; +} + +bool ESPHomeOTAComponent::handle_auth_read_() { + size_t hex_size = this->get_auth_hex_size_(); + const size_t to_read = hex_size * 2; // CNonce + Response + + // Try to read remaining bytes (CNonce + Response) + // We read cnonce+response starting at offset 1+hex_size (after auth_type and our nonce) + size_t cnonce_offset = 1 + hex_size; // Offset where cnonce should be stored in buffer + size_t remaining = to_read - this->auth_buf_pos_; + ssize_t read = this->client_->read(this->auth_buf_.get() + cnonce_offset + this->auth_buf_pos_, remaining); + + if (!this->handle_read_error_(read, LOG_STR("read auth"))) { return false; } - buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s CNonce is %s", LOG_STR_ARG(name), buf); - hasher->add(buf, hex_size); - hasher->calculate(); + this->auth_buf_pos_ += read; - // Log expected result (digest is already in hasher) - hasher->get_hex(buf); - buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Result is %s", LOG_STR_ARG(name), buf); - - // Read response into the buffer - if (!this->readall_(reinterpret_cast(buf), hex_size)) { - this->log_auth_warning_(LOG_STR("Reading response"), name); - return false; + // Check if we still need more data + if (this->auth_buf_pos_ < to_read) { + return false; // More to read, try again next loop } - buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Response is %s", LOG_STR_ARG(name), buf); - // Compare response directly with digest in hasher - bool matches = hasher->equals_hex(buf); + // We have all the data, verify it + bool matches = false; + +#ifdef USE_OTA_SHA256 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + sha256::SHA256 sha_hasher; + matches = this->verify_hash_auth_(&sha_hasher, hex_size); + } +#endif +#ifdef USE_OTA_MD5 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_AUTH) { + md5::MD5Digest md5_hasher; + matches = this->verify_hash_auth_(&md5_hasher, hex_size); + } +#endif if (!matches) { - this->log_auth_warning_(LOG_STR("Password mismatch"), name); + this->log_auth_warning_(LOG_STR("Password mismatch")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; } - return matches; + // Authentication successful - clean up auth state + this->cleanup_auth_(); + + return true; +} + +bool ESPHomeOTAComponent::prepare_auth_nonce_(HashBase *hasher) { + // Calculate required buffer size using the hasher + const size_t hex_size = hasher->get_size() * 2; + const size_t nonce_len = hasher->get_size() / 4; + + // Buffer layout after AUTH_READ completes: + // [0]: auth_type (1 byte) + // [1...hex_size]: nonce (hex_size bytes) - our random nonce sent in AUTH_SEND + // [1+hex_size...1+2*hex_size-1]: cnonce (hex_size bytes) - client's nonce + // [1+2*hex_size...1+3*hex_size-1]: response (hex_size bytes) - client's hash + // Total: 1 + 3*hex_size + const size_t auth_buf_size = 1 + 3 * hex_size; + this->auth_buf_ = std::make_unique(auth_buf_size); + this->auth_buf_pos_ = 0; + + // Generate nonce + char *buf = reinterpret_cast(this->auth_buf_.get() + 1); + if (!random_bytes(reinterpret_cast(buf), nonce_len)) { + this->log_auth_warning_(LOG_STR("Random failed")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_UNKNOWN); + return false; + } + + hasher->init(); + hasher->add(buf, nonce_len); + hasher->calculate(); + + // Prepare buffer: auth_type (1 byte) + nonce (hex_size bytes) + this->auth_buf_[0] = this->auth_type_; + hasher->get_hex(buf); + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char log_buf[hex_size + 1]; + // Log nonce for debugging + memcpy(log_buf, buf, hex_size); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: Nonce is %s", log_buf); +#endif + + return true; +} + +bool ESPHomeOTAComponent::verify_hash_auth_(HashBase *hasher, size_t hex_size) { + // Get pointers to the data in the buffer (see prepare_auth_nonce_ for buffer layout) + const char *nonce = reinterpret_cast(this->auth_buf_.get() + 1); // Skip auth_type byte + const char *cnonce = nonce + hex_size; // CNonce immediately follows nonce + const char *response = cnonce + hex_size; // Response immediately follows cnonce + + // Calculate expected hash: password + nonce + cnonce + hasher->init(); + hasher->add(this->password_.c_str(), this->password_.length()); + hasher->add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer) + hasher->calculate(); + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char log_buf[hex_size + 1]; + // Log CNonce + memcpy(log_buf, cnonce, hex_size); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: CNonce is %s", log_buf); + + // Log computed hash + hasher->get_hex(log_buf); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: Result is %s", log_buf); + + // Log received response + memcpy(log_buf, response, hex_size); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: Response is %s", log_buf); +#endif + + // Compare response + return hasher->equals_hex(response); +} + +size_t ESPHomeOTAComponent::get_auth_hex_size_() const { +#ifdef USE_OTA_SHA256 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + return SHA256_HEX_SIZE; + } +#endif +#ifdef USE_OTA_MD5 + return MD5_HEX_SIZE; +#else +#ifndef USE_OTA_SHA256 +#error "Either USE_OTA_MD5 or USE_OTA_SHA256 must be defined when USE_OTA_PASSWORD is enabled" +#endif +#endif +} + +void ESPHomeOTAComponent::cleanup_auth_() { + this->auth_buf_ = nullptr; + this->auth_buf_pos_ = 0; + this->auth_type_ = 0; } #endif // USE_OTA_PASSWORD diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 5bacb60706..1e26494fd0 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -14,6 +14,18 @@ namespace esphome { /// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA. class ESPHomeOTAComponent : public ota::OTAComponent { public: + enum class OTAState : uint8_t { + IDLE, + MAGIC_READ, // Reading magic bytes + MAGIC_ACK, // Sending OK and version after magic bytes + FEATURE_READ, // Reading feature flags from client + FEATURE_ACK, // Sending feature acknowledgment +#ifdef USE_OTA_PASSWORD + AUTH_SEND, // Sending authentication request + AUTH_READ, // Reading authentication data +#endif // USE_OTA_PASSWORD + DATA, // BLOCKING! Processing OTA data (update, etc.) + }; #ifdef USE_OTA_PASSWORD void set_auth_password(const std::string &password) { password_ = password; } #endif // USE_OTA_PASSWORD @@ -32,16 +44,39 @@ class ESPHomeOTAComponent : public ota::OTAComponent { void handle_handshake_(); void handle_data_(); #ifdef USE_OTA_PASSWORD - bool perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, const LogString *name, - char *buf); - void log_auth_warning_(const LogString *action, const LogString *hash_name); + bool handle_auth_send_(); + bool handle_auth_read_(); + bool select_auth_type_(); + bool prepare_auth_nonce_(HashBase *hasher); + bool verify_hash_auth_(HashBase *hasher, size_t hex_size); + size_t get_auth_hex_size_() const; + void cleanup_auth_(); + void log_auth_warning_(const LogString *msg); #endif // USE_OTA_PASSWORD bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); + + bool try_read_(size_t to_read, const LogString *desc); + bool try_write_(size_t to_write, const LogString *desc); + + inline bool would_block_(int error_code) const { return error_code == EAGAIN || error_code == EWOULDBLOCK; } + bool handle_read_error_(ssize_t read, const LogString *desc); + bool handle_write_error_(ssize_t written, const LogString *desc); + inline void transition_ota_state_(OTAState next_state) { + this->ota_state_ = next_state; + this->handshake_buf_pos_ = 0; // Reset buffer position for next state + } + void log_socket_error_(const LogString *msg); void log_read_error_(const LogString *what); void log_start_(const LogString *phase); + void log_remote_closed_(const LogString *during); void cleanup_connection_(); + inline void send_error_and_cleanup_(ota::OTAResponseTypes error) { + uint8_t error_byte = static_cast(error); + this->client_->write(&error_byte, 1); // Best effort, non-blocking + this->cleanup_connection_(); + } void yield_and_feed_watchdog_(); #ifdef USE_OTA_PASSWORD @@ -50,11 +85,19 @@ class ESPHomeOTAComponent : public ota::OTAComponent { std::unique_ptr server_; std::unique_ptr client_; + std::unique_ptr backend_; uint32_t client_connect_time_{0}; uint16_t port_; - uint8_t magic_buf_[5]; - uint8_t magic_buf_pos_{0}; + uint8_t handshake_buf_[5]; + OTAState ota_state_{OTAState::IDLE}; + uint8_t handshake_buf_pos_{0}; + uint8_t ota_features_{0}; +#ifdef USE_OTA_PASSWORD + std::unique_ptr auth_buf_; + uint8_t auth_buf_pos_{0}; + uint8_t auth_type_{0}; // Store auth type to know which hasher to use +#endif // USE_OTA_PASSWORD }; } // namespace esphome diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 2449b1249d..7fc42ea334 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -126,6 +126,7 @@ #define USE_OTA_MD5 #define USE_OTA_PASSWORD #define USE_OTA_SHA256 +#define ALLOW_OTA_DOWNGRADE_MD5 #define USE_OTA_STATE_CALLBACK #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE From 96868aa7549cdea7f214c1e2f2a051b8b72eb040 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 22:52:47 -0500 Subject: [PATCH 1556/1626] [socket] Reduce memory overhead for LWIP TCP accept queue on ESP8266/RP2040 (#10938) --- .../components/socket/lwip_raw_tcp_impl.cpp | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 2d64a275df..3377682474 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -9,7 +9,7 @@ #include "lwip/tcp.h" #include #include -#include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -50,12 +50,18 @@ class LWIPRawImpl : public Socket { errno = EBADF; return nullptr; } - if (accepted_sockets_.empty()) { + if (this->accepted_socket_count_ == 0) { errno = EWOULDBLOCK; return nullptr; } - std::unique_ptr sock = std::move(accepted_sockets_.front()); - accepted_sockets_.pop(); + // Take from front for FIFO ordering + std::unique_ptr sock = std::move(this->accepted_sockets_[0]); + // Shift remaining sockets forward + for (uint8_t i = 1; i < this->accepted_socket_count_; i++) { + this->accepted_sockets_[i - 1] = std::move(this->accepted_sockets_[i]); + } + this->accepted_socket_count_--; + LWIP_LOG("Connection accepted by application, queue size: %d", this->accepted_socket_count_); if (addr != nullptr) { sock->getpeername(addr, addrlen); } @@ -494,9 +500,18 @@ class LWIPRawImpl : public Socket { // nothing to do here, we just don't push it to the queue return ERR_OK; } + // Check if we've reached the maximum accept queue size + if (this->accepted_socket_count_ >= MAX_ACCEPTED_SOCKETS) { + LWIP_LOG("Rejecting connection, queue full (%d)", this->accepted_socket_count_); + // Abort the connection when queue is full + tcp_abort(newpcb); + // Must return ERR_ABRT since we called tcp_abort() + return ERR_ABRT; + } auto sock = make_unique(family_, newpcb); sock->init(); - accepted_sockets_.push(std::move(sock)); + this->accepted_sockets_[this->accepted_socket_count_++] = std::move(sock); + LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); return ERR_OK; } void err_fn(err_t err) { @@ -587,7 +602,20 @@ class LWIPRawImpl : public Socket { } struct tcp_pcb *pcb_; - std::queue> accepted_sockets_; + // Accept queue - holds incoming connections briefly until the event loop calls accept() + // This is NOT a connection pool - just a temporary queue between LWIP callbacks and the main loop + // 3 slots is plenty since connections are pulled out quickly by the event loop + // + // Memory analysis: std::array<3> vs original std::queue implementation: + // - std::queue uses std::deque internally which on 32-bit systems needs: + // 24 bytes (deque object) + 32+ bytes (map array) + heap allocations + // Total: ~56+ bytes minimum, plus heap fragmentation + // - std::array<3>: 12 bytes fixed (3 pointers × 4 bytes) + // Saves ~44+ bytes RAM per listening socket + avoids ALL heap allocations + // Used on ESP8266 and RP2040 (platforms using LWIP_TCP implementation) + static constexpr size_t MAX_ACCEPTED_SOCKETS = 3; + std::array, MAX_ACCEPTED_SOCKETS> accepted_sockets_; + uint8_t accepted_socket_count_ = 0; // Number of sockets currently in queue bool rx_closed_ = false; pbuf *rx_buf_ = nullptr; size_t rx_buf_offset_ = 0; From 6018f5f5d12e5a84f4398143c0070537e3960db9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Sep 2025 04:24:19 -0500 Subject: [PATCH 1557/1626] [api] Add configurable connection limits (#10939) --- esphome/components/api/__init__.py | 29 +++++++++++++++++++++++++++ esphome/components/api/api_server.cpp | 18 ++++++++++++++--- esphome/components/api/api_server.h | 8 +++++++- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6a0e092008..c91051ba20 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -59,6 +59,8 @@ CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" +CONF_LISTEN_BACKLOG = "listen_backlog" +CONF_MAX_CONNECTIONS = "max_connections" def validate_encryption_key(value): @@ -158,6 +160,29 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_CLIENT_DISCONNECTED): automation.validate_automation( single=True ), + # Connection limits to prevent memory exhaustion on resource-constrained devices + # Each connection uses ~500-1000 bytes of RAM plus system resources + # Platform defaults based on available RAM and network stack implementation: + cv.SplitDefault( + CONF_LISTEN_BACKLOG, + esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets + esp32=4, # More RAM (520KB), BSD sockets + rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266 + bk72xx=4, # Moderate RAM, BSD-style sockets + rtl87xx=4, # Moderate RAM, BSD-style sockets + host=4, # Abundant resources + ln882x=4, # Moderate RAM + ): cv.int_range(min=1, max=10), + cv.SplitDefault( + CONF_MAX_CONNECTIONS, + esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes + esp32=8, # 520KB RAM available + rp2040=4, # 264KB RAM but LWIP constraints + bk72xx=8, # Moderate RAM + rtl87xx=8, # Moderate RAM + host=8, # Abundant resources + ln882x=8, # Moderate RAM + ): cv.int_range(min=1, max=20), } ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), @@ -176,6 +201,10 @@ async def to_code(config): cg.add(var.set_password(config[CONF_PASSWORD])) cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) + if CONF_LISTEN_BACKLOG in config: + cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) + if CONF_MAX_CONNECTIONS in config: + cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) # Set USE_API_SERVICES if any services are enabled if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index dd6eb950a6..7fbe0e27f3 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -87,7 +87,7 @@ void APIServer::setup() { return; } - err = this->socket_->listen(4); + err = this->socket_->listen(this->listen_backlog_); if (err != 0) { ESP_LOGW(TAG, "Socket unable to listen: errno %d", errno); this->mark_failed(); @@ -140,9 +140,19 @@ void APIServer::loop() { while (true) { struct sockaddr_storage source_addr; socklen_t addr_len = sizeof(source_addr); + auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); if (!sock) break; + + // Check if we're at the connection limit + if (this->clients_.size() >= this->max_connections_) { + ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, sock->getpeername().c_str()); + // Immediately close - socket destructor will handle cleanup + sock.reset(); + continue; + } + ESP_LOGD(TAG, "Accept %s", sock->getpeername().c_str()); auto *conn = new APIConnection(std::move(sock), this); @@ -206,8 +216,10 @@ void APIServer::loop() { void APIServer::dump_config() { ESP_LOGCONFIG(TAG, "Server:\n" - " Address: %s:%u", - network::get_use_address().c_str(), this->port_); + " Address: %s:%u\n" + " Listen backlog: %u\n" + " Max connections: %u", + network::get_use_address().c_str(), this->port_, this->listen_backlog_, this->max_connections_); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_->has_psk())); if (!this->noise_ctx_->has_psk()) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 627870af1d..b9049c1700 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -44,6 +44,8 @@ class APIServer : public Component, public Controller { void set_reboot_timeout(uint32_t reboot_timeout); void set_batch_delay(uint16_t batch_delay); uint16_t get_batch_delay() const { return batch_delay_; } + void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } + void set_max_connections(uint8_t max_connections) { this->max_connections_ = max_connections; } // Get reference to shared buffer for API connections std::vector &get_shared_buffer_ref() { return shared_write_buffer_; } @@ -189,8 +191,12 @@ class APIServer : public Component, public Controller { // Group smaller types together uint16_t port_{6053}; uint16_t batch_delay_{100}; + // Connection limits - these defaults will be overridden by config values + // from cv.SplitDefault in __init__.py which sets platform-specific defaults + uint8_t listen_backlog_{4}; + uint8_t max_connections_{8}; bool shutting_down_ = false; - // 5 bytes used, 3 bytes padding + // 7 bytes used, 1 byte padding #ifdef USE_API_NOISE std::shared_ptr noise_ctx_ = std::make_shared(); From d30a3f0d830547e590d35f9c48cd1e26b2be0548 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Sep 2025 11:53:15 +0200 Subject: [PATCH 1558/1626] [captive_portal] Add DHCP Option 114 support for ESP32 --- .../wifi/wifi_component_esp_idf.cpp | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index aa0a993e79..2d1eba8885 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -27,6 +27,10 @@ #include "dhcpserver/dhcpserver.h" #endif // USE_WIFI_AP +#ifdef USE_CAPTIVE_PORTAL +#include "esphome/components/captive_portal/captive_portal.h" +#endif + #include "lwip/apps/sntp.h" #include "lwip/dns.h" #include "lwip/err.h" @@ -918,6 +922,22 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { return false; } +#if defined(USE_CAPTIVE_PORTAL) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0) + // Configure DHCP Option 114 (Captive Portal URI) if captive portal is enabled + // This provides a standards-compliant way for clients to discover the captive portal + if (captive_portal::global_captive_portal != nullptr) { + static char captive_portal_uri[32]; + snprintf(captive_portal_uri, sizeof(captive_portal_uri), "http://%s", network::IPAddress(&info.ip).str().c_str()); + err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captive_portal_uri, + strlen(captive_portal_uri)); + if (err != ESP_OK) { + ESP_LOGV(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(err)); + } else { + ESP_LOGV(TAG, "DHCP Captive Portal URI set to: %s", captive_portal_uri); + } + } +#endif + err = esp_netif_dhcps_start(s_ap_netif); if (err != ESP_OK) { From 0e623055df67dab42776747d9b78ec5527fd7fd5 Mon Sep 17 00:00:00 2001 From: Patrick Date: Tue, 30 Sep 2025 14:56:28 +0200 Subject: [PATCH 1559/1626] [mcp2515, canbus] error handling improvments (#10526) --- esphome/components/canbus/canbus.cpp | 12 +++++++----- esphome/components/canbus/canbus.h | 8 ++++---- esphome/components/mcp2515/mcp2515.cpp | 17 ++++++++++++++--- esphome/components/mcp2515/mcp2515_defs.h | 4 +++- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index 6e61f05be7..e208b0fd66 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -21,8 +21,8 @@ void Canbus::dump_config() { } } -void Canbus::send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, - const std::vector &data) { +canbus::Error Canbus::send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, + const std::vector &data) { struct CanFrame can_message; uint8_t size = static_cast(data.size()); @@ -45,13 +45,15 @@ void Canbus::send_data(uint32_t can_id, bool use_extended_id, bool remote_transm ESP_LOGVV(TAG, " data[%d]=%02x", i, can_message.data[i]); } - if (this->send_message(&can_message) != canbus::ERROR_OK) { + canbus::Error error = this->send_message(&can_message); + if (error != canbus::ERROR_OK) { if (use_extended_id) { - ESP_LOGW(TAG, "send to extended id=0x%08" PRIx32 " failed!", can_id); + ESP_LOGW(TAG, "send to extended id=0x%08" PRIx32 " failed with error %d!", can_id, error); } else { - ESP_LOGW(TAG, "send to standard id=0x%03" PRIx32 " failed!", can_id); + ESP_LOGW(TAG, "send to standard id=0x%03" PRIx32 " failed with error %d!", can_id, error); } } + return error; } void Canbus::add_trigger(CanbusTrigger *trigger) { diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 7319bfb4ad..56e2f2719b 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -70,11 +70,11 @@ class Canbus : public Component { float get_setup_priority() const override { return setup_priority::HARDWARE; } void loop() override; - void send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, - const std::vector &data); - void send_data(uint32_t can_id, bool use_extended_id, const std::vector &data) { + canbus::Error send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, + const std::vector &data); + canbus::Error send_data(uint32_t can_id, bool use_extended_id, const std::vector &data) { // for backwards compatibility only - this->send_data(can_id, use_extended_id, false, data); + return this->send_data(can_id, use_extended_id, false, data); } void set_can_id(uint32_t can_id) { this->can_id_ = can_id; } void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; } diff --git a/esphome/components/mcp2515/mcp2515.cpp b/esphome/components/mcp2515/mcp2515.cpp index 23104f5aeb..d40a64b68e 100644 --- a/esphome/components/mcp2515/mcp2515.cpp +++ b/esphome/components/mcp2515/mcp2515.cpp @@ -155,7 +155,7 @@ void MCP2515::prepare_id_(uint8_t *buffer, const bool extended, const uint32_t i canid = (uint16_t) (id >> 16); buffer[MCP_SIDL] = (uint8_t) (canid & 0x03); buffer[MCP_SIDL] += (uint8_t) ((canid & 0x1C) << 3); - buffer[MCP_SIDL] |= TXB_EXIDE_MASK; + buffer[MCP_SIDL] |= SIDL_EXIDE_MASK; buffer[MCP_SIDH] = (uint8_t) (canid >> 5); } else { buffer[MCP_SIDH] = (uint8_t) (canid >> 3); @@ -258,7 +258,7 @@ canbus::Error MCP2515::send_message(struct canbus::CanFrame *frame) { } } - return canbus::ERROR_FAILTX; + return canbus::ERROR_ALLTXBUSY; } canbus::Error MCP2515::read_message_(RXBn rxbn, struct canbus::CanFrame *frame) { @@ -272,7 +272,7 @@ canbus::Error MCP2515::read_message_(RXBn rxbn, struct canbus::CanFrame *frame) bool use_extended_id = false; bool remote_transmission_request = false; - if ((tbufdata[MCP_SIDL] & TXB_EXIDE_MASK) == TXB_EXIDE_MASK) { + if ((tbufdata[MCP_SIDL] & SIDL_EXIDE_MASK) == SIDL_EXIDE_MASK) { id = (id << 2) + (tbufdata[MCP_SIDL] & 0x03); id = (id << 8) + tbufdata[MCP_EID8]; id = (id << 8) + tbufdata[MCP_EID0]; @@ -315,6 +315,17 @@ canbus::Error MCP2515::read_message(struct canbus::CanFrame *frame) { rc = canbus::ERROR_NOMSG; } +#ifdef ESPHOME_LOG_HAS_DEBUG + uint8_t err = get_error_flags_(); + // The receive flowchart in the datasheet says that if rollover is set (BUKT), RX1OVR flag will be set + // once both buffers are full. However, the RX0OVR flag is actually set instead. + // We can just check for both though because it doesn't break anything. + if (err & (EFLG_RX0OVR | EFLG_RX1OVR)) { + ESP_LOGD(TAG, "receive buffer overrun"); + clear_rx_n_ovr_flags_(); + } +#endif + return rc; } diff --git a/esphome/components/mcp2515/mcp2515_defs.h b/esphome/components/mcp2515/mcp2515_defs.h index 2f5cf2a238..b33adcbba6 100644 --- a/esphome/components/mcp2515/mcp2515_defs.h +++ b/esphome/components/mcp2515/mcp2515_defs.h @@ -130,7 +130,9 @@ static const uint8_t CANSTAT_ICOD = 0x0E; static const uint8_t CNF3_SOF = 0x80; -static const uint8_t TXB_EXIDE_MASK = 0x08; +// applies to RXBn_SIDL, TXBn_SIDL and RXFn_SIDL +static const uint8_t SIDL_EXIDE_MASK = 0x08; + static const uint8_t DLC_MASK = 0x0F; static const uint8_t RTR_MASK = 0x40; From a5ba6237cb2957a167b6600226ea15b51ada8d73 Mon Sep 17 00:00:00 2001 From: Stephen Boyle Date: Tue, 30 Sep 2025 08:59:08 -0400 Subject: [PATCH 1560/1626] [ethernet] Add mac_address yaml configuration option (#10861) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/ethernet/__init__.py | 5 +++++ esphome/components/ethernet/ethernet_component.cpp | 6 +++++- esphome/components/ethernet/ethernet_component.h | 2 ++ tests/components/ethernet/common-dm9051.yaml | 1 + tests/components/ethernet/common-dp83848.yaml | 1 + tests/components/ethernet/common-ip101.yaml | 1 + tests/components/ethernet/common-jl1101.yaml | 1 + tests/components/ethernet/common-ksz8081.yaml | 1 + tests/components/ethernet/common-ksz8081rna.yaml | 1 + tests/components/ethernet/common-lan8720.yaml | 1 + tests/components/ethernet/common-rtl8201.yaml | 1 + tests/components/ethernet/common-w5500.yaml | 1 + 12 files changed, 21 insertions(+), 1 deletion(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 1723280bc7..7384bb26d3 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -27,6 +27,7 @@ from esphome.const import ( CONF_GATEWAY, CONF_ID, CONF_INTERRUPT_PIN, + CONF_MAC_ADDRESS, CONF_MANUAL_IP, CONF_MISO_PIN, CONF_MODE, @@ -197,6 +198,7 @@ BASE_SCHEMA = cv.Schema( "This option has been removed. Please use the [disabled] option under the " "new mdns component instead." ), + cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, } ).extend(cv.COMPONENT_SCHEMA) @@ -365,6 +367,9 @@ async def to_code(config): if phy_define := _PHY_TYPE_TO_DEFINE.get(config[CONF_TYPE]): cg.add_define(phy_define) + if mac_address := config.get(CONF_MAC_ADDRESS): + cg.add(var.set_fixed_mac(mac_address.parts)) + cg.add_define("USE_ETHERNET") # Disable WiFi when using Ethernet to save memory diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index cb43b2c83c..16f5903e3f 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -253,7 +253,11 @@ void EthernetComponent::setup() { // use ESP internal eth mac uint8_t mac_addr[6]; - esp_read_mac(mac_addr, ESP_MAC_ETH); + if (this->fixed_mac_.has_value()) { + memcpy(mac_addr, this->fixed_mac_->data(), 6); + } else { + esp_read_mac(mac_addr, ESP_MAC_ETH); + } err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_MAC_ADDR, mac_addr); ESPHL_ERROR_CHECK(err, "set mac address error"); diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index fae5bb1257..9a0da12241 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -84,6 +84,7 @@ class EthernetComponent : public Component { #endif void set_type(EthernetType type); void set_manual_ip(const ManualIP &manual_ip); + void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } network::IPAddresses get_ip_addresses(); network::IPAddress get_dns_address(uint8_t num); @@ -155,6 +156,7 @@ class EthernetComponent : public Component { esp_netif_t *eth_netif_{nullptr}; esp_eth_handle_t eth_handle_; esp_eth_phy_t *phy_{nullptr}; + optional> fixed_mac_; }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/tests/components/ethernet/common-dm9051.yaml b/tests/components/ethernet/common-dm9051.yaml index c878ca6e59..4526e7732d 100644 --- a/tests/components/ethernet/common-dm9051.yaml +++ b/tests/components/ethernet/common-dm9051.yaml @@ -12,3 +12,4 @@ ethernet: gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local + mac_address: "02:AA:BB:CC:DD:01" diff --git a/tests/components/ethernet/common-dp83848.yaml b/tests/components/ethernet/common-dp83848.yaml index 140c7d0d1b..7cedfeaf08 100644 --- a/tests/components/ethernet/common-dp83848.yaml +++ b/tests/components/ethernet/common-dp83848.yaml @@ -12,3 +12,4 @@ ethernet: gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local + mac_address: "02:AA:BB:CC:DD:01" diff --git a/tests/components/ethernet/common-ip101.yaml b/tests/components/ethernet/common-ip101.yaml index b5589220de..2dece15171 100644 --- a/tests/components/ethernet/common-ip101.yaml +++ b/tests/components/ethernet/common-ip101.yaml @@ -12,3 +12,4 @@ ethernet: gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local + mac_address: "02:AA:BB:CC:DD:01" diff --git a/tests/components/ethernet/common-jl1101.yaml b/tests/components/ethernet/common-jl1101.yaml index 2ada9495a0..b6ea884102 100644 --- a/tests/components/ethernet/common-jl1101.yaml +++ b/tests/components/ethernet/common-jl1101.yaml @@ -12,3 +12,4 @@ ethernet: gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local + mac_address: "02:AA:BB:CC:DD:01" diff --git a/tests/components/ethernet/common-ksz8081.yaml b/tests/components/ethernet/common-ksz8081.yaml index 7da8adb09a..f70d42319e 100644 --- a/tests/components/ethernet/common-ksz8081.yaml +++ b/tests/components/ethernet/common-ksz8081.yaml @@ -12,3 +12,4 @@ ethernet: gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local + mac_address: "02:AA:BB:CC:DD:01" diff --git a/tests/components/ethernet/common-ksz8081rna.yaml b/tests/components/ethernet/common-ksz8081rna.yaml index df04f06132..18efdae0e1 100644 --- a/tests/components/ethernet/common-ksz8081rna.yaml +++ b/tests/components/ethernet/common-ksz8081rna.yaml @@ -12,3 +12,4 @@ ethernet: gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local + mac_address: "02:AA:BB:CC:DD:01" diff --git a/tests/components/ethernet/common-lan8720.yaml b/tests/components/ethernet/common-lan8720.yaml index f227752f42..204c1d9210 100644 --- a/tests/components/ethernet/common-lan8720.yaml +++ b/tests/components/ethernet/common-lan8720.yaml @@ -12,3 +12,4 @@ ethernet: gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local + mac_address: "02:AA:BB:CC:DD:01" diff --git a/tests/components/ethernet/common-rtl8201.yaml b/tests/components/ethernet/common-rtl8201.yaml index 7c9c9d913c..8b9f2b86f2 100644 --- a/tests/components/ethernet/common-rtl8201.yaml +++ b/tests/components/ethernet/common-rtl8201.yaml @@ -12,3 +12,4 @@ ethernet: gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local + mac_address: "02:AA:BB:CC:DD:01" diff --git a/tests/components/ethernet/common-w5500.yaml b/tests/components/ethernet/common-w5500.yaml index 76661a75c3..b3e96f000d 100644 --- a/tests/components/ethernet/common-w5500.yaml +++ b/tests/components/ethernet/common-w5500.yaml @@ -12,3 +12,4 @@ ethernet: gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local + mac_address: "02:AA:BB:CC:DD:01" From b023453e81102cfee3418fdae65759fcad2cacb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Sep 2025 10:52:37 -0500 Subject: [PATCH 1561/1626] [captive_portal] Add DHCP Option 114 support for ESP32 (#10952) --- .../wifi/wifi_component_esp_idf.cpp | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index aa0a993e79..2d1eba8885 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -27,6 +27,10 @@ #include "dhcpserver/dhcpserver.h" #endif // USE_WIFI_AP +#ifdef USE_CAPTIVE_PORTAL +#include "esphome/components/captive_portal/captive_portal.h" +#endif + #include "lwip/apps/sntp.h" #include "lwip/dns.h" #include "lwip/err.h" @@ -918,6 +922,22 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { return false; } +#if defined(USE_CAPTIVE_PORTAL) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0) + // Configure DHCP Option 114 (Captive Portal URI) if captive portal is enabled + // This provides a standards-compliant way for clients to discover the captive portal + if (captive_portal::global_captive_portal != nullptr) { + static char captive_portal_uri[32]; + snprintf(captive_portal_uri, sizeof(captive_portal_uri), "http://%s", network::IPAddress(&info.ip).str().c_str()); + err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captive_portal_uri, + strlen(captive_portal_uri)); + if (err != ESP_OK) { + ESP_LOGV(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(err)); + } else { + ESP_LOGV(TAG, "DHCP Captive Portal URI set to: %s", captive_portal_uri); + } + } +#endif + err = esp_netif_dhcps_start(s_ap_netif); if (err != ESP_OK) { From d75b7708a554a6540c8f8045d7b5051e0227248f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:08:28 -0400 Subject: [PATCH 1562/1626] [sx126x] Add additional FSK CRC options (#10928) --- esphome/components/sx126x/__init__.py | 16 ++++++++++++++++ esphome/components/sx126x/sx126x.cpp | 16 +++++++++++++++- esphome/components/sx126x/sx126x.h | 10 +++++++++- esphome/components/sx126x/sx126x_reg.h | 2 ++ tests/components/sx126x/common.yaml | 4 ++++ 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index b6aeaf072c..370cd102d4 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -15,6 +15,10 @@ CONF_BANDWIDTH = "bandwidth" CONF_BITRATE = "bitrate" CONF_CODING_RATE = "coding_rate" CONF_CRC_ENABLE = "crc_enable" +CONF_CRC_INVERTED = "crc_inverted" +CONF_CRC_SIZE = "crc_size" +CONF_CRC_POLYNOMIAL = "crc_polynomial" +CONF_CRC_INITIAL = "crc_initial" CONF_DEVIATION = "deviation" CONF_DIO1_PIN = "dio1_pin" CONF_HW_VERSION = "hw_version" @@ -188,6 +192,14 @@ CONFIG_SCHEMA = ( cv.Required(CONF_BUSY_PIN): pins.internal_gpio_input_pin_schema, cv.Optional(CONF_CODING_RATE, default="CR_4_5"): cv.enum(CODING_RATE), cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean, + cv.Optional(CONF_CRC_INVERTED, default=True): cv.boolean, + cv.Optional(CONF_CRC_SIZE, default=2): cv.int_range(min=1, max=2), + cv.Optional(CONF_CRC_POLYNOMIAL, default=0x1021): cv.All( + cv.hex_int, cv.Range(min=0, max=0xFFFF) + ), + cv.Optional(CONF_CRC_INITIAL, default=0x1D0F): cv.All( + cv.hex_int, cv.Range(min=0, max=0xFFFF) + ), cv.Optional(CONF_DEVIATION, default=5000): cv.int_range(min=0, max=100000), cv.Required(CONF_DIO1_PIN): pins.internal_gpio_input_pin_schema, cv.Required(CONF_FREQUENCY): cv.int_range(min=137000000, max=1020000000), @@ -251,6 +263,10 @@ async def to_code(config): cg.add(var.set_shaping(config[CONF_SHAPING])) cg.add(var.set_bitrate(config[CONF_BITRATE])) cg.add(var.set_crc_enable(config[CONF_CRC_ENABLE])) + cg.add(var.set_crc_inverted(config[CONF_CRC_INVERTED])) + cg.add(var.set_crc_size(config[CONF_CRC_SIZE])) + cg.add(var.set_crc_polynomial(config[CONF_CRC_POLYNOMIAL])) + cg.add(var.set_crc_initial(config[CONF_CRC_INITIAL])) cg.add(var.set_payload_length(config[CONF_PAYLOAD_LENGTH])) cg.add(var.set_preamble_size(config[CONF_PREAMBLE_SIZE])) cg.add(var.set_preamble_detect(config[CONF_PREAMBLE_DETECT])) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index f5393c478a..bb59f26b79 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -235,6 +235,16 @@ void SX126x::configure() { buf[7] = (fdev >> 0) & 0xFF; this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 8); + // set crc params + if (this->crc_enable_) { + buf[0] = this->crc_initial_ >> 8; + buf[1] = this->crc_initial_ & 0xFF; + this->write_register_(REG_CRC_INITIAL, buf, 2); + buf[0] = this->crc_polynomial_ >> 8; + buf[1] = this->crc_polynomial_ & 0xFF; + this->write_register_(REG_CRC_POLYNOMIAL, buf, 2); + } + // set packet params and sync word this->set_packet_params_(this->get_max_packet_size()); if (!this->sync_value_.empty()) { @@ -276,7 +286,11 @@ void SX126x::set_packet_params_(uint8_t payload_length) { buf[4] = 0x00; buf[5] = (this->payload_length_ > 0) ? 0x00 : 0x01; buf[6] = payload_length; - buf[7] = this->crc_enable_ ? 0x06 : 0x01; + if (this->crc_enable_) { + buf[7] = (this->crc_inverted_ ? 0x04 : 0x00) + (this->crc_size_ & 0x02); + } else { + buf[7] = 0x01; + } buf[8] = 0x00; this->write_opcode_(RADIO_SET_PACKETPARAMS, buf, 9); } diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index fd5c37942d..47d6449738 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -67,6 +67,10 @@ class SX126x : public Component, void set_busy_pin(InternalGPIOPin *busy_pin) { this->busy_pin_ = busy_pin; } void set_coding_rate(uint8_t coding_rate) { this->coding_rate_ = coding_rate; } void set_crc_enable(bool crc_enable) { this->crc_enable_ = crc_enable; } + void set_crc_inverted(bool crc_inverted) { this->crc_inverted_ = crc_inverted; } + void set_crc_size(uint8_t crc_size) { this->crc_size_ = crc_size; } + void set_crc_polynomial(uint16_t crc_polynomial) { this->crc_polynomial_ = crc_polynomial; } + void set_crc_initial(uint16_t crc_initial) { this->crc_initial_ = crc_initial; } void set_deviation(uint32_t deviation) { this->deviation_ = deviation; } void set_dio1_pin(InternalGPIOPin *dio1_pin) { this->dio1_pin_ = dio1_pin; } void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } @@ -118,6 +122,11 @@ class SX126x : public Component, char version_[16]; SX126xBw bandwidth_{SX126X_BW_125000}; uint32_t bitrate_{0}; + bool crc_enable_{false}; + bool crc_inverted_{false}; + uint8_t crc_size_{0}; + uint16_t crc_polynomial_{0}; + uint16_t crc_initial_{0}; uint32_t deviation_{0}; uint32_t frequency_{0}; uint32_t payload_length_{0}; @@ -131,7 +140,6 @@ class SX126x : public Component, uint8_t shaping_{0}; uint8_t spreading_factor_{0}; int8_t pa_power_{0}; - bool crc_enable_{false}; bool rx_start_{false}; bool rf_switch_{false}; }; diff --git a/esphome/components/sx126x/sx126x_reg.h b/esphome/components/sx126x/sx126x_reg.h index 3b12d822b5..143f4a05da 100644 --- a/esphome/components/sx126x/sx126x_reg.h +++ b/esphome/components/sx126x/sx126x_reg.h @@ -53,6 +53,8 @@ enum SX126xOpCode : uint8_t { enum SX126xRegister : uint16_t { REG_VERSION_STRING = 0x0320, + REG_CRC_INITIAL = 0x06BC, + REG_CRC_POLYNOMIAL = 0x06BE, REG_GFSK_SYNCWORD = 0x06C0, REG_LORA_SYNCWORD = 0x0740, REG_OCP = 0x08E7, diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index 3f888c3ce4..05db2ef812 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -11,6 +11,10 @@ sx126x: pa_power: 3 bandwidth: 125_0kHz crc_enable: true + crc_initial: 0x1D0F + crc_polynomial: 0x1021 + crc_size: 2 + crc_inverted: true frequency: 433920000 modulation: LORA rx_start: true From c69603d916440b5a47caf4a06f86243406eafc4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Sep 2025 13:03:52 -0500 Subject: [PATCH 1563/1626] [dashboard] Replace polling with WebSocket for real-time updates (#10893) --- esphome/dashboard/const.py | 25 +- esphome/dashboard/core.py | 13 +- esphome/dashboard/entries.py | 24 +- esphome/dashboard/models.py | 76 +++++ esphome/dashboard/status/mdns.py | 21 +- esphome/dashboard/web_server.py | 268 +++++++++++++-- esphome/zeroconf.py | 15 +- tests/dashboard/conftest.py | 26 +- tests/dashboard/status/test_mdns.py | 72 ++++ tests/dashboard/test_entries.py | 209 ++++++++---- tests/dashboard/test_web_server.py | 494 +++++++++++++++++++++++++++- 11 files changed, 1125 insertions(+), 118 deletions(-) create mode 100644 esphome/dashboard/models.py diff --git a/esphome/dashboard/const.py b/esphome/dashboard/const.py index db66cb5ead..ada5575d0e 100644 --- a/esphome/dashboard/const.py +++ b/esphome/dashboard/const.py @@ -1,9 +1,26 @@ from __future__ import annotations -EVENT_ENTRY_ADDED = "entry_added" -EVENT_ENTRY_REMOVED = "entry_removed" -EVENT_ENTRY_UPDATED = "entry_updated" -EVENT_ENTRY_STATE_CHANGED = "entry_state_changed" +from esphome.enum import StrEnum + + +class DashboardEvent(StrEnum): + """Dashboard WebSocket event types.""" + + # Server -> Client events (backend sends to frontend) + ENTRY_ADDED = "entry_added" + ENTRY_REMOVED = "entry_removed" + ENTRY_UPDATED = "entry_updated" + ENTRY_STATE_CHANGED = "entry_state_changed" + IMPORTABLE_DEVICE_ADDED = "importable_device_added" + IMPORTABLE_DEVICE_REMOVED = "importable_device_removed" + INITIAL_STATE = "initial_state" # Sent on WebSocket connection + PONG = "pong" # Response to client ping + + # Client -> Server events (frontend sends to backend) + PING = "ping" # WebSocket keepalive from client + REFRESH = "refresh" # Force backend to poll for changes + + MAX_EXECUTOR_WORKERS = 48 diff --git a/esphome/dashboard/core.py b/esphome/dashboard/core.py index 11aa4f3cb5..b9ec56cd00 100644 --- a/esphome/dashboard/core.py +++ b/esphome/dashboard/core.py @@ -13,6 +13,7 @@ from typing import Any from esphome.storage_json import ignored_devices_storage_path from ..zeroconf import DiscoveredImport +from .const import DashboardEvent from .dns import DNSCache from .entries import DashboardEntries from .settings import DashboardSettings @@ -30,7 +31,7 @@ MDNS_BOOTSTRAP_TIME = 7.5 class Event: """Dashboard Event.""" - event_type: str + event_type: DashboardEvent data: dict[str, Any] @@ -39,22 +40,24 @@ class EventBus: def __init__(self) -> None: """Initialize the Dashboard event bus.""" - self._listeners: dict[str, set[Callable[[Event], None]]] = {} + self._listeners: dict[DashboardEvent, set[Callable[[Event], None]]] = {} def async_add_listener( - self, event_type: str, listener: Callable[[Event], None] + self, event_type: DashboardEvent, listener: Callable[[Event], None] ) -> Callable[[], None]: """Add a listener to the event bus.""" self._listeners.setdefault(event_type, set()).add(listener) return partial(self._async_remove_listener, event_type, listener) def _async_remove_listener( - self, event_type: str, listener: Callable[[Event], None] + self, event_type: DashboardEvent, listener: Callable[[Event], None] ) -> None: """Remove a listener from the event bus.""" self._listeners[event_type].discard(listener) - def async_fire(self, event_type: str, event_data: dict[str, Any]) -> None: + def async_fire( + self, event_type: DashboardEvent, event_data: dict[str, Any] + ) -> None: """Fire an event.""" event = Event(event_type, event_data) diff --git a/esphome/dashboard/entries.py b/esphome/dashboard/entries.py index de868fbf2b..95b8a7b2ae 100644 --- a/esphome/dashboard/entries.py +++ b/esphome/dashboard/entries.py @@ -12,13 +12,7 @@ from esphome import const, util from esphome.enum import StrEnum from esphome.storage_json import StorageJSON, ext_storage_path -from .const import ( - DASHBOARD_COMMAND, - EVENT_ENTRY_ADDED, - EVENT_ENTRY_REMOVED, - EVENT_ENTRY_STATE_CHANGED, - EVENT_ENTRY_UPDATED, -) +from .const import DASHBOARD_COMMAND, DashboardEvent from .util.subprocess import async_run_system_command if TYPE_CHECKING: @@ -102,12 +96,12 @@ class DashboardEntries: # "path/to/file.yaml": DashboardEntry, # ... # } - self._entries: dict[str, DashboardEntry] = {} + self._entries: dict[Path, DashboardEntry] = {} self._loaded_entries = False self._update_lock = asyncio.Lock() self._name_to_entry: dict[str, set[DashboardEntry]] = defaultdict(set) - def get(self, path: str) -> DashboardEntry | None: + def get(self, path: Path) -> DashboardEntry | None: """Get an entry by path.""" return self._entries.get(path) @@ -192,7 +186,7 @@ class DashboardEntries: return entry.state = state self._dashboard.bus.async_fire( - EVENT_ENTRY_STATE_CHANGED, {"entry": entry, "state": state} + DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} ) async def async_request_update_entries(self) -> None: @@ -260,22 +254,22 @@ class DashboardEntries: for entry in added: entries[entry.path] = entry name_to_entry[entry.name].add(entry) - bus.async_fire(EVENT_ENTRY_ADDED, {"entry": entry}) + bus.async_fire(DashboardEvent.ENTRY_ADDED, {"entry": entry}) for entry in removed: del entries[entry.path] name_to_entry[entry.name].discard(entry) - bus.async_fire(EVENT_ENTRY_REMOVED, {"entry": entry}) + bus.async_fire(DashboardEvent.ENTRY_REMOVED, {"entry": entry}) for entry in updated: if (original_name := original_names[entry]) != (current_name := entry.name): name_to_entry[original_name].discard(entry) name_to_entry[current_name].add(entry) - bus.async_fire(EVENT_ENTRY_UPDATED, {"entry": entry}) + bus.async_fire(DashboardEvent.ENTRY_UPDATED, {"entry": entry}) - def _get_path_to_cache_key(self) -> dict[str, DashboardCacheKeyType]: + def _get_path_to_cache_key(self) -> dict[Path, DashboardCacheKeyType]: """Return a dict of path to cache key.""" - path_to_cache_key: dict[str, DashboardCacheKeyType] = {} + path_to_cache_key: dict[Path, DashboardCacheKeyType] = {} # # The cache key is (inode, device, mtime, size) # which allows us to avoid locking since it ensures diff --git a/esphome/dashboard/models.py b/esphome/dashboard/models.py new file mode 100644 index 0000000000..47ddddd5ce --- /dev/null +++ b/esphome/dashboard/models.py @@ -0,0 +1,76 @@ +"""Data models and builders for the dashboard.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from esphome.zeroconf import DiscoveredImport + + from .core import ESPHomeDashboard + from .entries import DashboardEntry + + +class ImportableDeviceDict(TypedDict): + """Dictionary representation of an importable device.""" + + name: str + friendly_name: str | None + package_import_url: str + project_name: str + project_version: str + network: str + ignored: bool + + +class ConfiguredDeviceDict(TypedDict, total=False): + """Dictionary representation of a configured device.""" + + name: str + friendly_name: str | None + configuration: str + loaded_integrations: list[str] | None + deployed_version: str | None + current_version: str | None + path: str + comment: str | None + address: str | None + web_port: int | None + target_platform: str | None + + +class DeviceListResponse(TypedDict): + """Response for device list API.""" + + configured: list[ConfiguredDeviceDict] + importable: list[ImportableDeviceDict] + + +def build_importable_device_dict( + dashboard: ESPHomeDashboard, discovered: DiscoveredImport +) -> ImportableDeviceDict: + """Build the importable device dictionary.""" + return ImportableDeviceDict( + name=discovered.device_name, + friendly_name=discovered.friendly_name, + package_import_url=discovered.package_import_url, + project_name=discovered.project_name, + project_version=discovered.project_version, + network=discovered.network, + ignored=discovered.device_name in dashboard.ignored_devices, + ) + + +def build_device_list_response( + dashboard: ESPHomeDashboard, entries: list[DashboardEntry] +) -> DeviceListResponse: + """Build the device list response data.""" + configured = {entry.name for entry in entries} + return DeviceListResponse( + configured=[entry.to_dict() for entry in entries], + importable=[ + build_importable_device_dict(dashboard, res) + for res in dashboard.import_result.values() + if res.device_name not in configured + ], + ) diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py index 989517e1c3..881340ab24 100644 --- a/esphome/dashboard/status/mdns.py +++ b/esphome/dashboard/status/mdns.py @@ -13,10 +13,12 @@ from esphome.zeroconf import ( DashboardBrowser, DashboardImportDiscovery, DashboardStatus, + DiscoveredImport, ) -from ..const import SENTINEL +from ..const import SENTINEL, DashboardEvent from ..entries import DashboardEntry, EntryStateSource, bool_to_entry_state +from ..models import build_importable_device_dict if typing.TYPE_CHECKING: from ..core import ESPHomeDashboard @@ -77,6 +79,20 @@ class MDNSStatus: _LOGGER.debug("Not found in zeroconf cache: %s", resolver_name) return None + def _on_import_update(self, name: str, discovered: DiscoveredImport | None) -> None: + """Handle importable device updates.""" + if discovered is None: + # Device removed + self.dashboard.bus.async_fire( + DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": name} + ) + else: + # Device added + self.dashboard.bus.async_fire( + DashboardEvent.IMPORTABLE_DEVICE_ADDED, + {"device": build_importable_device_dict(self.dashboard, discovered)}, + ) + async def async_refresh_hosts(self) -> None: """Refresh the hosts to track.""" dashboard = self.dashboard @@ -133,7 +149,8 @@ class MDNSStatus: self._async_set_state(entry, result) stat = DashboardStatus(on_update) - imports = DashboardImportDiscovery() + + imports = DashboardImportDiscovery(self._on_import_update) dashboard.import_result = imports.import_state browser = DashboardBrowser( diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index b5601c9e0f..a79c67c3d2 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -4,8 +4,10 @@ import asyncio import base64 import binascii from collections.abc import Callable, Iterable +import contextlib import datetime import functools +from functools import partial import gzip import hashlib import importlib @@ -50,9 +52,10 @@ from esphome.util import get_serial_ports, shlex_quote from esphome.yaml_util import FastestAvailableSafeLoader from ..helpers import write_file -from .const import DASHBOARD_COMMAND -from .core import DASHBOARD, ESPHomeDashboard +from .const import DASHBOARD_COMMAND, DashboardEvent +from .core import DASHBOARD, ESPHomeDashboard, Event from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool +from .models import build_device_list_response from .util.subprocess import async_run_system_command from .util.text import friendly_name_slugify @@ -520,6 +523,243 @@ class EsphomeUpdateAllHandler(EsphomeCommandWebSocket): return [*DASHBOARD_COMMAND, "update-all", settings.config_dir] +# Dashboard polling constants +DASHBOARD_POLL_INTERVAL = 2 # seconds +DASHBOARD_ENTRIES_UPDATE_INTERVAL = 10 # seconds +DASHBOARD_ENTRIES_UPDATE_ITERATIONS = ( + DASHBOARD_ENTRIES_UPDATE_INTERVAL // DASHBOARD_POLL_INTERVAL +) + + +class DashboardSubscriber: + """Manages dashboard event polling task lifecycle based on active subscribers.""" + + def __init__(self) -> None: + """Initialize the dashboard subscriber.""" + self._subscribers: set[DashboardEventsWebSocket] = set() + self._event_loop_task: asyncio.Task | None = None + self._refresh_event: asyncio.Event = asyncio.Event() + + def subscribe(self, subscriber: DashboardEventsWebSocket) -> Callable[[], None]: + """Subscribe to dashboard updates and start event loop if needed.""" + self._subscribers.add(subscriber) + if not self._event_loop_task or self._event_loop_task.done(): + self._event_loop_task = asyncio.create_task(self._event_loop()) + _LOGGER.info("Started dashboard event loop") + return partial(self._unsubscribe, subscriber) + + def _unsubscribe(self, subscriber: DashboardEventsWebSocket) -> None: + """Unsubscribe from dashboard updates and stop event loop if no subscribers.""" + self._subscribers.discard(subscriber) + if ( + not self._subscribers + and self._event_loop_task + and not self._event_loop_task.done() + ): + self._event_loop_task.cancel() + self._event_loop_task = None + _LOGGER.info("Stopped dashboard event loop - no subscribers") + + def request_refresh(self) -> None: + """Signal the polling loop to refresh immediately.""" + self._refresh_event.set() + + async def _event_loop(self) -> None: + """Run the event polling loop while there are subscribers.""" + dashboard = DASHBOARD + entries_update_counter = 0 + + while self._subscribers: + # Signal that we need ping updates (non-blocking) + dashboard.ping_request.set() + if settings.status_use_mqtt: + dashboard.mqtt_ping_request.set() + + # Check if it's time to update entries or if refresh was requested + entries_update_counter += 1 + if ( + entries_update_counter >= DASHBOARD_ENTRIES_UPDATE_ITERATIONS + or self._refresh_event.is_set() + ): + entries_update_counter = 0 + await dashboard.entries.async_request_update_entries() + # Clear the refresh event if it was set + self._refresh_event.clear() + + # Wait for either timeout or refresh event + try: + async with asyncio.timeout(DASHBOARD_POLL_INTERVAL): + await self._refresh_event.wait() + # If we get here, refresh was requested - continue loop immediately + except TimeoutError: + # Normal timeout - continue with regular polling + pass + + +# Global dashboard subscriber instance +DASHBOARD_SUBSCRIBER = DashboardSubscriber() + + +@websocket_class +class DashboardEventsWebSocket(tornado.websocket.WebSocketHandler): + """WebSocket handler for real-time dashboard events.""" + + _event_listeners: list[Callable[[], None]] | None = None + _dashboard_unsubscribe: Callable[[], None] | None = None + + async def get(self, *args: str, **kwargs: str) -> None: + """Handle WebSocket upgrade request.""" + if not is_authenticated(self): + self.set_status(401) + self.finish("Unauthorized") + return + await super().get(*args, **kwargs) + + async def open(self, *args: str, **kwargs: str) -> None: # pylint: disable=invalid-overridden-method + """Handle new WebSocket connection.""" + # Ensure messages are sent immediately to avoid + # a 200-500ms delay when nodelay is not set. + self.set_nodelay(True) + + # Update entries first + await DASHBOARD.entries.async_request_update_entries() + # Send initial state + self._send_initial_state() + # Subscribe to events + self._subscribe_to_events() + # Subscribe to dashboard updates + self._dashboard_unsubscribe = DASHBOARD_SUBSCRIBER.subscribe(self) + _LOGGER.debug("Dashboard status WebSocket opened") + + def _send_initial_state(self) -> None: + """Send initial device list and ping status.""" + entries = DASHBOARD.entries.async_all() + + # Send initial state + self._safe_send_message( + { + "event": DashboardEvent.INITIAL_STATE, + "data": { + "devices": build_device_list_response(DASHBOARD, entries), + "ping": { + entry.filename: entry_state_to_bool(entry.state) + for entry in entries + }, + }, + } + ) + + def _subscribe_to_events(self) -> None: + """Subscribe to dashboard events.""" + async_add_listener = DASHBOARD.bus.async_add_listener + # Subscribe to all events + self._event_listeners = [ + async_add_listener( + DashboardEvent.ENTRY_STATE_CHANGED, self._on_entry_state_changed + ), + async_add_listener( + DashboardEvent.ENTRY_ADDED, + self._make_entry_handler(DashboardEvent.ENTRY_ADDED), + ), + async_add_listener( + DashboardEvent.ENTRY_REMOVED, + self._make_entry_handler(DashboardEvent.ENTRY_REMOVED), + ), + async_add_listener( + DashboardEvent.ENTRY_UPDATED, + self._make_entry_handler(DashboardEvent.ENTRY_UPDATED), + ), + async_add_listener( + DashboardEvent.IMPORTABLE_DEVICE_ADDED, self._on_importable_added + ), + async_add_listener( + DashboardEvent.IMPORTABLE_DEVICE_REMOVED, + self._on_importable_removed, + ), + ] + + def _on_entry_state_changed(self, event: Event) -> None: + """Handle entry state change event.""" + entry = event.data["entry"] + state = event.data["state"] + self._safe_send_message( + { + "event": DashboardEvent.ENTRY_STATE_CHANGED, + "data": { + "filename": entry.filename, + "name": entry.name, + "state": entry_state_to_bool(state), + }, + } + ) + + def _make_entry_handler( + self, event_type: DashboardEvent + ) -> Callable[[Event], None]: + """Create an entry event handler.""" + + def handler(event: Event) -> None: + self._safe_send_message( + {"event": event_type, "data": {"device": event.data["entry"].to_dict()}} + ) + + return handler + + def _on_importable_added(self, event: Event) -> None: + """Handle importable device added event.""" + # Don't send if device is already configured + device_name = event.data.get("device", {}).get("name") + if device_name and DASHBOARD.entries.get_by_name(device_name): + return + self._safe_send_message( + {"event": DashboardEvent.IMPORTABLE_DEVICE_ADDED, "data": event.data} + ) + + def _on_importable_removed(self, event: Event) -> None: + """Handle importable device removed event.""" + self._safe_send_message( + {"event": DashboardEvent.IMPORTABLE_DEVICE_REMOVED, "data": event.data} + ) + + def _safe_send_message(self, message: dict[str, Any]) -> None: + """Send a message to the WebSocket client, ignoring closed errors.""" + with contextlib.suppress(tornado.websocket.WebSocketClosedError): + self.write_message(json.dumps(message)) + + def on_message(self, message: str) -> None: + """Handle incoming WebSocket messages.""" + _LOGGER.debug("WebSocket received message: %s", message) + try: + data = json.loads(message) + except json.JSONDecodeError as err: + _LOGGER.debug("Failed to parse WebSocket message: %s", err) + return + + event = data.get("event") + _LOGGER.debug("WebSocket message event: %s", event) + if event == DashboardEvent.PING: + # Send pong response for client ping + _LOGGER.debug("Received client ping, sending pong") + self._safe_send_message({"event": DashboardEvent.PONG}) + elif event == DashboardEvent.REFRESH: + # Signal the polling loop to refresh immediately + _LOGGER.debug("Received refresh request, signaling polling loop") + DASHBOARD_SUBSCRIBER.request_refresh() + + def on_close(self) -> None: + """Handle WebSocket close.""" + # Unsubscribe from dashboard updates + if self._dashboard_unsubscribe: + self._dashboard_unsubscribe() + self._dashboard_unsubscribe = None + + # Unsubscribe from events + for remove_listener in self._event_listeners or []: + remove_listener() + + _LOGGER.debug("Dashboard status WebSocket closed") + + class SerialPortRequestHandler(BaseHandler): @authenticated async def get(self) -> None: @@ -874,28 +1114,7 @@ class ListDevicesHandler(BaseHandler): await dashboard.entries.async_request_update_entries() entries = dashboard.entries.async_all() self.set_header("content-type", "application/json") - configured = {entry.name for entry in entries} - - self.write( - json.dumps( - { - "configured": [entry.to_dict() for entry in entries], - "importable": [ - { - "name": res.device_name, - "friendly_name": res.friendly_name, - "package_import_url": res.package_import_url, - "project_name": res.project_name, - "project_version": res.project_version, - "network": res.network, - "ignored": res.device_name in dashboard.ignored_devices, - } - for res in dashboard.import_result.values() - if res.device_name not in configured - ], - } - ) - ) + self.write(json.dumps(build_device_list_response(dashboard, entries))) class MainRequestHandler(BaseHandler): @@ -1351,6 +1570,7 @@ def make_app(debug=get_bool_env(ENV_DEV)) -> tornado.web.Application: (f"{rel}wizard", WizardRequestHandler), (f"{rel}static/(.*)", StaticFileHandler, {"path": get_static_path()}), (f"{rel}devices", ListDevicesHandler), + (f"{rel}events", DashboardEventsWebSocket), (f"{rel}import", ImportRequestHandler), (f"{rel}secret_keys", SecretKeysRequestHandler), (f"{rel}json-config", JsonConfigRequestHandler), diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index fa496b3488..dc4ca77eb4 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -68,8 +68,11 @@ class DashboardBrowser(AsyncServiceBrowser): class DashboardImportDiscovery: - def __init__(self) -> None: + def __init__( + self, on_update: Callable[[str, DiscoveredImport | None], None] | None = None + ) -> None: self.import_state: dict[str, DiscoveredImport] = {} + self.on_update = on_update def browser_callback( self, @@ -85,7 +88,9 @@ class DashboardImportDiscovery: state_change, ) if state_change == ServiceStateChange.Removed: - self.import_state.pop(name, None) + removed = self.import_state.pop(name, None) + if removed and self.on_update: + self.on_update(name, None) return if state_change == ServiceStateChange.Updated and name not in self.import_state: @@ -139,7 +144,7 @@ class DashboardImportDiscovery: if friendly_name is not None: friendly_name = friendly_name.decode() - self.import_state[name] = DiscoveredImport( + discovered = DiscoveredImport( friendly_name=friendly_name, device_name=node_name, package_import_url=import_url, @@ -147,6 +152,10 @@ class DashboardImportDiscovery: project_version=project_version, network=network, ) + is_new = name not in self.import_state + self.import_state[name] = discovered + if is_new and self.on_update: + self.on_update(name, discovered) def update_device_mdns(self, node_name: str, version: str): storage_path = ext_storage_path(node_name + ".yaml") diff --git a/tests/dashboard/conftest.py b/tests/dashboard/conftest.py index 358be1bf5d..f95adef749 100644 --- a/tests/dashboard/conftest.py +++ b/tests/dashboard/conftest.py @@ -2,20 +2,42 @@ from __future__ import annotations -from unittest.mock import Mock +from pathlib import Path +from unittest.mock import MagicMock, Mock import pytest +import pytest_asyncio from esphome.dashboard.core import ESPHomeDashboard +from esphome.dashboard.entries import DashboardEntries @pytest.fixture -def mock_dashboard() -> Mock: +def mock_settings(tmp_path: Path) -> MagicMock: + """Create mock dashboard settings.""" + settings = MagicMock() + settings.config_dir = str(tmp_path) + settings.absolute_config_dir = tmp_path + return settings + + +@pytest.fixture +def mock_dashboard(mock_settings: MagicMock) -> Mock: """Create a mock dashboard.""" dashboard = Mock(spec=ESPHomeDashboard) + dashboard.settings = mock_settings dashboard.entries = Mock() dashboard.entries.async_all.return_value = [] dashboard.stop_event = Mock() dashboard.stop_event.is_set.return_value = True dashboard.ping_request = Mock() + dashboard.ignored_devices = set() + dashboard.bus = Mock() + dashboard.bus.async_fire = Mock() return dashboard + + +@pytest_asyncio.fixture +async def dashboard_entries(mock_dashboard: Mock) -> DashboardEntries: + """Create a DashboardEntries instance for testing.""" + return DashboardEntries(mock_dashboard) diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py index 7130c2c73a..56c6d254cf 100644 --- a/tests/dashboard/status/test_mdns.py +++ b/tests/dashboard/status/test_mdns.py @@ -8,7 +8,9 @@ import pytest import pytest_asyncio from zeroconf import AddressResolver, IPVersion +from esphome.dashboard.const import DashboardEvent from esphome.dashboard.status.mdns import MDNSStatus +from esphome.zeroconf import DiscoveredImport @pytest_asyncio.fixture @@ -166,3 +168,73 @@ async def test_async_setup_failure(mock_dashboard: Mock) -> None: result = mdns_status.async_setup() assert result is False assert mdns_status.aiozc is None + + +@pytest.mark.asyncio +async def test_on_import_update_device_added(mdns_status: MDNSStatus) -> None: + """Test _on_import_update when a device is added.""" + # Create a DiscoveredImport object + discovered = DiscoveredImport( + device_name="test_device", + friendly_name="Test Device", + package_import_url="https://example.com/package", + project_name="test_project", + project_version="1.0.0", + network="wifi", + ) + + # Call _on_import_update with a device + mdns_status._on_import_update("test_device", discovered) + + # Should fire IMPORTABLE_DEVICE_ADDED event + mock_dashboard = mdns_status.dashboard + mock_dashboard.bus.async_fire.assert_called_once() + call_args = mock_dashboard.bus.async_fire.call_args + assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED + assert "device" in call_args[0][1] + device_data = call_args[0][1]["device"] + assert device_data["name"] == "test_device" + assert device_data["friendly_name"] == "Test Device" + assert device_data["project_name"] == "test_project" + assert device_data["ignored"] is False + + +@pytest.mark.asyncio +async def test_on_import_update_device_ignored(mdns_status: MDNSStatus) -> None: + """Test _on_import_update when a device is ignored.""" + # Add device to ignored list + mdns_status.dashboard.ignored_devices.add("ignored_device") + + # Create a DiscoveredImport object for ignored device + discovered = DiscoveredImport( + device_name="ignored_device", + friendly_name="Ignored Device", + package_import_url="https://example.com/package", + project_name="test_project", + project_version="1.0.0", + network="ethernet", + ) + + # Call _on_import_update with an ignored device + mdns_status._on_import_update("ignored_device", discovered) + + # Should fire IMPORTABLE_DEVICE_ADDED event with ignored=True + mock_dashboard = mdns_status.dashboard + mock_dashboard.bus.async_fire.assert_called_once() + call_args = mock_dashboard.bus.async_fire.call_args + assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED + device_data = call_args[0][1]["device"] + assert device_data["name"] == "ignored_device" + assert device_data["ignored"] is True + + +@pytest.mark.asyncio +async def test_on_import_update_device_removed(mdns_status: MDNSStatus) -> None: + """Test _on_import_update when a device is removed.""" + # Call _on_import_update with None (device removed) + mdns_status._on_import_update("removed_device", None) + + # Should fire IMPORTABLE_DEVICE_REMOVED event + mdns_status.dashboard.bus.async_fire.assert_called_once_with( + DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": "removed_device"} + ) diff --git a/tests/dashboard/test_entries.py b/tests/dashboard/test_entries.py index 2f6d5667b7..9a3a776b28 100644 --- a/tests/dashboard/test_entries.py +++ b/tests/dashboard/test_entries.py @@ -2,14 +2,15 @@ from __future__ import annotations +import os from pathlib import Path import tempfile -from unittest.mock import MagicMock +from unittest.mock import Mock import pytest -import pytest_asyncio from esphome.core import CORE +from esphome.dashboard.const import DashboardEvent from esphome.dashboard.entries import DashboardEntries, DashboardEntry @@ -27,21 +28,6 @@ def setup_core(): CORE.reset() -@pytest.fixture -def mock_settings() -> MagicMock: - """Create mock dashboard settings.""" - settings = MagicMock() - settings.config_dir = "/test/config" - settings.absolute_config_dir = Path("/test/config") - return settings - - -@pytest_asyncio.fixture -async def dashboard_entries(mock_settings: MagicMock) -> DashboardEntries: - """Create a DashboardEntries instance for testing.""" - return DashboardEntries(mock_settings) - - def test_dashboard_entry_path_initialization() -> None: """Test DashboardEntry initializes with path correctly.""" test_path = Path("/test/config/device.yaml") @@ -78,15 +64,24 @@ def test_dashboard_entry_path_with_relative_path() -> None: @pytest.mark.asyncio async def test_dashboard_entries_get_by_path( - dashboard_entries: DashboardEntries, + dashboard_entries: DashboardEntries, tmp_path: Path ) -> None: """Test getting entry by path.""" - test_path = Path("/test/config/device.yaml") - entry = DashboardEntry(test_path, create_cache_key()) + # Create a test file + test_file = tmp_path / "device.yaml" + test_file.write_text("test config") - dashboard_entries._entries[str(test_path)] = entry + # Update entries to load the file + await dashboard_entries.async_update_entries() - result = dashboard_entries.get(str(test_path)) + # Verify the entry was loaded + all_entries = dashboard_entries.async_all() + assert len(all_entries) == 1 + entry = all_entries[0] + assert entry.path == test_file + + # Also verify get() works with Path + result = dashboard_entries.get(test_file) assert result == entry @@ -101,45 +96,54 @@ async def test_dashboard_entries_get_nonexistent_path( @pytest.mark.asyncio async def test_dashboard_entries_path_normalization( - dashboard_entries: DashboardEntries, + dashboard_entries: DashboardEntries, tmp_path: Path ) -> None: """Test that paths are handled consistently.""" - path1 = Path("/test/config/device.yaml") + # Create a test file + test_file = tmp_path / "device.yaml" + test_file.write_text("test config") - entry = DashboardEntry(path1, create_cache_key()) - dashboard_entries._entries[str(path1)] = entry + # Update entries to load the file + await dashboard_entries.async_update_entries() - result = dashboard_entries.get(str(path1)) - assert result == entry + # Get the entry by path + result = dashboard_entries.get(test_file) + assert result is not None @pytest.mark.asyncio async def test_dashboard_entries_path_with_spaces( - dashboard_entries: DashboardEntries, + dashboard_entries: DashboardEntries, tmp_path: Path ) -> None: """Test handling paths with spaces.""" - test_path = Path("/test/config/my device.yaml") - entry = DashboardEntry(test_path, create_cache_key()) + # Create a test file with spaces in name + test_file = tmp_path / "my device.yaml" + test_file.write_text("test config") - dashboard_entries._entries[str(test_path)] = entry + # Update entries to load the file + await dashboard_entries.async_update_entries() - result = dashboard_entries.get(str(test_path)) - assert result == entry - assert result.path == test_path + # Get the entry by path + result = dashboard_entries.get(test_file) + assert result is not None + assert result.path == test_file @pytest.mark.asyncio async def test_dashboard_entries_path_with_special_chars( - dashboard_entries: DashboardEntries, + dashboard_entries: DashboardEntries, tmp_path: Path ) -> None: """Test handling paths with special characters.""" - test_path = Path("/test/config/device-01_test.yaml") - entry = DashboardEntry(test_path, create_cache_key()) + # Create a test file with special characters + test_file = tmp_path / "device-01_test.yaml" + test_file.write_text("test config") - dashboard_entries._entries[str(test_path)] = entry + # Update entries to load the file + await dashboard_entries.async_update_entries() - result = dashboard_entries.get(str(test_path)) - assert result == entry + # Get the entry by path + result = dashboard_entries.get(test_file) + assert result is not None def test_dashboard_entries_windows_path() -> None: @@ -154,22 +158,25 @@ def test_dashboard_entries_windows_path() -> None: @pytest.mark.asyncio async def test_dashboard_entries_path_to_cache_key_mapping( - dashboard_entries: DashboardEntries, + dashboard_entries: DashboardEntries, tmp_path: Path ) -> None: """Test internal entries storage with paths and cache keys.""" - path1 = Path("/test/config/device1.yaml") - path2 = Path("/test/config/device2.yaml") + # Create test files + file1 = tmp_path / "device1.yaml" + file2 = tmp_path / "device2.yaml" + file1.write_text("test config 1") + file2.write_text("test config 2") - entry1 = DashboardEntry(path1, create_cache_key()) - entry2 = DashboardEntry(path2, (1, 1, 1.0, 1)) + # Update entries to load the files + await dashboard_entries.async_update_entries() - dashboard_entries._entries[str(path1)] = entry1 - dashboard_entries._entries[str(path2)] = entry2 + # Get entries and verify they have different cache keys + entry1 = dashboard_entries.get(file1) + entry2 = dashboard_entries.get(file2) - assert str(path1) in dashboard_entries._entries - assert str(path2) in dashboard_entries._entries - assert dashboard_entries._entries[str(path1)].cache_key == create_cache_key() - assert dashboard_entries._entries[str(path2)].cache_key == (1, 1, 1.0, 1) + assert entry1 is not None + assert entry2 is not None + assert entry1.cache_key != entry2.cache_key def test_dashboard_entry_path_property() -> None: @@ -183,21 +190,99 @@ def test_dashboard_entry_path_property() -> None: @pytest.mark.asyncio async def test_dashboard_entries_all_returns_entries_with_paths( - dashboard_entries: DashboardEntries, + dashboard_entries: DashboardEntries, tmp_path: Path ) -> None: """Test that all() returns entries with their paths intact.""" - paths = [ - Path("/test/config/device1.yaml"), - Path("/test/config/device2.yaml"), - Path("/test/config/subfolder/device3.yaml"), + # Create test files + files = [ + tmp_path / "device1.yaml", + tmp_path / "device2.yaml", + tmp_path / "device3.yaml", ] - for path in paths: - entry = DashboardEntry(path, create_cache_key()) - dashboard_entries._entries[str(path)] = entry + for file in files: + file.write_text("test config") + + # Update entries to load the files + await dashboard_entries.async_update_entries() all_entries = dashboard_entries.async_all() - assert len(all_entries) == len(paths) + assert len(all_entries) == len(files) retrieved_paths = [entry.path for entry in all_entries] - assert set(retrieved_paths) == set(paths) + assert set(retrieved_paths) == set(files) + + +@pytest.mark.asyncio +async def test_async_update_entries_removed_path( + dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path +) -> None: + """Test that removed files trigger ENTRY_REMOVED event.""" + + # Create a test file + test_file = tmp_path / "device.yaml" + test_file.write_text("test config") + + # First update to add the entry + await dashboard_entries.async_update_entries() + + # Verify entry was added + all_entries = dashboard_entries.async_all() + assert len(all_entries) == 1 + entry = all_entries[0] + + # Delete the file + test_file.unlink() + + # Second update to detect removal + await dashboard_entries.async_update_entries() + + # Verify entry was removed + all_entries = dashboard_entries.async_all() + assert len(all_entries) == 0 + + # Verify ENTRY_REMOVED event was fired + mock_dashboard.bus.async_fire.assert_any_call( + DashboardEvent.ENTRY_REMOVED, {"entry": entry} + ) + + +@pytest.mark.asyncio +async def test_async_update_entries_updated_path( + dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path +) -> None: + """Test that modified files trigger ENTRY_UPDATED event.""" + + # Create a test file + test_file = tmp_path / "device.yaml" + test_file.write_text("test config") + + # First update to add the entry + await dashboard_entries.async_update_entries() + + # Verify entry was added + all_entries = dashboard_entries.async_all() + assert len(all_entries) == 1 + entry = all_entries[0] + original_cache_key = entry.cache_key + + # Modify the file to change its mtime + test_file.write_text("updated config") + # Explicitly change the mtime to ensure it's different + stat = test_file.stat() + os.utime(test_file, (stat.st_atime, stat.st_mtime + 1)) + + # Second update to detect modification + await dashboard_entries.async_update_entries() + + # Verify entry is still there with updated cache key + all_entries = dashboard_entries.async_all() + assert len(all_entries) == 1 + updated_entry = all_entries[0] + assert updated_entry == entry # Same entry object + assert updated_entry.cache_key != original_cache_key # But cache key updated + + # Verify ENTRY_UPDATED event was fired + mock_dashboard.bus.async_fire.assert_any_call( + DashboardEvent.ENTRY_UPDATED, {"entry": entry} + ) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 14a7d7b136..5bbe7e78fc 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -2,11 +2,12 @@ from __future__ import annotations import asyncio from collections.abc import Generator +from contextlib import asynccontextmanager import gzip import json import os from pathlib import Path -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest import pytest_asyncio @@ -14,9 +15,19 @@ from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse from tornado.httpserver import HTTPServer from tornado.ioloop import IOLoop from tornado.testing import bind_unused_port +from tornado.websocket import WebSocketClientConnection, websocket_connect from esphome.dashboard import web_server +from esphome.dashboard.const import DashboardEvent from esphome.dashboard.core import DASHBOARD +from esphome.dashboard.entries import ( + DashboardEntry, + EntryStateSource, + bool_to_entry_state, +) +from esphome.dashboard.models import build_importable_device_dict +from esphome.dashboard.web_server import DashboardSubscriber +from esphome.zeroconf import DiscoveredImport from .common import get_fixture_path @@ -126,6 +137,33 @@ async def dashboard() -> DashboardTestHelper: io_loop.close() +@asynccontextmanager +async def websocket_connection(dashboard: DashboardTestHelper): + """Async context manager for WebSocket connections.""" + url = f"ws://127.0.0.1:{dashboard.port}/events" + ws = await websocket_connect(url) + try: + yield ws + finally: + if ws: + ws.close() + + +@pytest_asyncio.fixture +async def websocket_client(dashboard: DashboardTestHelper) -> WebSocketClientConnection: + """Create a WebSocket connection for testing.""" + url = f"ws://127.0.0.1:{dashboard.port}/events" + ws = await websocket_connect(url) + + # Read and discard initial state message + await ws.read_message() + + yield ws + + if ws: + ws.close() + + @pytest.mark.asyncio async def test_main_page(dashboard: DashboardTestHelper) -> None: response = await dashboard.fetch("/") @@ -810,3 +848,457 @@ def test_build_cache_arguments_name_without_address(mock_dashboard: Mock) -> Non mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( "my-device.local" ) + + +@pytest.mark.asyncio +async def test_websocket_connection_initial_state( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket connection and initial state.""" + async with websocket_connection(dashboard) as ws: + # Should receive initial state with configured and importable devices + msg = await ws.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "initial_state" + assert "devices" in data["data"] + assert "configured" in data["data"]["devices"] + assert "importable" in data["data"]["devices"] + + # Check configured devices + configured = data["data"]["devices"]["configured"] + assert len(configured) > 0 + assert configured[0]["name"] == "pico" # From test fixtures + + +@pytest.mark.asyncio +async def test_websocket_ping_pong( + dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection +) -> None: + """Test WebSocket ping/pong mechanism.""" + # Send ping + await websocket_client.write_message(json.dumps({"event": "ping"})) + + # Should receive pong + msg = await websocket_client.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "pong" + + +@pytest.mark.asyncio +async def test_websocket_invalid_json( + dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection +) -> None: + """Test WebSocket handling of invalid JSON.""" + # Send invalid JSON + await websocket_client.write_message("not valid json {]") + + # Send a valid ping to verify connection is still alive + await websocket_client.write_message(json.dumps({"event": "ping"})) + + # Should receive pong, confirming the connection wasn't closed by invalid JSON + msg = await websocket_client.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "pong" + + +@pytest.mark.asyncio +async def test_websocket_authentication_required( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket authentication when auth is required.""" + with patch( + "esphome.dashboard.web_server.is_authenticated" + ) as mock_is_authenticated: + mock_is_authenticated.return_value = False + + # Try to connect - should be rejected with 401 + url = f"ws://127.0.0.1:{dashboard.port}/events" + with pytest.raises(HTTPClientError) as exc_info: + await websocket_connect(url) + # Should get HTTP 401 Unauthorized + assert exc_info.value.code == 401 + + +@pytest.mark.asyncio +async def test_websocket_authentication_not_required( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket connection when no auth is required.""" + with patch( + "esphome.dashboard.web_server.is_authenticated" + ) as mock_is_authenticated: + mock_is_authenticated.return_value = True + + # Should be able to connect successfully + async with websocket_connection(dashboard) as ws: + msg = await ws.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "initial_state" + + +@pytest.mark.asyncio +async def test_websocket_entry_state_changed( + dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection +) -> None: + """Test WebSocket entry state changed event.""" + # Simulate entry state change + entry = DASHBOARD.entries.async_all()[0] + state = bool_to_entry_state(True, EntryStateSource.MDNS) + DASHBOARD.bus.async_fire( + DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} + ) + + # Should receive state change event + msg = await websocket_client.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "entry_state_changed" + assert data["data"]["filename"] == entry.filename + assert data["data"]["name"] == entry.name + assert data["data"]["state"] is True + + +@pytest.mark.asyncio +async def test_websocket_entry_added( + dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection +) -> None: + """Test WebSocket entry added event.""" + # Create a mock entry + mock_entry = Mock(spec=DashboardEntry) + mock_entry.filename = "test.yaml" + mock_entry.name = "test_device" + mock_entry.to_dict.return_value = { + "name": "test_device", + "filename": "test.yaml", + "configuration": "test.yaml", + } + + # Simulate entry added + DASHBOARD.bus.async_fire(DashboardEvent.ENTRY_ADDED, {"entry": mock_entry}) + + # Should receive entry added event + msg = await websocket_client.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "entry_added" + assert data["data"]["device"]["name"] == "test_device" + assert data["data"]["device"]["filename"] == "test.yaml" + + +@pytest.mark.asyncio +async def test_websocket_entry_removed( + dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection +) -> None: + """Test WebSocket entry removed event.""" + # Create a mock entry + mock_entry = Mock(spec=DashboardEntry) + mock_entry.filename = "removed.yaml" + mock_entry.name = "removed_device" + mock_entry.to_dict.return_value = { + "name": "removed_device", + "filename": "removed.yaml", + "configuration": "removed.yaml", + } + + # Simulate entry removed + DASHBOARD.bus.async_fire(DashboardEvent.ENTRY_REMOVED, {"entry": mock_entry}) + + # Should receive entry removed event + msg = await websocket_client.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "entry_removed" + assert data["data"]["device"]["name"] == "removed_device" + assert data["data"]["device"]["filename"] == "removed.yaml" + + +@pytest.mark.asyncio +async def test_websocket_importable_device_added( + dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection +) -> None: + """Test WebSocket importable device added event with real DiscoveredImport.""" + # Create a real DiscoveredImport object + discovered = DiscoveredImport( + device_name="new_import_device", + friendly_name="New Import Device", + package_import_url="https://example.com/package", + project_name="test_project", + project_version="1.0.0", + network="wifi", + ) + + # Directly fire the event as the mDNS system would + device_dict = build_importable_device_dict(DASHBOARD, discovered) + DASHBOARD.bus.async_fire( + DashboardEvent.IMPORTABLE_DEVICE_ADDED, {"device": device_dict} + ) + + # Should receive importable device added event + msg = await websocket_client.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "importable_device_added" + assert data["data"]["device"]["name"] == "new_import_device" + assert data["data"]["device"]["friendly_name"] == "New Import Device" + assert data["data"]["device"]["project_name"] == "test_project" + assert data["data"]["device"]["network"] == "wifi" + assert data["data"]["device"]["ignored"] is False + + +@pytest.mark.asyncio +async def test_websocket_importable_device_added_ignored( + dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection +) -> None: + """Test WebSocket importable device added event for ignored device.""" + # Add device to ignored list + DASHBOARD.ignored_devices.add("ignored_device") + + # Create a real DiscoveredImport object + discovered = DiscoveredImport( + device_name="ignored_device", + friendly_name="Ignored Device", + package_import_url="https://example.com/package", + project_name="test_project", + project_version="1.0.0", + network="ethernet", + ) + + # Directly fire the event as the mDNS system would + device_dict = build_importable_device_dict(DASHBOARD, discovered) + DASHBOARD.bus.async_fire( + DashboardEvent.IMPORTABLE_DEVICE_ADDED, {"device": device_dict} + ) + + # Should receive importable device added event with ignored=True + msg = await websocket_client.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "importable_device_added" + assert data["data"]["device"]["name"] == "ignored_device" + assert data["data"]["device"]["friendly_name"] == "Ignored Device" + assert data["data"]["device"]["network"] == "ethernet" + assert data["data"]["device"]["ignored"] is True + + +@pytest.mark.asyncio +async def test_websocket_importable_device_removed( + dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection +) -> None: + """Test WebSocket importable device removed event.""" + # Simulate importable device removed + DASHBOARD.bus.async_fire( + DashboardEvent.IMPORTABLE_DEVICE_REMOVED, + {"name": "removed_import_device"}, + ) + + # Should receive importable device removed event + msg = await websocket_client.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "importable_device_removed" + assert data["data"]["name"] == "removed_import_device" + + +@pytest.mark.asyncio +async def test_websocket_importable_device_already_configured( + dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection +) -> None: + """Test that importable device event is not sent if device is already configured.""" + # Get an existing configured device name + existing_entry = DASHBOARD.entries.async_all()[0] + + # Simulate importable device added with same name as configured device + DASHBOARD.bus.async_fire( + DashboardEvent.IMPORTABLE_DEVICE_ADDED, + { + "device": { + "name": existing_entry.name, + "friendly_name": "Should Not Be Sent", + "package_import_url": "https://example.com/package", + "project_name": "test_project", + "project_version": "1.0.0", + "network": "wifi", + } + }, + ) + + # Send a ping to ensure connection is still alive + await websocket_client.write_message(json.dumps({"event": "ping"})) + + # Should only receive pong, not the importable device event + msg = await websocket_client.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "pong" + + +@pytest.mark.asyncio +async def test_websocket_multiple_connections(dashboard: DashboardTestHelper) -> None: + """Test multiple WebSocket connections.""" + async with ( + websocket_connection(dashboard) as ws1, + websocket_connection(dashboard) as ws2, + ): + # Both should receive initial state + msg1 = await ws1.read_message() + assert msg1 is not None + data1 = json.loads(msg1) + assert data1["event"] == "initial_state" + + msg2 = await ws2.read_message() + assert msg2 is not None + data2 = json.loads(msg2) + assert data2["event"] == "initial_state" + + # Fire an event - both should receive it + entry = DASHBOARD.entries.async_all()[0] + state = bool_to_entry_state(False, EntryStateSource.MDNS) + DASHBOARD.bus.async_fire( + DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} + ) + + msg1 = await ws1.read_message() + assert msg1 is not None + data1 = json.loads(msg1) + assert data1["event"] == "entry_state_changed" + + msg2 = await ws2.read_message() + assert msg2 is not None + data2 = json.loads(msg2) + assert data2["event"] == "entry_state_changed" + + +@pytest.mark.asyncio +async def test_dashboard_subscriber_lifecycle(dashboard: DashboardTestHelper) -> None: + """Test DashboardSubscriber lifecycle.""" + subscriber = DashboardSubscriber() + + # Initially no subscribers + assert len(subscriber._subscribers) == 0 + assert subscriber._event_loop_task is None + + # Add a subscriber + mock_websocket = Mock() + unsubscribe = subscriber.subscribe(mock_websocket) + + # Should have started the event loop task + assert len(subscriber._subscribers) == 1 + assert subscriber._event_loop_task is not None + + # Unsubscribe + unsubscribe() + + # Should have stopped the task + assert len(subscriber._subscribers) == 0 + + +@pytest.mark.asyncio +async def test_dashboard_subscriber_entries_update_interval( + dashboard: DashboardTestHelper, +) -> None: + """Test DashboardSubscriber entries update interval.""" + # Patch the constants to make the test run faster + with ( + patch("esphome.dashboard.web_server.DASHBOARD_POLL_INTERVAL", 0.01), + patch("esphome.dashboard.web_server.DASHBOARD_ENTRIES_UPDATE_ITERATIONS", 2), + patch("esphome.dashboard.web_server.settings") as mock_settings, + patch("esphome.dashboard.web_server.DASHBOARD") as mock_dashboard, + ): + mock_settings.status_use_mqtt = False + + # Mock dashboard dependencies + mock_dashboard.ping_request = Mock() + mock_dashboard.ping_request.set = Mock() + mock_dashboard.entries = Mock() + mock_dashboard.entries.async_request_update_entries = Mock() + + subscriber = DashboardSubscriber() + mock_websocket = Mock() + + # Subscribe to start the event loop + unsubscribe = subscriber.subscribe(mock_websocket) + + # Wait for a few iterations to ensure entries update is called + await asyncio.sleep(0.05) # Should be enough for 2+ iterations + + # Unsubscribe to stop the task + unsubscribe() + + # Verify entries update was called + assert mock_dashboard.entries.async_request_update_entries.call_count >= 1 + # Verify ping request was set multiple times + assert mock_dashboard.ping_request.set.call_count >= 2 + + +@pytest.mark.asyncio +async def test_websocket_refresh_command( + dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection +) -> None: + """Test WebSocket refresh command triggers dashboard update.""" + with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber: + mock_subscriber.request_refresh = Mock() + + # Send refresh command + await websocket_client.write_message(json.dumps({"event": "refresh"})) + + # Give it a moment to process + await asyncio.sleep(0.01) + + # Verify request_refresh was called + mock_subscriber.request_refresh.assert_called_once() + + +@pytest.mark.asyncio +async def test_dashboard_subscriber_refresh_event( + dashboard: DashboardTestHelper, +) -> None: + """Test DashboardSubscriber refresh event triggers immediate update.""" + # Patch the constants to make the test run faster + with ( + patch( + "esphome.dashboard.web_server.DASHBOARD_POLL_INTERVAL", 1.0 + ), # Long timeout + patch( + "esphome.dashboard.web_server.DASHBOARD_ENTRIES_UPDATE_ITERATIONS", 100 + ), # Won't reach naturally + patch("esphome.dashboard.web_server.settings") as mock_settings, + patch("esphome.dashboard.web_server.DASHBOARD") as mock_dashboard, + ): + mock_settings.status_use_mqtt = False + + # Mock dashboard dependencies + mock_dashboard.ping_request = Mock() + mock_dashboard.ping_request.set = Mock() + mock_dashboard.entries = Mock() + mock_dashboard.entries.async_request_update_entries = AsyncMock() + + subscriber = DashboardSubscriber() + mock_websocket = Mock() + + # Subscribe to start the event loop + unsubscribe = subscriber.subscribe(mock_websocket) + + # Wait a bit to ensure loop is running + await asyncio.sleep(0.01) + + # Verify entries update hasn't been called yet (iterations not reached) + assert mock_dashboard.entries.async_request_update_entries.call_count == 0 + + # Request refresh + subscriber.request_refresh() + + # Wait for the refresh to be processed + await asyncio.sleep(0.01) + + # Now entries update should have been called + assert mock_dashboard.entries.async_request_update_entries.call_count == 1 + + # Unsubscribe to stop the task + unsubscribe() + + # Give it a moment to clean up + await asyncio.sleep(0.01) From f5e85a424f3c28a437e1620760956e443f7b32f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 08:29:49 +1300 Subject: [PATCH 1564/1626] Bump docker/login-action from 3.5.0 to 3.6.0 in the docker-actions group (#10943) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d003df5ea..2b3b3bdc1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,12 +102,12 @@ jobs: uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Log in to docker hub - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -182,13 +182,13 @@ jobs: - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: registry: ghcr.io username: ${{ github.actor }} From 2a915e4efd5119769e376390e6fbbd61961c3a1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Sep 2025 14:42:42 -0500 Subject: [PATCH 1565/1626] [deep_sleep] esp32 fixes to align with variant support (#10929) --- esphome/components/deep_sleep/__init__.py | 11 ++- .../deep_sleep/deep_sleep_component.h | 24 +++--- .../deep_sleep/deep_sleep_esp32.cpp | 78 ++++++++++++------- .../deep_sleep/common-esp32-all.yaml | 14 ++++ .../deep_sleep/common-esp32-ext1.yaml | 12 +++ .../deep_sleep/test.esp32-c6-idf.yaml | 2 +- .../components/deep_sleep/test.esp32-idf.yaml | 2 +- .../deep_sleep/test.esp32-s2-idf.yaml | 2 +- .../deep_sleep/test.esp32-s3-idf.yaml | 2 +- 9 files changed, 103 insertions(+), 44 deletions(-) create mode 100644 tests/components/deep_sleep/common-esp32-all.yaml create mode 100644 tests/components/deep_sleep/common-esp32-ext1.yaml diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 05ae60239d..19fb726016 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -197,7 +197,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ESP32_EXT1_WAKEUP): cv.All( cv.only_on_esp32, esp32.only_on_variant( - unsupported=[VARIANT_ESP32C3], msg_prefix="Wakeup from ext1" + unsupported=[VARIANT_ESP32C2, VARIANT_ESP32C3], + msg_prefix="Wakeup from ext1", ), cv.Schema( { @@ -214,7 +215,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_TOUCH_WAKEUP): cv.All( cv.only_on_esp32, esp32.only_on_variant( - unsupported=[VARIANT_ESP32C3], msg_prefix="Wakeup from touch" + unsupported=[ + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + ], + msg_prefix="Wakeup from touch", ), cv.boolean, ), diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 7a640b9ea5..38744163c7 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -34,7 +34,7 @@ enum WakeupPinMode { WAKEUP_PIN_MODE_INVERT_WAKEUP, }; -#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32C3) +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) struct Ext1Wakeup { uint64_t mask; esp_sleep_ext1_wakeup_mode_t wakeup_mode; @@ -50,7 +50,7 @@ struct WakeupCauseToRunDuration { uint32_t gpio_cause; }; -#endif +#endif // USE_ESP32 template class EnterDeepSleepAction; @@ -73,20 +73,22 @@ class DeepSleepComponent : public Component { void set_wakeup_pin(InternalGPIOPin *pin) { this->wakeup_pin_ = pin; } void set_wakeup_pin_mode(WakeupPinMode wakeup_pin_mode); -#endif +#endif // USE_ESP32 #if defined(USE_ESP32) -#if !defined(USE_ESP32_VARIANT_ESP32C3) - +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) void set_ext1_wakeup(Ext1Wakeup ext1_wakeup); - - void set_touch_wakeup(bool touch_wakeup); - #endif + +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + void set_touch_wakeup(bool touch_wakeup); +#endif + // Set the duration in ms for how long the code should run before entering // deep sleep mode, according to the cause the ESP32 has woken. void set_run_duration(WakeupCauseToRunDuration wakeup_cause_to_run_duration); -#endif +#endif // USE_ESP32 /// Set a duration in ms for how long the code should run before entering deep sleep mode. void set_run_duration(uint32_t time_ms); @@ -117,13 +119,13 @@ class DeepSleepComponent : public Component { InternalGPIOPin *wakeup_pin_; WakeupPinMode wakeup_pin_mode_{WAKEUP_PIN_MODE_IGNORE}; -#if !defined(USE_ESP32_VARIANT_ESP32C3) +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) optional ext1_wakeup_; #endif optional touch_wakeup_; optional wakeup_cause_to_run_duration_; -#endif +#endif // USE_ESP32 optional run_duration_; bool next_enter_deep_sleep_{false}; bool prevent_{false}; diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index e9d0a4981f..b93d9ce601 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -7,6 +7,26 @@ namespace esphome { namespace deep_sleep { +// Deep Sleep feature support matrix for ESP32 variants: +// +// | Variant | ext0 | ext1 | Touch | GPIO wakeup | +// |-----------|------|------|-------|-------------| +// | ESP32 | ✓ | ✓ | ✓ | | +// | ESP32-S2 | ✓ | ✓ | ✓ | | +// | ESP32-S3 | ✓ | ✓ | ✓ | | +// | ESP32-C2 | | | | ✓ | +// | ESP32-C3 | | | | ✓ | +// | ESP32-C5 | | (✓) | | (✓) | +// | ESP32-C6 | | ✓ | | ✓ | +// | ESP32-H2 | | ✓ | | | +// +// Notes: +// - (✓) = Supported by hardware but not yet implemented in ESPHome +// - ext0: Single pin wakeup using RTC GPIO (esp_sleep_enable_ext0_wakeup) +// - ext1: Multiple pin wakeup (esp_sleep_enable_ext1_wakeup) +// - Touch: Touch pad wakeup (esp_sleep_enable_touchpad_wakeup) +// - GPIO wakeup: GPIO wakeup for non-RTC pins (esp_deep_sleep_enable_gpio_wakeup) + static const char *const TAG = "deep_sleep"; optional DeepSleepComponent::get_run_duration_() const { @@ -30,13 +50,13 @@ void DeepSleepComponent::set_wakeup_pin_mode(WakeupPinMode wakeup_pin_mode) { this->wakeup_pin_mode_ = wakeup_pin_mode; } -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wakeup_ = ext1_wakeup; } - -#if !defined(USE_ESP32_VARIANT_ESP32H2) -void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) +void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif void DeepSleepComponent::set_run_duration(WakeupCauseToRunDuration wakeup_cause_to_run_duration) { @@ -72,9 +92,13 @@ bool DeepSleepComponent::prepare_to_sleep_() { } void DeepSleepComponent::deep_sleep_() { -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + // Timer wakeup - all variants support this if (this->sleep_duration_.has_value()) esp_sleep_enable_timer_wakeup(*this->sleep_duration_); + + // Single pin wakeup (ext0) - ESP32, S2, S3 only +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -95,32 +119,15 @@ void DeepSleepComponent::deep_sleep_() { } esp_sleep_enable_ext0_wakeup(gpio_pin, level); } - if (this->ext1_wakeup_.has_value()) { - esp_sleep_enable_ext1_wakeup(this->ext1_wakeup_->mask, this->ext1_wakeup_->wakeup_mode); - } - - if (this->touch_wakeup_.has_value() && *(this->touch_wakeup_)) { - esp_sleep_enable_touchpad_wakeup(); - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - } #endif -#if defined(USE_ESP32_VARIANT_ESP32H2) - if (this->sleep_duration_.has_value()) - esp_sleep_enable_timer_wakeup(*this->sleep_duration_); - if (this->ext1_wakeup_.has_value()) { - esp_sleep_enable_ext1_wakeup(this->ext1_wakeup_->mask, this->ext1_wakeup_->wakeup_mode); - } -#endif - -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) - if (this->sleep_duration_.has_value()) - esp_sleep_enable_timer_wakeup(*this->sleep_duration_); + // GPIO wakeup - C2, C3, C6 only +#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); - if (this->wakeup_pin_->get_flags() && gpio::FLAG_PULLUP) { + if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLUP) { gpio_sleep_set_pull_mode(gpio_pin, GPIO_PULLUP_ONLY); - } else if (this->wakeup_pin_->get_flags() && gpio::FLAG_PULLDOWN) { + } else if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLDOWN) { gpio_sleep_set_pull_mode(gpio_pin, GPIO_PULLDOWN_ONLY); } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); @@ -138,9 +145,26 @@ void DeepSleepComponent::deep_sleep_() { static_cast(level)); } #endif + + // Multiple pin wakeup (ext1) - All except C2, C3 +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) + if (this->ext1_wakeup_.has_value()) { + esp_sleep_enable_ext1_wakeup(this->ext1_wakeup_->mask, this->ext1_wakeup_->wakeup_mode); + } +#endif + + // Touch wakeup - ESP32, S2, S3 only +#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + if (this->touch_wakeup_.has_value() && *(this->touch_wakeup_)) { + esp_sleep_enable_touchpad_wakeup(); + esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + } +#endif + esp_deep_sleep_start(); } } // namespace deep_sleep } // namespace esphome -#endif +#endif // USE_ESP32 diff --git a/tests/components/deep_sleep/common-esp32-all.yaml b/tests/components/deep_sleep/common-esp32-all.yaml new file mode 100644 index 0000000000..b97eec76b9 --- /dev/null +++ b/tests/components/deep_sleep/common-esp32-all.yaml @@ -0,0 +1,14 @@ +deep_sleep: + run_duration: + default: 10s + gpio_wakeup_reason: 30s + touch_wakeup_reason: 15s + sleep_duration: 50s + wakeup_pin: ${wakeup_pin} + wakeup_pin_mode: INVERT_WAKEUP + esp32_ext1_wakeup: + pins: + - number: GPIO2 + - number: GPIO13 + mode: ANY_HIGH + touch_wakeup: true diff --git a/tests/components/deep_sleep/common-esp32-ext1.yaml b/tests/components/deep_sleep/common-esp32-ext1.yaml new file mode 100644 index 0000000000..9ed4279a33 --- /dev/null +++ b/tests/components/deep_sleep/common-esp32-ext1.yaml @@ -0,0 +1,12 @@ +deep_sleep: + run_duration: + default: 10s + gpio_wakeup_reason: 30s + sleep_duration: 50s + wakeup_pin: ${wakeup_pin} + wakeup_pin_mode: INVERT_WAKEUP + esp32_ext1_wakeup: + pins: + - number: GPIO2 + - number: GPIO5 + mode: ANY_HIGH diff --git a/tests/components/deep_sleep/test.esp32-c6-idf.yaml b/tests/components/deep_sleep/test.esp32-c6-idf.yaml index 10c17af0f5..11abe70711 100644 --- a/tests/components/deep_sleep/test.esp32-c6-idf.yaml +++ b/tests/components/deep_sleep/test.esp32-c6-idf.yaml @@ -2,4 +2,4 @@ substitutions: wakeup_pin: GPIO4 <<: !include common.yaml -<<: !include common-esp32.yaml +<<: !include common-esp32-ext1.yaml diff --git a/tests/components/deep_sleep/test.esp32-idf.yaml b/tests/components/deep_sleep/test.esp32-idf.yaml index 10c17af0f5..e45eb08349 100644 --- a/tests/components/deep_sleep/test.esp32-idf.yaml +++ b/tests/components/deep_sleep/test.esp32-idf.yaml @@ -2,4 +2,4 @@ substitutions: wakeup_pin: GPIO4 <<: !include common.yaml -<<: !include common-esp32.yaml +<<: !include common-esp32-all.yaml diff --git a/tests/components/deep_sleep/test.esp32-s2-idf.yaml b/tests/components/deep_sleep/test.esp32-s2-idf.yaml index 10c17af0f5..e45eb08349 100644 --- a/tests/components/deep_sleep/test.esp32-s2-idf.yaml +++ b/tests/components/deep_sleep/test.esp32-s2-idf.yaml @@ -2,4 +2,4 @@ substitutions: wakeup_pin: GPIO4 <<: !include common.yaml -<<: !include common-esp32.yaml +<<: !include common-esp32-all.yaml diff --git a/tests/components/deep_sleep/test.esp32-s3-idf.yaml b/tests/components/deep_sleep/test.esp32-s3-idf.yaml index 10c17af0f5..e45eb08349 100644 --- a/tests/components/deep_sleep/test.esp32-s3-idf.yaml +++ b/tests/components/deep_sleep/test.esp32-s3-idf.yaml @@ -2,4 +2,4 @@ substitutions: wakeup_pin: GPIO4 <<: !include common.yaml -<<: !include common-esp32.yaml +<<: !include common-esp32-all.yaml From ab1f8326eea0f62c51b3bc7e878f9e88b6514d4d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:12:45 +1300 Subject: [PATCH 1566/1626] [const] Move `CONF_ON_RESPONSE` to const.py (#10958) --- esphome/components/http_request/__init__.py | 2 +- esphome/const.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 4884085099..98dbc29a86 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_ID, CONF_METHOD, CONF_ON_ERROR, + CONF_ON_RESPONSE, CONF_TIMEOUT, CONF_TRIGGER_ID, CONF_URL, @@ -52,7 +53,6 @@ CONF_BUFFER_SIZE_TX = "buffer_size_tx" CONF_CA_CERTIFICATE_PATH = "ca_certificate_path" CONF_MAX_RESPONSE_BUFFER_SIZE = "max_response_buffer_size" -CONF_ON_RESPONSE = "on_response" CONF_HEADERS = "headers" CONF_COLLECT_HEADERS = "collect_headers" CONF_BODY = "body" diff --git a/esphome/const.py b/esphome/const.py index 3e93200f14..ec583beeb6 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -671,6 +671,7 @@ CONF_ON_PRESET_SET = "on_preset_set" CONF_ON_PRESS = "on_press" CONF_ON_RAW_VALUE = "on_raw_value" CONF_ON_RELEASE = "on_release" +CONF_ON_RESPONSE = "on_response" CONF_ON_SHUTDOWN = "on_shutdown" CONF_ON_SPEED_SET = "on_speed_set" CONF_ON_STATE = "on_state" From 502cd2b54d2c283a3a0aa882fc820bd22022d1b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 01:33:49 +0200 Subject: [PATCH 1567/1626] [logger] Optimize log formatting performance (33-67% faster) --- esphome/components/logger/logger.h | 92 ++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index b5fb15d347..48ad70f95d 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -37,7 +37,7 @@ struct device; namespace esphome::logger { // Color and letter constants for log levels -static const char *const LOG_LEVEL_COLORS[] = { +static constexpr const char *const LOG_LEVEL_COLORS[] = { "", // NONE ESPHOME_LOG_BOLD(ESPHOME_LOG_COLOR_RED), // ERROR ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_YELLOW), // WARNING @@ -48,16 +48,21 @@ static const char *const LOG_LEVEL_COLORS[] = { ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_WHITE), // VERY_VERBOSE }; -static const char *const LOG_LEVEL_LETTERS[] = { - "", // NONE - "E", // ERROR - "W", // WARNING - "I", // INFO - "C", // CONFIG - "D", // DEBUG - "V", // VERBOSE - "VV", // VERY_VERBOSE -}; +// Single character log level letters (E, W, I, C, D, V) +static constexpr char LOG_LEVEL_LETTER_CHARS[] = {'\0', 'E', 'W', 'I', 'C', 'D', 'V'}; + +// ANSI color codes are always 7 characters ("\033[0;32m") +static constexpr uint8_t ANSI_COLOR_LEN = 7; + +// Maximum header size (conservative estimate) +static constexpr uint16_t MAX_HEADER_SIZE = 128; + +// Compile-time string length calculation +static constexpr size_t constexpr_strlen(const char *str) { return *str ? 1 + constexpr_strlen(str + 1) : 0; } + +// Compile-time validation of log level string lengths +static_assert(constexpr_strlen(LOG_LEVEL_COLORS[0]) == 0, "Level 0 color must be empty"); +static_assert(constexpr_strlen(LOG_LEVEL_COLORS[1]) == 7, "Color codes must be 7 chars"); #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) /** Enum for logging UART selection @@ -215,14 +220,6 @@ class Logger : public Component { } } - // Format string to explicit buffer with varargs - inline void printf_to_buffer_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size, const char *format, ...) { - va_list arg; - va_start(arg, format); - this->format_body_to_buffer_(buffer, buffer_at, buffer_size, format, arg); - va_end(arg); - } - #ifndef USE_HOST const LogString *get_uart_selection_(); #endif @@ -318,26 +315,59 @@ class Logger : public Component { } #endif + // Helper: copy fixed-length data to buffer and advance position + static inline void copy_and_advance(char *buffer, uint16_t &pos, const char *data, uint8_t len) { + memcpy(buffer + pos, data, len); + pos += len; + } + + // Helper: copy string to buffer and advance position (calculates length with strlen) + static inline void copy_string(char *buffer, uint16_t &pos, const char *str) { + copy_and_advance(buffer, pos, str, strlen(str)); + } + inline void HOT write_header_to_buffer_(uint8_t level, const char *tag, int line, const char *thread_name, char *buffer, uint16_t *buffer_at, uint16_t buffer_size) { - // Format header - // uint8_t level is already bounded 0-255, just ensure it's <= 7 - if (level > 7) - level = 7; + uint16_t pos = *buffer_at; + if (pos + MAX_HEADER_SIZE > buffer_size) + return; - const char *color = esphome::logger::LOG_LEVEL_COLORS[level]; - const char *letter = esphome::logger::LOG_LEVEL_LETTERS[level]; + const char *color = LOG_LEVEL_COLORS[level]; + const uint8_t color_len = (level == 0) ? 0 : ANSI_COLOR_LEN; + + // Construct: [LEVEL][tag:line]: + copy_and_advance(buffer, pos, color, color_len); + buffer[pos++] = '['; + if (level != 0) { + if (level >= 7) { + buffer[pos++] = 'V'; // VERY_VERBOSE = "VV" + buffer[pos++] = 'V'; + } else { + buffer[pos++] = LOG_LEVEL_LETTER_CHARS[level]; + } + } + buffer[pos++] = ']'; + buffer[pos++] = '['; + copy_string(buffer, pos, tag); + buffer[pos++] = ':'; + buffer[pos++] = '0' + (line / 100) % 10; + buffer[pos++] = '0' + (line / 10) % 10; + buffer[pos++] = '0' + line % 10; + buffer[pos++] = ']'; #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) if (thread_name != nullptr) { - // Non-main task with thread name - this->printf_to_buffer_(buffer, buffer_at, buffer_size, "%s[%s][%s:%03u]%s[%s]%s: ", color, letter, tag, line, - ESPHOME_LOG_BOLD(ESPHOME_LOG_COLOR_RED), thread_name, color); - return; + copy_and_advance(buffer, pos, LOG_LEVEL_COLORS[1], ANSI_COLOR_LEN); // Bold red (error color) + buffer[pos++] = '['; + copy_string(buffer, pos, thread_name); + buffer[pos++] = ']'; + copy_and_advance(buffer, pos, color, color_len); } #endif - // Main task or non ESP32/LibreTiny platform - this->printf_to_buffer_(buffer, buffer_at, buffer_size, "%s[%s][%s:%03u]: ", color, letter, tag, line); + + buffer[pos++] = ':'; + buffer[pos++] = ' '; + *buffer_at = pos; } inline void HOT format_body_to_buffer_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size, const char *format, From 9bffa2faa6e28d4180e09e46e1929beda28c332d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 01:36:35 +0200 Subject: [PATCH 1568/1626] [logger] Optimize log formatting performance (33-67% faster) --- esphome/components/logger/logger.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 48ad70f95d..b926b0a72b 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -315,13 +315,11 @@ class Logger : public Component { } #endif - // Helper: copy fixed-length data to buffer and advance position static inline void copy_and_advance(char *buffer, uint16_t &pos, const char *data, uint8_t len) { memcpy(buffer + pos, data, len); pos += len; } - // Helper: copy string to buffer and advance position (calculates length with strlen) static inline void copy_string(char *buffer, uint16_t &pos, const char *str) { copy_and_advance(buffer, pos, str, strlen(str)); } From ec3adaae5cf181f2933dd14e80f5acb1dc5476dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 01:41:27 +0200 Subject: [PATCH 1569/1626] [logger] Optimize log formatting performance (33-67% faster) --- esphome/components/logger/logger.h | 9 --------- 1 file changed, 9 deletions(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index b926b0a72b..84efe80ea3 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -48,19 +48,10 @@ static constexpr const char *const LOG_LEVEL_COLORS[] = { ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_WHITE), // VERY_VERBOSE }; -// Single character log level letters (E, W, I, C, D, V) static constexpr char LOG_LEVEL_LETTER_CHARS[] = {'\0', 'E', 'W', 'I', 'C', 'D', 'V'}; - -// ANSI color codes are always 7 characters ("\033[0;32m") static constexpr uint8_t ANSI_COLOR_LEN = 7; - -// Maximum header size (conservative estimate) static constexpr uint16_t MAX_HEADER_SIZE = 128; - -// Compile-time string length calculation static constexpr size_t constexpr_strlen(const char *str) { return *str ? 1 + constexpr_strlen(str + 1) : 0; } - -// Compile-time validation of log level string lengths static_assert(constexpr_strlen(LOG_LEVEL_COLORS[0]) == 0, "Level 0 color must be empty"); static_assert(constexpr_strlen(LOG_LEVEL_COLORS[1]) == 7, "Color codes must be 7 chars"); From d558e68cf3b5bc3cdc277c99183f2d8987f43587 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 01:41:40 +0200 Subject: [PATCH 1570/1626] [logger] Optimize log formatting performance (33-67% faster) --- esphome/components/logger/logger.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 84efe80ea3..ac13bce1e6 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -37,7 +37,7 @@ struct device; namespace esphome::logger { // Color and letter constants for log levels -static constexpr const char *const LOG_LEVEL_COLORS[] = { +static const char *const LOG_LEVEL_COLORS[] = { "", // NONE ESPHOME_LOG_BOLD(ESPHOME_LOG_COLOR_RED), // ERROR ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_YELLOW), // WARNING From 2e47315d818c00acbc85228c1f754c4be9a4fde6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 01:42:14 +0200 Subject: [PATCH 1571/1626] [logger] Optimize log formatting performance (33-67% faster) --- esphome/components/logger/logger.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index ac13bce1e6..3b6b7b978a 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -51,9 +51,6 @@ static const char *const LOG_LEVEL_COLORS[] = { static constexpr char LOG_LEVEL_LETTER_CHARS[] = {'\0', 'E', 'W', 'I', 'C', 'D', 'V'}; static constexpr uint8_t ANSI_COLOR_LEN = 7; static constexpr uint16_t MAX_HEADER_SIZE = 128; -static constexpr size_t constexpr_strlen(const char *str) { return *str ? 1 + constexpr_strlen(str + 1) : 0; } -static_assert(constexpr_strlen(LOG_LEVEL_COLORS[0]) == 0, "Level 0 color must be empty"); -static_assert(constexpr_strlen(LOG_LEVEL_COLORS[1]) == 7, "Color codes must be 7 chars"); #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) /** Enum for logging UART selection From fd3c05b42e8097487fa22daf797e9992d35e7160 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Wed, 1 Oct 2025 03:33:56 +0200 Subject: [PATCH 1572/1626] [substitutions] fix #10825 set evaluation error (#10830) --- esphome/components/substitutions/jinja.py | 16 +++++++++++++--- .../substitutions/00-simple_var.approved.yaml | 7 +++++++ .../substitutions/00-simple_var.input.yaml | 7 +++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/esphome/components/substitutions/jinja.py b/esphome/components/substitutions/jinja.py index c6e40a668d..e7164d8fff 100644 --- a/esphome/components/substitutions/jinja.py +++ b/esphome/components/substitutions/jinja.py @@ -1,9 +1,10 @@ +from ast import literal_eval import logging import math import re import jinja2 as jinja -from jinja2.nativetypes import NativeEnvironment +from jinja2.sandbox import SandboxedEnvironment TemplateError = jinja.TemplateError TemplateSyntaxError = jinja.TemplateSyntaxError @@ -70,7 +71,7 @@ class Jinja: """ def __init__(self, context_vars): - self.env = NativeEnvironment( + self.env = SandboxedEnvironment( trim_blocks=True, lstrip_blocks=True, block_start_string="<%", @@ -90,6 +91,15 @@ class Jinja: **SAFE_GLOBAL_FUNCTIONS, } + def safe_eval(self, expr): + try: + result = literal_eval(expr) + if not isinstance(result, str): + return result + except (ValueError, SyntaxError, MemoryError, TypeError): + pass + return expr + def expand(self, content_str): """ Renders a string that may contain Jinja expressions or statements @@ -106,7 +116,7 @@ class Jinja: override_vars = content_str.upvalues try: template = self.env.from_string(content_str) - result = template.render(override_vars) + result = self.safe_eval(template.render(override_vars)) if isinstance(result, Undefined): # This happens when the expression is simply an undefined variable. Jinja does not # raise an exception, instead we get "Undefined". diff --git a/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml b/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml index c59975b2ae..795a788f62 100644 --- a/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml +++ b/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml @@ -5,6 +5,9 @@ substitutions: var21: '79' value: 33 values: 44 + position: + x: 79 + y: 82 esphome: name: test @@ -26,3 +29,7 @@ test_list: - Literal $values ${are not substituted} - ["list $value", "${is not}", "${substituted}"] - {"$dictionary": "$value", "${is not}": "${substituted}"} + - |- + {{{ "x", "79"}, { "y", "82"}}} + - '{{{"AA"}}}' + - '"HELLO"' diff --git a/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml b/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml index 3b7e7a6b4e..722e116d36 100644 --- a/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml +++ b/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml @@ -8,6 +8,9 @@ substitutions: var21: "79" value: 33 values: 44 + position: + x: 79 + y: 82 test_list: - "$var1" @@ -27,3 +30,7 @@ test_list: - !literal Literal $values ${are not substituted} - !literal ["list $value", "${is not}", "${substituted}"] - !literal {"$dictionary": "$value", "${is not}": "${substituted}"} + - |- # Test parsing things that look like a python set of sets when rendered: + {{{ "x", "${ position.x }"}, { "y", "${ position.y }"}}} + - ${ '{{{"AA"}}}' } + - ${ '"HELLO"' } From 4a3475f94db9663a213cb46d3e001805d7bf6c39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 03:54:28 +0200 Subject: [PATCH 1573/1626] preen --- esphome/components/logger/logger.h | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 3b6b7b978a..00e50b2cf6 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -48,8 +48,17 @@ static const char *const LOG_LEVEL_COLORS[] = { ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_WHITE), // VERY_VERBOSE }; -static constexpr char LOG_LEVEL_LETTER_CHARS[] = {'\0', 'E', 'W', 'I', 'C', 'D', 'V'}; +static constexpr char LOG_LEVEL_LETTER_CHARS[] = { + '\0', // NONE + 'E', // ERROR + 'W', // WARNING + 'I', // INFO + 'C', // CONFIG + 'D', // DEBUG + 'V', // VERBOSE (VERY_VERBOSE uses two 'V's) +}; static constexpr uint8_t ANSI_COLOR_LEN = 7; +// Maximum header size: 35 bytes fixed + 32 bytes tag + 16 bytes thread name = 83 bytes (45 byte safety margin) static constexpr uint16_t MAX_HEADER_SIZE = 128; #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) From e7b2cdd03cf71748eb89eb77050de4a38b89cf73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 03:59:43 +0200 Subject: [PATCH 1574/1626] preen --- esphome/components/logger/logger.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 00e50b2cf6..c3e402f4bc 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -324,6 +324,7 @@ class Logger : public Component { inline void HOT write_header_to_buffer_(uint8_t level, const char *tag, int line, const char *thread_name, char *buffer, uint16_t *buffer_at, uint16_t buffer_size) { uint16_t pos = *buffer_at; + // Early return if insufficient space - intentionally don't update buffer_at to prevent partial writes if (pos + MAX_HEADER_SIZE > buffer_size) return; From 922f4b6352cb2d41ea9240785b050eca6c7c8fef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 04:52:35 +0200 Subject: [PATCH 1575/1626] [web_server] Optimize handler methods with lookup tables to reduce flash usage (#10951) --- esphome/components/web_server/web_server.cpp | 204 +++++++++++-------- 1 file changed, 118 insertions(+), 86 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 03bc17f4fa..33141c2049 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -829,15 +829,28 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa } auto call = obj->make_call(); - if (match.method_equals("open")) { - call.set_command_open(); - } else if (match.method_equals("close")) { - call.set_command_close(); - } else if (match.method_equals("stop")) { - call.set_command_stop(); - } else if (match.method_equals("toggle")) { - call.set_command_toggle(); - } else if (!match.method_equals("set")) { + + // Lookup table for cover methods + static const struct { + const char *name; + cover::CoverCall &(cover::CoverCall::*action)(); + } METHODS[] = { + {"open", &cover::CoverCall::set_command_open}, + {"close", &cover::CoverCall::set_command_close}, + {"stop", &cover::CoverCall::set_command_stop}, + {"toggle", &cover::CoverCall::set_command_toggle}, + }; + + bool found = false; + for (const auto &method : METHODS) { + if (match.method_equals(method.name)) { + (call.*method.action)(); + found = true; + break; + } + } + + if (!found && !match.method_equals("set")) { request->send(404); return; } @@ -1483,15 +1496,28 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa } auto call = obj->make_call(); - if (match.method_equals("open")) { - call.set_command_open(); - } else if (match.method_equals("close")) { - call.set_command_close(); - } else if (match.method_equals("stop")) { - call.set_command_stop(); - } else if (match.method_equals("toggle")) { - call.set_command_toggle(); - } else if (!match.method_equals("set")) { + + // Lookup table for valve methods + static const struct { + const char *name; + valve::ValveCall &(valve::ValveCall::*action)(); + } METHODS[] = { + {"open", &valve::ValveCall::set_command_open}, + {"close", &valve::ValveCall::set_command_close}, + {"stop", &valve::ValveCall::set_command_stop}, + {"toggle", &valve::ValveCall::set_command_toggle}, + }; + + bool found = false; + for (const auto &method : METHODS) { + if (match.method_equals(method.name)) { + (call.*method.action)(); + found = true; + break; + } + } + + if (!found && !match.method_equals("set")) { request->send(404); return; } @@ -1555,17 +1581,28 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques auto call = obj->make_call(); parse_string_param_(request, "code", call, &decltype(call)::set_code); - if (match.method_equals("disarm")) { - call.disarm(); - } else if (match.method_equals("arm_away")) { - call.arm_away(); - } else if (match.method_equals("arm_home")) { - call.arm_home(); - } else if (match.method_equals("arm_night")) { - call.arm_night(); - } else if (match.method_equals("arm_vacation")) { - call.arm_vacation(); - } else { + // Lookup table for alarm control panel methods + static const struct { + const char *name; + alarm_control_panel::AlarmControlPanelCall &(alarm_control_panel::AlarmControlPanelCall::*action)(); + } METHODS[] = { + {"disarm", &alarm_control_panel::AlarmControlPanelCall::disarm}, + {"arm_away", &alarm_control_panel::AlarmControlPanelCall::arm_away}, + {"arm_home", &alarm_control_panel::AlarmControlPanelCall::arm_home}, + {"arm_night", &alarm_control_panel::AlarmControlPanelCall::arm_night}, + {"arm_vacation", &alarm_control_panel::AlarmControlPanelCall::arm_vacation}, + }; + + bool found = false; + for (const auto &method : METHODS) { + if (match.method_equals(method.name)) { + (call.*method.action)(); + found = true; + break; + } + } + + if (!found) { request->send(404); return; } @@ -1731,24 +1768,24 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { const auto &url = request->url(); const auto method = request->method(); - // Simple URL checks - if (url == "/") - return true; - + // Static URL checks + static const char *const STATIC_URLS[] = { + "/", #ifdef USE_ARDUINO - if (url == "/events") - return true; + "/events", #endif - #ifdef USE_WEBSERVER_CSS_INCLUDE - if (url == "/0.css") - return true; + "/0.css", #endif - #ifdef USE_WEBSERVER_JS_INCLUDE - if (url == "/0.js") - return true; + "/0.js", #endif + }; + + for (const auto &static_url : STATIC_URLS) { + if (url == static_url) + return true; + } #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS if (method == HTTP_OPTIONS && request->hasHeader(HEADER_CORS_REQ_PNA)) @@ -1768,92 +1805,87 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { if (!is_get_or_post) return false; - // GET-only components - if (is_get) { + // Use lookup tables for domain checks + static const char *const GET_ONLY_DOMAINS[] = { #ifdef USE_SENSOR - if (match.domain_equals("sensor")) - return true; + "sensor", #endif #ifdef USE_BINARY_SENSOR - if (match.domain_equals("binary_sensor")) - return true; + "binary_sensor", #endif #ifdef USE_TEXT_SENSOR - if (match.domain_equals("text_sensor")) - return true; + "text_sensor", #endif #ifdef USE_EVENT - if (match.domain_equals("event")) - return true; + "event", #endif - } + }; - // GET+POST components - if (is_get_or_post) { + static const char *const GET_POST_DOMAINS[] = { #ifdef USE_SWITCH - if (match.domain_equals("switch")) - return true; + "switch", #endif #ifdef USE_BUTTON - if (match.domain_equals("button")) - return true; + "button", #endif #ifdef USE_FAN - if (match.domain_equals("fan")) - return true; + "fan", #endif #ifdef USE_LIGHT - if (match.domain_equals("light")) - return true; + "light", #endif #ifdef USE_COVER - if (match.domain_equals("cover")) - return true; + "cover", #endif #ifdef USE_NUMBER - if (match.domain_equals("number")) - return true; + "number", #endif #ifdef USE_DATETIME_DATE - if (match.domain_equals("date")) - return true; + "date", #endif #ifdef USE_DATETIME_TIME - if (match.domain_equals("time")) - return true; + "time", #endif #ifdef USE_DATETIME_DATETIME - if (match.domain_equals("datetime")) - return true; + "datetime", #endif #ifdef USE_TEXT - if (match.domain_equals("text")) - return true; + "text", #endif #ifdef USE_SELECT - if (match.domain_equals("select")) - return true; + "select", #endif #ifdef USE_CLIMATE - if (match.domain_equals("climate")) - return true; + "climate", #endif #ifdef USE_LOCK - if (match.domain_equals("lock")) - return true; + "lock", #endif #ifdef USE_VALVE - if (match.domain_equals("valve")) - return true; + "valve", #endif #ifdef USE_ALARM_CONTROL_PANEL - if (match.domain_equals("alarm_control_panel")) - return true; + "alarm_control_panel", #endif #ifdef USE_UPDATE - if (match.domain_equals("update")) - return true; + "update", #endif + }; + + // Check GET-only domains + if (is_get) { + for (const auto &domain : GET_ONLY_DOMAINS) { + if (match.domain_equals(domain)) + return true; + } + } + + // Check GET+POST domains + if (is_get_or_post) { + for (const auto &domain : GET_POST_DOMAINS) { + if (match.domain_equals(domain)) + return true; + } } return false; From 40c4fadd2cfe3567efd61b054359a1cb4923a57e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 12:10:19 +0200 Subject: [PATCH 1576/1626] ansi color --- esphome/components/logger/logger.h | 45 +++++++++++++++++++----------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index c3e402f4bc..e085bb7fb0 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -36,16 +36,16 @@ struct device; namespace esphome::logger { -// Color and letter constants for log levels -static const char *const LOG_LEVEL_COLORS[] = { - "", // NONE - ESPHOME_LOG_BOLD(ESPHOME_LOG_COLOR_RED), // ERROR - ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_YELLOW), // WARNING - ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_GREEN), // INFO - ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_MAGENTA), // CONFIG - ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_CYAN), // DEBUG - ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_GRAY), // VERBOSE - ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_WHITE), // VERY_VERBOSE +// ANSI color code last digit (30-38 range, store only last digit to save RAM) +static constexpr char LOG_LEVEL_COLOR_DIGIT[] = { + '\0', // NONE + '1', // ERROR (31 = red) + '3', // WARNING (33 = yellow) + '2', // INFO (32 = green) + '5', // CONFIG (35 = magenta) + '6', // DEBUG (36 = cyan) + '7', // VERBOSE (37 = gray) + '8', // VERY_VERBOSE (38 = white) }; static constexpr char LOG_LEVEL_LETTER_CHARS[] = { @@ -57,7 +57,7 @@ static constexpr char LOG_LEVEL_LETTER_CHARS[] = { 'D', // DEBUG 'V', // VERBOSE (VERY_VERBOSE uses two 'V's) }; -static constexpr uint8_t ANSI_COLOR_LEN = 7; + // Maximum header size: 35 bytes fixed + 32 bytes tag + 16 bytes thread name = 83 bytes (45 byte safety margin) static constexpr uint16_t MAX_HEADER_SIZE = 128; @@ -321,6 +321,20 @@ class Logger : public Component { copy_and_advance(buffer, pos, str, strlen(str)); } + static inline void write_ansi_color_for_level(char *buffer, uint16_t &pos, uint8_t level) { + if (level == 0) + return; + // Construct ANSI escape sequence: "\033[{bold};3{color}m" + // Example: "\033[1;31m" for ERROR (bold red) + buffer[pos++] = '\033'; + buffer[pos++] = '['; + buffer[pos++] = (level == 1) ? '1' : '0'; // Only ERROR is bold + buffer[pos++] = ';'; + buffer[pos++] = '3'; + buffer[pos++] = LOG_LEVEL_COLOR_DIGIT[level]; + buffer[pos++] = 'm'; + } + inline void HOT write_header_to_buffer_(uint8_t level, const char *tag, int line, const char *thread_name, char *buffer, uint16_t *buffer_at, uint16_t buffer_size) { uint16_t pos = *buffer_at; @@ -328,11 +342,8 @@ class Logger : public Component { if (pos + MAX_HEADER_SIZE > buffer_size) return; - const char *color = LOG_LEVEL_COLORS[level]; - const uint8_t color_len = (level == 0) ? 0 : ANSI_COLOR_LEN; - // Construct: [LEVEL][tag:line]: - copy_and_advance(buffer, pos, color, color_len); + write_ansi_color_for_level(buffer, pos, level); buffer[pos++] = '['; if (level != 0) { if (level >= 7) { @@ -353,11 +364,11 @@ class Logger : public Component { #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) if (thread_name != nullptr) { - copy_and_advance(buffer, pos, LOG_LEVEL_COLORS[1], ANSI_COLOR_LEN); // Bold red (error color) + write_ansi_color_for_level(buffer, pos, 1); // Always use bold red for thread name buffer[pos++] = '['; copy_string(buffer, pos, thread_name); buffer[pos++] = ']'; - copy_and_advance(buffer, pos, color, color_len); + write_ansi_color_for_level(buffer, pos, level); // Restore original color } #endif From 848ba6b717979fd0ca54807116d366056f365735 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:29:10 +1300 Subject: [PATCH 1577/1626] [psram] Fix invalid variant error, add `supported()` check (#10962) --- esphome/components/psram/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index d559b2436b..6b85e7f720 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -62,6 +62,11 @@ SPIRAM_SPEEDS = { } +def supported() -> bool: + variant = get_esp32_variant() + return variant in SPIRAM_MODES + + def validate_psram_mode(config): esp32_config = fv.full_config.get()[PLATFORM_ESP32] if config[CONF_SPEED] == "120MHZ": @@ -95,7 +100,7 @@ def get_config_schema(config): variant = get_esp32_variant() speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])] if not speeds: - return cv.Invalid("PSRAM is not supported on this chip") + raise cv.Invalid("PSRAM is not supported on this chip") modes = SPIRAM_MODES[variant] return cv.Schema( { From 3c594a7520fb200dcd92200b930efe414ad8a04e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 12:38:33 +0200 Subject: [PATCH 1578/1626] preen --- esphome/components/logger/logger.h | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index e085bb7fb0..a2a402a56b 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -312,13 +312,10 @@ class Logger : public Component { } #endif - static inline void copy_and_advance(char *buffer, uint16_t &pos, const char *data, uint8_t len) { - memcpy(buffer + pos, data, len); - pos += len; - } - static inline void copy_string(char *buffer, uint16_t &pos, const char *str) { - copy_and_advance(buffer, pos, str, strlen(str)); + const size_t len = strlen(str); + memcpy(buffer + pos, str, len); + pos += len; } static inline void write_ansi_color_for_level(char *buffer, uint16_t &pos, uint8_t level) { From c95180504a178072903d61bc4cf8e5c632b546b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Sep 2025 22:42:58 -0500 Subject: [PATCH 1579/1626] [api] Prevent API from overriding noise encryption keys set in YAML (#10927) --- esphome/components/api/__init__.py | 1 + esphome/components/api/api_server.cpp | 11 +++- .../noise_encryption_key_protection.yaml | 10 ++++ .../test_noise_encryption_key_protection.py | 51 +++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/noise_encryption_key_protection.yaml create mode 100644 tests/integration/test_noise_encryption_key_protection.py diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 5fb84d3c21..b120503a2e 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -193,6 +193,7 @@ async def to_code(config): if key := encryption_config.get(CONF_KEY): decoded = base64.b64decode(key) cg.add(var.set_noise_psk(list(decoded))) + cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired # This will allow a plaintext client to provide a noise key, diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1f38f4a31a..a12cf13ce2 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -37,12 +37,14 @@ void APIServer::setup() { this->noise_pref_ = global_preferences->make_preference(hash, true); +#ifndef USE_API_NOISE_PSK_FROM_YAML + // Only load saved PSK if not set from YAML SavedNoisePsk noise_pref_saved{}; if (this->noise_pref_.load(&noise_pref_saved)) { ESP_LOGD(TAG, "Loaded saved Noise PSK"); - this->set_noise_psk(noise_pref_saved.psk); } +#endif #endif // Schedule reboot if no clients connect within timeout @@ -409,6 +411,12 @@ void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeo #ifdef USE_API_NOISE bool APIServer::save_noise_psk(psk_t psk, bool make_active) { +#ifdef USE_API_NOISE_PSK_FROM_YAML + // When PSK is set from YAML, this function should never be called + // but if it is, reject the change + ESP_LOGW(TAG, "Key set in YAML"); + return false; +#else auto &old_psk = this->noise_ctx_->get_psk(); if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) { ESP_LOGW(TAG, "New PSK matches old"); @@ -437,6 +445,7 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { }); } return true; +#endif } #endif diff --git a/tests/integration/fixtures/noise_encryption_key_protection.yaml b/tests/integration/fixtures/noise_encryption_key_protection.yaml new file mode 100644 index 0000000000..3ce84cd373 --- /dev/null +++ b/tests/integration/fixtures/noise_encryption_key_protection.yaml @@ -0,0 +1,10 @@ +esphome: + name: noise-key-test + +host: + +api: + encryption: + key: "zX9/JHxMKwpP0jUGsF0iESCm1wRvNgR6NkKVOhn7kSs=" + +logger: diff --git a/tests/integration/test_noise_encryption_key_protection.py b/tests/integration/test_noise_encryption_key_protection.py new file mode 100644 index 0000000000..03c43ca8d3 --- /dev/null +++ b/tests/integration/test_noise_encryption_key_protection.py @@ -0,0 +1,51 @@ +"""Integration test for noise encryption key protection from YAML.""" + +from __future__ import annotations + +import base64 + +from aioesphomeapi import InvalidEncryptionKeyAPIError +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_noise_encryption_key_protection( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that noise encryption key set in YAML cannot be changed via API.""" + # The key that's set in the YAML fixture + noise_psk = "zX9/JHxMKwpP0jUGsF0iESCm1wRvNgR6NkKVOhn7kSs=" + + # Keep ESPHome process running throughout all tests + async with run_compiled(yaml_config): + # First connection - test key change attempt + async with api_client_connected(noise_psk=noise_psk) as client: + # Verify connection is established + device_info = await client.device_info() + assert device_info is not None + + # Try to set a new encryption key via API + new_key = base64.b64encode( + b"x" * 32 + ) # Valid 32-byte key in base64 as bytes + + # This should fail since key was set in YAML + success = await client.noise_encryption_set_key(new_key) + assert success is False + + # Reconnect with the original key to verify it still works + async with api_client_connected(noise_psk=noise_psk) as client: + # Verify connection is still successful with original key + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "noise-key-test" + + # Verify that connecting with a wrong key fails + wrong_key = base64.b64encode(b"y" * 32).decode() # Different key + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=wrong_key) as client: + await client.device_info() From 158a59aa83b218e7cc593b706f1332d8f8dc8953 Mon Sep 17 00:00:00 2001 From: Vladimir Makeev Date: Mon, 29 Sep 2025 17:08:51 +0400 Subject: [PATCH 1580/1626] [sim800l] Fixed ignoring incoming calls. (#10865) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/sim800l/sim800l.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index d97b0ae364..55cadcf182 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -288,11 +288,15 @@ void Sim800LComponent::parse_cmd_(std::string message) { if (item == 3) { // stat uint8_t current_call_state = parse_number(message.substr(start, end - start)).value_or(6); if (current_call_state != this->call_state_) { - ESP_LOGD(TAG, "Call state is now: %d", current_call_state); - if (current_call_state == 0) - this->call_connected_callback_.call(); + if (current_call_state == 4) { + ESP_LOGV(TAG, "Premature call state '4'. Ignoring, waiting for RING"); + } else { + this->call_state_ = current_call_state; + ESP_LOGD(TAG, "Call state is now: %d", current_call_state); + if (current_call_state == 0) + this->call_connected_callback_.call(); + } } - this->call_state_ = current_call_state; break; } // item 4 = "" From 29658b79bc69a5e032170bab3d817f751f128485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 30 Sep 2025 03:29:16 +0100 Subject: [PATCH 1581/1626] [voice_assistant] Fix wakeword string being reset while referenced (#10945) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/voice_assistant/voice_assistant.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 743c90e700..a0cf1a155b 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -242,7 +242,6 @@ void VoiceAssistant::loop() { msg.flags = flags; msg.audio_settings = audio_settings; msg.set_wake_word_phrase(StringRef(this->wake_word_)); - this->wake_word_ = ""; // Reset media player state tracking #ifdef USE_MEDIA_PLAYER From 59c0ffb98b6b0998fdbca75795af50c46c2d9cbc Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:41:42 +1300 Subject: [PATCH 1582/1626] Bump version to 2025.9.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index b0b92dfd63..d14d1a2adb 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.9.2 +PROJECT_NUMBER = 2025.9.3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index dafd49c066..a7e1752a67 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.9.2" +__version__ = "2025.9.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 774efad78b37f5b162a4985042e4330600f65762 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 12:59:01 +0200 Subject: [PATCH 1583/1626] preen --- esphome/components/logger/logger.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index a2a402a56b..7d4c14df0b 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -314,7 +314,8 @@ class Logger : public Component { static inline void copy_string(char *buffer, uint16_t &pos, const char *str) { const size_t len = strlen(str); - memcpy(buffer + pos, str, len); + // Intentionally no null terminator, building larger string + memcpy(buffer + pos, str, len); // NOLINT(bugprone-not-null-terminated-result) pos += len; } From 4194a940ae4919ef37dd6b889d2ec1080c092dcb Mon Sep 17 00:00:00 2001 From: Piotr Szulc Date: Wed, 1 Oct 2025 13:10:37 +0200 Subject: [PATCH 1584/1626] [remote_transmitter] fix sending codes on libretiny (#10959) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/remote_transmitter/remote_transmitter.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 79d9cda93b..347e9d9d33 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -40,7 +40,13 @@ void RemoteTransmitterComponent::await_target_time_() { if (this->target_time_ == 0) { this->target_time_ = current_time; } else if ((int32_t) (this->target_time_ - current_time) > 0) { +#if defined(USE_LIBRETINY) + // busy loop for libretiny is required (see the comment inside micros() in wiring.c) + while ((int32_t) (this->target_time_ - micros()) > 0) + ; +#else delayMicroseconds(this->target_time_ - current_time); +#endif } } From 5cef75dbe11e4412c39dd5497d926726206e8be2 Mon Sep 17 00:00:00 2001 From: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com> Date: Wed, 1 Oct 2025 21:22:02 +1000 Subject: [PATCH 1585/1626] [hdc1080] remove delays and fix no check for sensor nullptr (#10947) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/hdc1080/hdc1080.cpp | 76 ++++++++++++++------------ esphome/components/hdc1080/hdc1080.h | 4 +- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/esphome/components/hdc1080/hdc1080.cpp b/esphome/components/hdc1080/hdc1080.cpp index 6d16133c36..71b7cd7e6e 100644 --- a/esphome/components/hdc1080/hdc1080.cpp +++ b/esphome/components/hdc1080/hdc1080.cpp @@ -7,24 +7,20 @@ namespace hdc1080 { static const char *const TAG = "hdc1080"; -static const uint8_t HDC1080_ADDRESS = 0x40; // 0b1000000 from datasheet static const uint8_t HDC1080_CMD_CONFIGURATION = 0x02; static const uint8_t HDC1080_CMD_TEMPERATURE = 0x00; static const uint8_t HDC1080_CMD_HUMIDITY = 0x01; void HDC1080Component::setup() { - const uint8_t data[2] = { - 0b00000000, // resolution 14bit for both humidity and temperature - 0b00000000 // reserved - }; + const uint8_t config[2] = {0x00, 0x00}; // resolution 14bit for both humidity and temperature - if (!this->write_bytes(HDC1080_CMD_CONFIGURATION, data, 2)) { - // as instruction is same as powerup defaults (for now), interpret as warning if this fails - ESP_LOGW(TAG, "HDC1080 initial config instruction error"); - this->status_set_warning(); + // if configuration fails - there is a problem + if (this->write_register(HDC1080_CMD_CONFIGURATION, config, 2) != i2c::ERROR_OK) { + this->mark_failed(); return; } } + void HDC1080Component::dump_config() { ESP_LOGCONFIG(TAG, "HDC1080:"); LOG_I2C_DEVICE(this); @@ -35,39 +31,51 @@ void HDC1080Component::dump_config() { LOG_SENSOR(" ", "Temperature", this->temperature_); LOG_SENSOR(" ", "Humidity", this->humidity_); } + void HDC1080Component::update() { - uint16_t raw_temp; + // regardless of what sensor/s are defined in yaml configuration + // the hdc1080 setup configuration used, requires both temperature and humidity to be read + + this->status_clear_warning(); + if (this->write(&HDC1080_CMD_TEMPERATURE, 1) != i2c::ERROR_OK) { this->status_set_warning(); return; } - delay(20); - if (this->read(reinterpret_cast(&raw_temp), 2) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - raw_temp = i2c::i2ctohs(raw_temp); - float temp = raw_temp * 0.0025177f - 40.0f; // raw * 2^-16 * 165 - 40 - this->temperature_->publish_state(temp); - uint16_t raw_humidity; - if (this->write(&HDC1080_CMD_HUMIDITY, 1) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - delay(20); - if (this->read(reinterpret_cast(&raw_humidity), 2) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - raw_humidity = i2c::i2ctohs(raw_humidity); - float humidity = raw_humidity * 0.001525879f; // raw * 2^-16 * 100 - this->humidity_->publish_state(humidity); + this->set_timeout(20, [this]() { + uint16_t raw_temperature; + if (this->read(reinterpret_cast(&raw_temperature), 2) != i2c::ERROR_OK) { + this->status_set_warning(); + return; + } - ESP_LOGD(TAG, "Got temperature=%.1f°C humidity=%.1f%%", temp, humidity); - this->status_clear_warning(); + if (this->temperature_ != nullptr) { + raw_temperature = i2c::i2ctohs(raw_temperature); + float temperature = raw_temperature * 0.0025177f - 40.0f; // raw * 2^-16 * 165 - 40 + this->temperature_->publish_state(temperature); + } + + if (this->write(&HDC1080_CMD_HUMIDITY, 1) != i2c::ERROR_OK) { + this->status_set_warning(); + return; + } + + this->set_timeout(20, [this]() { + uint16_t raw_humidity; + if (this->read(reinterpret_cast(&raw_humidity), 2) != i2c::ERROR_OK) { + this->status_set_warning(); + return; + } + + if (this->humidity_ != nullptr) { + raw_humidity = i2c::i2ctohs(raw_humidity); + float humidity = raw_humidity * 0.001525879f; // raw * 2^-16 * 100 + this->humidity_->publish_state(humidity); + } + }); + }); } -float HDC1080Component::get_setup_priority() const { return setup_priority::DATA; } } // namespace hdc1080 } // namespace esphome diff --git a/esphome/components/hdc1080/hdc1080.h b/esphome/components/hdc1080/hdc1080.h index 2ff7b6dc33..7ad0764f1f 100644 --- a/esphome/components/hdc1080/hdc1080.h +++ b/esphome/components/hdc1080/hdc1080.h @@ -12,13 +12,11 @@ class HDC1080Component : public PollingComponent, public i2c::I2CDevice { void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } - /// Setup the sensor and check for connection. void setup() override; void dump_config() override; - /// Retrieve the latest sensor values. This operation takes approximately 16ms. void update() override; - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::DATA; } protected: sensor::Sensor *temperature_{nullptr}; From 57d4cc151dc59babd4146caf2c8da6ecf42dd792 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 13:34:54 +0200 Subject: [PATCH 1586/1626] [core] Fix ComponentIterator alignment for 32-bit platforms --- esphome/core/component_iterator.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index fdc30485bc..641d42898a 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -168,8 +168,9 @@ class ComponentIterator { UPDATE, #endif MAX, - } state_{IteratorState::NONE}; + }; uint16_t at_{0}; // Supports up to 65,535 entities per type + IteratorState state_{IteratorState::NONE}; bool include_internal_{false}; template From a760f7d604db5252348f90e120266d0316c41106 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 15:04:26 +0200 Subject: [PATCH 1587/1626] [api] Remove ClientInfo::get_combined_info() to eliminate heap fragmentation --- esphome/components/api/api_connection.cpp | 12 ++++++------ esphome/components/api/api_connection.h | 11 ++--------- esphome/components/api/api_frame_helper.cpp | 3 ++- esphome/components/api/api_frame_helper_noise.cpp | 3 ++- .../components/api/api_frame_helper_plaintext.cpp | 3 ++- esphome/components/api/api_server.cpp | 2 +- .../components/voice_assistant/voice_assistant.cpp | 4 ++-- 7 files changed, 17 insertions(+), 21 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 30b98803d1..44b1bf8723 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -205,7 +205,7 @@ void APIConnection::loop() { // Disconnect if not responded within 2.5*keepalive if (now - this->last_traffic_ > KEEPALIVE_DISCONNECT_TIMEOUT) { on_fatal_error(); - ESP_LOGW(TAG, "%s is unresponsive; disconnecting", this->get_client_combined_info().c_str()); + ESP_LOGW(TAG, "%s (%s) is unresponsive; disconnecting", this->get_name(), this->get_peername()); } } else if (now - this->last_traffic_ > KEEPALIVE_TIMEOUT_MS && !this->flags_.remove) { // Only send ping if we're not disconnecting @@ -255,7 +255,7 @@ bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) { // remote initiated disconnect_client // don't close yet, we still need to send the disconnect response // close will happen on next loop - ESP_LOGD(TAG, "%s disconnected", this->get_client_combined_info().c_str()); + ESP_LOGD(TAG, "%s (%s) disconnected", this->get_name(), this->get_peername()); this->flags_.next_close = true; DisconnectResponse resp; return this->send_message(resp, DisconnectResponse::MESSAGE_TYPE); @@ -1385,7 +1385,7 @@ void APIConnection::complete_authentication_() { } this->flags_.connection_state = static_cast(ConnectionState::AUTHENTICATED); - ESP_LOGD(TAG, "%s connected", this->get_client_combined_info().c_str()); + ESP_LOGD(TAG, "%s (%s) connected", this->get_name(), this->get_peername()); #ifdef USE_API_CLIENT_CONNECTED_TRIGGER this->parent_->get_client_connected_trigger()->trigger(this->client_info_.name, this->client_info_.peername); #endif @@ -1609,12 +1609,12 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { #ifdef USE_API_PASSWORD void APIConnection::on_unauthenticated_access() { this->on_fatal_error(); - ESP_LOGD(TAG, "%s access without authentication", this->get_client_combined_info().c_str()); + ESP_LOGD(TAG, "%s (%s) no authentication", this->get_name(), this->get_peername()); } #endif void APIConnection::on_no_setup_connection() { this->on_fatal_error(); - ESP_LOGD(TAG, "%s access without full connection", this->get_client_combined_info().c_str()); + ESP_LOGD(TAG, "%s (%s) no connection setup", this->get_name(), this->get_peername()); } void APIConnection::on_fatal_error() { this->helper_->close(); @@ -1866,7 +1866,7 @@ void APIConnection::process_state_subscriptions_() { #endif // USE_API_HOMEASSISTANT_STATES void APIConnection::log_warning_(const LogString *message, APIError err) { - ESP_LOGW(TAG, "%s: %s %s errno=%d", this->get_client_combined_info().c_str(), LOG_STR_ARG(message), + ESP_LOGW(TAG, "%s (%s): %s %s errno=%d", this->get_name(), this->get_peername(), LOG_STR_ARG(message), LOG_STR_ARG(api_error_to_logstr(err)), errno); } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index cc7e4d6895..54535eaccd 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -19,14 +19,6 @@ namespace esphome::api { struct ClientInfo { std::string name; // Client name from Hello message std::string peername; // IP:port from socket - - std::string get_combined_info() const { - if (name == peername) { - // Before Hello message, both are the same - return name; - } - return name + " (" + peername + ")"; - } }; // Keepalive timeout in milliseconds @@ -278,7 +270,8 @@ class APIConnection final : public APIServerConnection { bool try_to_clear_buffer(bool log_out_of_space); bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override; - std::string get_client_combined_info() const { return this->client_info_.get_combined_info(); } + const char *get_name() const { return this->client_info_.name.c_str(); } + const char *get_peername() const { return this->client_info_.peername.c_str(); } protected: // Helper function to handle authentication completion diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index a284e09c4a..a63199a5c4 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -13,7 +13,8 @@ namespace esphome::api { static const char *const TAG = "api.frame_helper"; -#define HELPER_LOG(msg, ...) ESP_LOGVV(TAG, "%s: " msg, this->client_info_->get_combined_info().c_str(), ##__VA_ARGS__) +#define HELPER_LOG(msg, ...) \ + ESP_LOGVV(TAG, "%s (%s): " msg, this->client_info_->name.c_str(), this->client_info_->peername.c_str(), ##__VA_ARGS__) #ifdef HELPER_LOG_PACKETS #define LOG_PACKET_RECEIVED(buffer) ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(buffer).c_str()) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 0e49f93db5..ab27699f06 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -24,7 +24,8 @@ static const char *const PROLOGUE_INIT = "NoiseAPIInit"; #endif static constexpr size_t PROLOGUE_INIT_LEN = 12; // strlen("NoiseAPIInit") -#define HELPER_LOG(msg, ...) ESP_LOGVV(TAG, "%s: " msg, this->client_info_->get_combined_info().c_str(), ##__VA_ARGS__) +#define HELPER_LOG(msg, ...) \ + ESP_LOGVV(TAG, "%s (%s): " msg, this->client_info_->name.c_str(), this->client_info_->peername.c_str(), ##__VA_ARGS__) #ifdef HELPER_LOG_PACKETS #define LOG_PACKET_RECEIVED(buffer) ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(buffer).c_str()) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 859bb26630..ff72f3cb55 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -18,7 +18,8 @@ namespace esphome::api { static const char *const TAG = "api.plaintext"; -#define HELPER_LOG(msg, ...) ESP_LOGVV(TAG, "%s: " msg, this->client_info_->get_combined_info().c_str(), ##__VA_ARGS__) +#define HELPER_LOG(msg, ...) \ + ESP_LOGVV(TAG, "%s (%s): " msg, this->client_info_->name.c_str(), this->client_info_->peername.c_str(), ##__VA_ARGS__) #ifdef HELPER_LOG_PACKETS #define LOG_PACKET_RECEIVED(buffer) ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(buffer).c_str()) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 7fbe0e27f3..2b41a71769 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -177,7 +177,7 @@ void APIServer::loop() { // Network is down - disconnect all clients for (auto &client : this->clients_) { client->on_fatal_error(); - ESP_LOGW(TAG, "%s: Network down; disconnect", client->get_client_combined_info().c_str()); + ESP_LOGW(TAG, "%s (%s): Network down; disconnect", client->get_name(), client->get_peername()); } // Continue to process and clean up the clients below } diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index a0cf1a155b..bb429ca7df 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -429,8 +429,8 @@ void VoiceAssistant::client_subscription(api::APIConnection *client, bool subscr if (this->api_client_ != nullptr) { ESP_LOGE(TAG, "Multiple API Clients attempting to connect to Voice Assistant"); - ESP_LOGE(TAG, "Current client: %s", this->api_client_->get_client_combined_info().c_str()); - ESP_LOGE(TAG, "New client: %s", client->get_client_combined_info().c_str()); + ESP_LOGE(TAG, "Current client: %s (%s)", this->api_client_->get_name(), this->api_client_->get_peername()); + ESP_LOGE(TAG, "New client: %s (%s)", client->get_name(), client->get_peername()); return; } From db1aa823506662c1c5cb93bdb09dac6bb2befa1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 15:33:14 +0200 Subject: [PATCH 1588/1626] [core] Fix ComponentIterator alignment for 32-bit platforms (#10969) --- esphome/core/component_iterator.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index fdc30485bc..641d42898a 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -168,8 +168,9 @@ class ComponentIterator { UPDATE, #endif MAX, - } state_{IteratorState::NONE}; + }; uint16_t at_{0}; // Supports up to 65,535 entities per type + IteratorState state_{IteratorState::NONE}; bool include_internal_{false}; template From de21c61b6adc70ce39055c2827aacc33a646cbd6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 15:33:30 +0200 Subject: [PATCH 1589/1626] [logger] Optimize log formatting performance (35-72% faster) (#10960) --- esphome/components/logger/logger.h | 113 +++++++++++++++++++---------- 1 file changed, 74 insertions(+), 39 deletions(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index b5fb15d347..7d4c14df0b 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -36,29 +36,31 @@ struct device; namespace esphome::logger { -// Color and letter constants for log levels -static const char *const LOG_LEVEL_COLORS[] = { - "", // NONE - ESPHOME_LOG_BOLD(ESPHOME_LOG_COLOR_RED), // ERROR - ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_YELLOW), // WARNING - ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_GREEN), // INFO - ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_MAGENTA), // CONFIG - ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_CYAN), // DEBUG - ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_GRAY), // VERBOSE - ESPHOME_LOG_COLOR(ESPHOME_LOG_COLOR_WHITE), // VERY_VERBOSE +// ANSI color code last digit (30-38 range, store only last digit to save RAM) +static constexpr char LOG_LEVEL_COLOR_DIGIT[] = { + '\0', // NONE + '1', // ERROR (31 = red) + '3', // WARNING (33 = yellow) + '2', // INFO (32 = green) + '5', // CONFIG (35 = magenta) + '6', // DEBUG (36 = cyan) + '7', // VERBOSE (37 = gray) + '8', // VERY_VERBOSE (38 = white) }; -static const char *const LOG_LEVEL_LETTERS[] = { - "", // NONE - "E", // ERROR - "W", // WARNING - "I", // INFO - "C", // CONFIG - "D", // DEBUG - "V", // VERBOSE - "VV", // VERY_VERBOSE +static constexpr char LOG_LEVEL_LETTER_CHARS[] = { + '\0', // NONE + 'E', // ERROR + 'W', // WARNING + 'I', // INFO + 'C', // CONFIG + 'D', // DEBUG + 'V', // VERBOSE (VERY_VERBOSE uses two 'V's) }; +// Maximum header size: 35 bytes fixed + 32 bytes tag + 16 bytes thread name = 83 bytes (45 byte safety margin) +static constexpr uint16_t MAX_HEADER_SIZE = 128; + #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) /** Enum for logging UART selection * @@ -215,14 +217,6 @@ class Logger : public Component { } } - // Format string to explicit buffer with varargs - inline void printf_to_buffer_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size, const char *format, ...) { - va_list arg; - va_start(arg, format); - this->format_body_to_buffer_(buffer, buffer_at, buffer_size, format, arg); - va_end(arg); - } - #ifndef USE_HOST const LogString *get_uart_selection_(); #endif @@ -318,26 +312,67 @@ class Logger : public Component { } #endif + static inline void copy_string(char *buffer, uint16_t &pos, const char *str) { + const size_t len = strlen(str); + // Intentionally no null terminator, building larger string + memcpy(buffer + pos, str, len); // NOLINT(bugprone-not-null-terminated-result) + pos += len; + } + + static inline void write_ansi_color_for_level(char *buffer, uint16_t &pos, uint8_t level) { + if (level == 0) + return; + // Construct ANSI escape sequence: "\033[{bold};3{color}m" + // Example: "\033[1;31m" for ERROR (bold red) + buffer[pos++] = '\033'; + buffer[pos++] = '['; + buffer[pos++] = (level == 1) ? '1' : '0'; // Only ERROR is bold + buffer[pos++] = ';'; + buffer[pos++] = '3'; + buffer[pos++] = LOG_LEVEL_COLOR_DIGIT[level]; + buffer[pos++] = 'm'; + } + inline void HOT write_header_to_buffer_(uint8_t level, const char *tag, int line, const char *thread_name, char *buffer, uint16_t *buffer_at, uint16_t buffer_size) { - // Format header - // uint8_t level is already bounded 0-255, just ensure it's <= 7 - if (level > 7) - level = 7; + uint16_t pos = *buffer_at; + // Early return if insufficient space - intentionally don't update buffer_at to prevent partial writes + if (pos + MAX_HEADER_SIZE > buffer_size) + return; - const char *color = esphome::logger::LOG_LEVEL_COLORS[level]; - const char *letter = esphome::logger::LOG_LEVEL_LETTERS[level]; + // Construct: [LEVEL][tag:line]: + write_ansi_color_for_level(buffer, pos, level); + buffer[pos++] = '['; + if (level != 0) { + if (level >= 7) { + buffer[pos++] = 'V'; // VERY_VERBOSE = "VV" + buffer[pos++] = 'V'; + } else { + buffer[pos++] = LOG_LEVEL_LETTER_CHARS[level]; + } + } + buffer[pos++] = ']'; + buffer[pos++] = '['; + copy_string(buffer, pos, tag); + buffer[pos++] = ':'; + buffer[pos++] = '0' + (line / 100) % 10; + buffer[pos++] = '0' + (line / 10) % 10; + buffer[pos++] = '0' + line % 10; + buffer[pos++] = ']'; #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) if (thread_name != nullptr) { - // Non-main task with thread name - this->printf_to_buffer_(buffer, buffer_at, buffer_size, "%s[%s][%s:%03u]%s[%s]%s: ", color, letter, tag, line, - ESPHOME_LOG_BOLD(ESPHOME_LOG_COLOR_RED), thread_name, color); - return; + write_ansi_color_for_level(buffer, pos, 1); // Always use bold red for thread name + buffer[pos++] = '['; + copy_string(buffer, pos, thread_name); + buffer[pos++] = ']'; + write_ansi_color_for_level(buffer, pos, level); // Restore original color } #endif - // Main task or non ESP32/LibreTiny platform - this->printf_to_buffer_(buffer, buffer_at, buffer_size, "%s[%s][%s:%03u]: ", color, letter, tag, line); + + buffer[pos++] = ':'; + buffer[pos++] = ' '; + *buffer_at = pos; } inline void HOT format_body_to_buffer_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size, const char *format, From 1deb79a24b0a0d52e036b9a6d17651c1e59351ce Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 2 Oct 2025 02:36:17 +1300 Subject: [PATCH 1590/1626] [core] Add some types to `loader.py` (#10967) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/loader.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/loader.py b/esphome/loader.py index 7b2472521a..ec2f5101da 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -192,7 +192,7 @@ def install_custom_components_meta_finder(): install_meta_finder(custom_components_dir) -def _lookup_module(domain, exception): +def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: if domain in _COMPONENT_CACHE: return _COMPONENT_CACHE[domain] @@ -219,16 +219,16 @@ def _lookup_module(domain, exception): return manif -def get_component(domain, exception=False): +def get_component(domain: str, exception: bool = False) -> ComponentManifest | None: assert "." not in domain return _lookup_module(domain, exception) -def get_platform(domain, platform): +def get_platform(domain: str, platform: str) -> ComponentManifest | None: full = f"{platform}.{domain}" return _lookup_module(full, False) -_COMPONENT_CACHE = {} +_COMPONENT_CACHE: dict[str, ComponentManifest] = {} CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve() _COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config) From 08afc3030a90394deb5f3bc80bb933d6a1497cf2 Mon Sep 17 00:00:00 2001 From: Carl Reid <33623601+carlreid@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:47:32 +0200 Subject: [PATCH 1591/1626] [psram] raise instead of returning invalid object (#10954) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- tests/component_tests/psram/test_psram.py | 194 ++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/component_tests/psram/test_psram.py diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py new file mode 100644 index 0000000000..3e40a8d192 --- /dev/null +++ b/tests/component_tests/psram/test_psram.py @@ -0,0 +1,194 @@ +"""Tests for PSRAM component.""" + +from typing import Any + +import pytest + +from esphome.components.esp32.const import ( + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +import esphome.config_validation as cv +from esphome.const import CONF_ESPHOME, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + +UNSUPPORTED_PSRAM_VARIANTS = [ + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, +] + +SUPPORTED_PSRAM_VARIANTS = [ + VARIANT_ESP32, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32P4, +] + + +@pytest.mark.parametrize( + ("config", "error_match"), + [ + pytest.param( + {}, + r"PSRAM is not supported on this chip", + id="psram_not_supported", + ), + ], +) +@pytest.mark.parametrize("variant", UNSUPPORTED_PSRAM_VARIANTS) +def test_psram_configuration_errors_unsupported_variants( + config: Any, + error_match: str, + variant: str, + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: variant}, + full_config={CONF_ESPHOME: {}}, + ) + """Test detection of invalid PSRAM configuration on unsupported variants.""" + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=error_match): + CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize("variant", SUPPORTED_PSRAM_VARIANTS) +def test_psram_configuration_valid_supported_variants( + variant: str, + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: variant}, + full_config={ + CONF_ESPHOME: {}, + "esp32": { + "variant": variant, + "cpu_frequency": "160MHz", + "framework": {"type": "esp-idf"}, + }, + }, + ) + """Test that PSRAM configuration is valid on supported variants.""" + from esphome.components.psram import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + + # This should not raise an exception + config = CONFIG_SCHEMA({}) + FINAL_VALIDATE_SCHEMA(config) + + +def _setup_psram_final_validation_test( + esp32_config: dict, + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> str: + """Helper function to set up ESP32 configuration for PSRAM final validation tests.""" + # Use ESP32S3 for schema validation to allow all options, then override for final validation + schema_variant = "ESP32S3" + final_variant = esp32_config.get("variant", "ESP32S3") + full_esp32_config = { + "variant": final_variant, + "cpu_frequency": esp32_config.get("cpu_frequency", "240MHz"), + "framework": {"type": "esp-idf"}, + } + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: schema_variant}, + full_config={ + CONF_ESPHOME: {}, + "esp32": full_esp32_config, + }, + ) + set_component_config("esp32", full_esp32_config) + + return final_variant + + +@pytest.mark.parametrize( + ("config", "esp32_config", "expect_error", "error_match"), + [ + pytest.param( + {"speed": "120MHz"}, + {"cpu_frequency": "160MHz"}, + True, + r"PSRAM 120MHz requires 240MHz CPU frequency", + id="120mhz_requires_240mhz_cpu", + ), + pytest.param( + {"mode": "octal"}, + {"variant": "ESP32"}, + True, + r"Octal PSRAM is only supported on ESP32-S3", + id="octal_mode_only_esp32s3", + ), + pytest.param( + {"mode": "quad", "enable_ecc": True}, + {}, + True, + r"ECC is only available in octal mode", + id="ecc_only_in_octal_mode", + ), + pytest.param( + {"speed": "120MHZ"}, + {"cpu_frequency": "240MHZ"}, + False, + None, + id="120mhz_with_240mhz_cpu", + ), + pytest.param( + {"mode": "octal"}, + {"variant": "ESP32S3"}, + False, + None, + id="octal_mode_on_esp32s3", + ), + pytest.param( + {"mode": "octal", "enable_ecc": True}, + {"variant": "ESP32S3"}, + False, + None, + id="ecc_in_octal_mode", + ), + ], +) +def test_psram_final_validation( + config: Any, + esp32_config: dict, + expect_error: bool, + error_match: str | None, + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """Test PSRAM final validation for both error and valid cases.""" + from esphome.components.psram import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + from esphome.core import CORE + + final_variant = _setup_psram_final_validation_test( + esp32_config, set_core_config, set_component_config + ) + + validated_config = CONFIG_SCHEMA(config) + + # Update CORE variant for final validation + CORE.data["esp32"][KEY_VARIANT] = final_variant + + if expect_error: + with pytest.raises(cv.Invalid, match=error_match): + FINAL_VALIDATE_SCHEMA(validated_config) + else: + # This should not raise an exception + FINAL_VALIDATE_SCHEMA(validated_config) From 1b5ad59da5df28a7b8eaa3109c7cf4428db0e187 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 15:57:16 +0200 Subject: [PATCH 1592/1626] [api] Reduce flash usage in user services by eliminating vector copy --- esphome/components/api/user_services.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 5f040e8433..170a2f7090 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -25,8 +25,8 @@ template enums::ServiceArgType to_service_arg_type(); template class UserServiceBase : public UserServiceDescriptor { public: - UserServiceBase(std::string name, const std::array &arg_names) - : name_(std::move(name)), arg_names_(arg_names) { + UserServiceBase(const std::string &name, const std::array &arg_names) + : name_(name), arg_names_(arg_names) { this->key_ = fnv1_hash(this->name_); } @@ -55,7 +55,7 @@ template class UserServiceBase : public UserServiceDescriptor { protected: virtual void execute(Ts... x) = 0; - template void execute_(std::vector args, seq type) { + template void execute_(const std::vector &args, seq type) { this->execute((get_execute_arg_value(args[S]))...); } From dab9a77c1a254274f9112f370f31ae363b79cdd9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 16:01:52 +0200 Subject: [PATCH 1593/1626] lint --- esphome/components/api/user_services.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 170a2f7090..dba2d055bf 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -25,8 +25,8 @@ template enums::ServiceArgType to_service_arg_type(); template class UserServiceBase : public UserServiceDescriptor { public: - UserServiceBase(const std::string &name, const std::array &arg_names) - : name_(name), arg_names_(arg_names) { + UserServiceBase(std::string name, const std::array &arg_names) + : name_(std::move(name)), arg_names_(arg_names) { this->key_ = fnv1_hash(this->name_); } From f4aea8fa7acb76cbba38a0c1305708e78a51e246 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 16:35:26 +0200 Subject: [PATCH 1594/1626] tweak --- esphome/components/api/api_connection.cpp | 15 ++++++++------- esphome/components/api/api_connection.h | 4 ++-- esphome/components/api/api_server.cpp | 3 ++- .../voice_assistant/voice_assistant.cpp | 5 +++-- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 44b1bf8723..2d12bf5f09 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -205,7 +205,8 @@ void APIConnection::loop() { // Disconnect if not responded within 2.5*keepalive if (now - this->last_traffic_ > KEEPALIVE_DISCONNECT_TIMEOUT) { on_fatal_error(); - ESP_LOGW(TAG, "%s (%s) is unresponsive; disconnecting", this->get_name(), this->get_peername()); + ESP_LOGW(TAG, "%s (%s) is unresponsive; disconnecting", this->client_info_.name.c_str(), + this->client_info_.peername.c_str()); } } else if (now - this->last_traffic_ > KEEPALIVE_TIMEOUT_MS && !this->flags_.remove) { // Only send ping if we're not disconnecting @@ -255,7 +256,7 @@ bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) { // remote initiated disconnect_client // don't close yet, we still need to send the disconnect response // close will happen on next loop - ESP_LOGD(TAG, "%s (%s) disconnected", this->get_name(), this->get_peername()); + ESP_LOGD(TAG, "%s (%s) disconnected", this->client_info_.name.c_str(), this->client_info_.peername.c_str()); this->flags_.next_close = true; DisconnectResponse resp; return this->send_message(resp, DisconnectResponse::MESSAGE_TYPE); @@ -1385,7 +1386,7 @@ void APIConnection::complete_authentication_() { } this->flags_.connection_state = static_cast(ConnectionState::AUTHENTICATED); - ESP_LOGD(TAG, "%s (%s) connected", this->get_name(), this->get_peername()); + ESP_LOGD(TAG, "%s (%s) connected", this->client_info_.name.c_str(), this->client_info_.peername.c_str()); #ifdef USE_API_CLIENT_CONNECTED_TRIGGER this->parent_->get_client_connected_trigger()->trigger(this->client_info_.name, this->client_info_.peername); #endif @@ -1609,12 +1610,12 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { #ifdef USE_API_PASSWORD void APIConnection::on_unauthenticated_access() { this->on_fatal_error(); - ESP_LOGD(TAG, "%s (%s) no authentication", this->get_name(), this->get_peername()); + ESP_LOGD(TAG, "%s (%s) no authentication", this->client_info_.name.c_str(), this->client_info_.peername.c_str()); } #endif void APIConnection::on_no_setup_connection() { this->on_fatal_error(); - ESP_LOGD(TAG, "%s (%s) no connection setup", this->get_name(), this->get_peername()); + ESP_LOGD(TAG, "%s (%s) no connection setup", this->client_info_.name.c_str(), this->client_info_.peername.c_str()); } void APIConnection::on_fatal_error() { this->helper_->close(); @@ -1866,8 +1867,8 @@ void APIConnection::process_state_subscriptions_() { #endif // USE_API_HOMEASSISTANT_STATES void APIConnection::log_warning_(const LogString *message, APIError err) { - ESP_LOGW(TAG, "%s (%s): %s %s errno=%d", this->get_name(), this->get_peername(), LOG_STR_ARG(message), - LOG_STR_ARG(api_error_to_logstr(err)), errno); + ESP_LOGW(TAG, "%s (%s): %s %s errno=%d", this->client_info_.name.c_str(), this->client_info_.peername.c_str(), + LOG_STR_ARG(message), LOG_STR_ARG(api_error_to_logstr(err)), errno); } void APIConnection::log_socket_operation_failed_(APIError err) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 54535eaccd..a21574f6d5 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -270,8 +270,8 @@ class APIConnection final : public APIServerConnection { bool try_to_clear_buffer(bool log_out_of_space); bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override; - const char *get_name() const { return this->client_info_.name.c_str(); } - const char *get_peername() const { return this->client_info_.peername.c_str(); } + const std::string &get_name() const { return this->client_info_.name; } + const std::string &get_peername() const { return this->client_info_.peername; } protected: // Helper function to handle authentication completion diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 2b41a71769..a8fdb635cf 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -177,7 +177,8 @@ void APIServer::loop() { // Network is down - disconnect all clients for (auto &client : this->clients_) { client->on_fatal_error(); - ESP_LOGW(TAG, "%s (%s): Network down; disconnect", client->get_name(), client->get_peername()); + ESP_LOGW(TAG, "%s (%s): Network down; disconnect", client->client_info_.name.c_str(), + client->client_info_.peername.c_str()); } // Continue to process and clean up the clients below } diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index bb429ca7df..7ece73994f 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -429,8 +429,9 @@ void VoiceAssistant::client_subscription(api::APIConnection *client, bool subscr if (this->api_client_ != nullptr) { ESP_LOGE(TAG, "Multiple API Clients attempting to connect to Voice Assistant"); - ESP_LOGE(TAG, "Current client: %s (%s)", this->api_client_->get_name(), this->api_client_->get_peername()); - ESP_LOGE(TAG, "New client: %s (%s)", client->get_name(), client->get_peername()); + ESP_LOGE(TAG, "Current client: %s (%s)", this->api_client_->get_name().c_str(), + this->api_client_->get_peername().c_str()); + ESP_LOGE(TAG, "New client: %s (%s)", client->get_name().c_str(), client->get_peername().c_str()); return; } From f3330118ba8382fd2786a1ad8f294da8ab831229 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 18:13:57 +0200 Subject: [PATCH 1595/1626] [api] Add configurable send queue limit to prevent OOM crashes --- esphome/components/api/__init__.py | 15 +++++++ esphome/components/api/api_frame_helper.cpp | 46 +++++++++++++-------- esphome/components/api/api_frame_helper.h | 12 ++++-- esphome/core/defines.h | 1 + 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index c91051ba20..a1e0f9a768 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -61,6 +61,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" CONF_LISTEN_BACKLOG = "listen_backlog" CONF_MAX_CONNECTIONS = "max_connections" +CONF_MAX_SEND_QUEUE = "max_send_queue" def validate_encryption_key(value): @@ -183,6 +184,19 @@ CONFIG_SCHEMA = cv.All( host=8, # Abundant resources ln882x=8, # Moderate RAM ): cv.int_range(min=1, max=20), + # Maximum queued send buffers per connection before dropping connection + # Each buffer uses ~8-12 bytes overhead plus actual message size + # Platform defaults based on available RAM and typical message rates: + cv.SplitDefault( + CONF_MAX_SEND_QUEUE, + esp8266=5, # Limited RAM, need to fail fast + esp32=8, # More RAM, can buffer more + rp2040=5, # Limited RAM + bk72xx=8, # Moderate RAM + rtl87xx=8, # Moderate RAM + host=16, # Abundant resources + ln882x=8, # Moderate RAM + ): cv.int_range(min=1, max=32), } ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), @@ -205,6 +219,7 @@ async def to_code(config): cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) if CONF_MAX_CONNECTIONS in config: cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) + cg.add_define("API_MAX_SEND_QUEUE", config.get(CONF_MAX_SEND_QUEUE, 5)) # Set USE_API_SERVICES if any services are enabled if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index a284e09c4a..08ed375a0d 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -80,7 +80,7 @@ const LogString *api_error_to_logstr(APIError err) { // Default implementation for loop - handles sending buffered data APIError APIFrameHelper::loop() { - if (!this->tx_buf_.empty()) { + if (this->tx_buf_count_ > 0) { APIError err = try_send_tx_buf_(); if (err != APIError::OK && err != APIError::WOULD_BLOCK) { return err; @@ -102,9 +102,16 @@ APIError APIFrameHelper::handle_socket_write_error_() { // Helper method to buffer data from IOVs void APIFrameHelper::buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, uint16_t offset) { - SendBuffer buffer; - buffer.size = total_write_len - offset; - buffer.data = std::make_unique(buffer.size); + // Check if queue is full + if (this->tx_buf_count_ >= API_MAX_SEND_QUEUE) { + HELPER_LOG("Send queue full (%u buffers), dropping connection", this->tx_buf_count_); + this->state_ = State::FAILED; + return; + } + + auto buffer = std::make_unique(); + buffer->size = total_write_len - offset; + buffer->data = std::make_unique(buffer->size); uint16_t to_skip = offset; uint16_t write_pos = 0; @@ -117,12 +124,16 @@ void APIFrameHelper::buffer_data_from_iov_(const struct iovec *iov, int iovcnt, // Include this segment (partially or fully) const uint8_t *src = reinterpret_cast(iov[i].iov_base) + to_skip; uint16_t len = static_cast(iov[i].iov_len) - to_skip; - std::memcpy(buffer.data.get() + write_pos, src, len); + std::memcpy(buffer->data.get() + write_pos, src, len); write_pos += len; to_skip = 0; } } - this->tx_buf_.push_back(std::move(buffer)); + + // Add to circular buffer + this->tx_buf_[this->tx_buf_tail_] = std::move(buffer); + this->tx_buf_tail_ = (this->tx_buf_tail_ + 1) % API_MAX_SEND_QUEUE; + this->tx_buf_count_++; } // This method writes data to socket or buffers it @@ -140,7 +151,7 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ #endif // Try to send any existing buffered data first if there is any - if (!this->tx_buf_.empty()) { + if (this->tx_buf_count_ > 0) { APIError send_result = try_send_tx_buf_(); // If real error occurred (not just WOULD_BLOCK), return it if (send_result != APIError::OK && send_result != APIError::WOULD_BLOCK) { @@ -149,7 +160,7 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ // If there is still data in the buffer, we can't send, buffer // the new data and return - if (!this->tx_buf_.empty()) { + if (this->tx_buf_count_ > 0) { this->buffer_data_from_iov_(iov, iovcnt, total_write_len, 0); return APIError::OK; // Success, data buffered } @@ -177,32 +188,31 @@ APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, uint16_ } // Common implementation for trying to send buffered data -// IMPORTANT: Caller MUST ensure tx_buf_ is not empty before calling this method +// IMPORTANT: Caller MUST ensure tx_buf_count_ > 0 before calling this method APIError APIFrameHelper::try_send_tx_buf_() { // Try to send from tx_buf - we assume it's not empty as it's the caller's responsibility to check - bool tx_buf_empty = false; - while (!tx_buf_empty) { + while (this->tx_buf_count_ > 0) { // Get the first buffer in the queue - SendBuffer &front_buffer = this->tx_buf_.front(); + SendBuffer *front_buffer = this->tx_buf_[this->tx_buf_head_].get(); // Try to send the remaining data in this buffer - ssize_t sent = this->socket_->write(front_buffer.current_data(), front_buffer.remaining()); + ssize_t sent = this->socket_->write(front_buffer->current_data(), front_buffer->remaining()); if (sent == -1) { return this->handle_socket_write_error_(); } else if (sent == 0) { // Nothing sent but not an error return APIError::WOULD_BLOCK; - } else if (static_cast(sent) < front_buffer.remaining()) { + } else if (static_cast(sent) < front_buffer->remaining()) { // Partially sent, update offset // Cast to ensure no overflow issues with uint16_t - front_buffer.offset += static_cast(sent); + front_buffer->offset += static_cast(sent); return APIError::WOULD_BLOCK; // Stop processing more buffers if we couldn't send a complete buffer } else { // Buffer completely sent, remove it from the queue - this->tx_buf_.pop_front(); - // Update empty status for the loop condition - tx_buf_empty = this->tx_buf_.empty(); + this->tx_buf_[this->tx_buf_head_].reset(); + this->tx_buf_head_ = (this->tx_buf_head_ + 1) % API_MAX_SEND_QUEUE; + this->tx_buf_count_--; // Continue loop to try sending the next buffer } } diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index c11d701ffe..f98a6f8928 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -1,7 +1,8 @@ #pragma once +#include #include -#include #include +#include #include #include #include @@ -79,7 +80,7 @@ class APIFrameHelper { virtual APIError init() = 0; virtual APIError loop(); virtual APIError read_packet(ReadPacketBuffer *buffer) = 0; - bool can_write_without_blocking() { return state_ == State::DATA && tx_buf_.empty(); } + bool can_write_without_blocking() { return state_ == State::DATA && tx_buf_count_ == 0; } std::string getpeername() { return socket_->getpeername(); } int getpeername(struct sockaddr *addr, socklen_t *addrlen) { return socket_->getpeername(addr, addrlen); } APIError close() { @@ -161,7 +162,7 @@ class APIFrameHelper { }; // Containers (size varies, but typically 12+ bytes on 32-bit) - std::deque tx_buf_; + std::array, API_MAX_SEND_QUEUE> tx_buf_; std::vector reusable_iovs_; std::vector rx_buf_; @@ -174,7 +175,10 @@ class APIFrameHelper { State state_{State::INITIALIZE}; uint8_t frame_header_padding_{0}; uint8_t frame_footer_size_{0}; - // 5 bytes total, 3 bytes padding + uint8_t tx_buf_head_{0}; + uint8_t tx_buf_tail_{0}; + uint8_t tx_buf_count_{0}; + // 8 bytes total, 0 bytes padding // Common initialization for both plaintext and noise protocols APIError init_common_(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7fc42ea334..5516b06040 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -115,6 +115,7 @@ #define USE_API_NOISE #define USE_API_PLAINTEXT #define USE_API_SERVICES +#define API_MAX_SEND_QUEUE 8 #define USE_MD5 #define USE_SHA256 #define USE_MQTT From adade2952aa30f52e439b09eb2e9dfb6b91476ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 18:17:41 +0200 Subject: [PATCH 1596/1626] 64 --- esphome/components/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index a1e0f9a768..b56d4a9eb4 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -196,7 +196,7 @@ CONFIG_SCHEMA = cv.All( rtl87xx=8, # Moderate RAM host=16, # Abundant resources ln882x=8, # Moderate RAM - ): cv.int_range(min=1, max=32), + ): cv.int_range(min=1, max=64), } ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), From 8137d7600a30792f755ef018591d0ef25c0894d8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:26:25 -0400 Subject: [PATCH 1597/1626] [rtttl] Fix warning (#10972) --- esphome/components/rtttl/rtttl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 5aedc74489..2c48105490 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -374,7 +374,7 @@ void Rtttl::loop() { this->last_note_ = millis(); } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE static const LogString *state_to_string(State state) { switch (state) { case STATE_STOPPED: From 638c6cc14e2d64fc52ebdc29d76daf1f16ce92d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 20:26:47 +0200 Subject: [PATCH 1598/1626] [api] Reduce flash usage in user services by eliminating vector copy (#10971) --- esphome/components/api/user_services.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 5f040e8433..dba2d055bf 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -55,7 +55,7 @@ template class UserServiceBase : public UserServiceDescriptor { protected: virtual void execute(Ts... x) = 0; - template void execute_(std::vector args, seq type) { + template void execute_(const std::vector &args, seq type) { this->execute((get_execute_arg_value(args[S]))...); } From a4c794c9fa3141ad09273fff34b6ac8e0f5dd76a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 20:34:51 +0200 Subject: [PATCH 1599/1626] tweak, compiler optimizes it away anyways though --- esphome/components/api/api_frame_helper.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 08ed375a0d..f0a6d92d8b 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -109,9 +109,11 @@ void APIFrameHelper::buffer_data_from_iov_(const struct iovec *iov, int iovcnt, return; } - auto buffer = std::make_unique(); - buffer->size = total_write_len - offset; - buffer->data = std::make_unique(buffer->size); + uint16_t buffer_size = total_write_len - offset; + auto &buffer = this->tx_buf_[this->tx_buf_tail_]; + buffer = std::make_unique(); + buffer->size = buffer_size; + buffer->data = std::make_unique(buffer_size); uint16_t to_skip = offset; uint16_t write_pos = 0; @@ -130,8 +132,7 @@ void APIFrameHelper::buffer_data_from_iov_(const struct iovec *iov, int iovcnt, } } - // Add to circular buffer - this->tx_buf_[this->tx_buf_tail_] = std::move(buffer); + // Update circular buffer tracking this->tx_buf_tail_ = (this->tx_buf_tail_ + 1) % API_MAX_SEND_QUEUE; this->tx_buf_count_++; } From 4b10bf09be76c69d564da360bc09a8da053d1d11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 20:36:40 +0200 Subject: [PATCH 1600/1626] tweak, compiler optimizes it away anyways though --- esphome/components/api/api_frame_helper.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index f0a6d92d8b..c9a5b2885e 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -111,9 +111,11 @@ void APIFrameHelper::buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t buffer_size = total_write_len - offset; auto &buffer = this->tx_buf_[this->tx_buf_tail_]; - buffer = std::make_unique(); - buffer->size = buffer_size; - buffer->data = std::make_unique(buffer_size); + buffer = std::make_unique(SendBuffer{ + .data = std::make_unique(buffer_size), + .size = buffer_size, + .offset = 0, + }); uint16_t to_skip = offset; uint16_t write_pos = 0; From 65384ef31aad3643af57a8464119297629ba7a97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 21:08:31 +0200 Subject: [PATCH 1601/1626] style --- esphome/components/api/api_frame_helper.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index f98a6f8928..3184250e8c 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -80,7 +80,7 @@ class APIFrameHelper { virtual APIError init() = 0; virtual APIError loop(); virtual APIError read_packet(ReadPacketBuffer *buffer) = 0; - bool can_write_without_blocking() { return state_ == State::DATA && tx_buf_count_ == 0; } + bool can_write_without_blocking() { return this->state_ == State::DATA && this->tx_buf_count_ == 0; } std::string getpeername() { return socket_->getpeername(); } int getpeername(struct sockaddr *addr, socklen_t *addrlen) { return socket_->getpeername(addr, addrlen); } APIError close() { From b25248658793aa717435b1d9157cbe6ce4f6074d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 21:13:05 +0200 Subject: [PATCH 1602/1626] preen --- esphome/components/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index b56d4a9eb4..4e121fa404 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -219,7 +219,7 @@ async def to_code(config): cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) if CONF_MAX_CONNECTIONS in config: cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) - cg.add_define("API_MAX_SEND_QUEUE", config.get(CONF_MAX_SEND_QUEUE, 5)) + cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) # Set USE_API_SERVICES if any services are enabled if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: From a9dc0628c42d3539e2e0ea5555c5d9a4afeb135a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 22:21:58 +0200 Subject: [PATCH 1603/1626] [mdns][openthread] Use std::array for mdns services and remove unnecessary copy --- esphome/components/mdns/__init__.py | 30 ++++++++++-- esphome/components/mdns/mdns_component.cpp | 50 +++++++------------- esphome/components/mdns/mdns_component.h | 16 ++++--- esphome/components/openthread/openthread.cpp | 11 +++-- esphome/components/openthread/openthread.h | 1 - 5 files changed, 58 insertions(+), 50 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index a84fe5a249..f023f84750 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -91,12 +91,36 @@ async def to_code(config): cg.add_define("USE_MDNS") + # Calculate compile-time service count + service_count = 0 + + # Check if API component is enabled (it may create a service at runtime) + if cg.is_defined("USE_API"): + service_count += 1 + + # Check for prometheus + if cg.is_defined("USE_PROMETHEUS"): + service_count += 1 + + # Check for web_server + if cg.is_defined("USE_WEBSERVER"): + service_count += 1 + + # Count extra services from config + extra_services_count = len(config[CONF_SERVICES]) + if extra_services_count > 0: + service_count += extra_services_count + cg.add_define("USE_MDNS_EXTRA_SERVICES") + + # Ensure at least 1 service (fallback service) + if service_count == 0: + service_count = 1 + + cg.add_define("MDNS_SERVICE_COUNT", service_count) + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if config[CONF_SERVICES]: - cg.add_define("USE_MDNS_EXTRA_SERVICES") - for service in config[CONF_SERVICES]: txt = [ cg.StructInitializer( diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 5d9788198f..5a5286c5bc 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -73,33 +73,11 @@ MDNS_STATIC_CONST_CHAR(NETWORK_THREAD, "thread"); void MDNSComponent::compile_records_() { this->hostname_ = App.get_name(); - - // Calculate exact capacity needed for services vector - size_t services_count = 0; -#ifdef USE_API - if (api::global_api_server != nullptr) { - services_count++; - } -#endif -#ifdef USE_PROMETHEUS - services_count++; -#endif -#ifdef USE_WEBSERVER - services_count++; -#endif -#ifdef USE_MDNS_EXTRA_SERVICES - services_count += this->services_extra_.size(); -#endif - // Reserve for fallback service if needed - if (services_count == 0) { - services_count = 1; - } - this->services_.reserve(services_count); + this->services_count_ = 0; #ifdef USE_API if (api::global_api_server != nullptr) { - this->services_.emplace_back(); - auto &service = this->services_.back(); + auto &service = this->services_[this->services_count_++]; service.service_type = MDNS_STR(SERVICE_ESPHOMELIB); service.proto = MDNS_STR(SERVICE_TCP); service.port = api::global_api_server->get_port(); @@ -178,30 +156,29 @@ void MDNSComponent::compile_records_() { #endif // USE_API #ifdef USE_PROMETHEUS - this->services_.emplace_back(); - auto &prom_service = this->services_.back(); + auto &prom_service = this->services_[this->services_count_++]; prom_service.service_type = MDNS_STR(SERVICE_PROMETHEUS); prom_service.proto = MDNS_STR(SERVICE_TCP); prom_service.port = USE_WEBSERVER_PORT; #endif #ifdef USE_WEBSERVER - this->services_.emplace_back(); - auto &web_service = this->services_.back(); + auto &web_service = this->services_[this->services_count_++]; web_service.service_type = MDNS_STR(SERVICE_HTTP); web_service.proto = MDNS_STR(SERVICE_TCP); web_service.port = USE_WEBSERVER_PORT; #endif #ifdef USE_MDNS_EXTRA_SERVICES - this->services_.insert(this->services_.end(), this->services_extra_.begin(), this->services_extra_.end()); + for (const auto &extra_service : this->services_extra_) { + this->services_[this->services_count_++] = extra_service; + } #endif #if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_WEBSERVER) && !defined(USE_MDNS_EXTRA_SERVICES) // Publish "http" service if not using native API or any other services // This is just to have *some* mDNS service so that .local resolution works - this->services_.emplace_back(); - auto &fallback_service = this->services_.back(); + auto &fallback_service = this->services_[this->services_count_++]; fallback_service.service_type = "_http"; fallback_service.proto = "_tcp"; fallback_service.port = USE_WEBSERVER_PORT; @@ -209,6 +186,12 @@ void MDNSComponent::compile_records_() { #endif } +#ifdef USE_MDNS_EXTRA_SERVICES +void MDNSComponent::add_extra_service(MDNSService service) { + this->services_[this->services_count_++] = std::move(service); +} +#endif + void MDNSComponent::dump_config() { ESP_LOGCONFIG(TAG, "mDNS:\n" @@ -216,7 +199,8 @@ void MDNSComponent::dump_config() { this->hostname_.c_str()); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE ESP_LOGV(TAG, " Services:"); - for (const auto &service : this->services_) { + for (uint8_t i = 0; i < this->services_count_; i++) { + const auto &service = this->services_[i]; ESP_LOGV(TAG, " - %s, %s, %d", service.service_type.c_str(), service.proto.c_str(), const_cast &>(service.port).value()); for (const auto &record : service.txt_records) { @@ -227,8 +211,6 @@ void MDNSComponent::dump_config() { #endif } -std::vector MDNSComponent::get_services() { return this->services_; } - } // namespace mdns } // namespace esphome #endif diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index f87ef08bcd..e653e9384f 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -1,14 +1,17 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_MDNS +#include #include -#include #include "esphome/core/automation.h" #include "esphome/core/component.h" namespace esphome { namespace mdns { +// Service count is calculated at compile time by Python codegen +// MDNS_SERVICE_COUNT will always be defined + struct MDNSTXTRecord { std::string key; TemplatableValue value; @@ -36,18 +39,17 @@ class MDNSComponent : public Component { float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } #ifdef USE_MDNS_EXTRA_SERVICES - void add_extra_service(MDNSService service) { services_extra_.push_back(std::move(service)); } + void add_extra_service(MDNSService service); #endif - std::vector get_services(); + const std::array &get_services() const { return services_; } + uint8_t get_services_count() const { return services_count_; } void on_shutdown() override; protected: -#ifdef USE_MDNS_EXTRA_SERVICES - std::vector services_extra_{}; -#endif - std::vector services_{}; + std::array services_{}; + uint8_t services_count_{0}; std::string hostname_; void compile_records_(); }; diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 5b5c113f83..3caec9b698 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -143,11 +143,12 @@ void OpenThreadSrpComponent::setup() { return; } - // Copy the mdns services to our local instance so that the c_str pointers remain valid for the lifetime of this - // component - this->mdns_services_ = this->mdns_->get_services(); - ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", this->mdns_services_.size()); - for (const auto &service : this->mdns_services_) { + // Use mdns services directly - they remain valid for the lifetime of the mdns component + const auto &mdns_services = this->mdns_->get_services(); + uint8_t mdns_count = this->mdns_->get_services_count(); + ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_count); + for (uint8_t i = 0; i < mdns_count; i++) { + const auto &service = mdns_services[i]; otSrpClientBuffersServiceEntry *entry = otSrpClientBuffersAllocateService(instance); if (!entry) { ESP_LOGW(TAG, "Failed to allocate service entry"); diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index a9aff78e56..5d139c633d 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -57,7 +57,6 @@ class OpenThreadSrpComponent : public Component { protected: esphome::mdns::MDNSComponent *mdns_{nullptr}; - std::vector mdns_services_; std::vector> memory_pool_; void *pool_alloc_(size_t size); }; From 21d7dc2b9bca4c5e98f27e3820b0086c91931f8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 22:25:11 +0200 Subject: [PATCH 1604/1626] [mdns][openthread] Use std::array for mdns services and remove unnecessary copy --- esphome/components/mdns/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index f023f84750..ced4753b75 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -95,15 +95,15 @@ async def to_code(config): service_count = 0 # Check if API component is enabled (it may create a service at runtime) - if cg.is_defined("USE_API"): + if "api" in CORE.config: service_count += 1 # Check for prometheus - if cg.is_defined("USE_PROMETHEUS"): + if "prometheus" in CORE.config: service_count += 1 # Check for web_server - if cg.is_defined("USE_WEBSERVER"): + if "web_server" in CORE.config: service_count += 1 # Count extra services from config From 518402f0310350c192b057fd21164d3c9354528b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 22:31:04 +0200 Subject: [PATCH 1605/1626] preen --- esphome/core/defines.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7fc42ea334..12bf02d856 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -82,6 +82,7 @@ #define USE_LVGL_TILEVIEW #define USE_LVGL_TOUCHSCREEN #define USE_MDNS +#define MDNS_SERVICE_COUNT 3 #define USE_MEDIA_PLAYER #define USE_NEXTION_TFT_UPLOAD #define USE_NUMBER From 2eb35f83b79c7ea1466a1e47ee9d8d221f0a74f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 22:31:55 +0200 Subject: [PATCH 1606/1626] preen --- esphome/components/mdns/mdns_component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 5a5286c5bc..6dba821258 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -197,7 +197,7 @@ void MDNSComponent::dump_config() { "mDNS:\n" " Hostname: %s", this->hostname_.c_str()); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, " Services:"); for (uint8_t i = 0; i < this->services_count_; i++) { const auto &service = this->services_[i]; From c12eba95908b59a6469130ee9440cd0bf5469a87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 22:32:28 +0200 Subject: [PATCH 1607/1626] preen --- esphome/components/mdns/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index ced4753b75..550a39216b 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -113,10 +113,7 @@ async def to_code(config): cg.add_define("USE_MDNS_EXTRA_SERVICES") # Ensure at least 1 service (fallback service) - if service_count == 0: - service_count = 1 - - cg.add_define("MDNS_SERVICE_COUNT", service_count) + cg.add_define("MDNS_SERVICE_COUNT", max(1, service_count)) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) From 03e0fbd65759d74651aff30fc69b73a0c4c92c3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 22:33:22 +0200 Subject: [PATCH 1608/1626] preen --- esphome/components/mdns/__init__.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 550a39216b..a22085978d 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -92,19 +92,10 @@ async def to_code(config): cg.add_define("USE_MDNS") # Calculate compile-time service count - service_count = 0 - - # Check if API component is enabled (it may create a service at runtime) - if "api" in CORE.config: - service_count += 1 - - # Check for prometheus - if "prometheus" in CORE.config: - service_count += 1 - - # Check for web_server - if "web_server" in CORE.config: - service_count += 1 + # Each of these components may create a service at runtime + service_count = sum( + 1 for key in ("api", "prometheus", "web_server") if key in CORE.config + ) # Count extra services from config extra_services_count = len(config[CONF_SERVICES]) From 30df2cb9ee97ed294f6c3310c47ac65f2e9dba87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 22:33:50 +0200 Subject: [PATCH 1609/1626] preen --- esphome/components/mdns/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index a22085978d..8c3193430c 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -95,12 +95,9 @@ async def to_code(config): # Each of these components may create a service at runtime service_count = sum( 1 for key in ("api", "prometheus", "web_server") if key in CORE.config - ) + ) + len(config[CONF_SERVICES]) - # Count extra services from config - extra_services_count = len(config[CONF_SERVICES]) - if extra_services_count > 0: - service_count += extra_services_count + if config[CONF_SERVICES]: cg.add_define("USE_MDNS_EXTRA_SERVICES") # Ensure at least 1 service (fallback service) From b4b8b43bd77f414f4d0784277dcc6ddac950e2ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 22:34:32 +0200 Subject: [PATCH 1610/1626] preen --- esphome/components/mdns/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 8c3193430c..12ec7353b5 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -17,6 +17,9 @@ from esphome.coroutine import CoroPriority CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] +# Components that create mDNS services at runtime +COMPONENTS_WITH_MDNS_SERVICES = ("api", "prometheus", "web_server") + mdns_ns = cg.esphome_ns.namespace("mdns") MDNSComponent = mdns_ns.class_("MDNSComponent", cg.Component) MDNSTXTRecord = mdns_ns.struct("MDNSTXTRecord") @@ -92,9 +95,8 @@ async def to_code(config): cg.add_define("USE_MDNS") # Calculate compile-time service count - # Each of these components may create a service at runtime service_count = sum( - 1 for key in ("api", "prometheus", "web_server") if key in CORE.config + 1 for key in COMPONENTS_WITH_MDNS_SERVICES if key in CORE.config ) + len(config[CONF_SERVICES]) if config[CONF_SERVICES]: From de2838fa6683c09916c180d53cf8369f009d6c67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 22:35:33 +0200 Subject: [PATCH 1611/1626] preen --- esphome/components/mdns/__init__.py | 2 ++ esphome/components/mdns/mdns_component.cpp | 3 +++ 2 files changed, 5 insertions(+) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 12ec7353b5..ce0241677d 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -18,6 +18,8 @@ CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] # Components that create mDNS services at runtime +# IMPORTANT: If you add a new component here, you must also update the corresponding +# #ifdef blocks in mdns_component.cpp compile_records_() method COMPONENTS_WITH_MDNS_SERVICES = ("api", "prometheus", "web_server") mdns_ns = cg.esphome_ns.namespace("mdns") diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 6dba821258..58952e94e9 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -75,6 +75,9 @@ void MDNSComponent::compile_records_() { this->hostname_ = App.get_name(); this->services_count_ = 0; + // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES + // in mdns/__init__.py. If you add a new service here, update both locations. + #ifdef USE_API if (api::global_api_server != nullptr) { auto &service = this->services_[this->services_count_++]; From 636d1e16f254b53fe05a1e0207ce91ecbf9d10be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 23:05:30 +0200 Subject: [PATCH 1612/1626] update comment --- esphome/components/openthread/openthread.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 3caec9b698..0fc77a9c81 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -143,7 +143,7 @@ void OpenThreadSrpComponent::setup() { return; } - // Use mdns services directly - they remain valid for the lifetime of the mdns component + // Get mdns services and copy their data (strings are copied with strdup below) const auto &mdns_services = this->mdns_->get_services(); uint8_t mdns_count = this->mdns_->get_services_count(); ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_count); From 03c869bd432faa5e3307d903b7032e258ef920db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 23:05:59 +0200 Subject: [PATCH 1613/1626] update comment --- esphome/components/mdns/mdns_esp32.cpp | 3 ++- esphome/components/mdns/mdns_esp8266.cpp | 3 ++- esphome/components/mdns/mdns_libretiny.cpp | 3 ++- esphome/components/mdns/mdns_rp2040.cpp | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index ffd86afec1..69a6fd3b8a 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -25,7 +25,8 @@ void MDNSComponent::setup() { mdns_hostname_set(this->hostname_.c_str()); mdns_instance_name_set(this->hostname_.c_str()); - for (const auto &service : this->services_) { + for (uint8_t i = 0; i < this->services_count_; i++) { + const auto &service = this->services_[i]; std::vector txt_records; for (const auto &record : service.txt_records) { mdns_txt_item_t it{}; diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 2c90d57021..9d701fe856 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -16,7 +16,8 @@ void MDNSComponent::setup() { MDNS.begin(this->hostname_.c_str()); - for (const auto &service : this->services_) { + for (uint8_t i = 0; i < this->services_count_; i++) { + const auto &service = this->services_[i]; // Strip the leading underscore from the proto and service_type. While it is // part of the wire protocol to have an underscore, and for example ESP-IDF // expects the underscore to be there, the ESP8266 implementation always adds diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index 7a41ec9dce..7f3a05e515 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -16,7 +16,8 @@ void MDNSComponent::setup() { MDNS.begin(this->hostname_.c_str()); - for (const auto &service : this->services_) { + for (uint8_t i = 0; i < this->services_count_; i++) { + const auto &service = this->services_[i]; // Strip the leading underscore from the proto and service_type. While it is // part of the wire protocol to have an underscore, and for example ESP-IDF // expects the underscore to be there, the ESP8266 implementation always adds diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 95894323f4..395d335ffd 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -16,7 +16,8 @@ void MDNSComponent::setup() { MDNS.begin(this->hostname_.c_str()); - for (const auto &service : this->services_) { + for (uint8_t i = 0; i < this->services_count_; i++) { + const auto &service = this->services_[i]; // Strip the leading underscore from the proto and service_type. While it is // part of the wire protocol to have an underscore, and for example ESP-IDF // expects the underscore to be there, the ESP8266 implementation always adds From 76defeac39bb129a6cc0c278829b4310da71a0cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 23:26:29 +0200 Subject: [PATCH 1614/1626] preen --- esphome/components/mdns/mdns_component.cpp | 26 ++++++-------------- esphome/components/mdns/mdns_component.h | 10 +++----- esphome/components/mdns/mdns_esp32.cpp | 3 +-- esphome/components/mdns/mdns_esp8266.cpp | 3 +-- esphome/components/mdns/mdns_libretiny.cpp | 3 +-- esphome/components/mdns/mdns_rp2040.cpp | 3 +-- esphome/components/openthread/openthread.cpp | 8 +++--- esphome/core/helpers.h | 3 +++ 8 files changed, 21 insertions(+), 38 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 58952e94e9..eb9a355d40 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -73,14 +73,13 @@ MDNS_STATIC_CONST_CHAR(NETWORK_THREAD, "thread"); void MDNSComponent::compile_records_() { this->hostname_ = App.get_name(); - this->services_count_ = 0; // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // in mdns/__init__.py. If you add a new service here, update both locations. #ifdef USE_API if (api::global_api_server != nullptr) { - auto &service = this->services_[this->services_count_++]; + auto &service = this->services_[this->services_.count()++]; service.service_type = MDNS_STR(SERVICE_ESPHOMELIB); service.proto = MDNS_STR(SERVICE_TCP); service.port = api::global_api_server->get_port(); @@ -159,29 +158,23 @@ void MDNSComponent::compile_records_() { #endif // USE_API #ifdef USE_PROMETHEUS - auto &prom_service = this->services_[this->services_count_++]; + auto &prom_service = this->services_[this->services_.count()++]; prom_service.service_type = MDNS_STR(SERVICE_PROMETHEUS); prom_service.proto = MDNS_STR(SERVICE_TCP); prom_service.port = USE_WEBSERVER_PORT; #endif #ifdef USE_WEBSERVER - auto &web_service = this->services_[this->services_count_++]; + auto &web_service = this->services_[this->services_.count()++]; web_service.service_type = MDNS_STR(SERVICE_HTTP); web_service.proto = MDNS_STR(SERVICE_TCP); web_service.port = USE_WEBSERVER_PORT; #endif -#ifdef USE_MDNS_EXTRA_SERVICES - for (const auto &extra_service : this->services_extra_) { - this->services_[this->services_count_++] = extra_service; - } -#endif - #if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_WEBSERVER) && !defined(USE_MDNS_EXTRA_SERVICES) // Publish "http" service if not using native API or any other services // This is just to have *some* mDNS service so that .local resolution works - auto &fallback_service = this->services_[this->services_count_++]; + auto &fallback_service = this->services_[this->services_.count()++]; fallback_service.service_type = "_http"; fallback_service.proto = "_tcp"; fallback_service.port = USE_WEBSERVER_PORT; @@ -189,12 +182,6 @@ void MDNSComponent::compile_records_() { #endif } -#ifdef USE_MDNS_EXTRA_SERVICES -void MDNSComponent::add_extra_service(MDNSService service) { - this->services_[this->services_count_++] = std::move(service); -} -#endif - void MDNSComponent::dump_config() { ESP_LOGCONFIG(TAG, "mDNS:\n" @@ -202,8 +189,7 @@ void MDNSComponent::dump_config() { this->hostname_.c_str()); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, " Services:"); - for (uint8_t i = 0; i < this->services_count_; i++) { - const auto &service = this->services_[i]; + for (const auto &service : this->services_) { ESP_LOGV(TAG, " - %s, %s, %d", service.service_type.c_str(), service.proto.c_str(), const_cast &>(service.port).value()); for (const auto &record : service.txt_records) { @@ -214,6 +200,8 @@ void MDNSComponent::dump_config() { #endif } +StaticVector MDNSComponent::get_services() { return this->services_; } + } // namespace mdns } // namespace esphome #endif diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index e653e9384f..4e1320c0e5 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -1,10 +1,10 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_MDNS -#include #include #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome { namespace mdns { @@ -39,17 +39,15 @@ class MDNSComponent : public Component { float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } #ifdef USE_MDNS_EXTRA_SERVICES - void add_extra_service(MDNSService service); + void add_extra_service(MDNSService service) { services_.push_back(std::move(service)); } #endif - const std::array &get_services() const { return services_; } - uint8_t get_services_count() const { return services_count_; } + StaticVector get_services(); void on_shutdown() override; protected: - std::array services_{}; - uint8_t services_count_{0}; + StaticVector services_{}; std::string hostname_; void compile_records_(); }; diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 69a6fd3b8a..ffd86afec1 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -25,8 +25,7 @@ void MDNSComponent::setup() { mdns_hostname_set(this->hostname_.c_str()); mdns_instance_name_set(this->hostname_.c_str()); - for (uint8_t i = 0; i < this->services_count_; i++) { - const auto &service = this->services_[i]; + for (const auto &service : this->services_) { std::vector txt_records; for (const auto &record : service.txt_records) { mdns_txt_item_t it{}; diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 9d701fe856..2c90d57021 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -16,8 +16,7 @@ void MDNSComponent::setup() { MDNS.begin(this->hostname_.c_str()); - for (uint8_t i = 0; i < this->services_count_; i++) { - const auto &service = this->services_[i]; + for (const auto &service : this->services_) { // Strip the leading underscore from the proto and service_type. While it is // part of the wire protocol to have an underscore, and for example ESP-IDF // expects the underscore to be there, the ESP8266 implementation always adds diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index 7f3a05e515..7a41ec9dce 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -16,8 +16,7 @@ void MDNSComponent::setup() { MDNS.begin(this->hostname_.c_str()); - for (uint8_t i = 0; i < this->services_count_; i++) { - const auto &service = this->services_[i]; + for (const auto &service : this->services_) { // Strip the leading underscore from the proto and service_type. While it is // part of the wire protocol to have an underscore, and for example ESP-IDF // expects the underscore to be there, the ESP8266 implementation always adds diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 395d335ffd..95894323f4 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -16,8 +16,7 @@ void MDNSComponent::setup() { MDNS.begin(this->hostname_.c_str()); - for (uint8_t i = 0; i < this->services_count_; i++) { - const auto &service = this->services_[i]; + for (const auto &service : this->services_) { // Strip the leading underscore from the proto and service_type. While it is // part of the wire protocol to have an underscore, and for example ESP-IDF // expects the underscore to be there, the ESP8266 implementation always adds diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 0fc77a9c81..698688b425 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -144,11 +144,9 @@ void OpenThreadSrpComponent::setup() { } // Get mdns services and copy their data (strings are copied with strdup below) - const auto &mdns_services = this->mdns_->get_services(); - uint8_t mdns_count = this->mdns_->get_services_count(); - ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_count); - for (uint8_t i = 0; i < mdns_count; i++) { - const auto &service = mdns_services[i]; + auto mdns_services = this->mdns_->get_services(); + ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_services.size()); + for (const auto &service : mdns_services) { otSrpClientBuffersServiceEntry *entry = otSrpClientBuffersAllocateService(instance); if (!entry) { ESP_LOGW(TAG, "Failed to allocate service entry"); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a28718de5a..d3852c3969 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -130,6 +130,9 @@ template class StaticVector { size_t size() const { return count_; } bool empty() const { return count_ == 0; } + // Direct access to increment size for efficient initialization + size_t &count() { return count_; } + T &operator[](size_t i) { return data_[i]; } const T &operator[](size_t i) const { return data_[i]; } From 785ad0cd47aad446fd2aeccb5f43061ca517da21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 23:27:14 +0200 Subject: [PATCH 1615/1626] preen --- esphome/core/helpers.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d3852c3969..39d39c1c94 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -130,7 +130,7 @@ template class StaticVector { size_t size() const { return count_; } bool empty() const { return count_ == 0; } - // Direct access to increment size for efficient initialization + // Direct access to size counter for efficient in-place construction size_t &count() { return count_; } T &operator[](size_t i) { return data_[i]; } From 86bfedc30e6ca55c2e07bc1c9a4f51d0e4da4e69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 23:30:33 +0200 Subject: [PATCH 1616/1626] preen --- esphome/components/mdns/mdns_component.cpp | 2 -- esphome/components/mdns/mdns_component.h | 4 ++-- esphome/components/openthread/openthread.cpp | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index eb9a355d40..e22bba16f6 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -200,8 +200,6 @@ void MDNSComponent::dump_config() { #endif } -StaticVector MDNSComponent::get_services() { return this->services_; } - } // namespace mdns } // namespace esphome #endif diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 4e1320c0e5..9193aaaf31 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -39,10 +39,10 @@ class MDNSComponent : public Component { float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } #ifdef USE_MDNS_EXTRA_SERVICES - void add_extra_service(MDNSService service) { services_.push_back(std::move(service)); } + void add_extra_service(MDNSService service) { this->services_.push_back(std::move(service)); } #endif - StaticVector get_services(); + const StaticVector &get_services() const { return this->services_; } void on_shutdown() override; diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 698688b425..57b972d195 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -144,7 +144,7 @@ void OpenThreadSrpComponent::setup() { } // Get mdns services and copy their data (strings are copied with strdup below) - auto mdns_services = this->mdns_->get_services(); + const auto &mdns_services = this->mdns_->get_services(); ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_services.size()); for (const auto &service : mdns_services) { otSrpClientBuffersServiceEntry *entry = otSrpClientBuffersAllocateService(instance); From 15ca069d58f5debed1eec6983c1a0c8f14e699f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Oct 2025 23:34:12 +0200 Subject: [PATCH 1617/1626] preen --- esphome/components/mdns/mdns_component.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 9193aaaf31..fdbe5b11e7 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -39,7 +39,7 @@ class MDNSComponent : public Component { float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } #ifdef USE_MDNS_EXTRA_SERVICES - void add_extra_service(MDNSService service) { this->services_.push_back(std::move(service)); } + void add_extra_service(MDNSService service) { this->services_[this->services_.count()++] = std::move(service); } #endif const StaticVector &get_services() const { return this->services_; } From efc8a8b904b343a76df2cc3e1197326d2f16178c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Oct 2025 00:02:08 +0200 Subject: [PATCH 1618/1626] [lock] Replace std::set with bitmask (saves 388B flash + 23B RAM per lock) --- esphome/components/copy/lock/copy_lock.cpp | 2 +- esphome/components/lock/lock.h | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/esphome/components/copy/lock/copy_lock.cpp b/esphome/components/copy/lock/copy_lock.cpp index 67a8acffec..25bd8c33ef 100644 --- a/esphome/components/copy/lock/copy_lock.cpp +++ b/esphome/components/copy/lock/copy_lock.cpp @@ -11,7 +11,7 @@ void CopyLock::setup() { traits.set_assumed_state(source_->traits.get_assumed_state()); traits.set_requires_code(source_->traits.get_requires_code()); - traits.set_supported_states(source_->traits.get_supported_states()); + traits.set_supported_states_mask(source_->traits.get_supported_states_mask()); traits.set_supports_open(source_->traits.get_supports_open()); this->publish_state(source_->state); diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 04c4cd71cd..68065cc6d9 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -5,7 +5,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences.h" -#include namespace esphome { namespace lock { @@ -44,16 +43,16 @@ class LockTraits { bool get_assumed_state() const { return this->assumed_state_; } void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } - bool supports_state(LockState state) const { return supported_states_.count(state); } - std::set get_supported_states() const { return supported_states_; } - void set_supported_states(std::set states) { supported_states_ = std::move(states); } - void add_supported_state(LockState state) { supported_states_.insert(state); } + bool supports_state(LockState state) const { return supported_states_mask_ & (1 << state); } + uint8_t get_supported_states_mask() const { return supported_states_mask_; } + void set_supported_states_mask(uint8_t mask) { supported_states_mask_ = mask; } + void add_supported_state(LockState state) { supported_states_mask_ |= (1 << state); } protected: bool supports_open_{false}; bool requires_code_{false}; bool assumed_state_{false}; - std::set supported_states_ = {LOCK_STATE_NONE, LOCK_STATE_LOCKED, LOCK_STATE_UNLOCKED}; + uint8_t supported_states_mask_{(1 << LOCK_STATE_NONE) | (1 << LOCK_STATE_LOCKED) | (1 << LOCK_STATE_UNLOCKED)}; }; /** This class is used to encode all control actions on a lock device. From b44b3cbb41acdf0008cbf1fd0625d4a9ebc9faf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Oct 2025 00:15:55 +0200 Subject: [PATCH 1619/1626] back compat --- esphome/components/lock/lock.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 68065cc6d9..6448f909f5 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -5,6 +5,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences.h" +#include namespace esphome { namespace lock { @@ -44,6 +45,12 @@ class LockTraits { void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } bool supports_state(LockState state) const { return supported_states_mask_ & (1 << state); } + void set_supported_states(std::span states) { + supported_states_mask_ = 0; + for (auto state : states) { + supported_states_mask_ |= (1 << state); + } + } uint8_t get_supported_states_mask() const { return supported_states_mask_; } void set_supported_states_mask(uint8_t mask) { supported_states_mask_ = mask; } void add_supported_state(LockState state) { supported_states_mask_ |= (1 << state); } From ff55e03a5fcb126954b06b885957b6b173385077 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Oct 2025 00:20:25 +0200 Subject: [PATCH 1620/1626] back compat --- esphome/components/lock/lock.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 6448f909f5..9737569921 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -5,7 +5,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences.h" -#include +#include namespace esphome { namespace lock { @@ -45,7 +45,7 @@ class LockTraits { void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } bool supports_state(LockState state) const { return supported_states_mask_ & (1 << state); } - void set_supported_states(std::span states) { + void set_supported_states(std::initializer_list states) { supported_states_mask_ = 0; for (auto state : states) { supported_states_mask_ |= (1 << state); From f2aa5a754cb4654045ee64db2026828cf119d459 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Oct 2025 01:44:30 +0200 Subject: [PATCH 1621/1626] [api] Remove ClientInfo::get_combined_info() to eliminate heap fragmentation (#10970) --- esphome/components/api/api_connection.cpp | 15 ++++++++------- esphome/components/api/api_connection.h | 11 ++--------- esphome/components/api/api_frame_helper.cpp | 3 ++- esphome/components/api/api_frame_helper_noise.cpp | 3 ++- .../components/api/api_frame_helper_plaintext.cpp | 3 ++- esphome/components/api/api_server.cpp | 3 ++- .../voice_assistant/voice_assistant.cpp | 5 +++-- 7 files changed, 21 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 30b98803d1..2d12bf5f09 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -205,7 +205,8 @@ void APIConnection::loop() { // Disconnect if not responded within 2.5*keepalive if (now - this->last_traffic_ > KEEPALIVE_DISCONNECT_TIMEOUT) { on_fatal_error(); - ESP_LOGW(TAG, "%s is unresponsive; disconnecting", this->get_client_combined_info().c_str()); + ESP_LOGW(TAG, "%s (%s) is unresponsive; disconnecting", this->client_info_.name.c_str(), + this->client_info_.peername.c_str()); } } else if (now - this->last_traffic_ > KEEPALIVE_TIMEOUT_MS && !this->flags_.remove) { // Only send ping if we're not disconnecting @@ -255,7 +256,7 @@ bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) { // remote initiated disconnect_client // don't close yet, we still need to send the disconnect response // close will happen on next loop - ESP_LOGD(TAG, "%s disconnected", this->get_client_combined_info().c_str()); + ESP_LOGD(TAG, "%s (%s) disconnected", this->client_info_.name.c_str(), this->client_info_.peername.c_str()); this->flags_.next_close = true; DisconnectResponse resp; return this->send_message(resp, DisconnectResponse::MESSAGE_TYPE); @@ -1385,7 +1386,7 @@ void APIConnection::complete_authentication_() { } this->flags_.connection_state = static_cast(ConnectionState::AUTHENTICATED); - ESP_LOGD(TAG, "%s connected", this->get_client_combined_info().c_str()); + ESP_LOGD(TAG, "%s (%s) connected", this->client_info_.name.c_str(), this->client_info_.peername.c_str()); #ifdef USE_API_CLIENT_CONNECTED_TRIGGER this->parent_->get_client_connected_trigger()->trigger(this->client_info_.name, this->client_info_.peername); #endif @@ -1609,12 +1610,12 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { #ifdef USE_API_PASSWORD void APIConnection::on_unauthenticated_access() { this->on_fatal_error(); - ESP_LOGD(TAG, "%s access without authentication", this->get_client_combined_info().c_str()); + ESP_LOGD(TAG, "%s (%s) no authentication", this->client_info_.name.c_str(), this->client_info_.peername.c_str()); } #endif void APIConnection::on_no_setup_connection() { this->on_fatal_error(); - ESP_LOGD(TAG, "%s access without full connection", this->get_client_combined_info().c_str()); + ESP_LOGD(TAG, "%s (%s) no connection setup", this->client_info_.name.c_str(), this->client_info_.peername.c_str()); } void APIConnection::on_fatal_error() { this->helper_->close(); @@ -1866,8 +1867,8 @@ void APIConnection::process_state_subscriptions_() { #endif // USE_API_HOMEASSISTANT_STATES void APIConnection::log_warning_(const LogString *message, APIError err) { - ESP_LOGW(TAG, "%s: %s %s errno=%d", this->get_client_combined_info().c_str(), LOG_STR_ARG(message), - LOG_STR_ARG(api_error_to_logstr(err)), errno); + ESP_LOGW(TAG, "%s (%s): %s %s errno=%d", this->client_info_.name.c_str(), this->client_info_.peername.c_str(), + LOG_STR_ARG(message), LOG_STR_ARG(api_error_to_logstr(err)), errno); } void APIConnection::log_socket_operation_failed_(APIError err) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index cc7e4d6895..a21574f6d5 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -19,14 +19,6 @@ namespace esphome::api { struct ClientInfo { std::string name; // Client name from Hello message std::string peername; // IP:port from socket - - std::string get_combined_info() const { - if (name == peername) { - // Before Hello message, both are the same - return name; - } - return name + " (" + peername + ")"; - } }; // Keepalive timeout in milliseconds @@ -278,7 +270,8 @@ class APIConnection final : public APIServerConnection { bool try_to_clear_buffer(bool log_out_of_space); bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override; - std::string get_client_combined_info() const { return this->client_info_.get_combined_info(); } + const std::string &get_name() const { return this->client_info_.name; } + const std::string &get_peername() const { return this->client_info_.peername; } protected: // Helper function to handle authentication completion diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index a284e09c4a..a63199a5c4 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -13,7 +13,8 @@ namespace esphome::api { static const char *const TAG = "api.frame_helper"; -#define HELPER_LOG(msg, ...) ESP_LOGVV(TAG, "%s: " msg, this->client_info_->get_combined_info().c_str(), ##__VA_ARGS__) +#define HELPER_LOG(msg, ...) \ + ESP_LOGVV(TAG, "%s (%s): " msg, this->client_info_->name.c_str(), this->client_info_->peername.c_str(), ##__VA_ARGS__) #ifdef HELPER_LOG_PACKETS #define LOG_PACKET_RECEIVED(buffer) ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(buffer).c_str()) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 0e49f93db5..ab27699f06 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -24,7 +24,8 @@ static const char *const PROLOGUE_INIT = "NoiseAPIInit"; #endif static constexpr size_t PROLOGUE_INIT_LEN = 12; // strlen("NoiseAPIInit") -#define HELPER_LOG(msg, ...) ESP_LOGVV(TAG, "%s: " msg, this->client_info_->get_combined_info().c_str(), ##__VA_ARGS__) +#define HELPER_LOG(msg, ...) \ + ESP_LOGVV(TAG, "%s (%s): " msg, this->client_info_->name.c_str(), this->client_info_->peername.c_str(), ##__VA_ARGS__) #ifdef HELPER_LOG_PACKETS #define LOG_PACKET_RECEIVED(buffer) ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(buffer).c_str()) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 859bb26630..ff72f3cb55 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -18,7 +18,8 @@ namespace esphome::api { static const char *const TAG = "api.plaintext"; -#define HELPER_LOG(msg, ...) ESP_LOGVV(TAG, "%s: " msg, this->client_info_->get_combined_info().c_str(), ##__VA_ARGS__) +#define HELPER_LOG(msg, ...) \ + ESP_LOGVV(TAG, "%s (%s): " msg, this->client_info_->name.c_str(), this->client_info_->peername.c_str(), ##__VA_ARGS__) #ifdef HELPER_LOG_PACKETS #define LOG_PACKET_RECEIVED(buffer) ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(buffer).c_str()) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 7fbe0e27f3..a8fdb635cf 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -177,7 +177,8 @@ void APIServer::loop() { // Network is down - disconnect all clients for (auto &client : this->clients_) { client->on_fatal_error(); - ESP_LOGW(TAG, "%s: Network down; disconnect", client->get_client_combined_info().c_str()); + ESP_LOGW(TAG, "%s (%s): Network down; disconnect", client->client_info_.name.c_str(), + client->client_info_.peername.c_str()); } // Continue to process and clean up the clients below } diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index a0cf1a155b..7ece73994f 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -429,8 +429,9 @@ void VoiceAssistant::client_subscription(api::APIConnection *client, bool subscr if (this->api_client_ != nullptr) { ESP_LOGE(TAG, "Multiple API Clients attempting to connect to Voice Assistant"); - ESP_LOGE(TAG, "Current client: %s", this->api_client_->get_client_combined_info().c_str()); - ESP_LOGE(TAG, "New client: %s", client->get_client_combined_info().c_str()); + ESP_LOGE(TAG, "Current client: %s (%s)", this->api_client_->get_name().c_str(), + this->api_client_->get_peername().c_str()); + ESP_LOGE(TAG, "New client: %s (%s)", client->get_name().c_str(), client->get_peername().c_str()); return; } From 772450f1b3ebf07100da4606e4e614fe9ae50ff0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Oct 2025 11:43:27 +0200 Subject: [PATCH 1622/1626] no mod --- esphome/components/logger/logger.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 7d4c14df0b..867b8949b3 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -355,9 +355,12 @@ class Logger : public Component { buffer[pos++] = '['; copy_string(buffer, pos, tag); buffer[pos++] = ':'; - buffer[pos++] = '0' + (line / 100) % 10; - buffer[pos++] = '0' + (line / 10) % 10; - buffer[pos++] = '0' + line % 10; + int hundreds = line / 100; + line -= hundreds * 100; + int tens = line / 10; + buffer[pos++] = '0' + hundreds; + buffer[pos++] = '0' + tens; + buffer[pos++] = '0' + (line - tens * 10); buffer[pos++] = ']'; #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) From a4cb14a76a854568102d3f34ddcc474bed885e59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Oct 2025 13:18:19 +0200 Subject: [PATCH 1623/1626] Apply Copilot review suggestion: use remainder variable instead of modifying line parameter --- esphome/components/logger/logger.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 867b8949b3..a1f3df97dd 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -356,11 +356,11 @@ class Logger : public Component { copy_string(buffer, pos, tag); buffer[pos++] = ':'; int hundreds = line / 100; - line -= hundreds * 100; - int tens = line / 10; + int remainder = line - hundreds * 100; + int tens = remainder / 10; buffer[pos++] = '0' + hundreds; buffer[pos++] = '0' + tens; - buffer[pos++] = '0' + (line - tens * 10); + buffer[pos++] = '0' + (remainder - tens * 10); buffer[pos++] = ']'; #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) From 5374df73ed58d758d52a1ea3b3dd9af4801c34af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Oct 2025 16:18:06 +0200 Subject: [PATCH 1624/1626] number --- esphome/components/number/number_call.cpp | 39 ++++++++++++++++------- esphome/components/number/number_call.h | 6 ++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/esphome/components/number/number_call.cpp b/esphome/components/number/number_call.cpp index 4219f85328..122cb04470 100644 --- a/esphome/components/number/number_call.cpp +++ b/esphome/components/number/number_call.cpp @@ -7,6 +7,21 @@ namespace number { static const char *const TAG = "number"; +// Helper functions to reduce code size for logging +void NumberCall::log_perform_warning_(const LogString *message) { + ESP_LOGW(TAG, "'%s': %s", this->parent_->get_name().c_str(), LOG_STR_ARG(message)); +} + +void NumberCall::log_perform_warning_value_range_(const LogString *comparison, const LogString *limit_type, float val, + float limit) { + ESP_LOGW(TAG, "'%s': %f %s %s %f", this->parent_->get_name().c_str(), val, LOG_STR_ARG(comparison), + LOG_STR_ARG(limit_type), limit); +} + +void NumberCall::log_perform_warning_two_strings_(const LogString *prefix, const LogString *suffix) { + ESP_LOGW(TAG, "'%s': %s %s", this->parent_->get_name().c_str(), LOG_STR_ARG(prefix), LOG_STR_ARG(suffix)); +} + NumberCall &NumberCall::set_value(float value) { return this->with_operation(NUMBER_OP_SET).with_value(value); } NumberCall &NumberCall::number_increment(bool cycle) { @@ -42,7 +57,7 @@ void NumberCall::perform() { const auto &traits = parent->traits; if (this->operation_ == NUMBER_OP_NONE) { - ESP_LOGW(TAG, "'%s' - NumberCall performed without selecting an operation", name); + this->log_perform_warning_(LOG_STR("No operation")); return; } @@ -51,28 +66,28 @@ void NumberCall::perform() { float max_value = traits.get_max_value(); if (this->operation_ == NUMBER_OP_SET) { - ESP_LOGD(TAG, "'%s' - Setting number value", name); + ESP_LOGD(TAG, "'%s': Setting value", name); if (!this->value_.has_value() || std::isnan(*this->value_)) { - ESP_LOGW(TAG, "'%s' - No value set for NumberCall", name); + this->log_perform_warning_(LOG_STR("No value")); return; } target_value = this->value_.value(); } else if (this->operation_ == NUMBER_OP_TO_MIN) { if (std::isnan(min_value)) { - ESP_LOGW(TAG, "'%s' - Can't set to min value through NumberCall: no min_value defined", name); + this->log_perform_warning_two_strings_(LOG_STR("min"), LOG_STR("value undefined")); } else { target_value = min_value; } } else if (this->operation_ == NUMBER_OP_TO_MAX) { if (std::isnan(max_value)) { - ESP_LOGW(TAG, "'%s' - Can't set to max value through NumberCall: no max_value defined", name); + this->log_perform_warning_two_strings_(LOG_STR("max"), LOG_STR("value undefined")); } else { target_value = max_value; } } else if (this->operation_ == NUMBER_OP_INCREMENT) { - ESP_LOGD(TAG, "'%s' - Increment number, with%s cycling", name, this->cycle_ ? "" : "out"); + ESP_LOGD(TAG, "'%s': Increment, with%s cycling", name, this->cycle_ ? "" : "out"); if (!parent->has_state()) { - ESP_LOGW(TAG, "'%s' - Can't increment number through NumberCall: no active state to modify", name); + this->log_perform_warning_two_strings_(LOG_STR("Can't increment,"), LOG_STR("no state")); return; } auto step = traits.get_step(); @@ -85,9 +100,9 @@ void NumberCall::perform() { } } } else if (this->operation_ == NUMBER_OP_DECREMENT) { - ESP_LOGD(TAG, "'%s' - Decrement number, with%s cycling", name, this->cycle_ ? "" : "out"); + ESP_LOGD(TAG, "'%s': Decrement, with%s cycling", name, this->cycle_ ? "" : "out"); if (!parent->has_state()) { - ESP_LOGW(TAG, "'%s' - Can't decrement number through NumberCall: no active state to modify", name); + this->log_perform_warning_two_strings_(LOG_STR("Can't decrement,"), LOG_STR("no state")); return; } auto step = traits.get_step(); @@ -102,15 +117,15 @@ void NumberCall::perform() { } if (target_value < min_value) { - ESP_LOGW(TAG, "'%s' - Value %f must not be less than minimum %f", name, target_value, min_value); + this->log_perform_warning_value_range_(LOG_STR("<"), LOG_STR("min"), target_value, min_value); return; } if (target_value > max_value) { - ESP_LOGW(TAG, "'%s' - Value %f must not be greater than maximum %f", name, target_value, max_value); + this->log_perform_warning_value_range_(LOG_STR(">"), LOG_STR("max"), target_value, max_value); return; } - ESP_LOGD(TAG, " New number value: %f", target_value); + ESP_LOGD(TAG, " New value: %f", target_value); this->parent_->control(target_value); } diff --git a/esphome/components/number/number_call.h b/esphome/components/number/number_call.h index bd50170be5..a23f6b8e5a 100644 --- a/esphome/components/number/number_call.h +++ b/esphome/components/number/number_call.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "number_traits.h" namespace esphome { @@ -33,6 +34,11 @@ class NumberCall { NumberCall &with_cycle(bool cycle); protected: + void log_perform_warning_(const LogString *message); + void log_perform_warning_value_range_(const LogString *comparison, const LogString *limit_type, float val, + float limit); + void log_perform_warning_two_strings_(const LogString *prefix, const LogString *suffix); + Number *const parent_; NumberOperation operation_{NUMBER_OP_NONE}; optional value_; From 1fc4d1acfb8acdce18914a3b3eebb429b555d3ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Oct 2025 16:20:42 +0200 Subject: [PATCH 1625/1626] number --- esphome/components/number/number_call.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/number/number_call.cpp b/esphome/components/number/number_call.cpp index 122cb04470..024908ba29 100644 --- a/esphome/components/number/number_call.cpp +++ b/esphome/components/number/number_call.cpp @@ -74,20 +74,20 @@ void NumberCall::perform() { target_value = this->value_.value(); } else if (this->operation_ == NUMBER_OP_TO_MIN) { if (std::isnan(min_value)) { - this->log_perform_warning_two_strings_(LOG_STR("min"), LOG_STR("value undefined")); + this->log_perform_warning_two_strings_(LOG_STR("min"), LOG_STR("undefined")); } else { target_value = min_value; } } else if (this->operation_ == NUMBER_OP_TO_MAX) { if (std::isnan(max_value)) { - this->log_perform_warning_two_strings_(LOG_STR("max"), LOG_STR("value undefined")); + this->log_perform_warning_two_strings_(LOG_STR("max"), LOG_STR("undefined")); } else { target_value = max_value; } } else if (this->operation_ == NUMBER_OP_INCREMENT) { - ESP_LOGD(TAG, "'%s': Increment, with%s cycling", name, this->cycle_ ? "" : "out"); + ESP_LOGD(TAG, "'%s': Increment with%s cycling", name, this->cycle_ ? "" : "out"); if (!parent->has_state()) { - this->log_perform_warning_two_strings_(LOG_STR("Can't increment,"), LOG_STR("no state")); + this->log_perform_warning_two_strings_(LOG_STR("Can't increment"), LOG_STR("no state")); return; } auto step = traits.get_step(); @@ -100,9 +100,9 @@ void NumberCall::perform() { } } } else if (this->operation_ == NUMBER_OP_DECREMENT) { - ESP_LOGD(TAG, "'%s': Decrement, with%s cycling", name, this->cycle_ ? "" : "out"); + ESP_LOGD(TAG, "'%s': Decrement with%s cycling", name, this->cycle_ ? "" : "out"); if (!parent->has_state()) { - this->log_perform_warning_two_strings_(LOG_STR("Can't decrement,"), LOG_STR("no state")); + this->log_perform_warning_two_strings_(LOG_STR("Can't decrement"), LOG_STR("no state")); return; } auto step = traits.get_step(); From 8931dcf409b06d3c04a12f81d8e01a064d3b7845 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Oct 2025 16:33:22 +0200 Subject: [PATCH 1626/1626] reduce --- esphome/components/number/number_call.cpp | 12 ++++-------- esphome/components/number/number_call.h | 1 - 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/esphome/components/number/number_call.cpp b/esphome/components/number/number_call.cpp index 024908ba29..669dd65184 100644 --- a/esphome/components/number/number_call.cpp +++ b/esphome/components/number/number_call.cpp @@ -18,10 +18,6 @@ void NumberCall::log_perform_warning_value_range_(const LogString *comparison, c LOG_STR_ARG(limit_type), limit); } -void NumberCall::log_perform_warning_two_strings_(const LogString *prefix, const LogString *suffix) { - ESP_LOGW(TAG, "'%s': %s %s", this->parent_->get_name().c_str(), LOG_STR_ARG(prefix), LOG_STR_ARG(suffix)); -} - NumberCall &NumberCall::set_value(float value) { return this->with_operation(NUMBER_OP_SET).with_value(value); } NumberCall &NumberCall::number_increment(bool cycle) { @@ -74,20 +70,20 @@ void NumberCall::perform() { target_value = this->value_.value(); } else if (this->operation_ == NUMBER_OP_TO_MIN) { if (std::isnan(min_value)) { - this->log_perform_warning_two_strings_(LOG_STR("min"), LOG_STR("undefined")); + this->log_perform_warning_(LOG_STR("min undefined")); } else { target_value = min_value; } } else if (this->operation_ == NUMBER_OP_TO_MAX) { if (std::isnan(max_value)) { - this->log_perform_warning_two_strings_(LOG_STR("max"), LOG_STR("undefined")); + this->log_perform_warning_(LOG_STR("max undefined")); } else { target_value = max_value; } } else if (this->operation_ == NUMBER_OP_INCREMENT) { ESP_LOGD(TAG, "'%s': Increment with%s cycling", name, this->cycle_ ? "" : "out"); if (!parent->has_state()) { - this->log_perform_warning_two_strings_(LOG_STR("Can't increment"), LOG_STR("no state")); + this->log_perform_warning_(LOG_STR("Can't increment, no state")); return; } auto step = traits.get_step(); @@ -102,7 +98,7 @@ void NumberCall::perform() { } else if (this->operation_ == NUMBER_OP_DECREMENT) { ESP_LOGD(TAG, "'%s': Decrement with%s cycling", name, this->cycle_ ? "" : "out"); if (!parent->has_state()) { - this->log_perform_warning_two_strings_(LOG_STR("Can't decrement"), LOG_STR("no state")); + this->log_perform_warning_(LOG_STR("Can't decrement, no state")); return; } auto step = traits.get_step(); diff --git a/esphome/components/number/number_call.h b/esphome/components/number/number_call.h index a23f6b8e5a..807207f0ec 100644 --- a/esphome/components/number/number_call.h +++ b/esphome/components/number/number_call.h @@ -37,7 +37,6 @@ class NumberCall { void log_perform_warning_(const LogString *message); void log_perform_warning_value_range_(const LogString *comparison, const LogString *limit_type, float val, float limit); - void log_perform_warning_two_strings_(const LogString *prefix, const LogString *suffix); Number *const parent_; NumberOperation operation_{NUMBER_OP_NONE};